Writing Your First Clean Test in JUnit 6 (Best Practices)

Writing a test that compiles and passes is easy. Writing a test that is clean, readable, trustworthy, and maintainable takes deliberate practice. This guide walks you through exactly what separates a good JUnit 6 test from a great one — structure, naming, assertions, test isolation, and the habits that experienced Java developers rely on every day.

Every example here follows the patterns established in the JUnit 6 Tutorial series, building on the lifecycle and project structure knowledge from earlier chapters.

The Anatomy of a Clean JUnit 6 Test

Every well-written test has three distinct phases, known as the Arrange-Act-Assert (AAA) pattern. Each phase has one job:

  • Arrange — Set up the objects, data, and state needed for the test
  • Act — Call the method or trigger the behaviour being tested
  • Assert — Verify the outcome is what you expected
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class BankAccountTest {

    // ----------------------------------------------------------------
    // CLEAN TEST — follows Arrange-Act-Assert with clear separation
    // ----------------------------------------------------------------
    @Test
    @DisplayName("Depositing funds increases balance by the deposited amount")
    void depositingFundsIncreasesBalance() {

        // Arrange: set up the object under test and the input
        BankAccount account = new BankAccount("ACC001", 100.00);
        double depositAmount = 50.00;

        // Act: call the method being tested
        account.deposit(depositAmount);

        // Assert: verify the outcome
        assertEquals(150.00, account.getBalance(), 0.001,
            "Balance should be initial 100 + deposited 50 = 150");
    }

    // ----------------------------------------------------------------
    // CLEAN TEST — exception testing with descriptive assertion message
    // ----------------------------------------------------------------
    @Test
    @DisplayName("Depositing a negative amount throws IllegalArgumentException")
    void depositingNegativeAmountThrowsException() {

        // Arrange
        BankAccount account = new BankAccount("ACC001", 100.00);

        // Act + Assert combined: assertThrows captures the exception
        IllegalArgumentException exception = assertThrows(
            IllegalArgumentException.class,
            () -> account.deposit(-10.00),
            "Negative deposit should throw IllegalArgumentException"
        );

        // Assert the exception message contains meaningful information
        assertTrue(exception.getMessage().contains("negative"),
            "Exception message should mention 'negative'");
    }
}

Best Practice 1: One Behaviour Per Test

Each test method should verify one specific behaviour. When a test fails, you want to know immediately what went wrong without reading a 50-line test method:

// Bad: tests multiple unrelated behaviours in one method
@Test
void testBankAccount() {
    BankAccount account = new BankAccount("ACC001", 100.00);
    account.deposit(50.00);
    assertEquals(150.00, account.getBalance(), 0.001);
    account.withdraw(30.00);
    assertEquals(120.00, account.getBalance(), 0.001);
    assertThrows(IllegalArgumentException.class, () -> account.withdraw(9999.00));
    // If the first assertion fails, the others never run
    // The test name gives no indication which behaviour failed
}

// Good: each test verifies one specific behaviour
@Test
@DisplayName("Deposit increases balance by the deposited amount")
void depositIncreasesBalance() {
    BankAccount account = new BankAccount("ACC001", 100.00);
    account.deposit(50.00);
    assertEquals(150.00, account.getBalance(), 0.001);
}

@Test
@DisplayName("Withdrawal decreases balance by the withdrawn amount")
void withdrawalDecreasesBalance() {
    BankAccount account = new BankAccount("ACC001", 100.00);
    account.withdraw(30.00);
    assertEquals(70.00, account.getBalance(), 0.001);
}

@Test
@DisplayName("Withdrawing more than balance throws InsufficientFundsException")
void withdrawingMoreThanBalanceThrowsException() {
    BankAccount account = new BankAccount("ACC001", 100.00);
    assertThrows(InsufficientFundsException.class, () -> account.withdraw(9999.00));
}

Best Practice 2: Use @DisplayName for Human-Readable Test Names

