• Stars
    star
    258
  • Rank 158,189 (Top 4 %)
  • Language
    Swift
  • License
    MIT License
  • Created over 2 years ago
  • Updated 3 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

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

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.

More Repositories

1

swift-composable-architecture

A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.
Swift
12,150
star
2

swift-snapshot-testing

πŸ“Έ Delightful Swift snapshot testing.
Swift
3,737
star
3

isowords

Open source game built in SwiftUI and the Composable Architecture.
Swift
2,667
star
4

swift-navigation

Bringing simple and powerful navigation tools to all Swift platforms, inspired by SwiftUI.
Swift
1,950
star
5

swift-dependencies

A dependency management library inspired by SwiftUI's "environment."
Swift
1,527
star
6

swift-tagged

🏷 A wrapper type for safer, expressive code.
Swift
1,364
star
7

swift-overture

🎼 A library for function composition.
Swift
1,139
star
8

pointfreeco

🎬 The source for www.pointfree.co, a video series on functional programming and the Swift programming language.
Swift
1,098
star
9

episode-code-samples

πŸ’Ύ Point-Free episode code.
Swift
950
star
10

swift-case-paths

🧰 Case paths extends the key path hierarchy to enum cases.
Swift
905
star
11

swift-parsing

A library for turning nebulous data into well-structured data, with a focus on composition, performance, generality, and ergonomics.
Swift
847
star
12

swift-nonempty

🎁 A compile-time guarantee that a collection contains a value.
Swift
839
star
13

swift-custom-dump

A collection of tools for debugging, diffing, and testing your application's data structures.
Swift
795
star
14

swift-html

πŸ—Ί A Swift DSL for type-safe, extensible, and transformable HTML documents.
Swift
760
star
15

combine-schedulers

⏰ A few schedulers that make working with Combine more testable and more versatile.
Swift
701
star
16

swift-perception

Observable tools, backported.
Swift
536
star
17

swift-identified-collections

A library of data structures for working with collections of identifiable elements in an ergonomic, performant way.
Swift
529
star
18

swift-web

πŸ•Έ A collection of Swift server-side frameworks for handling HTML, CSS, routing and middleware.
Swift
481
star
19

swift-prelude

🎢 A collection of types and functions that enhance the Swift language.
Swift
469
star
20

swift-validated

πŸ›‚ A result type that accumulates multiple errors.
Swift
392
star
21

swift-issue-reporting

Report issues in your application and library code as Xcode runtime warnings, breakpoints, assertions, and do so in a testable manner.
Swift
361
star
22

swift-url-routing

A bidirectional router with more type safety and less fuss.
Swift
347
star
23

swift-concurrency-extras

Useful, testable Swift concurrency.
Swift
315
star
24

swift-gen

🎱 Composable, transformable, controllable randomness.
Swift
266
star
25

swift-macro-testing

Magical testing tools for Swift macros.
Swift
263
star
26

syncups

A rebuild of Apple’s β€œScrumdinger” application using modern, best practices for SwiftUI development.
Swift
205
star
27

swift-enum-properties

🀝 Struct and enum data access in harmony.
Swift
200
star
28

composable-core-location

A library that bridges the Composable Architecture and Core Location.
Swift
108
star
29

vapor-routing

A bidirectional Vapor router with more type safety and less fuss.
Swift
89
star
30

swift-html-vapor

πŸ’§ Vapor plugin for type-safe, transformable HTML views.
Swift
84
star
31

swift-playground-templates

🏫 A collection of helpful Xcode playground templates.
Makefile
81
star
32

pointfreeco-server

Point-Free server code.
40
star
33

TrySyncUps

The starting project for our try! Swift 2024 Composable Architecture workshop.
Swift
38
star
34

composable-core-motion

A library that bridges the Composable Architecture and Core Motion.
Swift
29
star
35

swift-boundaries

🐣 Functional core, imperative shell.
Swift
28
star
36

swift-quickcheck

🏁 An implementation of QuickCheck in Swift.
Swift
25
star
37

swift-either

For those times you want A or B!
Swift
21
star
38

swift-algebras

Algebraic laws bundled into concrete data types.
20
star
39

swift-parser-printer

↔️ Parsing and printing
Swift
15
star
40

swift-html-kitura

☁️ Kitura plugin for type-safe, transformable HTML views.
Swift
14
star
41

swiftui-navigation

This package is now Swift Navigation:
Swift
13
star
42

homebrew-swift

Ruby
3
star
43

swift-bugs

3
star
44

Ccmark

Swift
2
star
45

.github

1
star