Debugging JUnit 6 Tests: Fix Failures Like a Pro

A failing test is information β€” but only if you know how to extract it. Developers who debug test failures quickly share a systematic approach: they read error messages carefully, reproduce failures in isolation, understand their tools, and know where common failure patterns come from. This guide teaches you every technique you need to diagnose and fix JUnit 6 test failures like a professional.

Step 1: Read the Error Message Properly

Most developers skim error messages. Professional debuggers read them completely. Here is how to decode a JUnit 6 failure output:

org.opentest4j.AssertionFailedError: Order status should be CONFIRMED     <-- 1. Custom message (your clue)
  expected: <CONFIRMED>                                                    <-- 2. What you expected
   but was: <PENDING>                                                      <-- 3. What actually happened
	 at com.example.OrderServiceTest.lambda$0(OrderServiceTest.java:47)      <-- 4. Lambda inside assertAll
	 at org.junit.jupiter.api.AssertAll...                                   <-- framework frames (ignore)
	 at com.example.OrderServiceTest.placingValidOrderConfirmsIt(            <-- 5. Your test method
              OrderServiceTest.java:43)                                     <-- 6. Exact line in YOUR code

Decomposing the failure:
  1. "Order status should be CONFIRMED" β€” written by you in assertEquals(CONFIRMED, ..., "Order status..")
  2. expected: CONFIRMED β€” what the test asserts
  3. but was: PENDING   β€” what the code returned β€” THE REAL CLUE
  4. Lambda line β€” the assertion inside assertAll
  5+6. Your test method at line 43 β€” go here first

Step 2: Isolate the Failing Test

# Run just the failing test class
mvn test -Dtest=OrderServiceTest

# Run just the failing method
mvn test -Dtest=OrderServiceTest#placingValidOrderConfirmsIt

# Gradle equivalent
./gradlew test --tests "com.example.OrderServiceTest.placingValidOrderConfirmsIt"

# Run with verbose output to see all System.out from the test
mvn test -Dtest=OrderServiceTest -Dsurefire.useFile=false

Step 3: Add Diagnostic Assertions

When the failure message is not informative enough, add assertions that capture intermediate state:

@Test
@DisplayName("Placing a valid order confirms it")
void placingValidOrderConfirmsIt() {
    Order createdOrder = orderService.place("[email protected]", 99.99);

    // Diagnostic: print intermediate state to understand what is happening
    System.out.println("[DEBUG] Created order: " + createdOrder);
    System.out.println("[DEBUG] Order status: " + createdOrder.getStatus());
    System.out.println("[DEBUG] Payment result: " + createdOrder.getPaymentResult());

    // More granular assertions to pinpoint exactly where it goes wrong
    assertNotNull(createdOrder,                    "Order must be created");
    assertNotNull(createdOrder.getId(),             "Order ID must be assigned");
    assertNotNull(createdOrder.getPaymentResult(), "Payment must be processed before status check");
    assertTrue(createdOrder.getPaymentResult().isSuccessful(),
        "Payment must succeed: " + createdOrder.getPaymentResult().getErrorCode());

    // Final assertion: only fails here if all above pass
    assertEquals(OrderStatus.CONFIRMED, createdOrder.getStatus(),
        "Order should be CONFIRMED after successful payment, was: "
        + createdOrder.getStatus() + ", payment: " + createdOrder.getPaymentResult());
}

Step 4: Debugging in IntelliJ IDEA

  • Click the green β–Ά icon next to the @Test method β†’ select Debug instead of Run
  • Set a breakpoint on the line containing the failing assertion
  • Inspect variables in the Debug panel: check the actual object state, not just what the assertion reports
  • Use Evaluate Expression (Alt+F8) to execute arbitrary Java expressions at the breakpoint
  • Use Step Into (F7) to trace execution into the production method under test

Common Failure Patterns and Their Root Causes

Pattern 1: NullPointerException in a Test

// Failure:
// java.lang.NullPointerException at OrderServiceTest.java:35

// Root cause checklist:
// 1. Is the object under test initialised in @BeforeEach?
// 2. Is @InjectMocks used but a required mock is missing?
// 3. Did the method return null when you expected an object?
// 4. Is there a @BeforeAll that should be static but is not?

// Fix: ensure setup is correct
@BeforeEach
void setUp() {
    // Was this missing or called from a wrong lifecycle method?
    orderService = new OrderService(orderRepository, emailService);
    // Or: @InjectMocks handles this if @ExtendWith(MockitoExtension.class) is present
}

// Assert non-null before using the result
Order order = orderService.create("[email protected]", 49.99);
assertNotNull(order, "create() must not return null for valid input"); // catch it here
assertNotNull(order.getId(), "Order ID must be set after creation");

Pattern 2: Test Passes Alone But Fails in the Suite

// Symptom: mvn test -Dtest=OrderServiceTest passes, but mvn test fails
// Root cause: shared mutable state between tests

// BAD: static state shared across all tests
private static List<Order> globalOrderList = new ArrayList<>();

@Test
void testA() {
    globalOrderList.add(new Order()); // adds to shared state
    assertEquals(1, globalOrderList.size()); // passes alone
}

@Test
void testB() {
    globalOrderList.add(new Order()); // now list has 2 items because testA ran first
    assertEquals(1, globalOrderList.size()); // FAILS when testA ran before it
}

// FIX: instance state reset by @BeforeEach
private List<Order> orderList;

