Common JUnit Testing Mistakes and How to Avoid Them

Everyone makes testing mistakes — but the same mistakes get made over and over by developers at every experience level. This guide collects the most damaging and most common JUnit testing anti-patterns, explains exactly why each is harmful, and shows the correct approach with before/after code. Every example below uses current JUnit 6 (Jupiter) API — org.junit.jupiter.api.Assertions static imports, current annotations — and calls out the spots where JUnit 6 specifically changes the calculus versus JUnit 5. Work through this list once and you will never make these mistakes again.

Mistake 1: Testing Implementation, Not Behaviour

The most fundamental mistake. Tests that verify how code works internally break every time the implementation changes, even if the behaviour stays correct.

// WRONG: tests internal implementation details
@Test
void testOrderService() {
    orderService.placeOrder(order);
    // Verifying internal calls — breaks if you refactor without changing behaviour
    verify(orderRepository).findByCustomerId(anyLong());
    verify(cacheService).invalidate("orders:customer:1");
    verify(metricsService).increment("orders.placed");
    verify(auditLogger).log(any(AuditEvent.class));
    // This test breaks if you rename a method, add a cache layer, or change logging
}

// CORRECT: tests observable behaviour
@Test
@DisplayName("Placing an order persists it and confirms via email")
void placingOrderPersistsItAndSendsConfirmation() {
    Order placed = orderService.placeOrder(order);

    // Verify what the consumer of this service observes
    assertNotNull(placed.getId(),              "Order must be assigned an ID");
    assertEquals(OrderStatus.CONFIRMED,        placed.getStatus());
    verify(emailService).sendConfirmation(order.getCustomerEmail()); // external contract
    // Internal caching, metrics, auditing: tested in their own unit tests
}

Mistake 2: Assertions Without Messages

// WRONG: failure message is cryptic
assertEquals("CONFIRMED", order.getStatus().name());
// Failure: expected:  but was: 
// No context: WHICH order? After WHAT operation? In WHAT test scenario?

// CORRECT: failure message explains context
assertEquals(OrderStatus.CONFIRMED, order.getStatus(),
    "Order should be CONFIRMED after successful payment authorisation, "
    + "got: " + order.getStatus()
    + ", payment result: " + order.getPaymentResult());
// Failure message now contains everything needed to diagnose without running the test again

Mistake 3: One Giant Test Class for Everything

// WRONG: hundreds of unrelated tests in one class
class OrderTests {
    // 50 tests for creating orders
    // 30 tests for updating orders
    // 20 tests for cancelling orders
    // 15 tests for the payment flow
    // 10 tests for email notifications
    // 5 tests for audit logging
    // Total: 130 tests in one 2000-line class
    // Result: impossible to navigate, slow to scroll, hard to understand scope
}

// CORRECT: focused test classes with clear scope
class OrderCreationTest    { /* 50 tests for creation only */ }
class OrderUpdateTest      { /* 30 tests for update only */ }
class OrderCancellationTest{ /* 20 tests for cancellation */ }
class PaymentFlowTest      { /* 15 tests for payment */ }
// OR: one class with @Nested groups
@DisplayName("OrderService")
class OrderServiceTest {
    @Nested @DisplayName("Order Creation")
    class Creation { /* 50 tests */ }
    @Nested @DisplayName("Order Update")
    class Update { /* 30 tests */ }
    @Nested @DisplayName("Order Cancellation")
    class Cancellation { /* 20 tests */ }
}

JUnit 5 → 6 note: the @Nested grouping pattern itself is unchanged, but JUnit 6’s Java 17 baseline means you can use a record-based @TestInstance(Lifecycle.PER_CLASS) nested class with constructor injection more freely than under JUnit 5’s Java 8 baseline, where some teams avoided per-class lifecycle to dodge state-leak bugs in older JVMs. If you are splitting a giant class, this is a good moment to also revisit whether each new class needs PER_METHOD (the default, and still the safer choice for mutable fixtures) or genuinely benefits from PER_CLASS.

Mistake 4: Swapped assertEquals Arguments

// WRONG: actual value passed as 'expected'
assertEquals(order.getStatus(), OrderStatus.CONFIRMED);
// Failure message: "expected:  but was: "
// This is backwards — PENDING is the actual value from your code
// The failure message is deeply misleading

// CORRECT: expected value FIRST, actual value SECOND (always)
assertEquals(OrderStatus.CONFIRMED, order.getStatus(), "Order status");
// Failure message: "Order status expected:  but was: "
// Now it reads: "I expected CONFIRMED, the code returned PENDING" — clear!

