cswork / libraryOpen practice workspace

Articles

JUnit Interview Questions: Test Lifecycles, Parameterized Tests, and Parallel Execution

Prepare for JUnit interview questions on test lifecycles, parameterized tests, shared state, resource locks, and parallel execution with verified examples.

Two tests pass alone and fail together. Each gets a fresh test-class instance, each adds one item, and each expects a list of size one. The hidden detail is that the list is static. Turning on parallel execution exposes the shared state; changing assertion syntax does nothing to remove it.

This guide pins its examples to JUnit Jupiter 5.12.2. Official behavior is linked to that version's documentation. Original fixtures were run with Maven 3.9.16, Surefire 3.5.2, and Java 26.0.2.1 using release 17 compilation. Candidate reports are not used, and these exercises are not attributed to a particular employer or presented as a latest-version migration guide.

The Spring Boot interview guide covers application context. This article's central rule is narrower: test isolation depends on who owns mutable resources, not simply on how many lifecycle annotations you add.

What does PER_METHOD actually isolate?

Official fact: Jupiter's default test-instance lifecycle is PER_METHOD; PER_CLASS reuses one instance for the class. A per-class lifecycle permits non-static class lifecycle methods, but introduces shared instance state across tests. TestInstance reference.

A new Java object gives a test fresh instance fields when those fields initialize fresh objects. It does not create a new copy of a static collection, an external database, a system property, or a server listening on a fixed port.

Use this resource map before proposing a lifecycle change:

ResourceDoes a fresh test instance isolate it?Repair to consider
New list stored in an instance fieldUsually, if it is not shared elsewhereKeep ownership within the invocation
Static mutable cacheNoRemove shared ownership or coordinate access
Database rows under a fixed customer IDNoGive tests separate data namespaces and cleanup
File written to one fixed pathNoAllocate a scoped temporary directory
Process-wide system propertyNoRestore it and coordinate all participating tests

The table is a design aid, not a promise that an annotation repairs every resource. In an interview, name the concrete object or external identifier that two tests can touch. “The tests share state” becomes actionable only when you can show that alias.

What runs before each parameterized invocation?

Official fact: A parameterized test is invoked separately for each argument set and participates in the usual test lifecycle. Its setup should therefore be understood per invocation, rather than as a one-time prelude to the whole argument table. ParameterizedTest reference.

Our passing fixture used an instance list and a setup counter. For two item inputs and four blank-string cases, each invocation observed one setup call. A final check counted six distinct test instances. These are local observations from the pinned configuration, not an assumption inferred from green test output.

Official fact: Jupiter does not guarantee an intuitive ordering for multiple @BeforeEach methods declared in one class. The documentation recommends avoiding dependent setup methods whose correctness relies on their ordering. BeforeEach reference.

If one setup method creates a database connection and another requires it, make that dependency explicit in a single setup sequence or an appropriately designed extension. Naming the methods firstSetup and secondSetup is not an ordering contract.

@BeforeAll serves a different purpose: it runs once for the class and is static by default. A PER_CLASS lifecycle can support a non-static version. BeforeAll reference.

My proposed design shares an expensive immutable fixture only when sharing is intentional. Mutable per-test state still needs its own initialization and cleanup. A once-per-class setup is not automatically faster in a useful way if every test then needs synchronization around its output.

How should you choose parameterized-test inputs?

Start from a behavior partition: ordinary input, boundary input, absent input, and malformed input may exercise different promises. Ten similar strings can add less coverage than one null and one empty string if the implementation treats them differently.

Official fact: @NullAndEmptySource combines null and empty sources. It does not stand for every whitespace-only string. NullAndEmptySource reference.

This executed fixture checks the input categories themselves; in an application test, replace the predicate with the behavior under test and its expected outcome.

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t"})
void blankInputs(String input) {
    assertTrue(input == null || input.isBlank());
}

For multiple arguments or constructed objects, @MethodSource can provide argument sets. A factory in the test class is static unless PER_CLASS is used; an external factory is static. MethodSource reference.

Choose the source that makes the case readable. A short CSV row can express a numeric input and expected output clearly. A complicated object graph hidden inside an escaped CSV string usually needs a factory instead.

CSV has its own edge cases: an empty quoted value and an unquoted empty field have different empty-string versus null behavior. Confirm those semantics before claiming your test covers both. CsvSource reference.

Ask whether a failing invocation identifies the broken contract. Give cases descriptive names or recognizable arguments, and keep one assertion story per parameterized method. Combining unrelated behaviors merely because their input types match makes failures harder to interpret.

Why does the shared-list fixture fail in parallel?