@BeforeEach
void setUp() {
    orderList = new ArrayList<>(); // fresh list for every test
}

@Test
void testA() {
    orderList.add(new Order());
    assertEquals(1, orderList.size()); // always passes
}

@Test
void testB() {
    orderList.add(new Order());
    assertEquals(1, orderList.size()); // always passes, independent of testA
}

Pattern 3: Mockito UnnecessaryStubbingException

// Failure:
// org.mockito.exceptions.misusing.UnnecessaryStubbingException:
// Unnecessary stubbing detected in test class OrderServiceTest

// Root cause: you stubbed a mock but the test never called the stubbed method
// MockitoExtension (strict mode) detects this β€” it is a sign of dead test code

// BAD: stub not needed for THIS test
@Test
void testOrderCreation() {
    when(emailService.send(any())).thenReturn(true);  // never called in this test
    Order order = orderService.createDraftOrder();    // doesn't send emails
    assertEquals(OrderStatus.DRAFT, order.getStatus());
}

// FIX 1: Remove the unnecessary stub
@Test
void testOrderCreation() {
    Order order = orderService.createDraftOrder();
    assertEquals(OrderStatus.DRAFT, order.getStatus());
}

// FIX 2: If the stub is needed only sometimes, use lenient() stubbing
@Test
void testOrderCreation() {
    lenient().when(emailService.send(any())).thenReturn(true); // won't fail if unused
    Order order = orderService.createDraftOrder();
    assertEquals(OrderStatus.DRAFT, order.getStatus());
}

Pattern 4: Assertion Error With Wrong expected/actual Order

// Confusing failure message:
// expected: <PENDING> but was: <CONFIRMED>
// Wait β€” PENDING is what the code returned? That seems backwards...

// Root cause: arguments are swapped β€” actual value passed as 'expected'
// WRONG order:
assertEquals(order.getStatus(), OrderStatus.CONFIRMED); // actual first!

// Failure message: "expected: <PENDING> but was: <CONFIRMED>"
// This is backwards and deeply confusing

// CORRECT order: expected value FIRST, actual value SECOND
assertEquals(OrderStatus.CONFIRMED, order.getStatus(), "Order status");
// Failure message: "Order status expected: <CONFIRMED> but was: <PENDING>"
// Now it reads correctly: you expected CONFIRMED, got PENDING

Debugging Toolkit Quick Reference

SituationTool / Technique
Test fails in suite but not aloneRun with -Djunit.jupiter.execution.order.random=true to expose order dependencies
Need to see System.out in MavenAdd -Dsurefire.useFile=false or configure <useFile>false</useFile>
Intermittent / flaky failureRun 10x with @RepeatedTest(10) to confirm and characterise
Wrong mock behaviourAdd System.out.println(mock(xxx.class)) to verify mock is the right instance
Spring context fails to startEnable debug logging: @SpringBootTest(properties="logging.level.root=DEBUG")
Testcontainers fails to startCheck Docker is running; add withStartupTimeout(Duration.ofSeconds(120))

Frequently Asked Questions (FAQs)

Q1: How do I make JUnit 6 show more detail when a test fails?

Always include a descriptive message as the last argument to every assertion: assertEquals(expected, actual, "Descriptive message here"). For complex objects, override toString() in your domain classes so that failure messages print meaningful state. For collection assertions, assertIterableEquals shows the first differing element clearly.

Q2: My test throws an unexpected exception instead of failing cleanly. How do I investigate?

Read the full stack trace from bottom to top, looking for the first frame that contains your own package name (e.g., com.example). That is the line in your code where the exception originated. Ignore the JUnit framework frames above it. If the exception comes from a mock, check whether your stub covers the exact method signature being called β€” argument type mismatches cause Mockito to return null/default instead of your stub.

Q3: How do I debug a test that hangs and never finishes?

Add @Timeout(value = 10, unit = TimeUnit.SECONDS) to the hanging test. When it times out, JUnit prints a thread dump showing exactly where the test was blocked. Common causes: waiting for an async event that never fires, a database deadlock, an infinite loop in production code, or a Testcontainers container that never became healthy. The thread dump pinpoints the exact blocking call.

Q4: How do I reproduce a test failure that only happens in CI, not locally?

The most common differences between CI and local are: (1) parallel test execution in CI, (2) different JVM timezone or locale, (3) different available memory, (4) different Docker image versions for Testcontainers. To reproduce: run tests in parallel locally (junit.jupiter.execution.parallel.enabled=true), set the same JVM timezone (-Duser.timezone=UTC), and pin Docker image versions with exact tags in your container declarations.

Q5: Are there static analysis tools that can find test bugs before I run them?

Yes. SpotBugs with the fb-contrib plugin detects common JUnit anti-patterns (missing assertions, incorrect exception expectations). ArchUnit can enforce structural test rules (all test methods must have @DisplayName, no static mutable fields in test classes). IntelliJ IDEA’s inspections flag swapped assertEquals arguments and unused stubs in real time as you type.

See Also

Conclusion

Debugging test failures is a skill that improves with practice and the right mental model. Read error messages from bottom to top. Isolate the failing test. Add diagnostic assertions to expose intermediate state. Recognise common patterns β€” shared state, wrong argument order, unnecessary stubs, NPEs from uninitialised objects. With these techniques, what used to take 30 minutes of head-scratching takes 5 minutes of systematic investigation.

Next: How to Fix Flaky Tests in JUnit 6 β€” identify the root causes of intermittent test failures and apply permanent fixes.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.