@DisplayName lets you write a full English sentence describing what the test verifies. This makes test reports readable by everyone on the team — including non-developers:

// These method names appear in reports exactly as-is
void test1() { }
void depositWorks() { }

// These produce readable, sentence-like report entries
@Test
@DisplayName("Depositing £0 should not change the account balance")
void zeroDepositDoesNotChangeBalance() {
    BankAccount account = new BankAccount("ACC001", 100.00);
    account.deposit(0.00);
    assertEquals(100.00, account.getBalance(), 0.001);
}

Best Practice 3: Provide Assertion Failure Messages

When an assertion fails, the error message tells you what went wrong. Without a custom message, you get generic output. With one, you immediately understand the failure context:

// Without message: AssertionFailedError: expected:  but was: 
assertEquals(150.00, account.getBalance(), 0.001);

// With message: AssertionFailedError: Balance after deposit should be 150
//   expected:  but was: 
assertEquals(150.00, account.getBalance(), 0.001,
    "Balance after deposit should be 150");

// Use a Supplier for expensive message construction
// The lambda only executes when the assertion FAILS
assertEquals(150.00, account.getBalance(), 0.001,
    () -> "Expected balance 150 but got " + account.getBalance()
        + " after depositing 50 into account " + account.getId());

Best Practice 4: Use assertAll for Independent Assertions

When you need to check multiple properties of an object, assertAll runs every assertion even if earlier ones fail — giving you the complete picture in one test run:

@Test
@DisplayName("Newly created account has correct initial state")
void newAccountHasCorrectInitialState() {
    BankAccount account = new BankAccount("ACC999", 500.00);

    // assertAll: ALL assertions run even if one fails.
    // Compare to chaining assertions, where the first failure stops all others.
    assertAll("newly created bank account properties",
        () -> assertEquals("ACC999",  account.getId(),        "Account ID"),
        () -> assertEquals(500.00,    account.getBalance(),   0.001, "Initial balance"),
        () -> assertFalse(            account.isFrozen(),     "New account should not be frozen"),
        () -> assertNotNull(          account.getCreatedAt(), "Creation timestamp must be set")
    );
}

Best Practice 5: Never Put Logic in Tests

Tests with if, for, or while statements become hard to understand and can hide bugs. Use parameterized tests instead:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

// Bad: loop inside a test hides failures and is hard to read
@Test
void multipleDepositsAllWork() {
    double[] amounts = {10.0, 50.0, 100.0};
    for (double amount : amounts) {
        BankAccount account = new BankAccount("ACC001", 0.0);
        account.deposit(amount);
        assertEquals(amount, account.getBalance(), 0.001); // which iteration failed?
    }
}

// Good: @ParameterizedTest runs a separate test per input row
@ParameterizedTest(name = "Depositing {0} into account with {1} balance gives {2}")
@CsvSource({
    "10.0,  0.0,  10.0",
    "50.0,  100.0, 150.0",
    "0.0,   75.0,  75.0"
})
void depositProducesCorrectBalance(double depositAmount,
                                   double initialBalance,
                                   double expectedBalance) {
    BankAccount account = new BankAccount("ACC001", initialBalance);
    account.deposit(depositAmount);
    assertEquals(expectedBalance, account.getBalance(), 0.001);
}

Best Practice 6: Keep Tests Fast and Independent

  • Fast: Unit tests should run in milliseconds. If a test takes seconds, it probably belongs in an integration test class tagged @Tag("slow").
  • Independent: Tests must not depend on execution order. Each test arranges its own state via @BeforeEach. Never rely on the result of a previous test method.
  • Repeatable: Tests must produce the same result every time, on any machine, regardless of environment.
  • Self-validating: A test either passes or fails — no human judgement needed to interpret the result.

Complete Clean Test Class Example

package com.example.bank;

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("BankAccount — deposit and withdrawal behaviour")
class BankAccountTest {

