EP 210 · Clocks · Oct 24, 2022 ·Members

Video #210: Clocks: Controlling Time

smart_display

Loading stream…

Video #210: Clocks: Controlling Time

Episode: Video #210 Date: Oct 24, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep210-clocks-controlling-time

Episode thumbnail

Description

With “immediate” and “unimplemented” conformances of the Clock protocol under our belt, let’s build something more complicated: a “test” clock that can tell time when and how to flow. We’ll explore why we’d ever need such a thing and what it unlocks.

Video

Cloudflare Stream video ID: 004241679cb8d85f50a9666e84b79481 Local file: video_210_clocks-controlling-time.mp4 *(download with --video 210)*

References

Transcript

0:05

So, this is pretty amazing. After diving deep into the Clock protocol so that we could understand what core concept it represents, and seeing how it differs from Combine schedulers, we showed how to make use of clocks in our features. There were a number of false starts to do that, first needing to wrap our minds around clock existentials and primary associated types and then coming to grips with some missing APIs in the standard library, but once we got out of the weeds we had the ability to swap clocks in and out of our feature code. We could use a continuous clock in the feature when running on a device, but sub out for a more controllable clock in tests and SwiftUI previews.

0:40

That led us to the creation of the “immediate” clock and the “unimplemented” clock. Both are powerful, and allow you to make your tests stronger and make it so that you don’t have to literally wait for time to pass in order to see how your feature behaves.

0:53

But there’s one more kind of clock that we want to have for our tests, and it’s even more powerful than any of the other clocks we have discussed. While the immediate clock allows us to squash all of time into a single instant, that isn’t always what we want. Sometimes we want to actually control the flow of time in our feature so that we can see how multiple time-based asynchronous tasks interleave their execution, or so that we can wiggle ourselves in between time-based tasks to see what is happening between events.

1:24

This is something we explored quite a bit in our series of episodes on Combine schedulers . When writing highly reactive code that needs to leverage time-based operators, such as delays, debounces and throttles, it becomes very important to have a scheduler for which you can explicitly control the flow of time.

1:41

So, let’s see why exactly we want this tool, and what it takes to implement it. The problem

1:48

Let’s start by looking at the test we wrote for the welcome message behavior: func testWelcome() async { let model = FeatureModel(clock: ImmediateClock()) XCTAssertEqual(model.message, nil) await model.task() XCTAssertEqual(model.message, "Welcome!") }

1:53

Currently we are squashing all of time to a single instant so that we can emulate what happens when the view appears and assert on the welcome message being hydrated without having to literally wait for 5 seconds to pass.

2:03

This is great, but we aren’t getting any test coverage on the amount of time. What if we wanted to show that you have to actually wait 5 seconds before the message appears.

2:12

Now, typically you aren’t actually going to worry about that, especially in our current situation where the sleep duration is hard coded in the model. Like do we really want our test to fail if someday we up it to 6 seconds? Maybe some do, but I’m sure also some don’t, and for those people an immediate scheduler is just fine.

2:28

But, what if there was logic in the model that guided how much time we sleep for. Then it’s a lot more likely that we would want to get test coverage on that. For example, suppose that the first time you see this view we wait for 5 seconds before showing the welcome message, perhaps because there’s a lot on the screen to take in visually, but if it is not your first time we only wait for 1 second.

2:48

We could turn to user defaults to accomplish this: func task() async { do { defer { UserDefaults.standard .set(true, forKey: "hasSeenViewBefore") } try await self.clock.sleep( for: UserDefaults.standard .bool(forKey: "hasSeenViewBefore") ? .seconds(1) : .seconds(5) ) self.message = "Welcome!" } catch {} }

3:28

This is the simplest way to get the job done, but we’re not recommending you write actual production code like this. Most likely you should inject a user defaults store into the feature so that you aren’t trampling on values during tests, or allowing values to bleed over from one test to another. And we probably shouldn’t be hard coding the key like that. But we aren’t going to worry about things like that now, and we highly encourage our viewers to clean up this code to see how it can be made better.

3:52

So, with our feature’s logic seriously beefed up, we might hope we can write a test. We can certainly split our existing test into two tests, one in which the user default value is false and one where it is true : func testWelcome_FirstTimer() async { UserDefaults.standard .set(false, forKey: "hasSeenViewBefore") let model = FeatureModel(clock: ImmediateClock()) XCTAssertEqual(model.message, nil) await model.task() XCTAssertEqual(model.message, "Welcome!") } func testWelcome_RepeatVisitor() async { UserDefaults.standard .set(true, forKey: "hasSeenViewBefore") let model = FeatureModel(clock: ImmediateClock()) XCTAssertEqual(model.message, nil) await model.task() XCTAssertEqual(model.message, "Welcome!") }

