A test needs three levels of chained MockK stubbing to set up one collaborator. How do you decide between keeping the deep stub and changing the design or the test?
answer
- chain in a test = navigation path encoded
- ownership decides: reshape vs contain
- data holders → real objects, never mocks
- third-party graph → one shared fixture / owned wrapper
- relaxed root hides a broken chain
basics
~20 sAsk who owns the chain. If you own the types, a three-level stub is a design signal — pass the leaf value in, or use a real object or fake. If the chain belongs to a third-party API you cannot reshape, deep stubs are legitimate; isolate them behind a test fixture.
solid answer
~60 sA chained stub encodes a **navigation path** into the test. The test now fails not only when behaviour breaks but when anyone changes how the object graph is traversed, and the failure surfaces as a *no answer found* on an intermediate you never wrote. My decision rule is ownership plus stability: - **You own the types** → the depth is telling you the unit reaches too far. Pass the leaf value or a narrow interface into the unit; then the test needs one stub, not three. - **You own them but the graph is genuinely a value tree** (config, DTOs) → build real instances. They are cheap, honest, and give real equality. - **Third-party or generated API** (cloud SDK clients, builder-shaped graphs) → deep stubbing is legitimate; you cannot reshape it. Isolate it in one shared fixture/builder so the navigation path is written once. I also watch for relaxation hiding the problem: a relaxed root makes every unstubbed hop succeed silently, which turns a brittle test into a test that asserts nothing. My rule of thumb is that chain depth above two is a review conversation, not an automatic reject.
code
kotlin · 5 lines// before: test needs every { order.customer.address.country } returns "DE"
fun taxFor(order: Order) = rates[order.customer.address.country]
// after: the unit takes what it needs
fun taxFor(country: String) = rates[country]go deeper
Say that long chains of stubs make tests fragile and that real objects are usually simpler for plain data.
Contrast deep stubbing with passing the needed value in, and note that data holders should be constructed, not mocked.
Reason from ownership and stability, contain third-party chains in one fixture or behind an owned interface, and flag relaxation masking a broken path.
Turn it into a reviewable policy with thresholds and exceptions, and explain the tradeoff as a maintenance loan taken deliberately rather than a rule.
## What a deep stub actually buys and costs Chained stubbing lets one `every` set up an entire path: MockK auto-creates a mock for each intermediate return value and wires it as that call's answer. The convenience is real — one line instead of three mocks and three stubs. The cost is that the test now knows the *shape of the object graph*, not just the contract of the collaborator it uses. Three specific costs recur: 1. **Structural coupling.** The test asserts, implicitly, that the value is reached by `a.b().c().d()`. Any refactor that keeps behaviour identical but changes the traversal breaks the test. This is the Law-of-Demeter argument, arriving as a test-maintenance bill. 2. **Opaque failures.** When the path diverges — a different argument, an extra hop, a null branch — the error names an auto-created intermediate mock. Engineers who did not write the test spend real time mapping that back to the chain. 3. **False confidence.** Deep stubs make it easy to construct object graphs that could never exist in production: a child that returns a value its real parent would never produce, or a combination the domain forbids. The test passes; the system is untested. ## The decision rule **Who owns the types in the chain?** *You own them.* Then depth is feedback about the production design, not about MockK. A unit that walks `order.customer.address.country` is doing the collaborator's work. The fix is almost always to move the navigation to the caller and pass the leaf — a country code, a resolved address — into the unit. The test collapses to a parameter. If several call sites need the same traversal, that traversal is a missing method (`order.shippingCountry()`) or a missing collaborator. *You own them and they are plain data.* Config trees, DTOs, response models: build the real objects. Kotlin data classes with default values and `copy` make this cheap, and you get genuine equality, real nullability and no risk of an impossible graph. Mocking a data holder is nearly always a mistake. *You do not own them.* Cloud SDKs, generated clients, framework types with builder-shaped graphs — you cannot flatten what you did not design. Deep stubbing is the pragmatic answer. Contain it: put the chain in one place (a fixture object, a small builder function, a `@BeforeEach` helper) so the navigation path appears once and every test consumes a ready-made collaborator. Better still, wrap the third-party client behind your own narrow interface in production code and mock *that* — then the deep stub exists only in the handful of tests covering the adapter, and integration or contract tests cover the adapter for real. ## Secondary signals I weigh - **Stability of the chain.** A path in third-party API surface that has been stable for years is a much safer thing to encode than a path through your own actively-refactored domain. - **How many tests share it.** One deep stub in one test is noise; the same chain copy-pasted into thirty tests is a maintenance liability that will be paid for in a single afternoon of refactoring pain. - **Whether relaxation is masking it.** A relaxed root mock silently answers every hop you forgot, so a broken navigation path produces a passing, meaningless test rather than a failure. If a deep-stubbed test only passes because the root is relaxed, treat that as a defect, not a convenience. - **What the test is really about.** If the chain is incidental setup for a test about something else, a fake or a real object removes noise. If the chain *is* the subject — you are testing that the adapter navigates correctly — then stubbing it explicitly is exactly right, and the depth is intentional. ## The policy I would set for a team - Depth one is unremarkable; depth two needs a reason; depth three or more triggers a conversation in review. - Never mock types you own that are pure data. - Third-party graphs are wrapped behind an owned interface wherever the wrapping is cheap; where it is not, the deep stub lives in exactly one shared fixture. - No deep-stubbed test may depend on relaxation to pass. ## How I would phrase the tradeoff to a skeptic Deep stubbing is not wrong; it is a loan. It buys setup speed today against the risk that the object graph moves tomorrow. When you own the graph, you can pay the loan off permanently by changing the design, so you should. When you do not own it, the loan is unavoidable and the discipline is to take it out once, in one place, rather than in every test file.
- Someone argues deep stubs are fine because they keep tests short. What is your counter?Short is not the goal; cheap to keep correct is. A chained stub is short today and expensive on the day the traversal changes, because it fails in every test that encoded the path, with an error naming a mock nobody wrote. Where you own the types, moving the traversal out of the unit makes the test both shorter and decoupled, so the brevity argument actually favours the redesign.
- When would you say a deep stub is the *right* answer even in code you own?When the navigation itself is the behaviour under test — for example an adapter whose whole job is to walk a graph and extract a value. There the chain is the specification, not incidental setup, and stubbing it explicitly documents the contract. Even then I would keep it to one test class and cover the real traversal with an integration test.
saying these in an interview costs you the question
- Treating deep stubbing as always wrong, with no allowance for third-party APIs.
- Mocking data classes and config trees instead of constructing them.
- Copy-pasting the same three-level chain across dozens of test files.
- Adding relaxation to make a deep-stubbed test pass instead of fixing the path.
- Framing it purely as a MockK question rather than a production-design signal.