Open Sourcing swift-clocks

Monday October 24, 2022

Over the past couple weeks we explored Swift 5.7’s new Clock protocol, compared it to Combine’s Scheduler protocol, and defined a whole bunch of useful conformances.

We believe these tools are highly applicable to everyone working with time-based asynchrony in Swift, and so today we are excited to release them in a new open source library.

Motivation

The Clock protocol in Swift provides a powerful abstraction for time-based asynchrony in Swift’s structured concurrency. With just a single sleep method you can express many powerful async operators, such as timers, debounce, throttle, timeout and more (see swift-async-algorithms).

However, the moment you use a concrete clock in your asynchronous code, or use Task.sleep directly, you instantly lose the ability to easily test and preview your features, forcing you to wait for real world time to pass to see how your feature works.

Tools

This library provides new Clock conformances that allow you to turn any time-based asynchronous code into something that is easier to test and debug:

TestClock

This is a clock whose time can be controlled in a deterministic manner.

This clock is useful for testing how the flow of time affects asynchronous and concurrent code. This includes any code that makes use of sleep or any time-based async operators, such as debounce, throttle, timeout, and more.

For example, suppose you have a model that encapsulates the behavior of a timer that can be started and stopped, and with each tick of the timer a count value is incremented:

@MainActor
class FeatureModel: ObservableObject {
  @Published var count = 0
  let clock: any Clock<Duration>
  var timerTask: Task<Void, Error>?

  init(clock: any Clock<Duration>) {
    self.clock = clock
  }
  func startTimerButtonTapped() {
    timerTask = Task {
      while true {
        try await clock.sleep(for: .seconds(1))
        count += 1
      }
    }
  }
  func stopTimerButtonTapped() {
    timerTask?.cancel()
    timerTask = nil
  }
}

Note that we have explicitly forced a clock to be provided in order to construct the FeatureModel. This makes it possible to use a real life clock, such as ContinuousClock, when running on a device or simulator, and use a more controllable clock in tests, such as the TestClock.

To write a test for this feature we can construct a FeatureModel with a TestClock, then advance the clock forward and assert on how the model changes:

func testTimer() async {
  let clock = TestClock()
  let model = FeatureModel(clock: clock)

  XCTAssertEqual(model.count, 0)
  model.startTimerButtonTapped()

  // Advance the clock 1 second and prove that the model's
  // count incremented by one.
  await clock.advance(by: .seconds(1))
  XCTAssertEqual(model.count, 1)

  // Advance the clock 4 seconds and prove that the model's
  // count incremented by 4.
  await clock.advance(by: .seconds(4))
  XCTAssertEqual(model.count, 5)

  // Stop the timer, run the clock until there is no more
  // suspensions, and prove that the count did not increment.
  model.stopTimerButtonTapped()
  await clock.run()
  XCTAssertEqual(model.count, 5)
}

This test is easy to write, passes deterministically, and takes a fraction of a second to run. If you were to use a concrete clock in your feature, such a test would be difficult to write. You would have to wait for real time to pass, slowing down your test suite, and you would have to take extra care to allow for the inherent imprecision in time-based asynchrony so that you do not have flakey tests.

ImmediateClock

A clock that does not suspend when sleeping.

This clock is useful for squashing all of time down to a single instant, forcing any sleeps to execute immediately. For example, suppose you have a feature that needs to wait 5 seconds before performing some action, like showing a welcome message:

struct Feature: View {
  @State var message: String?

  var body: some View {
    VStack {
      if let message {
        Text(message)
      }
    }
    .task {
      do {
        try await Task.sleep(for: .seconds(5))
        message = "Welcome!"
      } catch {}
    }
  }
}

This is currently using a real life clock by calling out to Task.sleep, which means that while iterating on the styling and behavior of this feature in an Xcode preview you will have to wait for 5 real life seconds to pass before you see the effect. This will severely hurt you ability to quickly iterate on the feature in an Xcode preview.

The fix is to have your view hold onto a clock so that it can be controlled from the outside:

struct Feature: View {
  @State var message: String?
  let clock: any Clock<Duration>

  var body: some View {
    VStack {
      if let message {
        Text(message)
      }
    }
    .task {
      do {
        try await clock.sleep(for: .seconds(5))
        message = "Welcome!"
      } catch {}
    }
  }
}