4:24

Both of these tests pass, but all they prove is that at some later time the message flips to the welcome message. We don’t have coverage on how long each one took. To do that we need a more granular notion of controlling time in tests.

4:38

There’s another situation where this crops up that is even more pernicious, and that is when timers are involved. Let’s add another piece of functionality to our feature. We will add a button so that when it is tapped, a timer is started, and each tick of the timer increments the count. Thanks to structured concurrency, this is quite straightforward: func startTimerButtonTapped() async throws { while true { try await self.clock.sleep(for: .seconds(1)) self.count += 1 } } … Button("Start timer") { Task { try await self.model.startTimerButtonTapped() } }

5:25

And that’s all it takes. We even made use of the injected clock in hopes that this code will also be testable. It’s pretty amazing to see easy to create this feature leveraging structured concurrency. There’s no need to track cancellables just to keep the asynchronous work alive, it’s all tied to the scope of the method.

5:27

We can even give this a spin in the preview to see that it does indeed work. The timer counts up super fast, much faster than once per second, but that’s because we are using an immediate clock in the preview, and so we are just in a tight, infinite loop incrementing the count.

5:46

Now of course we probably want a way to stop the timer. In order to do that we need to have an handle on the asynchronous work that drive the timer. This means rather than firing up a new task in the view layer, we will create the task in model and store it: var timerTask: Task<Never, Error>? func startTimerButtonTapped() { self.timerTask = Task<Never, Error> { while true { try await self.clock.sleep(for: .seconds(1)) self.count += 1 } } }

6:18

And then we can add a new endpoint to the model for stopping the timer by cancelling the task and nil -ing it out: func stopTimerButtonTapped() { self.timerTask?.cancel() self.timerTask = nil }

6:32

And then we can update the view to show a start or stop button depending on the state of the task: if self.model.timerTask == nil { Button("Start timer") { self.model.startTimerButtonTapped() } } else { Button("Stop timer") { self.model.stopTimerButtonTapped() } }

6:53

That’s all it takes to implement the feature, and we can give it a spin in the preview. However, we will find that the timer does not stop. The view is definitely calling the method, and the method is definitely cancelling the task, so what gives?

7:12

Well, it turns out our immediate clock implementation is not fully correct. We should be checking if the current asynchronous context is cancelled when sleeping with the clock, and if it is we should be throwing an error. This is enough to do using the throwing checkCancellation method: struct ImmediateClock: Clock { … func sleep( until deadline: Instant, tolerance: Duration? ) async throws { try Task.checkCancellation() } }

7:33

And now the stop button works as we expect.

7:37

So, the feature works, but the question is: can we test it?

7:41

We’d like to be able to write a test that exercises the flow of the user starting the timer, waiting a moment, and then stopping the timer. We could try just hitting those two methods and then asserting on what changed after: func testTimer() async { let model = FeatureModel(clock: ImmediateClock()) model.startTimerButtonTapped() model.stopTimerButtonTapped() XCTAssertNil(model.timerTask) XCTAssertEqual(model.count, 0) }

8:46

This passes, but it’s not capturing much logic. How do we know a timer really spun up and did any real work? We weren’t even able to detect the count changing at all since we cancelled immediately.

8:59

We could try waiting a minuscule amount of time to let the timer do a little bit of work, but because we are using an immediate clock we have no idea how many ticks of the timer are actually going to make it through: func testTimer() async throws { let model = FeatureModel(clock: ImmediateClock()) model.startTimerButtonTapped() try await Task.sleep(for: .milliseconds(10)) model.stopTimerButtonTapped() XCTAssertNil(model.timerTask) XCTAssertEqual(model.count, 1) }

9:21

On one run of this test we get 296 ticks: testTimer(): XCTAssertEqual failed: (“296”) is not equal to (“1”)

9:30

But then another run we get 337 ticks: testTimer(): XCTAssertEqual failed: (“337”) is not equal to (“1”)

9:42

We could of course go back to a continuous clock and start waiting for real time to pass: func testTimer() async throws { let model = FeatureModel(clock: ContinuousClock()) model.startTimerButtonTapped() try await Task.sleep(for: .seconds(2) + .milliseconds(10)) model.stopTimerButtonTapped() XCTAssertNil(model.timerTask) XCTAssertEqual(model.count, 2) }

