Using MockK, how would you stub an HTTP client so that the first two calls throw an IOException and the third returns a response — and what should you be careful about when reusing one exception instance across calls?
answer
- throws / throwsMany / andThenThrows chain with values
- fail, fail, succeed = one stub
- last entry repeats — success goes last
- throws stores the INSTANCE: stale trace, shared mutation
- fresh exception per call ⇒ throw inside answers { }
basics
~20 sMix terminators in one chain: every { client.get(any()) } throws IOException("boom") andThenThrows IOException("boom") andThen response. Each entry is a separate answer. The same Throwable instance is rethrown every time it is used, so its stack trace stays from where you built it and any mutation on it is shared.
solid answer
~50 sAn answer sequence can mix values and failures freely: ```kotlin every { client.get(any()) } throwsMany listOf(IOException("1"), IOException("2")) andThen okResponse // or every { client.get(any()) } throws IOException("1") andThenThrows IOException("2") andThen okResponse ``` The terminators `throws`/`throwsMany` and the chain steps `andThen`, `andThenThrows`, `andThenThrowsMany`, `andThenMany` and `andThenAnswer` all append to the same ordered list, so "fail, fail, succeed" is one stub. Two cautions. First, exhaustion repeats the **last** entry, so put the success last unless you want the failure to be permanent. Second, `throws ex` stores the *instance*: MockK rethrows that same object on every use, so its stack trace points at the test line where you constructed it, not at the call site, and identity-based assertions or suppressed-exception mutation are shared. When each attempt should look distinct, build the exception inside an `answers { throw ... }` step.
code
kotlin · 10 linesval client = mockk<HttpClient>()
every { client.get(any()) } throwsMany listOf(
IOException("attempt 1"),
IOException("attempt 2"),
) andThen Response(200, "ok")
val body = RetryingFetcher(client, maxAttempts = 3).fetch("/x")
assertEquals("ok", body)
verify(exactly = 3) { client.get(any()) }go deeper
Show you can write the chain at all — throws followed by andThen — and know that failures and values live in the same sequence.
Add the ordering rule: whatever is last repeats forever, so the terminal behavior must be chosen deliberately, and pair the chain with a call-count verification.
Discuss the instance-reuse consequences of throws — stack trace origin, shared mutation via addSuppressed, identity — and when to throw from an answer block instead.
Talk about what makes retry tests trustworthy overall: asserting observable outcomes and attempt counts, matching the caught exception types, and keeping failure injection readable rather than a long positional script.
## The shape of the problem Retry and circuit-breaker code is exactly the code that most needs a test, and testing it means a collaborator that fails a bounded number of times and then works. MockK supports this directly because failures and values are the *same kind of thing* in its answer model: an answer is anything that can produce a result for an invocation, and throwing is one such result. ## Composing the sequence The stub scope offers `throws(ex)` and `throwsMany(list)` alongside `returns`/`returnsMany`, and the additional-answer scope returned by any of them offers `andThen`, `andThenMany`, `andThenThrows`, `andThenThrowsMany` and `andThenAnswer { }`. Any of these may be combined in one chain, so all of the following are legal and equivalent in effect: ```kotlin every { c.get(any()) } throws e1 andThenThrows e2 andThen ok every { c.get(any()) } throwsMany listOf(e1, e2) andThen ok every { c.get(any()) } answers { throw IOException("1") } andThenAnswer { throw IOException("2") } andThen ok ``` The sequence is one answer object holding an ordered list and a counter; each matching call consumes the next entry. ## Rule one: order the failure and the success correctly Because an exhausted sequence repeats its final entry, the placement of the success is not cosmetic: - `throws … andThen ok` — after the failure budget is spent the call succeeds forever. This is the shape you want for "retry until success". - `returns ok andThenThrows ex` — the collaborator works once and is then permanently broken. This is the shape for "degrade after first failure" or "partially failing batch". A frequent bug is writing `throws e andThen ok` to test a *three*-attempt retry and being surprised the code passes on attempt two; the chain only ever fails once. Count the failures deliberately and pair the chain with a verification of the attempt count, since the chain itself will never complain about too many calls. ## Rule two: `throws` captures an instance, not a factory `throws ex` registers a constant answer holding the `Throwable` object you passed. MockK rethrows *that object* each time the entry is used. Consequences worth knowing: - **Stack traces.** The trace was filled in where you constructed the exception — typically the test setup line — so a failure report inside the code under test points back at the test file rather than at the call site. This is normally harmless, and occasionally very confusing when you are reading logs from a test that captures and prints traces. - **Shared mutable state on the throwable.** Suppressed exceptions, `initCause`, and anything else that mutates the throwable accumulate across calls, because it is one object. Code that calls `addSuppressed` in a retry loop can end up building a chain on the shared instance, and in pathological cases (self-suppression, cause cycles) that turns into an exception thrown *while* handling the exception. - **Identity comparisons.** If the code under test collects failures and de-duplicates by identity, two "different" attempts will look like the same failure. When any of that matters, produce a fresh exception per call with an answer step: `answers { throw IOException("attempt failed") }`. The block runs at call time, so each invocation constructs a new object with a stack trace rooted at the real call site. `throwsMany listOf(IOException("1"), IOException("2"))` is the middle ground — distinct instances, but still created up front. ## Rule three: match what the production code actually catches A retry test is only meaningful if the stubbed exception type is one the code retries on. Stubbing an `IOException` against a `catch (e: HttpException)` produces a test that passes for the wrong reason (the exception escapes and the test asserts a failure that never involved the retry logic). Assert on the observable outcome — the returned result and the number of attempts — rather than merely "it didn't throw". ## Suspend collaborators All of the above holds for suspend functions recorded with `coEvery`; the sequence and the throw semantics are unchanged. When the failure itself must be produced asynchronously or after a delay, that belongs in a coroutine-aware answer block rather than in `throws`. ## Checklist - Failures and values chain together in one stub. - Last entry repeats — put the terminal behavior last on purpose. - `throws` reuses one instance: stale stack trace, shared mutation, identical identity. - Fresh-per-call failure ⇒ throw from inside an answer block. - Always pair the chain with an explicit attempt-count verification.
- Why might the stack trace of the exception your retry code logs point at the test file rather than the production call site?Because `throws ex` stores the exception instance you constructed, usually on the setup line of the test, and rethrows that same object. A Throwable's stack trace is captured at construction, not at throw time. Constructing the exception inside an answers block instead gives each call a fresh instance whose trace starts at the real invocation.
- How do you make sure a fail-fail-succeed test actually proves the retry limit, rather than passing by accident?Assert the number of attempts explicitly with a verification such as verify(exactly = 3), and check the returned value. The chain alone cannot prove it, since a fourth call would silently reuse the last answer. If an extra call must be a failure, end the chain with andThenThrows so the surplus invocation is loud.
saying these in an interview costs you the question
- Assuming a single `throws` entry keeps failing for the whole test.
- Putting the success first and the failure last when testing retry-until-success.
- Expecting a new exception object (and a fresh stack trace) on each call from `throws ex`.
- Treating "the test didn't throw" as proof the retry logic ran, without asserting the attempt count.
- Stubbing an exception type the production code does not actually catch.