Controllable clock support in `swift-testing`

Since @MahdiBM posted about something similar recently, I felt the urge to explain why this hasn't made any progress.

It was pointed out to me, and I later came to the same conclusion, that a controllable clock would not be very helpful with the current state of Concurrency. I don't want to go into much detail, because there is already quite a lengthy discussion about something similar here: Reliably testing code that adopts Swift Concurrency?.

Essentially, even seemingly very simple code can't reliably be tested:

@MainActor class Model {
  
  var counter = 0
  let clock: any Clock<Duration>
  
  init(clock: any Clock<Duration>) {
    self.clock = clock
  }
  
  func doSomething() async throws {
    for _ in 0..<3 {
      try await clock.sleep(for: .seconds(1))
      counter += 1
    }
  }
}

Assuming this is the test for this function:

@Test @MainActor func test() async {
  let clock = TestClock<Duration>()
  let model = Model(clock: clock)
  let task = Task {
    await model.doSomething()
  }
  assert(model.counter == 0)
  clock.advance(by: .seconds(1))
  assert(model.counter == 1)
  clock.advance(by: .seconds(1))
  assert(model.counter == 2)
  clock.advance(by: .seconds(1))
  assert(model.counter == 3)
  await task.value
}

(advance(by:) is a function that will advance the test clock by the given duration, emulating time ticking)

There are multiple problems about this:
You don't know when the code inside the Task will be scheduled. By the time you call .advance the first time, you need the sleep inside the model to already have calculated its deadline. If advance is called before sleep, the clock ticks and sleep will calculate its deadline with the already ticked clock.

You can somewhat mitigate this with the new Task.immediate.

However, this brings me to another problem. Clocks functions sleep(for:) and sleep(until:) are not nonisolated(nonsending), which means there is a suspension point at sleep(for:), which means Task.immediate will end its immediacy right before the deadline would be calculated, coming to the same result as before.

Pointfree's version of this clock works around these problems, by using their megaYield function. But even if you could work around this, it still feels like there is a missing piece in this puzzle. Something that would make it easier to control how things are scheduled. Until then, I can't seem to figure out how to make use of a TestClock reliably. Maybe I am also missing something, but I could not find a way to make this work, yet.

3 Likes