10:00

But even that doesn’t cut it: testTimer(): XCTAssertEqual failed: (“1”) is not equal to (“2”)

10:02

Due to the imprecision of sleeps, waiting for 2 real life seconds plus a handful of milliseconds is not enough to get the second tick. We need to wait for just a little bit more time: try await Task.sleep(for: .seconds(2) + .milliseconds(100))

10:17

And now the test passes, but we are back to a slow test, and honestly one I don’t trust. I have no idea if waiting 2,100 milliseconds is enough time to get the second tick, and so maybe this test will non-deterministically fail in the future.

10:38

All of this is showing that there is still more work to be done to test time-based asynchronous code. The immediate clock takes us far for many situations where we need to sleep a task in a manner where our test doesn’t really care about the amount of time slept, but sometimes caring about that is unavoidable.

10:56

Sometimes there’s logic governing the amount of time sleeping, and you really do want to get test coverage on that. And most of the times that you use a timer, an immediate clock is not going to be very helpful since it puts you into a tight, infinite loop that is impossible to predict. The solution: test clocks

11:11

So we really do need a more advanced kind of clock. One where we can completely control the flow of time. Let’s first theorize what we would want the call site of such a clock to look like in tests, and then see how we can make that syntax a reality.

11:28

Take the welcome message tests as an example. Right now they use an immediate clock, which means the test can’t actually prove how much time must pass before the message appears: func testWelcome_FirstTimer() async { UserDefaults.standard .set(false, forKey: "hasSeenViewBefore") let model = FeatureModel(clock: ImmediateClock()) XCTAssertEqual(model.message, nil) await model.task() XCTAssertEqual(model.message, "Welcome!") } func testWelcome_RepeatVisitor() async { UserDefaults.standard .set(true, forKey: "hasSeenViewBefore") let model = FeatureModel(clock: ImmediateClock()) XCTAssertEqual(model.message, nil) await model.task() XCTAssertEqual(model.message, "Welcome!") }

11:42

Suppose we had a test clock available, and when you ask it to sleep, it suspends forever, or until someone tells the test clock to move its internal time forward. That would allow us to show that when time moves forward 5 seconds, the welcome message shows for a first timer: func testWelcome_FirstTimer() async { UserDefaults.standard .set(false, forKey: "hasSeenViewBefore") let clock = TestClock() let model = FeatureModel(clock: clock) XCTAssertEqual(model.message, nil) await model.task() await clock.advance(by: .seconds(5)) XCTAssertEqual(model.message, "Welcome!") }

12:25

And similarly, for a non-first timer we have to only wait for 1 second: func testWelcome_RepeatVisitor() async { UserDefaults.standard .set(true, forKey: "hasSeenViewBefore") let clock = TestClock() let model = FeatureModel(clock: clock) XCTAssertEqual(model.message, nil) await model.task() await clock.advance(by: .seconds(1)) XCTAssertEqual(model.message, "Welcome!") }

12:36

Both of these tests should execute instantly. We don’t have to actually wait for 5 or 1 seconds of real world time to pass. Instead, the clock will keep track of all the sleeps that have been scheduled with it, and as the clock’s time is moved forward it will unsuspend any sleep tasks whose deadline has been reached.

12:57

This is essentially how the test scheduler for Combine works, which was created in an episode over two years ago and then open sourced . In fact, there are going to be a ton of similarities between what we do for test schedulers and what we will do for test clocks, so let’s quickly re-familiarize ourselves with that code.

13:15

Let’s open up the CombineSchedulers project, and let’s look at a test that demonstrates how it works: func testAdvance() { let scheduler = DispatchQueue.test var value: Int? Just(1) .delay(for: 1, scheduler: scheduler) .sink { value = $0 } .store(in: &self.cancellables) XCTAssertEqual(value, nil) scheduler.advance(by: .milliseconds(250)) XCTAssertEqual(value, nil) scheduler.advance(by: .milliseconds(250)) XCTAssertEqual(value, nil) scheduler.advance(by: .milliseconds(250)) XCTAssertEqual(value, nil) scheduler.advance(by: .milliseconds(250)) XCTAssertEqual(value, 1) }

13:27

Here we construct a publisher that immediately emits the value 1, but then we delay that emission by 1 second on a test scheduler. This means that the publisher does not emit until we tell the test scheduler to advance its internal time by 1 second. We can even advance by small amounts and see that the value is never emitted, and it’s not until in total a full second has been advanced that we get the emission.

