Test time-dependent code (a debounce) without Thread.sleep by injecting a clock
Debouncer.run(_:) schedules its action after a fixed delay with DispatchQueue.main.asyncAfter, so a test must wait real time to observe it. Refactor it to accept an injected scheduler (or clock) so a test can advance time deterministically without Thread.sleep.
Requirements:
- Inject the timing dependency; default to the real scheduler in production.
- A test must advance virtual time, not wait real wall-clock seconds.
final class Debouncer {
private let delay: TimeInterval
init(delay: TimeInterval) { self.delay = delay }
func run(_ action: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { action() }
}
}
Write the implementation.
Make time a dependency, not a global call. Inject an abstraction — a Scheduler protocol or Swift's Clock — and schedule through it, defaulting to the real one in production. The test passes a virtual scheduler and advances it, firing the action without Thread.sleep.
- ✗Waiting real wall-clock time with
Thread.sleepinstead of virtual time - ✗Leaning on an expectation timeout to paper over nondeterministic timing
- ✗Calling
DispatchQueuetimers directly instead of through an injected scheduler
- →How does a virtual scheduler advance time without actually waiting?
- →Why does zeroing the delay change the behaviour you meant to test?
Inject an abstraction over time; production gets the real scheduler, the test gets a virtual one.
final class Debouncer<S: Scheduler> {
private let delay: S.SchedulerTimeType.Stride
private let scheduler: S
private var token: AnyCancellable?
init(delay: S.SchedulerTimeType.Stride,
scheduler: S = DispatchQueue.main) {
self.delay = delay
self.scheduler = scheduler
}
func run(_ action: @escaping () -> Void) {
token = Just(())
.delay(for: delay, scheduler: scheduler)
.sink { _ in action() }
}
}
// Test — advance a virtual scheduler instead of sleeping:
func testDebounceFiresAfterDelay() {
let scheduler = DispatchQueue.test // e.g. combine-schedulers' TestScheduler
let debouncer = Debouncer(delay: .seconds(1), scheduler: scheduler)
var fired = 0
debouncer.run { fired += 1 }
scheduler.advance(by: .milliseconds(999))
XCTAssertEqual(fired, 0)
scheduler.advance(by: .milliseconds(1))
XCTAssertEqual(fired, 1)
}
Because the debouncer schedules through the injected Scheduler, production still uses DispatchQueue.main, while the test drives a TestScheduler and advance(by:) moves virtual time forward instantly. No Thread.sleep, no timeout — the test is fully deterministic. Swift's Clock protocol supports the same pattern for async code.