// Memory aid: assertEquals(WHAT_YOU_WANT, WHAT_YOU_GOT, "context");

Mistake 5: Using @SpringBootTest for Unit Tests

// WRONG: Spring context loaded for pure business logic
@SpringBootTest  // 5+ second startup for a test that only does arithmetic
class DiscountCalculatorTest {
    @Autowired DiscountCalculator calculator; // could be: new DiscountCalculator()

    @Test
    void tenPercentDiscountOnHundredGivesNinety() {
        assertEquals(90.0, calculator.apply(100.0, 0.10), 0.001);
    }
}

// CORRECT: pure unit test, no Spring
class DiscountCalculatorTest {
    private final DiscountCalculator calculator = new DiscountCalculator();

    @Test
    void tenPercentDiscountOnHundredGivesNinety() {
        assertEquals(90.0, calculator.apply(100.0, 0.10), 0.001);
    }
    // 1000x faster. Same coverage. No Spring dependency at all.
}

JUnit 5 → 6 note: JUnit 6 requires Java 17 as the minimum runtime, which removes the older justification some teams gave for leaning on @SpringBootTest everywhere — “we need Spring’s classpath scanning to wire records/sealed-class based test fixtures cleanly.” Java 17’s native record and pattern-matching support means plain unit tests can use those features directly without any container, so there is even less reason to reach for a full Spring context out of habit.

Mistake 6: Tests That Always Pass (Missing Assertions)

// WRONG: test has no assertions — always passes, tests nothing
@Test
void testOrderCreation() {
    // This test will ALWAYS pass because it never asserts anything
    orderService.createOrder("[email protected]", 99.99);
    // No assertions = no value = false sense of security
}

// ALSO WRONG: catches exception but doesn't rethrow — failure swallowed
@Test
void testOrderCreation() {
    try {
        orderService.createOrder(null, 99.99);
    } catch (Exception e) {
        // swallowed! Test passes even when it should fail
    }
}

// CORRECT: every test has meaningful assertions
@Test
void creatingOrderWithValidInputAssignsIdAndSetsStatus() {
    Order order = orderService.createOrder("[email protected]", 99.99);
    assertNotNull(order.getId(),              "Order must have an assigned ID");
    assertEquals(OrderStatus.PENDING,         order.getStatus());
    assertEquals("[email protected]",         order.getCustomerEmail());
}

// Use assertDoesNotThrow for "should not fail" verification
@Test
void validOrderCreationDoesNotThrowAnyException() {
    assertDoesNotThrow(
        () -> orderService.createOrder("[email protected]", 49.99),
        "Creating an order with valid input must not throw"
    );
}

JUnit 5 → 6 note: the static imports here — assertNotNull, assertEquals, assertDoesNotThrow — all still come from org.junit.jupiter.api.Assertions in JUnit 6, unchanged from JUnit 5. If your IDE autocompletes a different package, double-check your dependency coordinates; a stray junit-jupiter 5.x artifact left over from a partial upgrade is a common source of confusing compile errors after migrating a module to JUnit 6.

Mistake 7: Testing Random Data Without a Fixed Seed

// WRONG: Random without seed = flaky tests
@Test
void sortingAlgorithmSortsCorrectly() {
    List numbers = new ArrayList();
    Random rng = new Random(); // different values every run
    for (int i = 0; i < 10; i++) numbers.add(rng.nextInt(100));

    List sorted = sorter.sort(numbers);
    // Might find an edge case today but not tomorrow
    assertEquals(numbers.stream().sorted().collect(toList()), sorted);
}

// CORRECT: Use seeded Random for reproducible test data
@Test
void sortingAlgorithmSortsCorrectly() {
    Random seededRng = new Random(42L); // FIXED seed = same values every run
    List numbers = new ArrayList();
    for (int i = 0; i < 10; i++) numbers.add(seededRng.nextInt(100));

    List sorted = sorter.sort(numbers);
    assertEquals(numbers.stream().sorted().collect(Collectors.toList()), sorted,
        "Sorted output must match natural ordering of input");
    // Reproducible: seed 42 always gives the same input, so same test behaviour
}

JUnit 5 → 6 note: this mistake compounds badly if your build also enables JUnit 6’s parallel test execution (junit.jupiter.execution.parallel.enabled=true in junit-platform.properties). An unseeded Random combined with parallel execution makes failures even harder to reproduce, because the failing run’s thread interleaving is also non-deterministic. If you are turning on parallelism as part of a JUnit 6 upgrade, fix unseeded random data first — otherwise flaky failures become close to undebuggable.

Common Mistakes Quick Reference