14:02

The advance(to:) is where the real magic happens. It is responsible for moving the scheduler’s internal time forward, and executing any scheduled worked along the way.

14:18

It does this by first holding onto an array of scheduled units of work as an instance variable in the test scheduler: private var scheduled: [( sequence: UInt, date: SchedulerTimeType, action: () -> Void )] = []

14:26

We need to hold onto the absolute date of when the item is scheduled, as well as a closure that represents the actual work to be performed. We also need to keep track of a sequence integer that is used to order units of work that are scheduled at the exact same time. This is only needed for timers, for the situation where you have two timers with different intervals, and if they ever coalesce to emit at the same time we want the first created timer to be executed first. It’s a very small detail and is specific to schedulers since they bake in the notion of timers, whereas clocks do not.

15:14

Then, when work is scheduled on the scheduler we don’t execute it at all. Instead, we add it to the array of scheduled items: public func schedule( options _: SchedulerOptions?, _ action: @escaping () -> Void ) { self.lock.sync { self.scheduled.append( (self.nextSequence(), self.now, action) ) } }

15:34

We need to lock access to the array because technically work can be scheduled on a scheduler from many different threads.

15:48

And finally, the advance(to:) method does the work of moving time forward and executing work items. It does this by first starting a while loop that keeps looping as long as the internal now time is less than or equal to the final deadline: while self.lock.sync(operation: { self.now }) <= finalDate { … }

16:04

In this loop we will be searching for work scheduled before the deadline so that we can perform it, and once there’s no work left we will exit the loop. This loop is what makes it possible to handle reentrant scheduled work with a test scheduler, because the act of executing a scheduled unit of work could enqueue a new unit of work, and we want to pick that new work up in the loop.

16:27

To find the next unit of work to execute we need to sort the array of scheduled items to get the one of the earliest schedule date and earliest sequence number: self.scheduled.sort { ($0.date, $0.sequence) < ($1.date, $1.sequence) }

16:44

Then we can pluck off the first item, and if its date is greater than the final deadline we can early out of the loop and move the internal now time to the final date because there’s nothing we need to execute: guard let next = self.scheduled.first, finalDate >= next.date else { self.now = finalDate self.lock.unlock() return }

17:00

If we get past the guard it means we have an item to execute. So, we can move the internal now time to the date of that item, remove the item from the array of scheduled items, and then execute it: self.now = next.date self.scheduled.removeFirst() self.lock.unlock() next.action()

17:14

And that’s all it takes.

17:15

There’s also an async version of this method, which is something we discussed on our series of episodes that introduced more of Swift’s concurrency tools to the Composable Architecture. We added methods on schedulers that allowed you to sleep them in an asynchronous context, but in order to make such an operation testable we needed advance(to:) to be async so that work started after a sleep could be picked up in tests.

17:41

The only significant difference between the sync and async versions of the method is that once we get into the while loop we perform what is affectionately known as a megaYield : while self.lock.sync(operation: { self.now }) <= finalDate { await Task.megaYield() … }

17:52

This is unfortunately necessary, but sadly not even sufficient in some cases. A megaYield simply performs multiple Task.yields under the hood, and its sole purpose is to give other tasks in the system time to start their work before or after a scheduled unit of work starts. Without this, it is possible in tests that we try to advance a scheduler before the scheduler has even been used, resulting in a test that fails because it wasn’t able to hook into the system at the right moment.

18:25

This is definitely not how we think test schedulers and clocks should work, but sadly there doesn’t seem to be an alternative right now. We started a discussion about reliably testing async code in Swift on the forums, and while many share the same frustrations we do and there is enthusiasm for a better way, Swift unfortunately just does not provide the tools today for us to make this better. There is a chance that custom executors may help with this in the future, but currently they are under-documented and can only be really accessed by calling out to C++ functions in Swift, which is difficult and fraught.

18:59

But, outside the megaYield , the only other thing different in this implementation is this weird dance where we construct a closure just to immediately execute it, and then use the return value to determine if we should exit the loop or not: let return = { () -> Bool in … }() if return { return }

19:20

This is necessary because it’s generally not safe to use locks in an asynchronous context. Locks require that you lock and unlock on the same thread, and in an asynchronous context every single line after an await could potentially be executed on a different thread. This means something seemingly innocent like this: self.lock.lock() await Task.yield() self.lock.unlock() Instance method ‘lock’ is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in Swift 6 Instance method ‘unlock’ is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in Swift 6

19:45

