How do you test code that depends on time: debounce, timeouts, retries and expiry?

22.3.2 · L5 · Interview

Predict first

A debouncer waits one second after the last keystroke. Three keystrokes arrive, then a manual clock advances one second. What does the debouncer report before and after?

import Foundation

final class ManualClock: Clock, @unchecked Sendable {
    struct Instant: InstantProtocol, Comparable {
        var offset: Duration
        func advanced(by d: Duration) -> Instant { .init(offset: offset + d) }
        func duration(to o: Instant) -> Duration { o.offset - offset }
        static func < (a: Instant, b: Instant) -> Bool { a.offset < b.offset }
    }
    var now = Instant(offset: .zero)
    let minimumResolution = Duration.zero
    private var waiters: [(Instant, CheckedContinuation<Void, Never>)] = []
    private let lock = NSLock()

    func sleep(until deadline: Instant, tolerance: Duration?) async throws {
        if deadline <= now { return }
        await withCheckedContinuation { c in lock.lock(); waiters.append((deadline, c)); lock.unlock() }
    }
    func advance(by d: Duration) {
        lock.lock(); now = now.advanced(by: d)
        let due = waiters.filter { $0.0 <= now }
        waiters.removeAll { $0.0 <= now }
        lock.unlock()
        due.forEach { $0.1.resume() }
    }
}

actor Debouncer<C: Clock<Duration>> {
    let clock: C
    var fired: [String] = []
    var task: Task<Void, Never>?
    init(clock: C) { self.clock = clock }
    func send(_ value: String) {
        task?.cancel()
        task = Task { [clock] in
            try? await clock.sleep(for: .seconds(1))
            if !Task.isCancelled { self.fired.append(value) }
        }
    }
    func result() -> [String] { fired }
}

let clock = ManualClock()
let debouncer = Debouncer(clock: clock)
await debouncer.send("a"); await debouncer.send("ab"); await debouncer.send("abc")
try await Task.sleep(nanoseconds: 50_000_000)
print("before", await debouncer.result())
clock.advance(by: .seconds(1))
try await Task.sleep(nanoseconds: 50_000_000)
print("after", await debouncer.result())
  • before [], then after ["abc"]
  • before ["a"], then after ["a", "ab", "abc"]
  • before ["abc"], then after ["abc"]

Mental model. A film director controlling the clock on the set: the actors hold their pose until the director says “advance one hour”, so a day can be shot in seconds and nothing is left to chance.

Code that waits on real time is slow to test and flaky, because a one-second debounce costs a second and a 10 ms margin is sometimes missed. The fix is dependency injection of time. Swift’s Clock protocol makes this natural: write the code against a generic Clock (or an injected value) and call clock.sleep(for:) instead of Task.sleep; in production pass ContinuousClock() and in tests pass a manual clock whose time only moves when the test says advance(by:). A manual clock resumes every pending sleep whose deadline has passed, so a test that types three characters and then advances one second sees exactly one debounced result, in microseconds and with no race, as in the check below. Open-source packages supply ready-made test clocks (for example Point-Free’s swift-clocks with a TestClock and an ImmediateClock), or you can write the small one shown.

The same idea covers other sources of “now”. Inject a Date provider (() -> Date) for expiry and scheduling logic, a scheduler for Combine (Q313), and a retry policy that takes its delays from a clock so backoff tests do not wait. Test the edges: just before and just after a deadline, time moving backwards, daylight-saving jumps (use fixed calendars and time zones). Keep the production clock out of the logic: only the composition root knows whether time is real. For the few tests that must use real time (an integration smoke test), keep the delays tiny and the assertions loose, and run them in a separate tier.

Each new keystroke cancels the previous wait, and nothing fires until the controlled clock passes the deadline, then only the last value does.

Common traps

  • Calling Task.sleep or Date() directly in logic. The test can only wait for real time or hope.
  • Asserting elapsed time in a test. Machines are noisy; assert on states reached after advancing a controlled clock.

They might follow up with

  • What does a manual clock do on advance? It moves its now forward and resumes every sleeper whose deadline has been reached.
  • Why make the code generic over Clock? So production uses the real clock and tests substitute one they control.

Related questions

  • 22.3.1 How do you test async code: async test functions, expectations, confirmations, actors and the main actor?
  • 22.4.2 What makes a test non-deterministic, and how do you control time zones, locales, randomness, UUIDs and the…
  • 11.6.2 How do you test Combine pipelines, and what is a TestScheduler pattern?

← How do you test async code: async test functions, expectations…