    private BankAccount account; // fresh instance per test

    @BeforeEach
    void createAccountWithInitialBalance() {
        // Arrange shared setup: every test starts with a 200.00 account
        account = new BankAccount("ACC-TEST", 200.00);
    }

    @Test
    @DisplayName("New account has the specified initial balance")
    void newAccountHasCorrectInitialBalance() {
        assertEquals(200.00, account.getBalance(), 0.001);
    }

    @ParameterizedTest(name = "Depositing {0} increases balance correctly")
    @ValueSource(doubles = {0.01, 10.0, 100.0, 999.99})
    void depositIncreasesBalanceByDepositedAmount(double amount) {
        double expectedBalance = 200.00 + amount;
        account.deposit(amount);
        assertEquals(expectedBalance, account.getBalance(), 0.001,
            () -> "After depositing " + amount + ", balance should be " + expectedBalance);
    }

    @Test
    @DisplayName("Withdrawing exact balance leaves zero balance")
    void withdrawingEntireBalanceLeavesZero() {
        account.withdraw(200.00);
        assertEquals(0.00, account.getBalance(), 0.001);
    }

    @Test
    @DisplayName("Overdraft attempt throws InsufficientFundsException")
    void overdraftThrowsInsufficientFundsException() {
        assertThrows(InsufficientFundsException.class,
            () -> account.withdraw(201.00));
    }
}

Expected Output

BankAccount — deposit and withdrawal behaviour
  ✔ New account has the specified initial balance
  ✔ Depositing 0.01 increases balance correctly
  ✔ Depositing 10.0 increases balance correctly
  ✔ Depositing 100.0 increases balance correctly
  ✔ Depositing 999.99 increases balance correctly
  ✔ Withdrawing exact balance leaves zero balance
  ✔ Overdraft attempt throws InsufficientFundsException

Tests run: 7, Failures: 0, Errors: 0, Skipped: 0

Frequently Asked Questions (FAQs)

Q1: Should I always use the AAA pattern?

Yes, for virtually all unit tests. The Arrange-Act-Assert pattern is widely understood and makes tests easy to read even months after they were written. The only common variant is Given-When-Then (popular in BDD), which maps directly to Arrange-Act-Assert with different vocabulary. Both are correct.

Q2: How long should a test method be?

A well-written test method is typically 5–15 lines. If your test is growing beyond 20–30 lines, it is likely testing too many things at once or has complex arrange logic that belongs in a helper method or @BeforeEach. Long test methods are a code smell in the test suite.

Q3: Should tests have comments?

Use a blank line to visually separate Arrange, Act, and Assert instead of inline comments whenever possible. Comments like // Arrange are acceptable when you are teaching others the pattern, but in a mature codebase, the code structure itself should communicate the phases. Use @DisplayName to document intent at the test level.

Q4: Is it OK to have a test with no assertion?

Generally no. A test with no assertion always passes, making it useless as a verification tool. The only valid exception is a test that uses assertDoesNotThrow implicitly — but even then, using assertDoesNotThrow() explicitly is better practice because it communicates intent. If you want to confirm that code does not throw, say so explicitly.

Q5: What is the difference between fail() and assertThrows()?

assertThrows(ExceptionType.class, executable) is the idiomatic JUnit 6 way to verify exception throwing — it captures and returns the exception for further inspection. fail("message") unconditionally fails the test at that point; it is useful in catch blocks when you want to fail if an exception was NOT thrown, but assertThrows is the cleaner, more expressive choice in modern JUnit 6 tests.

See Also

Conclusion

Clean tests are not about being clever — they are about being clear. One behaviour per test, descriptive display names, meaningful assertion messages, fast and independent execution, and no logic inside the test method. These six practices will make your JUnit 6 test suite a genuine asset rather than a maintenance burden.

Next: JUnit 6 Assertions: All Methods Explained with Real Examples — every assertion method in the library, with examples of when to use each one.

Leave a Reply

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