…is problematic, and depending on the implementation of the lock, it may even crash.

19:57

By opening up a synchronous closure and invoking it immediately, we can demarcate a little area of an async function to run synchronously, and hence provably runs on just a single thread, and from that context it is totally fine to use locks.

20:18

So, that’s a quick refresher on how test schedulers work, and amazingly, the implementation of a test clock looks almost identical to this code. We’ll even be able to copy-and-paste a bunch of this.

20:29

Let’s start by getting some basic scaffolding into place. Since clocks need to be sendable, and since test clock needs to manage some internal, mutable state for its current instant, we may be tempted to make the TestClock an actor: public actor TestClock: Clock { }

20:54

And like immediate clocks, we will leverage the standard library’s Duration and make a custom Instant to satisfy the associated types: actor TestClock: Clock { typealias Duration = Swift.Duration struct Instant: InstantProtocol { private var offset: Duration = .zero public func advanced(by duration: Duration) -> Self { .init(offset: self.offset + duration) } public func duration(to other: Self) -> Duration { other.offset - self.offset } public static func < (lhs: Self, rhs: Self) -> Bool { lhs.offset < rhs.offset } } }

21:13

And then we could have Swift fill in some of the requirements for us: actor TestClock: Clock { var now: Instant var minimumResolution: Duration … } Actor-isolated property ‘now’ cannot be used to satisfy nonisolated protocol requirement

21:22

But that can’t work because any data held in an actor is isolated, which means to access it you must await in an asynchronous context, but the Clock protocol doesn’t say anything about needing to await to access now or minimumResolution . Therefore an actor cannot be used to satisfy this protocol.

21:42

Instead we must use a class, make it unchecked sendable, and we will have to be responsible for making it safe to use concurrently, operating fully outside the purview of the compiler: class TestClock: Clock, @unchecked Sendable { }

21:56

We will provide a default for the minimum resolution, which doesn’t really have a purpose in a test clock, and we will provide an initializer for the now value: class TestClock: Clock, @unchecked Sendable { var now: Instant var minimumResolution: Duration = .zero init(now: Instant = .init()) { self.now = now } … }

22:05

All that is left to implement is the sleep method: class TestClock: Clock, @unchecked Sendable { … func sleep(until deadline: Instant, tolerance: Duration?) async throws { } }

22:09

Technically everything is compiling, but of course this is not finished. Right now when we tell a test clock to sleep it just won’t suspend at all, making it act more like an immediate clock than anything.

22:21

There is one edge case we can handle right away where the test clock does not need to suspend, and that is if anyone tries to sleep until a deadline that is before now . We might as well early out because there’s nothing to do then: func sleep( until deadline: Instant, tolerance: Duration? ) async throws { guard deadline > self.now else { return } … }

22:42

If we get past that guard then we should suspend until some later moment when the clock’s internal time is advanced forward. This requires some kind of bridge from the purely asynchronous world of the sleep method, to an imperative world elsewhere when the advance method is called. The tool that Swift ships to help with these kinds of situations is known as an “unsafe continuation”, and it’s something we’ve passingly mentioned and briefly used a few times on Point-Free.

23:12

The idea is that we want to simultaneously suspend and get a handle on that suspension so that we can later un-suspend. Perhaps the most prototypical way to do this is with continuations, and in particular the withUnsafeContinuation free function: await withUnsafeContinuation { continuation in }

23:31

This will suspend until the .resume() method is invoked on the continuation. It acts as a kind of bridge between the async/await world and the callback-based approach to asynchrony.

23:46

The continuation can even be escaped from this closure and stored for long periods of time. This is starting to sound similar to what we did for the test scheduler where we stored the action closure that we wanted to execute: private var scheduled: [( sequence: UInt, date: SchedulerTimeType, action: () -> Void )] = []

3:44:05

But instead now we want to store the continuation so that we can later resume it.

24:09

Let’s give this a shot. We can start by holding onto an array of the scheduled units of work. This includes the unsafe continuation that we need to resume at a later date, as well as the deadline of when to resume: private var scheduled: [( deadline: Instant, continuation: UnsafeContinuation<Void, Never> )] = []

24:33

Then in the sleep method we can suspend to create the unsafe continuation, and store the continuation and deadline in our array of scheduled items. await withUnsafeContinuation { continuation in self.scheduled.append( (deadline: deadline, continuation: continuation) ) }

24:53

So, the sleep method will now suspend until someone comes along and resumes the continuation we are storing.

25:01

