swift-clocks

1.0.2

⏰ A few clocks that make working with Swift concurrency more testable and more versatile.
pointfreeco/swift-clocks

What's New

1.0.2

2023-12-07T22:26:31Z

What's Changed

  • Fixed: 1.0.1 introduced a regression in test clock behavior, in which clocks would not suspend when sleeping for zero seconds as they should (#26).

Full Changelog: 1.0.1...1.0.2

swift-clocks

CI

⏰ A few clocks that make working with Swift concurrency more testable and more versatile.

Learn More

This library was designed in episodes on Point-Free, a video series exploring the Swift programming language hosted by Brandon Williams and Stephen Celis.

You can watch all of the episodes here.

video poster image

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.

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

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 be started and stopped, and with each tick of the timer a count value was 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() {
    self.timerTask = Task {
      while true {
        try await self.clock.sleep(for: .seconds(1))
        self.count += 1
      }
    }
  }
  func stopTimerButtonTapped() {
    self.timerTask?.cancel()
    self.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 = self.message {
        Text(self.message)
      }
    }
    .task {
      do {
        try await Task.sleep(for: .seconds(5))
        self.message = "Welcome!"
      } catch {}
    }
  }
}

This is currently using a real life clock by calling out to Task.sleep, which means every change you make to the styling and behavior of this feature you must 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 = self.message {
        Text(self.message)
      }
    }
    .task {
      do {
        try await self.clock.sleep(for: .seconds(5))
        self.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 = self.message {
        Text(self.message)
      }
    }
    .task {
      do {
        try await self.clock.sleep(for: .seconds(5))
        self.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: some Clock<Duration>) {
    self.clock = clock
  }
  func incrementButtonTapped() {
    self.count += 1
  }
  func decrementButtonTapped() {
    self.count -= 1
  }
  func startTimerButtonTapped() {
    self.timerTask = Task {
      for await _ in self.clock.timer(interval: .seconds(1)) {
        self.count += 1
      }
    }
  }
  func stopTimerButtonTapped() {
    self.timerTask?.cancel()
    self.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)
  self.model.incrementButtonTapped()
  XCTAssertEqual(model.count, 1)
  self.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

All clocks now come with a method 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() {
    self.timerTask = Task {
      for await _ in self.clock.timer(interval: .seconds(1)) {
        self.count += 1
      }
    }
  }
  func stopTimerButtonTapped() {
    self.timerTask?.cancel()
    self.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()
}

AnyClock

A concrete version of any Clock.

This type makes it possible to pass clock existentials to APIs that would otherwise prohibit it.

For example, the Async Algorithms package provides a number of APIs that take clocks, but due to limitations in Swift, they cannot take a clock existential of the form any Clock:

class Model: ObservableObject {
  let clock: any Clock<Duration>
  init(clock: some Clock<Duration>) {
    self.clock = clock
  }

  func task() async {
    // 🛑 Type 'any Clock<Duration>' cannot conform to 'Clock'
    for await _ in stream.debounce(for: .seconds(1), clock: self.clock) {
      // ...
    }
  }
}

By using a concrete AnyClock, instead, we can work around this limitation:

// ✅
for await _ in stream.debounce(for: .seconds(1), clock: AnyClock(self.clock)) {
  // ...
}

Documentation

The latest documentation for this library is available here.

License

This library is released under the MIT license. See LICENSE for details.

Description

  • Swift Tools 5.6.0
View More Packages from this Author

Dependencies

Last updated: Thu Dec 19 2024 09:09:44 GMT-1000 (Hawaii-Aleutian Standard Time)