Point-Free TCA ep.4
Test
Sort Test
- Because of the Effect which is still running, the test will fail.
An effect returned for this action is still running. It must complete before the end of the test. …
- The only way to make that happen is to literally have the test suite wait for a second. The
assert
method supports inserting little imperative tasks like that in between steps, and it’s called ado
block
store.assert(
.send(.todo(index: 0, action: .checkboxTapped)) {
(...)
},
.do {
// Do any imperative work
}
)
.do {
// Do any imperative work
_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 1)
}
- Now if we run tests we get a new failure:
failed - Received 1 unexpected action: … Unhandled actions: [ AppAction.todoDelayCompleted, ]
- to prove that we expected to receive this action we need to add an additional step to our assertion
.do {
// Do any imperative work
_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 1)
},
.receive(.todoDelayCompleted)
- Now we can see the logic properly
- For this, we add the closure.
.receive(.todoDelayCompleted) {
$0.todos.swapAt(0, 1)
}
Complete ToDo
- same as the upper case, we need to add the
.do
thing
store.assert(
.send(.todo(index: 0, action: .checkboxTapped)) {
// make the mutations we expect to happen to the value passed to the closure.
$0.todos[0].isComplete = true
},
.do {
// Do any imperative work
_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 1)
}
)
- And then, we can face the error message:
testCompletingTodo(): The store received 1 unexpected action after this one: … Unhandled actions: [ [0]: .todoDelayCompleted ]
- the solution is the same, too.
store.assert(
.send(.todo(index: 0, action: .checkboxTapped)) {
// make the mutations we expect to happen to the value passed to the closure.
$0.todos[0].isComplete = true
},
.do {
// Do any imperative work
_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 1)
},
.receive(.todoDelayCompleted)
)
Cancellation
- Paste the test function of sorting
- We want to test the cancellation, so we need to tap the checkbox twice.
store.assert(
.send(.todo(index: 0, action: .checkboxTapped)) {
$0.todos[0].isComplete = true
},
.do {
// Do any imperative work
_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 0.5)
},
.send(.todo(index: 0, action: .checkboxTapped)) {
$0.todos[0].isComplete = false
},
.do {
// Do any imperative work
_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 1)
},
.receive(.todoDelayCompleted)
)
Debounce
- The combination of delaying an effect and cancelling inflight effects when starting a new one has a name that is well-known in the reactive programming communities: it’s called debounce.
case .todo(index: \_, action: .checkboxTapped):
struct CancelDelayId: Hashable {}
return Effect(value: AppAction.todoDelayCompleted)
.delay(for: 1, scheduler: DispatchQueue.main)
.eraseToEffect()
.cancellable(id: CancelDelayId(), cancelInFlight: true)
- This code can be simplified in 1 line.
case .todo(index: \_, action: .checkboxTapped):
struct CancelDelayId: Hashable {}
return Effect(value: AppAction.todoDelayCompleted)
.debounce(id: CancelDelayId(), for: 1, scheduler: DispatchQueue.main)
- There are also other type of
debounce
which hasn't theid
as a parameter.
The Reason why debouncing is problematic
- It takes 3.5 seconds to running the test
Test Suite 'TCA_ToDoListTests' passed at 2023-03-10 15:23:10.790. Executed 4 tests, with 0 failures (0 unexpected) in 3.532 (3.536) seconds
-
That means 99.5% of the test suite time is spent waiting. Worse, if we have dozens or hundreds of these tests that time could easily add up to minutes of wasted time.
- So we need the tool for
time controlling
-
Before using this tool, we need to know well about the Scheduler in Combine
Scheduler
is a protocol in the Combine framework, and it represents a type that can describe when and how to execute a closure. Many types that ship with Foundation conform to this protocol because they can also schedule work, for exampleDispatchQueue
,RunLoop
andOperationQueue
.
DispatchQueue.main.schedule {
print("DispatchQueue")
}
DispatchQueue.main.schedule(after: .init(.now() + 1)) {
print("DispatchQueue", "delayed")
}
- We can use
.asyncAfter
instead of.schedule
, btw
var cancellables: Set<AnyCancellable> = []
OperationQueue.main.schedule(after: .init(Date()), interval: 1) {
print("OperationQueue", "timer")
}.store(in: &cancellables)
DispatchQueue -> now() RunLoop -> Date() OperationQueue -> Date()
-
The
Scheduler
protocol is the way Combine abstracts away the responsibility of when and how to execute a unit of work. Any Combine operator that involves time or threading takes a scheduler as an argument, including delaying, throttling, timeouts, debouncing, and more: - Here is the operators that have the Scheduler as a parameter.
Just(1) .receive(on: Scheduler) .subscribe(on: Scheduler) .timeout(SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, scheduler: Scheduler) .throttle(for: SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, scheduler: Scheduler, latest: Bool) .debounce(for: SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, scheduler: Scheduler) .delay(for: SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, scheduler: Scheduler)
- Anytime we use a
DispatchQueue
,RunLoop
orOperationQueue
we are hopelessly in the realm of the real world, with all of its complexities and vagaries, and we have no way to control the scheduler except for literally waiting for time to pass.
TestScheduler
let scheduler = DispatchQueue.testScheduler
scheduler.schedule {
print("TestScheduler")
}
- Nothing will be printed until the code below is added.
scheduler.advance()
- The Upper code will be changed too.
- Previous
scheduler.schedule(after: init(.now() + 1)) { print("TestScheduler", "delayed") }
- Present
scheduler.schedule(after: scheduler.now.advanced(by: 1)) { print("TestScheduler", "delayed") }
- We can control the time like this:
scheduler.advance(by: 1)
- We can do it in Timer too
scheduler.schedule(after: scheduler.now, interval: 1) {
print("TestScheduler", "delayed")
}
scheduler.advance()
scheduler.advance(by: 1000) // the 1000 lines will be printed
- the use of
DispatchQueue.main
in our reducer is an unintended side-effect, and it must be controlled. This means we need to move it to be a dependency in our environment just like theUUID
function.
struct AppEnvironment {
var mainQueue: ???
var uuid: () -> UUID
}
- ??? wouldn't be
DispatchQueue
- Because then we couldn’t substitute in a test scheduler when we are in tests.
- ??? wouldn't be
Scheduler
- because the protocol has associated types.
🛑 Protocol ‘Scheduler’ can only be used as a generic constraint because it has Self or associated type requirements
- because the protocol has associated types.
- ??? wouldn't be like this:
var mainQueue: Scheduler where .SchedulerTimeType == DispatchQueue.SchedulerTimeType, .SchedulerOptions == DispatchQueue.SchedulerOptions
-
until now,,,,
- so we have to turn to
a more ad hoc solution
. - We need one of those “Any” type erasers for the
Scheduler
protocol. The standard library and various frameworks from Apple has multiple type erased wrappers, such as:// AnyHashable // AnyIterator // AnyCollection // AnySubscriber // AnyCancellable // AnyPublisher // AnyView
- You use them in places where you’d like to be able to use the bare protocol.
var mainQueue: AnyScheduler<DispatchQueue.SchedulerTimeType, DispatchQueue.SchedulerOptions>
- this code is too verbose, so
var mainQueue: AnySchedulerOf<DispatchQueue>
case .todo(index: \_, action: .checkboxTapped):
struct CancelDelayId: Hashable {}
return Effect(value: AppAction.todoDelayCompleted)
.debounce(id: CancelDelayId(), for: 1, scheduler: environment.mainQueue)
- We need to match the type, so we use
.eraseToAnyScheduler()
in the real App
environment: AppEnvironment(
mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
uuid: UUID.init
)
- We will use the different Scheduler in the Test Suite.
environment: AppEnvironment(
mainQueue: DispatchQueue.testScheduler.eraseToAnyScheduler(),
uuid: { fatalError("unimplemented") }
)
- But when we use
.eraseToAnyScheduler()
, this erase all the info that we had that identified it as aTestScheduler
.- We don't have access to anything that allows us need to advance time on this test scheduler.
- So we store this in our scheduler variable.
let scheduler = DispatchQueue.testScheduler
//_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 0.5)
self.scheduler.advance(by: 0.5)
- Now, Check the test suite time!
Test Suite 'TCA_ToDoListTests' passed at 2023-03-10 16:26:37.384. Executed 4 tests, with 0 failures (0 unexpected) in 0.036 (0.042) seconds