The only way for a test clock to ever resume from a sleep is if we tell the test clock to do so. That means we need a method on TestClock to tell it to advance its internal time to some future deadline, and in the process it will resume any sleeping continuations that come before the deadline: public func advance(to deadline: Instant) async { }

25:27

The implementation of this is going to look nearly identical to what we did in the test scheduler. In fact, I’m going to copy-and-paste that implementation and just make a few small changes.

25:45

We of course get a few compiler errors, but they are quite easy to fix. First, there is a mention of a lock but we haven’ added a lock to our TestClock . This is definitely a problem, because as you remember we couldn’t make TestClock into an actor, and so thread safety is up to us.

26:03

So, let’s add a lock to the class: public final class TestClock : Clock, @unchecked Sendable { private let lock = NSRecursiveLock() … }

26:08

And we’ll need to copy over the little sync helper we have defined on locks, which gives you an easy way to lock around the execution of a synchronous closure: extension NSRecursiveLock { @inlinable @discardableResult func sync<R>(operation: () -> R) -> R { self.lock() defer { self.unlock() } return operation() } }

26:28

With that we actually got a decent amount of the advance method compiling. We just need to do a few things:

26:30

First, the advance method using the terminology deadline to represent the instant we want to advance to, but the test scheduler used instant . So let’s rename those.

26:40

Next we need to bring our megaYield helper over.

26:54

Then we sort the scheduled units of work, but now we only have to sort by their deadlines: self.scheduled.sort { $0.deadline < $1.deadline }

27:09

Then there’s a couple of spots where we refer to a scheduled item’s date , but now it’s called deadline .

27:20

And finally, instead of invoking the scheduled item’s closure we will resume its continuation: next.continuation.resume()

27:28

Amazingly this now compiles, and this is the correct implementation of the advance method.

27:40

We can also make a convenience method that allows you to sleep for a duration rather than until a deadline: public func advance(by duration: Duration) async { await self.advance(to: self.now.advanced(by: duration)) }

28:05

And there’s only one last small change we need to make and this test clock is basically ready for prime time. Now that we have a lock in the class that we are using to synchronize access to the various mutable bits of state, we should now lock when we add items to the array of scheduled items: public func sleep( until deadline: Instant, tolerance: Duration? = nil ) async throws { guard self.lock.sync(operation: { deadline > self.now }) else { return } await withUnsafeContinuation { continuation in self.lock.sync { self.scheduled.append( (deadline: deadline, continuation: continuation) ) } } }

28:50

We now have a test clock that we can use in tests. It’s pretty incredible to see just how similar it is to the test scheduler that we developed more than 2 years ago.

29:00

So, let’s give it a spin!

29:02

Recall that we had two tests that exercise the showing of the welcome message for two different flows: one where the user has not seen it before, and one where they have. The delay used for showing the message is a little longer for first-timers.

29:22

Previously we couldn’t get test coverage on that since the immediate clock squashed all of time into a single instant. I guess we could have used a continuous clock, but then we’d have to measure how long the test took in order to confirm that the delays were as long as we expect, which would be weird.

29:38

Now our theoretical tests are even compiling: func testWelcome_FirstTimer() async { UserDefaults.standard .set(false, forKey: "hasSeenViewBefore") let clock = TestClock() let model = FeatureModel(clock: clock) XCTAssertEqual(model.message, "") await model.task() await clock.advance(by: .seconds(5)) XCTAssertEqual(model.message, "Welcome!") }

29:45

However when we run things, the test hangs forever. The test has definitely started, but nothing seems to be happening: Test Case '-[ClockExplorationTests.ClockExplorationTests testWelcome_FirstTimer]' started.

30:00

The problem is when we tell our model to await .task() and do its work, under the hood it is going to ask the clock to sleep and suspend for as long as it does, so we never get to the line after, where we tell the clock to advance. await model.task() await clock.advance(by: .seconds(5))

30:16

We can’t do that work inline because it will suspend until the test clock is advanced, but we won’t get a chance to advance the test clock because we will be stuck on awaiting the model’s task. So, we have no choice but to run the model’s work in a new, parallel task: Task { await model.task() } await clock.advance(by: .seconds(5))

30:28

This test now passes, and does so immediately: Test Suite 'ClockExplorationsTests' passed. Executed 1 test, with 0 failures (0 unexpected) in 0.006 (0.006) seconds

30:42

And note that thanks to the megaYield performed inside the advance(by:) method, the task we spin up has enough time to fire up and start the model’s work. This is crucial, and it’s why we don’t have to manually perform yields in the test like we were previously doing.