Here is the core of the intentionally failing fixture. Imports are omitted. The collection is thread-safe so that the exercise isolates interference between tests rather than corruption of the collection itself.

static final List<String> shared = new CopyOnWriteArrayList<>();
static final CyclicBarrier bothAdded = new CyclicBarrier(2);

@ParameterizedTest
@ValueSource(strings = {"A", "B"})
void supposedlyIsolated(String item) throws Exception {
    shared.add(item);
    bothAdded.await(5, TimeUnit.SECONDS);
    assertEquals(1, shared.size());
}

Both invocations add an item before either assertion proceeds. Each therefore sees two items. The local run produced two expected assertion failures, with no barrier timeout. The fixture demonstrates that a thread-safe data structure is not the same as test isolation.

Adding shared.clear() in @BeforeEach is not a complete repair for concurrent execution. One invocation could clear the collection after another has started using it. That creates an additional interleaving instead of assigning separate ownership.

The simplest repair for this exercise is to move the list into an instance field under PER_METHOD, or create it within the test method. Our repaired fixture passed both item cases. It also checked the six-instance lifecycle observation described earlier.

The barrier is a teaching mechanism, not a recommended pattern for ordinary unit tests. Its timeout makes a scheduling problem visible instead of allowing the demonstration to hang indefinitely. Do not retain this two-party barrier when serializing those same invocations; the first would wait for work that cannot begin.

What must be configured for parallel execution?

Official fact: Jupiter parallel execution is opt-in. Enabling it and choosing an execution mode are separate configuration decisions. A fixed strategy defines desired parallelism; it should not be treated as an unconditional limit on every thread involved in the test environment. Pinned user guide.

Our fixture placed these settings in src/test/resources/junit-platform.properties:

junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=2

The execution annotation can select SAME_THREAD or CONCURRENT for applicable nodes. Classes using PER_CLASS or certain ordering configurations require special care; do not assume the default concurrent setting overrides every lifecycle safeguard. Execution reference.

In an interview, separate Jupiter scheduling from Maven's module parallelism and forked test JVMs. They are different layers. A suite may run sequentially inside each process while several processes still collide on the same database or port.

Before increasing concurrency, list scarce resources and external rate limits. If twenty tests each create a large fixture, a faster scheduler can turn a stable suite into a memory or connection-pressure problem. Measure useful suite completion time, failure rate, and resource use together rather than celebrating a higher worker count.

When is ResourceLock the right repair?

Official fact: @ResourceLock coordinates tests declaring access to a named shared resource. Its synchronization includes relevant lifecycle methods, so setup and cleanup can participate in the protected interval. Read and read-write access modes express different conflicts. ResourceLock reference.

Our second repair retained one shared list, removed the barrier, and annotated both test methods with the same resource name:

@Test
@ResourceLock("shared-fixture")
void a() {
    shared.add("A");
    assertEquals(List.of("A"), shared);
}

A @BeforeEach method cleared the list; the other test used the same lock name and expected its own value. Both passed. Across the isolated parameterized fixture and locked fixture, the run completed eight passing invocations.

This repair is appropriate when sharing is intentional. It also creates a serial region, so it may limit speedup. My preference for independent unit tests is separate state when practical, with locking reserved for resources that genuinely must be shared.

The lock name is a coordination agreement among participating tests, not a lock understood by another JVM or an external database. A test that touches the resource without declaring the same agreement can still interfere. Across processes, use isolated namespaces or an external coordination mechanism suited to that resource.

What other lifecycle traps should you explain?

Official fact: Dynamic tests generated by a factory do not receive individual @BeforeEach and @AfterEach callbacks. That differs from ordinary parameterized invocations. DynamicTest reference.

If a factory captures a mutable list and returns several dynamic tests, do not assume the list resets before each generated case. Construct independent state inside each executable or choose a parameterized test when its lifecycle matches the requirement.

Official fact: @TempDir supports scoped temporary directories. An instance field or suitable parameter can provide isolation, while a static field intentionally shares a directory across the class. TempDir reference.

For file tests, use that scope deliberately and close file handles before cleanup. Unique directory allocation solves a different problem from deleting yesterday's file at a fixed path; deletion alone does not prevent two concurrent tests from using that path now.

Finally, verify discovery. A build that reports success with zero intended tests is not evidence of correctness. Surefire's JUnit Platform support depends on the configured engine and test selection. Surefire documentation.

My closing diagnostic would compare isolated and suite runs, inspect test counts and failed invocation names, and record shared resource identifiers. A deterministic reproducer with an explicit resource boundary is more informative than rerunning a flaky suite until it turns green.

Sources and Further Reading