Then you can construct this view with a ContinuousClock when running on a device or simulator, and use an ImmediateClock when running in an Xcode preview:

struct Feature_Previews: PreviewProvider {
  static var previews: some View {
    Feature(clock: ImmediateClock())
  }
}

Now the welcome message will be displayed immediately with every change made to the view. No need to wait for 5 real world seconds to pass.

You can also propagate a clock to a SwiftUI view via the continuousClock and suspendingClock environment values that ship with the library:

struct Feature: View {
  @State var message: String?
  @Environment(\.continuousClock) var clock

  var body: some View {
    VStack {
      if let message = message {
        Text(message)
      }
    }
    .task {
      do {
        try await clock.sleep(for: .seconds(5))
        message = "Welcome!"
      } catch {}
    }
  }
}

struct Feature_Previews: PreviewProvider {
  static var previews: some View {
    Feature()
      .environment(\.continuousClock, ImmediateClock())
  }
}
UnimplementedClock

A clock that causes an XCTest failure when any of its endpoints are invoked.

This clock is useful when a clock dependency must be provided to test a feature, but you don’t actually expect the clock to be used in the particular execution flow you are exercising.

For example, consider the following model that encapsulates the behavior of being able to increment and decrement a count, as well as starting and stopping a timer that increments the counter every second:

@MainActor
class FeatureModel: ObservableObject {
  @Published var count = 0
  let clock: any Clock<Duration>
  var timerTask: Task<Void, Error>?

  init(clock: any Clock<Duration>) {
    self.clock = clock
  }
  func incrementButtonTapped() {
    count += 1
  }
  func decrementButtonTapped() {
    count -= 1
  }
  func startTimerButtonTapped() {
    timerTask = Task {
      for await _ in clock.timer(interval: .seconds(1)) {
        count += 1
      }
    }
  }
  func stopTimerButtonTapped() {
    timerTask?.cancel()
    timerTask = nil
  }
}

If we test the flow of the user incrementing and decrementing the count, there is no need for the clock. We don’t expect any time-based asynchrony to occur. To make this clear, we can use an UnimplementedClock:

func testIncrementDecrement() {
  let model = FeatureModel(clock: UnimplementedClock())

  XCTAssertEqual(model.count, 0)
  model.incrementButtonTapped()
  XCTAssertEqual(model.count, 1)
  model.decrementButtonTapped()
  XCTAssertEqual(model.count, 0)
}

If this test passes it definitively proves that the clock is not used at all in the user flow being tested, making this test stronger. If in the future the increment and decrement endpoints start making use of time-based asynchrony using the clock, we will be instantly notified by test failures. This will help us find the tests that should be updated to assert on the new behavior in the feature.

Timers

The library ships with a method defined on all clocks that allows you to create an AsyncSequence-based timer on an interval specified by a duration. This allows you to handle timers with simple for await syntax, such as this observable object that exposes the ability to start and stop a timer for incrementing a value every second:

@MainActor
class FeatureModel: ObservableObject {
  @Published var count = 0
  let clock: any Clock<Duration>
  var timerTask: Task<Void, Error>?

  init(clock: any Clock<Duration>) {
    self.clock = clock
  }
  func startTimerButtonTapped() {
    timerTask = Task {
      for await _ in clock.timer(interval: .seconds(1)) {
        count += 1
      }
    }
  }
  func stopTimerButtonTapped() {
    timerTask?.cancel()
    timerTask = nil
  }
}

This feature can also be easily tested by making use of the TestClock discussed above:

func testTimer() async {
  let clock = TestClock()
  let model = FeatureModel(clock: clock)

  XCTAssertEqual(model.count, 0)
  model.startTimerButtonTapped()

  await clock.advance(by: .seconds(1))
  XCTAssertEqual(model.count, 1)

  await clock.advance(by: .seconds(4))
  XCTAssertEqual(model.count, 5)

  model.stopTimerButtonTapped()
  await clock.run()
}

Try it today

swift-clocks 0.1.0 is available today! Give it a spin to introduce controllable, time-based asynchrony to your Swift applications. And if you’re a user of the Composable Architecture, try out the latest release to bring these tools to your reducers!

Get started with our free plan

Our free plan includes 1 subscriber-only episode of your choice, access to 62 free episodes with transcripts and code samples, and weekly updates from our newsletter.

View plans and pricing