31:01

We have now taken control over time rather than it controlling us. We can tell a clock to instantly jump to a future date, letting us test time-based asynchrony in a simple and deterministic fashion.

31:13

We can also easily write a test to show that non-first timers only have to wait one second until their welcome message is shown: func testWelcome_RepeatVisitor() async { UserDefaults.standard .set(true, forKey: "hasSeenViewBefore") let clock = TestClock() let model = FeatureModel(clock: clock) XCTAssertEqual(model.message, nil) Task { await model.task() } await clock.advance(by: .seconds(1)) XCTAssertEqual(model.message, "Welcome!") }

31:29

So, this is pretty incredible. It was very easy to add a nuanced test proving how some time-based asynchrony works.

31:38

We can even strengthen this test, because while we test everything up to the welcome message showing on the screen, more could happen before the model’s task endpoint completes. To do so, we can get a handle on the task wrapping the model’s work, and await it. let task = Task { await model.task() } … await task.value Testing time exhaustively

32:12

It’s worth mentioning that testing time-based asynchrony can be tricky sometimes, requiring you to employ tricks like spinning up a new task so that you don’t hold up the test task, and then later having to await the task so that you can make sure it performed all of its work. We think that this is just how things have to be to test complex models using only the tools Swift gives us, and the only way to make things better is to create new tools.

32:42

And in our opinion, this is why things are much simpler when testing features with the Composable Architecture, and it’s why we’ve said a few times that we think testing asynchronous code with the library is perhaps the best way to test asynchronous code in Swift, in general. The only reason it’s possible is because features built in the Composable Architecture form a closed system, and our TestStore can monitor everything happening in that system. There’s no need to manually spin up tasks and track their lifecycle. It’s all handled automatically by the TestStore . Instead, you can just concentrate on sending actions into the system and asserting on how the system evolves over time.

33:20

So, this is cool, but it gets better. We can also write a test for the timer functionality.

33:30

We can start by creating a test clock to use in the feature’s model: func testTimer() async throws { let clock = TestClock() let model = FeatureModel(clock: clock) … }

33:40

Then, when we start the timer we can first assert that the internal task is indeed not nil , proving that the timer has definitely started: model.startTimerButtonTapped() XCTAssertNotNil(model.timerTask)

33:51

Then we can advance the test clock by a second to prove that the model’s count increments by 1: await clock.advance(by: .seconds(1)) XCTAssertEqual(model.count, 1)

34:08

And we can do it again: await clock.advance(by: .seconds(1)) XCTAssertEqual(model.count, 2)

34:13

We can even advance it by 8 more seconds to show that the count goes up to 10: await clock.advance(by: .seconds(8)) XCTAssertEqual(model.count, 10)

34:21

And finally we can hit the stop button and show that the timer task has been cleared out: model.stopTimerButtonTapped() XCTAssertNil(model.timerTask)

34:33

Amazingly this test passes, but it’s not as strong as it could be. In fact, there is a bug here even though the test is passing. While we have shown that the timer’s task was nil ’d out, and we can be reasonably sure it was cancelled, is it true that the timer on the inside of the task has actually stopped?

34:58

To see if this is the case, let’s advance the clock again and make sure the count does not increment: await clock.advance(by: .seconds(100)) XCTAssertEqual(model.count, 10)

35:14

Well, unfortunately we get a test failure: testTimer(): XCTAssertEqual failed: (“110”) is not equal to (“10”)

35:16

The count incremented another 100 times, which means the timer is still somehow alive even though we cancelled its task.

35:22

There is actually a serious bug in the test clock right now that makes it behave differently from a continuous or suspending clock. It does not participate in cooperative cancellation, and so when we cancel the task it keeps humming along as if nothing happened. By not cooperatively cancelling in the test clock it has diverged from how real clocks behave, and that makes our test not capture what would actually happen in reality.

35:44

The reason our clock is not cooperatively cancelling is because of the usage of withUnsafeContinuation : await withUnsafeContinuation { continuation in self.lock.lock() self.scheduled.append( (deadline: deadline, continuation: continuation) ) self.lock.unlock() }

35:56

If the surrounding asynchronous context that this is running in is cancelled, it will continue suspending. this isn’t documented in Swift or in the evolution proposals, but it does seem to be on purpose.

36:05

If you want to this piece of code to participate in cooperative cancellation, the easiest way to do so is to use an AsyncStream instead. In many ways their APIs are very similar, but streams do cooperatively cancel by breaking out of a for await when it detects cancellation.

