How do you test code that finishes later or depends on the current time, without putting a sleep in the test?
Make time and scheduling injectable. Take a java.time.Clock as a dependency so a test can fix it, and replace a fixed sleep with a bounded poll on the observable outcome, so a fast machine does not wait and a slow one does not fail.
What the interviewer is scoring
- Does the candidate make the clock a dependency instead of intercepting a static call
- Whether waiting is expressed as a bounded poll on the outcome rather than as a fixed delay
- That the assertion targets observable state rather than a log line or an intermediate interaction
- Can they say what running the asynchronous path synchronously stops the test from covering
- Whether a flaky test is treated as a defect report rather than as something to retry
Answer
Why a sleep is both too slow and too short
A Thread.sleep in a test encodes a guess about how long the work takes on the machine running it. The guess is simultaneously too large on a developer laptop, where it pads every run with idle seconds that multiply across a suite, and too small on a loaded CI agent, where the work occasionally takes longer and the test fails for reasons unrelated to the code. Both directions cost you: the first slows the feedback loop, the second erodes trust in the suite, and doubling the sleep makes the first problem worse without fixing the second.
The deeper objection is that a sleep tests nothing about timing anyway. It asserts that the outcome had appeared by an arbitrary deadline, which is neither the requirement nor a property anybody stated. What you want to assert is that the outcome appears, and separately that it appears within a bound you are willing to state.
Make the clock a dependency
Time-dependent logic becomes ordinary logic as soon as the current instant arrives from outside. java.time.Clock exists for this: it is an abstraction over "now", with Clock.systemUTC for production and Clock.fixed and Clock.offset for tests, and every now factory in java.time accepts one.
class SubscriptionService {
private final Clock clock; // injected, systemUTC in production
boolean isExpired(Subscription s) {
// Never call Instant.now with no argument in code you want to test.
return s.expiresAt().isBefore(Instant.now(clock));
}
}
// The test states the instant it cares about, with no waiting anywhere.
var atRenewal = Clock.fixed(Instant.parse("2026-03-01T00:00:00Z"), ZoneOffset.UTC);
The alternative people reach for is a mocking library that intercepts the static Instant.now, and it is worth being able to say why that is second best. It makes the test pass while leaving the design untestable by any other means, it is process-global so it interacts badly with parallel test execution, and it hides the dependency instead of declaring it.
Two related traps follow from the same rule. A test that computes its expected value from Instant.now() and compares it with a value the code computed from Instant.now() fails whenever the two calls straddle a boundary. And a test that fixes the clock but leaves the time zone implicit passes in one office and fails in another, so name the zone every time.
Wait for the outcome, not for a duration
Where the work genuinely happens on another thread, replace the sleep with a bounded poll: repeatedly check the condition at a short interval and fail if it has not become true within a stated timeout. That is fast when the work is fast, tolerant when the machine is slow, and it makes the deadline explicit as a documented bound rather than a magic number in the middle of the test. Awaitility is the usual library for expressing this on the JVM, and hand-rolling the loop is a dozen lines if you would rather not add a dependency.
The condition matters as much as the waiting. Poll for the outcome the caller cares about — the row is present, the message is on the queue, the balance changed — rather than for an intermediate signal such as a log line or a mock having been called. Intermediate signals can appear before the work is durable, giving you a test that passes and a system that does not work.
Where you own the callback, a CountDownLatch is better than polling: the test blocks until the code counts it down, so it finishes at the instant the work does with no interval to tune. It needs a hook, though, so it fits code you can pass a listener to rather than code whose effect you can only observe from outside.
Take the asynchrony out where the asynchrony is not the point
Most tests of a component that happens to dispatch work asynchronously are not testing the dispatch. For those, the simplest determinism is to substitute an executor that runs the task on the calling thread, so the whole path becomes synchronous and the assertion follows the call with nothing to wait for. Spring ships a synchronous task executor for exactly this substitution, and it takes one line of test configuration.
Be explicit with yourself about what that stops covering, because this is where the trade-off lives. Running on the caller's thread means the test can no longer detect that the task depended on something thread-confined, such as a security context or a request-scoped bean that will not be present on a pool thread in production. So keep one or two tests that exercise the real executor and poll for the outcome, and let the rest run synchronously. That mix is much faster than making every test wait, and it still covers the case the synchronous version cannot see.
Treat a flake as a defect
A test that passes on retry has told you something true: two orderings exist, and you have observed both. Sometimes the test is at fault, and sometimes the code has a race that will surface in production at a less convenient moment, so the first job is deciding which. @RepeatedTest in JUnit 5 run under load is a cheap way to make an intermittent failure frequent enough to diagnose, and JUnit's @Timeout is worth applying broadly so that a hung test fails with a report instead of stalling the build until the runner is killed.
What is not worth doing is adding a global retry rule to the build. It converts a signal you could have acted on into a slightly slower green build, and the race it reported is still there. Quarantining one flaky test with a linked ticket is defensible; retrying everything by default is how a suite stops meaning anything.
Inject the clock and poll for the outcome. A test that sleeps is asserting a deadline nobody specified, and it is the one test in the suite that gets slower and less reliable at the same time.
Likely follow-ups
- What would you assert to prove a scheduled job behaves correctly at midnight in another time zone?
- Where does a latch beat a polling wait, and where is it worse?
- If a test fails only in CI, how do you narrow that down?
- How do you test a retry with exponential backoff without waiting for the backoff?
Related questions
- A test passes on its own and fails when the whole suite runs. How do you track that down?mediumAlso on testing and flaky-tests5 min
- How do you test a service that calls a third-party API, without calling it?mediumAlso on testing4 min
- Where do you draw the line between a unit test and an integration test, and how do you keep a few thousand of them under ten minutes?hardAlso on junit5 min
- How do you test a Node service, and what do you refuse to mock?mediumAlso on testing5 min
- Go gives you no project layout and no dependency-injection container. How do you structure a service so it stays testable as it grows?mediumAlso on testing5 min
- A test passes on your machine and fails in CI roughly once a week. How do you track it down?mediumAlso on flaky-tests5 min
- Your test suite fails randomly about one run in five. How do you get it under control?hardAlso on flaky-tests7 min
- A list of a million small objects uses far more memory than the data inside them. Where is it going?mediumSame kind of round: concept5 min