What design seams does a codebase need so that time-dependent and asynchronous behaviour can be verified without sleeps, real timers, or background thread pools in the test run?
answer
- clock injected, never called directly
- executor injected, never self-spawned threads
- fire-and-forget → give it a handle
- same-thread executor = deterministic but no real concurrency
- no ambient globals; seeded randomness
basics
~20 sMake time and execution injected dependencies: pass in a clock and an executor/scheduler rather than calling the system clock or spawning threads inline, and give async operations an observable completion handle. Tests then substitute a fake clock and a same-thread or manually pumped scheduler.
solid answer
~50 sThree seams cover most cases. **Time.** Nothing reads the system clock or sleeps directly; a clock abstraction is constructor-injected, and delays are scheduled against it so a fake can fire them. **Execution.** Components do not create their own threads or reach for a global default pool. They accept an executor/scheduler; the test injects a same-thread executor (tasks run inline, so async becomes synchronous) or a manually pumped one for ordering control. **Observability of completion.** Fire-and-forget methods are untestable by construction. Return a future, invoke a completion callback, or expose an idle/quiescence query so the test can wait on a fact. Supporting rules: keep the concurrency at the edges and the logic in pure functions that need no seam at all; avoid ambient globals and singletons, which make tests order-dependent and block parallel execution; do not use these seams to change production semantics, only to substitute implementations of the same contract.
code
text · 21 linesHARD-WIRED (untestable without sleeping):
class OrderExpirer:
fun start():
spawnThread {
loop:
if systemClock.now() > order.deadline: expire(order)
sleep(1s)
}
SEAMED:
class OrderExpirer(clock, scheduler):
fun start():
scheduler.schedulePeriodic(every = 1s) {
if clock.now() > order.deadline: expire(order)
}
TEST:
scheduler = ManualScheduler(); clock = FakeClock()
expirer = OrderExpirer(clock, scheduler); expirer.start()
clock.advanceBy(order.ttl + 1tick); scheduler.runDueTasks()
assert order.state == EXPIRED // no threads, no waitinggo deeper
Name the two main seams — inject the clock, inject the executor — and say tests then use a fake clock and an inline executor so no waiting is needed.
Add the completion-observability seam, explain why globals and self-spawned threads block substitution, and state that inline execution removes real concurrency.
Argue for pushing concurrency to the edges with pure logic in the middle, total seams enforced by lint, seeded randomness, and a deliberate second layer of genuinely concurrent tests.
Frame time, execution and randomness as injected platform capabilities across services, which buys deterministic tests, simulation and replay; discuss the governance cost of keeping the seams total.
## The problem Code that calls the system clock, sleeps, or spawns its own threads has hard-wired two of the least controllable things in a program. A test can then only observe it by waiting in real time and hoping. "Seams" are the points where a test can substitute a different implementation without changing behaviour — and for async code there are three that matter. ## Seam 1: time Replace every direct read of the system clock and every raw sleep with calls on an injected time abstraction offering `now()` and `scheduleAfter(delay, task)`. Production supplies a system-backed implementation; tests supply a fake whose time only moves on command. This makes timeouts, backoff, TTLs, rate limits and scheduled work assertable in microseconds and at exact boundaries. The seam must be total — a single hidden clock read anywhere on the path makes the test nondeterministic again, which is why teams often lint for direct clock/sleep usage outside one adapter. ## Seam 2: execution A component that constructs its own thread or submits to a global default pool decides its own concurrency, and the test cannot intervene. Accept an executor/scheduler as a dependency instead. Tests can then inject: - a **same-thread (direct) executor** — `submit(task)` runs the task inline, so the whole flow becomes synchronous and assertions after the call see the finished state. This is the cheapest way to make an async unit test deterministic, at the cost of not exercising real concurrency at all; - a **manually pumped scheduler** — tasks are queued, and the test runs them one at a time, choosing the order, which lets you reproduce a specific interleaving; - a **fake with quiescence support** — the test submits and then drains until idle. The same argument applies to anything else that starts work in the background: schedulers, message consumers, retry loops. ## Seam 3: observable completion Even with injected time and executors, a method that returns nothing and finishes later gives a test nothing to wait on. Provide at least one of: a future/promise, a completion callback, a state transition the test can observe, or a `awaitIdle()`-style quiescence query on the component that owns the work. This also improves production code, since the caller usually needs to know about failures too — silent fire-and-forget work is where swallowed exceptions hide. ## Supporting design rules - **Push concurrency to the edges.** The more logic lives in pure functions over plain data, the less needs any seam at all. Decide *what* to do in testable pure code; do the scheduling in a thin shell. - **No ambient globals.** A static clock or a global default pool is shared mutable state across tests: they become order-dependent and cannot run in parallel. Constructor injection scopes the substitution to one test. - **Same contract, not a special test mode.** The fake implements the production interface. A production `if (testMode)` branch means you are no longer testing production behaviour, and it is a footgun in a live system. - **Deterministic randomness too.** Jitter, shuffling and retry randomisation should draw from an injected, seedable source, otherwise the schedule is still nondeterministic. - **Bounded resources visible.** Injecting the pool also lets a test assert on pool behaviour — rejection when full, ordering, and that work was actually submitted. ## The tradeoff to state out loud Substituting a same-thread executor makes tests deterministic by *deleting the concurrency*. Such tests verify logic and orchestration, not thread-safety: they will never find a data race, a lock-ordering deadlock or a visibility bug. So the pyramid needs both layers — fast deterministic tests for logic, plus a small number of genuinely concurrent stress or model-checked tests for the memory-and-locking questions. Claiming the deterministic suite proves thread safety is the classic overreach.
- If tests inject a same-thread executor everywhere, what is no longer being tested?All of the genuinely concurrent behaviour: data races on shared state, visibility of writes between threads, lock-ordering deadlocks, and anything that depends on two operations overlapping. Running tasks inline also changes ordering — work that would have been deferred now completes before the caller returns, which can mask re-entrancy bugs or produce different results. Keep a smaller layer of real-concurrency tests for those properties and treat the deterministic suite as a logic and orchestration check.
- Why is a static, globally mutable fake clock a bad idea even though it is convenient?It is shared state across the whole test process, so one test advancing it affects others, results depend on execution order, and tests cannot safely run in parallel. It also leaks into production code as an ambient dependency, hiding the fact that a component depends on time at all. Constructor injection makes the dependency explicit and scopes the substitution to a single instance.
saying these in an interview costs you the question
- Components that construct their own threads or use a global default pool, then are declared 'testable with sleeps'.
- Adding an `if (isTest)` branch in production code instead of substituting an implementation of the same interface.
- Fire-and-forget methods with no future, callback or idle query, defended as 'the caller does not care'.
- Believing a suite that runs everything on a same-thread executor demonstrates thread safety.
- Leaving jitter and random backoff on an unseeded global random source, keeping the schedule nondeterministic.