#MistakeConsequenceFix
1Testing implementation detailsTests break on safe refactorsTest observable behaviour only
2Assertions without messagesCryptic failure outputAlways add a descriptive message
3One giant test classUnnavigable, slow to maintainSplit by concern or use @Nested
4Swapped assertEquals argsMisleading failure messagesexpected first, actual second
5@SpringBootTest for unit tests1000x slower than necessaryUse new Object() for no-dependency classes
6No assertions in testFalse confidence, always greenEvery test must assert something
7Unseeded RandomFlaky, non-reproducibleUse new Random(fixedSeed)

AI Prompts for Catching These Mistakes

Audit a Test Class Against This Mistake List

Check the JUnit 6 test class below against these seven anti-patterns: testing
implementation details instead of behaviour, assertions with no failure message,
one oversized test class, swapped assertEquals arguments (expected/actual
reversed), @SpringBootTest used for pure unit logic, tests with no real
assertions, and unseeded Random usage. Test class to audit: [paste your test class here]

What it does: Scans the pasted class against all seven mistakes in this guide and returns a numbered list of violations with the exact line and which mistake it matches, plus a corrected version of just the affected methods.

When to use it: Right before opening a PR that touches test code, or as a one-time sweep when inheriting an unfamiliar test suite. Pair it with mutation testing (see Mutation Testing with PIT and JUnit 6) to confirm the fixes actually improved coverage, not just appearance.

Frequently Asked Questions (FAQs)

Q1: Is it ever acceptable to test implementation details?

Rarely. The main exception is security-critical code where you need to verify that a specific algorithm (e.g., bcrypt, AES-256) is being used, not just that the output is correct. Even then, prefer testing the outcome (e.g., the encrypted value can be decrypted correctly, not that a specific cipher class was instantiated). For performance-critical paths, verify via benchmarks rather than mock call counts.

Q2: I inherited a codebase with all these mistakes. Where do I start?

Start with the highest-value fixes in this order: (1) tests with no assertions (Mistake 6) — they provide zero value and are the fastest to find with a quick grep for test methods with no assert call, (2) @SpringBootTest-to-unit-test conversions (Mistake 5) for the slowest tests, since this gives the biggest CI time win for the least risk, (3) assertion messages (Mistake 2) on the most-frequently-run test classes. Do not try to fix everything at once — the Refactoring Legacy Tests playbook provides a structured migration approach.

Q3: How do I catch swapped assertEquals arguments automatically?

IntelliJ IDEA has a built-in inspection (“Probable bugs → JUnit: assertEquals arguments order suspect”) that detects when the expected value looks like a variable and the actual value looks like a literal. Enable it in Settings → Inspections. SpotBugs also flags this with the IJU_ASSERT_METHOD_INVOCATION_WITHOUT_MESSAGE rule. Enable these inspections in your IDE and add them to your CI SpotBugs configuration so swapped arguments fail the build, not just the code review.

Q4: Does JUnit 6 change anything about how assertEquals reports failures?

The assertion API surface (assertEquals, assertAll, message ordering) is unchanged from JUnit 5 — the expected-then-actual-then-message argument order from Mistake 4 still applies exactly as before. What changes in JUnit 6 is the platform underneath: a Java 17+ baseline and refined extension model, not the assertion contract itself. If you see different failure-message formatting after upgrading, check whether your IDE or build tool is rendering JUnit 6 output through an updated test reporter rather than assuming the assertion semantics changed.

Q5: Should I use AssertJ instead of JUnit’s built-in Assertions?

Both are valid. JUnit 6’s built-in assertions cover the majority of use cases. AssertJ provides a more fluent, readable API (assertThat(order.getStatus()).isEqualTo(CONFIRMED)) and superior collection/string assertions. For teams that find the fluent style more readable, AssertJ is an excellent complement. It is already included in spring-boot-starter-test. Use whichever your team reads most clearly — consistency within a project matters more than which library you choose.

See Also

Conclusion

Every mistake in this guide has one thing in common: it reduces the value of the test without reducing its cost. Tests that test implementation details cost maintenance time whenever you refactor. Tests without assertions consume CI time without providing coverage. Tests with swapped arguments mislead you during debugging. None of these are JUnit 6-specific bugs — they are habits that carry over from JUnit 4 and JUnit 5 if nobody breaks the pattern. Recognise these patterns early, apply the fixes, and your test suite will become a genuine asset that earns its runtime cost many times over.

Next: Property-Based Testing in JUnit 6 with jqwik — go beyond example-based tests and let the framework generate hundreds of inputs automatically to find edge cases you never thought of.

Leave a Reply

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