skip to content

A team keeps adding Mockito constructor-interception scopes to unit-test classes that instantiate their collaborators with `new` internally. How would you decide between keeping that approach and refactoring the code to inject the collaborator instead?

level: principalimportance: should knowfreq 28%

answer

  1. interception = workaround, injection = design fix
  2. third-party / legacy / dangerous constructor → intercept
  3. factory or Supplier for per-call instances
  4. couples tests to a construction site
  5. scaffold first, then refactor and delete

basics

~20 s

Interception is a workaround for code you cannot change: third-party classes, legacy, or a risky refactor. For your own code, injecting the collaborator (constructor parameter or a small factory interface) removes the bytecode magic, makes the dependency visible in the API, and keeps tests readable. Prefer the refactor; keep interception at the edges.

solid answer

~50 s

I treat constructor interception as debt-financed testability. It works, but it couples the test to the *implementation detail* that the class calls `new` on a specific type; rename or replace that type and the test silently stops mocking anything. It also needs the inline mock maker, is thread-local, and skips constructor bodies, so it hides real initialisation defects. The refactor — take the collaborator as a constructor parameter, or inject a factory when the object must be created per call — makes the dependency part of the class's contract, works with any mock maker, and lets the test read as plain wiring. My rule: refactor code we own and can deploy; use interception for third-party or legacy classes, for constructors with expensive or dangerous side effects, and as a temporary scaffold that lets us characterise behaviour *before* refactoring. I also watch for many scopes in one test class — that usually means a god object creating its own world, and the fix is design, not more interception.

go deeper

for a junior

Say that if you can pass the collaborator in, do that; interception is for code you cannot change.

for a middle

Contrast the two seams concretely and mention the factory/Supplier option for per-call instances.

for a senior

Weigh blast radius, threading, refactor risk and constructor side effects, and describe using interception as temporary scaffolding.

for a principal

Frame frequency of use as a codebase health metric, set a policy (allowed at third-party boundaries, discouraged in the domain), and connect it to reuse and lifecycle management, not just test style.

## Two ways to break a hard-coded dependency When a class does `new Collaborator(...)` inside a method, a unit test has two routes to control that collaborator. **Interception.** `Mockito.mockConstruction(Collaborator.class)` instruments the class so that, inside a thread-local scope, construction yields a mock. Nothing about the production class changes. **Inversion.** Change the production class so the collaborator arrives from outside: a constructor parameter for a long-lived dependency, or an injected factory/supplier when a fresh instance is genuinely needed per operation. The test then passes a mock directly. ## What inversion buys - **Honest API.** The dependency appears in the constructor signature, so readers and DI containers see it. Hidden `new` calls make a class look cheaper than it is. - **No instrumentation.** No inline mock maker, no Java agent warnings, no interaction with coverage or bytecode tooling, faster tests. - **Robust tests.** The test refers to a type it is given, not to a construction site buried in a method body. Refactoring the production method does not silently disarm the mock. - **Reuse.** The same seam serves other needs — alternate implementations, decorators, retries, per-tenant configuration. - **Real constructor coverage.** Interception skips the constructor body, so validation and initialisation logic there is never exercised in those tests. ## What interception buys - **Zero production change.** Critical for third-party classes, generated code, and code under a change freeze. - **No API churn.** Adding a parameter to a widely used constructor can ripple through hundreds of call sites; interception avoids the ripple while you are still learning the code. - **Neutralising dangerous constructors.** If the constructor opens sockets, spawns threads, reads files or blocks, skipping its body is exactly what a unit test wants. - **A safety net for the refactor itself.** Legacy-code practice is to write characterisation tests first, using whatever seam you can get, then refactor under their protection and simplify the tests afterwards. Interception is an excellent temporary seam for that. ## The costs to name out loud Interception is *global within its scope for that type on that thread*: every construction of the type is affected, including ones you did not intend, such as a helper the test framework itself builds. It is thread-local, so async production code slips through. A leaked scope corrupts unrelated tests. And it encodes an implementation detail as a test expectation, which is the classic mechanism by which a suite becomes resistant to refactoring — the tests break when the code changes shape, not when the behaviour changes. ## A decision rule 1. Do we own and can we deploy the class? If yes, prefer inversion; the refactor is usually smaller than the argument about it. 2. Is a fresh instance genuinely needed per call? Inject a factory or `Supplier`, not the instance. 3. Is the class third-party or frozen? Interception, or wrap it behind a thin adapter interface we own and mock the adapter — often the better long-term move, because the adapter also isolates us from the library's API. 4. Is the constructor itself dangerous in tests? Interception is fine even for our own code, but consider whether that constructor should be doing work at all. 5. Are we about to refactor? Use interception as scaffolding, then delete it once the seam exists. ## Organisational angle If constructor interception appears in dozens of test classes, that is a metric, not a style choice: it says the codebase's units create their own dependencies, which also blocks reuse, complicates lifecycle management, and pushes teams toward heavy integration tests. I would track its usage, allow it explicitly at boundaries with third-party libraries, and treat new occurrences in core domain code as review findings with the refactor as the expected fix.

  • The class legitimately needs a new instance per request, so a constructor parameter will not do. What is the injection-shaped answer?
    Inject a factory: a small interface or a `Supplier<Collaborator>`/`Function<Args, Collaborator>` field supplied at construction. Production wires it to `Collaborator::new`; the test passes a lambda returning a prepared mock, and can also assert how many instances were requested and with what arguments. It keeps per-call creation while making the dependency explicit.
  • When is wrapping a third-party class in your own adapter better than intercepting its construction?
    When the library type appears in more than one place or its API is awkward to mock. An adapter interface you own gives a stable, narrow surface that is trivially mockable with plain Mockito, isolates the codebase from library upgrades, and lets one integration test cover the real library while the rest of the suite stays fast.

Interception is picking the lock because nobody gave you a key; injection is fitting a door handle. Picking is fine on a building you do not own — doing it every morning on your own house means it is time for a handle.

saying these in an interview costs you the question

  • Treating constructor interception as a normal, default testing technique for in-house code.
  • Claiming a constructor parameter is impossible when a factory or Supplier solves the per-call case.
  • Ignoring that skipped constructor bodies mean initialisation logic is never tested.
  • Arguing the refactor is unsafe while having no characterisation tests — which interception could provide.
  • Assuming interception scales to async code, where the thread-local registration does not apply.

context