skip to content

How would you use Mockito to test that a retry policy re-invokes a flaky collaborator — failing twice, then succeeding — and what should the test assert beyond the final result?

level: seniorimportance: should knowfreq 42%

answer

  1. throw, throw, return = transient failure
  2. single throw = permanent (tail repeats)
  3. verify times(n) or the count is unasserted
  4. inject sleeper/clock, never Thread.sleep
  5. also test the non-retryable path

basics

~20 s

Stub a sequence: two failures then success (doThrow().doThrow().doNothing() for void, or thenThrow().thenThrow().thenReturn() for a value). Assert the successful outcome AND verify(collaborator, times(3)) — because the last stubbed answer repeats forever, extra retries would otherwise go unnoticed.

solid answer

~40 s

Model the failure sequence in one stubbing and pin the call count explicitly: ```java when(client.fetch(ID)) .thenThrow(new TimeoutException()) .thenThrow(new TimeoutException()) .thenReturn(payload); Result r = service.load(ID); assertEquals(payload, r.body()); verify(client, times(3)).fetch(ID); verifyNoMoreInteractions(client); ``` The verification is the load-bearing half. Consecutive stubbing sticks on its last answer forever, so a component that retries three times and one that retries fifty both return `payload` and both go green without `times(3)`. I'd also cover the exhaustion case in a second test — stub failures all the way (a single `thenThrow` is enough, since the last answer repeats) and assert the policy gives up with the expected exception after exactly N attempts. Backoff delays should not be tested with real sleeps: inject a clock or sleeper collaborator and verify the requested waits, so the test stays fast and deterministic.

code

java · 12 lines
java
@Test
void retriesTwiceThenSucceeds() throws Exception {
    when(client.fetch(ID))
        .thenThrow(new TimeoutException())
        .thenThrow(new TimeoutException())
        .thenReturn(payload);

    assertEquals(payload, service.load(ID).body());

    verify(client, times(3)).fetch(ID);
    verifyNoMoreInteractions(client);
}

go deeper

for a junior

Show the throw-throw-return sequence and remember to verify the number of invocations.

for a middle

Explain why the exact count matters given last-answer persistence, and use a single throw for the exhaustion test.

for a senior

Cover injected clocks/sleepers for backoff, argument capture to prove the retry resent the same idempotency key, and the non-retryable classification test.

for a principal

Set the boundary: unit tests pin decision logic; connection pools, circuit breakers and interruption semantics need an integration test against a controllable fake server, and retry budgets need to be reasoned about as a system-wide amplification risk.

## The shape of the test A retry policy has three observable behaviours: it eventually succeeds when the failure is transient, it gives up after a bounded number of attempts when the failure is persistent, and it does not retry failures it should not retry. Consecutive stubbing is the natural way to express the first, and the same mechanism plus its last-answer persistence expresses the second almost for free. **Transient failure, value-returning collaborator:** ```java when(client.fetch(ID)) .thenThrow(new TimeoutException()) .thenThrow(new TimeoutException()) .thenReturn(payload); ``` **Transient failure, void collaborator:** ```java doThrow(new IOException()).doThrow(new IOException()).doNothing() .when(mailer).send(msg); ``` **Persistent failure:** a single throw entry is enough. Because the final answer repeats indefinitely, `thenThrow(new TimeoutException())` alone fails every attempt, however many the policy makes. ## Why the verification is the real assertion The stub is permissive by design. Once the queue is exhausted, Mockito serves the last answer forever and never complains that the method was called more often than the sequence covers. So `assertEquals(payload, result)` is satisfied by a correct 3-attempt policy, by a buggy 30-attempt policy, and by a policy with no upper bound at all. The interaction assertion is what turns a permissive stub into a real specification: ```java verify(client, times(3)).fetch(ID); ``` Use the exact count, not `atLeastOnce()`. The retry budget is a policy decision with production consequences — retry storms amplify an outage — so the test should fail when someone changes it silently. For the give-up test, `verify(client, times(maxAttempts))` plus an assertion on the thrown exception (and, ideally, that the original cause is preserved) pins the contract from both ends. `verifyNoMoreInteractions(collaborator)` adds a guard that the policy did not also call some other method on the collaborator — for example closing and reopening a session per attempt. ## Keeping time out of the test Real retry policies wait between attempts. If the code calls `Thread.sleep` directly, the test either becomes slow or is written with a tiny backoff that no longer resembles production. The fix is design, not mocking tricks: inject the delay mechanism as a collaborator — a `Sleeper`, a `Clock`, or the scheduler your framework already provides — and stub it to return instantly while verifying the requested durations: ```java verify(sleeper).sleep(Duration.ofMillis(100)); verify(sleeper).sleep(Duration.ofMillis(200)); ``` That also lets you assert the backoff *curve* (exponential, capped, jittered within a range) rather than merely that some waiting happened. Note that with jitter you should assert a range, not an exact value, or the test becomes flaky by construction. ## Which failures are retryable A policy that retries everything is a bug: retrying a 400-level validation error or a non-idempotent write is harmful. Consecutive stubbing makes the negative case just as easy — stub a non-retryable exception on the first call and verify exactly one invocation: ```java when(client.fetch(ID)).thenThrow(new BadRequestException()); assertThrows(BadRequestException.class, () -> service.load(ID)); verify(client, times(1)).fetch(ID); ``` This test is often more valuable than the happy retry test, because over-broad retrying is the failure mode that hurts in production. ## Same-argument checks When retries should resend an identical request, capture the arguments across all attempts and compare them: ```java ArgumentCaptor<Request> captor = ArgumentCaptor.forClass(Request.class); verify(client, times(3)).fetch(captor.capture()); assertThat(captor.getAllValues()).allMatch(r -> r.idempotencyKey().equals(key)); ``` This catches a retry that rebuilds the request and accidentally mints a new idempotency key — a bug that produces duplicate side effects in production and is otherwise invisible. ## Where mocks stop being the right tool If the retry logic interacts with connection pools, circuit breakers, timeouts and thread interruption, a stub sequence only covers the decision logic, not the integration. The unit test should stay narrow — attempts, ordering, backoff request, retryable classification — and the interaction with a real transport belongs in an integration test against a controllable fake server that can be told to fail the first two requests. ## Guidance One scenario per test, named after the scenario. Stub the shortest sequence that expresses it. Always pair it with an exact-count verification. Keep sleeps injected. And add the non-retryable case, because that is the one production will punish you for missing.

  • For the give-up test, do you need to stub as many exceptions as the policy has attempts?
    No. Because a consecutive chain repeats its final answer indefinitely, a single thenThrow(...) makes every attempt fail regardless of how many the policy makes. Stubbing N copies adds noise and, worse, couples the arrange block to the retry count in a second place. Assert the count once, in the verification.
  • How do you test exponential backoff without making the suite slow?
    Do not let the production code call Thread.sleep directly. Inject the waiting mechanism — a Sleeper, a Clock, or the framework's scheduler — stub it to return immediately, and verify the durations it was asked for. That asserts the backoff curve itself rather than merely that time passed, and keeps the test to milliseconds. With jitter, assert a range rather than an exact value.

saying these in an interview costs you the question

  • Asserting only the final result and omitting verify(times(n)), so an unbounded retry loop still passes
  • Using atLeastOnce() when the retry budget is a real policy decision
  • Stubbing one exception per expected attempt, unaware that the last answer repeats
  • Letting the test sleep for real backoff durations
  • Never testing that non-retryable failures are not retried

context