36:21

Updating the code is quite straightforward. First, our array of scheduled items will hold onto an AsyncStream.Continuation rather than an UnsafeContinuation : private var scheduled: [( deadline: Instant, continuation: AsyncStream<Never>.Continuation )] = []

36:34

We’re even able to Never as the output type of the stream since we don’t need it to ever emit. We just need it to exist until it is finished.

36:48

Then, when sleeping we will create an AsyncStream , add its continuation to the array of scheduled items, await for the stream to finish, and then throw if things were cancelled: let stream = await AsyncStream<Never> { self.lock.sync { self.scheduled.append( (deadline: deadline, continuation: continuation) ) } } for await _ in stream {} try Task.checkCancellation()

37:29

And finally, in advance(to:) , once we have detected its time for a particular unit of work to stop sleeping, we will finish() its continuation rather than resuming: next.continuation.finish()

37:38

With that one small change the test now passes. The test clock can see the moment it is cancelled, causing a CancellationError to be thrown, which breaks us out of the infinite while loop, and causing the count to not be incremented anymore.

37:48

This has greatly increased the strength of this test as we are now seeing that after the stop button was tapped the timer has seemed to be torn down as it is not emitting any more values.

37:58

But actually, all we’ve really shown is that the timer doesn’t emit anything after 100 seconds. What if it emitted something after 1,000 seconds? Well, we could check that by increasing the amount we advance: await clock.advance(by: .seconds(1_000))

38:10

And that passes.

38:14

But what if really it emitted something after a million seconds? Guess we would need to advance by that much too: await clock.advance(by: .seconds(1_000_000))

38:20

That also passes. So, I have quite a bit of confidence that the timer will never emit again, but I don’t have proof that it never will.

38:31

To prove it, what we need to do is check that there’s no more work scheduled in the test clock. Currently the scheduled array is private to the test clock, so we can’t just inspect it in a test. And it’s probably a good idea to keep it private.

38:44

There’s another way of proving that a test clock will never execute anything again. We can add a run method to it that forces all future work to immediately execute: public func run() async { while let deadline = self.lock.sync(operation: { self.scheduled.first?.deadline }) { await self.advance(to: deadline) } }

39:38

And then rather than advancing by 1,000,000 seconds we can just tell the clock to run itself until the end of time: await clock.run() XCTAssertEqual(model.count, 10)

39:48

This passes, and definitively proves that the clock will never execute any more work. Conclusion

39:54

So, we have now accomplished everything we wanted. We took back control over time-based asynchrony using clocks, rather than letting time control us. We can now be free to sprinkle in all kinds of nuanced and complex time-based logic into our features, and as long as we inject a clock existential into the feature, we can be sure that everything will remain testable.

40:14

If you don’t care about the actual amounts of time passing in the feature, then you can squash all of time down to a single instant and just forget that little sleeps are happening inside your feature. This is helpful not only in tests, where you don’t want to slow down your test suite just because you need to perform a sleep, but also in previews. If you did not control time in your feature you would create a really slow, frustrating feedback loop whenever you wanted to make small tweaks to your feature’s logic or styling.

40:42

And if you do care about how much time is actually passing in your features, then you can use a test clock. This lets you quickly advance the clock forward to any future time, and it will coordinate how all the various sleep tasks are suspending and un-suspending.

40:55

And on top of all of that we also dabbled with an “unimplemented” clock, which is one that causes an XCTest failure if its sleep method is ever called. This is perfect to use when testing execution flows of your feature that you don’t expect to ever schedule any time-based asynchrony. If the test passes with an unimplemented clock, then you have proof that no sleeps ever happened in the future. And then if someday you do get a failure, it’s because the feature started doing something new, and you probably want to update your test to account for that new behavior.

41:35

So, that concludes our series on Swift clocks. We definitely think that clocks are the future of time-based asynchrony, and there is little reason to use Combine schedulers anymore.

41:46

Next week we will be starting a whole new topic, and it’s a big one.

41:51

Until next time! References SE-0329: Clock, Instant, and Duration Philippe Hausler • Sep 29, 2021 The proposal that introduced the Clock protocol to the Swift standard library. https://github.com/apple/swift-evolution/blob/main/proposals/0329-clock-instant-duration.md Reliably testing code that adopts Swift Concurrency Brandon Williams and Stephen Celis • May 13, 2022 A Swift forum post in which we highlight a problem with testing Swift concurrency with the tools that ship today. https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 Downloads Sample code 0210-clocks-pt2 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .