skip to content

Your team's JUnit 5 tests all need a set of collaborators — clients, fixtures, seeded data. When is writing custom ParameterResolver extensions the right way to supply them, and when do you instead reach for a framework's DI extension or plain factory helpers in the test code?

level: principalimportance: nice to knowfreq 24%

answer

  1. default = plain construction; promote deliberately
  2. promote on: breadth, resource lifecycle, per-injection-point variation
  3. container already owns the graph → use its extension (fidelity)
  4. composition via @ExtendWith beats base-class inheritance
  5. risk: resolvers become an undocumented DI container

basics

~20 s

Use a custom ParameterResolver when the same non-trivial collaborator is needed across many classes, its construction or cleanup is real work, or it must vary per injection point. Use plain helper factories for one-off or cheap objects, and an existing DI extension when a container already owns object graphs.

solid answer

~50 s

I decide on three axes. **Breadth and cost.** A collaborator built in one line, used by one class, belongs in a `@BeforeEach` or a static factory — an extension there is pure indirection. A collaborator needed by thirty classes, whose construction involves configuration, ports or cleanup, justifies a resolver: one place to change, no copy-paste setup. **Who owns the object graph.** If a container already wires the objects — a Spring context, for example — I use its test extension rather than re-implementing wiring; hand-rolled resolvers that fetch from a container duplicate a solved problem and drift from production configuration. **Variation at the injection point.** A resolver shines when the annotation on the parameter parameterises the object — a client for a named service, a fixture in a given state. Factories cannot express that as cleanly. The risk I actively manage: a pile of resolvers becomes an undocumented DI container. So one responsibility per resolver, annotation-gated claims, and lifetime and cleanup written down.

code

java · 28 lines
java
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Fixture {
    CustomerState state();
}

public class CustomerFixtureResolver implements ParameterResolver {

    @Override
    public boolean supportsParameter(ParameterContext pc, ExtensionContext ec) {
        return pc.isAnnotated(Fixture.class) && pc.getParameter().getType() == Customer.class;
    }

    @Override
    public Object resolveParameter(ParameterContext pc, ExtensionContext ec) {
        CustomerState state = pc.findAnnotation(Fixture.class).orElseThrow().state();
        return CustomerFactory.inState(state);
    }
}

@ExtendWith(CustomerFixtureResolver.class)
class RenewalTest {

    @Test
    void refusesRenewalForExpiredCustomer(@Fixture(state = CustomerState.EXPIRED) Customer customer) {
        assertFalse(new RenewalPolicy().canRenew(customer));
    }
}

go deeper

for a junior

Recognise that JUnit can inject parameters and that simple collaborators are usually built in setup code; do not attempt the strategy comparison.

for a middle

Contrast a @BeforeEach field with a resolver and name the practical wins: no duplicated setup, dependencies visible in the signature.

for a senior

Give the promotion criteria — breadth, resource lifecycle, per-injection-point variation — and discuss lifetime, cleanup and collision risk.

for a principal

Own the strategy: default position, promotion criteria, when to defer to a framework extension for fidelity to production wiring, and the governance that stops a resolver set from becoming an undocumented container.

## The question behind the question Every test needs collaborators. JUnit 5 offers three broadly reasonable ways to get them into a test: construct them in the test (fields plus setup methods, or static factory helpers), inject them via a custom `ParameterResolver`, or delegate to an extension from a framework that already owns object construction. The interviewer is probing whether you can pick deliberately rather than by habit, and whether you can name the costs of the option you like. ## Option A — plain construction in the test A field assigned in `@BeforeEach`, or a static `Fixtures.newCustomer()` helper. Advantages: zero indirection, ordinary Java, discoverable by any reader following the call, trivially debuggable and refactorable by the IDE. Disadvantages: duplicated across classes, and no framework-driven cleanup, so resource lifetime is manual. Default to this. It is right for cheap objects, for value fixtures, and any time the construction is one obvious line. "We wrote an extension" is not evidence of engineering maturity when a factory method would do. ## Option B — a custom ParameterResolver The resolver claims parameters and builds their values. It pays off when at least one of these holds: - **Breadth.** Many test classes need the same collaborator. Setup logic that would otherwise be copy-pasted (or forced into an inheritance hierarchy of base test classes) collapses into one extension. Avoiding a deep test-class hierarchy is a genuine architectural win: composition via `@ExtendWith` beats inheritance. - **Non-trivial construction or teardown.** Something that needs configuration, a port, a temporary directory, or deterministic closing. An extension can build lazily, cache at the right scope, and release on close, which a helper method cannot do on its own. - **Per-injection-point variation.** The parameter's annotation configures the object: `@Client(service = "billing")`, `@Fixture(state = EXPIRED)`. The dependency becomes visible and configurable in the method signature — the test reads as a function of its declared inputs, which is a real readability gain over hidden fields. - **Isolation.** Resolution happens per invocation, so each test naturally gets its own instance unless you deliberately share one — a good default for parallel execution. The costs: an extension is a second place to look; a broad claim collides with other resolvers and fails the whole class; lifetime becomes implicit, so a reader cannot tell from the test whether the object is per-method or shared; and enough resolvers in a codebase amount to a DI container that nobody designed or documented. ## Option C — an existing DI-based extension When a container already builds the graph in production — Spring being the common case, with its test extension — reuse it. The argument is not convenience but **fidelity**: the test exercises objects wired the way production wires them, and configuration changes propagate automatically. Writing your own resolver that pulls beans out of a container reimplements caching, context lifecycle and configuration merging that the framework extension already handles, usually worse. The counter-consideration is weight. Standing up a container for a test that needs one collaborator makes a millisecond unit test into a second-long one and couples the test to configuration far outside its subject. For pure unit tests, hand-built collaborators are faster and sharper; container-based injection belongs to tests whose subject genuinely includes the wiring. ## A decision heuristic 1. Is the object cheap and local to one class? → construct it in the test. 2. Is it needed broadly, or does it own a resource with a lifecycle? → custom resolver. 3. Does its construction duplicate production wiring already owned by a container? → the framework's extension. 4. Does the test's subject include the wiring itself? → the framework's extension, definitively. ## Governing the resolver set If you go the resolver route at scale, treat the resolvers as a small internal API: - **One responsibility per resolver.** A single class resolving six unrelated types is a container in disguise and makes claims impossible to reason about. - **Annotation-gated claims.** Requiring a marker annotation on the parameter prevents collisions with other extensions (two resolvers claiming one parameter is a hard failure) and documents intent at the call site. - **Explicit lifetime.** Decide and document whether each injected object is per-invocation, per-class or per-run, and make cleanup deterministic by tying resources to the scope that owns them. - **Discoverability.** Register them behind composed annotations with meaningful names (`@WithBillingClient`) so a reader of the test can find the extension from the annotation. - **Test the extensions.** They are ordinary classes; a small unit test on `supportsParameter` catches over-broad claims before they break a whole suite. - **Watch parallel execution.** Anything cached above test scope is shared across threads and must be safe for that. ## How to close the answer The strongest answer names a default (plain construction), the specific conditions that promote a collaborator to an extension (breadth, resource lifecycle, per-injection-point variation), the case for delegating to a framework extension (fidelity to production wiring, when the wiring is part of the subject), and the failure mode of overusing resolvers (an accidental DI container with invisible lifetimes). The absence of a single right answer is the point: the interviewer wants the axes, not a verdict.

  • What concrete signal tells you a set of custom resolvers has become an accidental DI container?
    When a single resolver claims several unrelated types, when resolvers start depending on each other's cached objects, or when nobody can say from reading a test whether an injected object is fresh or shared. At that point lifetimes are implicit and collisions are likely; the remedy is to split by responsibility, gate claims with marker annotations, and document scope per injected type.
  • How does injecting collaborators as parameters change test readability compared with fields set in @BeforeEach?
    Parameters make each test's dependencies explicit in its own signature, so a reader sees exactly what this test needs rather than scanning shared fields and setup methods, and unused collaborators stop being silently constructed for every test. The tradeoff is that construction moves out of sight into an extension, so the extension needs a self-explanatory name and documented lifetime.
  • Why can parameter injection be a better fit than a base test class for shared setup?
    A base class forces single inheritance and tends to accumulate unrelated setup for the union of its subclasses, so tests pay for fixtures they do not use. Extensions compose: a class declares only the ones it needs via `@ExtendWith`, several can apply together, and the shared code lives in a class you can test on its own.

saying these in an interview costs you the question

  • Answering with one universal rule instead of the conditions under which each approach wins
  • Building a custom resolver that pulls objects out of a DI container the framework's own extension already exposes
  • Treating extensions as inherently more professional than a plain factory method
  • Ignoring lifetime and cleanup — injecting resources with no defined scope or close
  • Standing up a full application context for tests whose subject does not include the wiring

context