Good Unit Tests: Naming, Structure, and Coverage

1. Overview

A good unit test is not just one that “passes”. It must be easy to read, easy to maintain, and when it fails it must immediately tell you where the problem is without needing to debug all day. This article covers three pillars: naming tests so they’re self-explanatory, structuring tests with AAA (Arrange-Act-Assert), and the right perspective on coverage so you don’t fall into the trap of “100% coverage but useless”.

After reading, you’ll have a concrete checklist to self-review your tests before creating a pull request.

2. Required Dependencies

Examples in this article use JUnit 5 and AssertJ. If your project uses Spring Boot, spring-boot-starter-test already includes both.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

3. Naming Tests: Names Must Tell a Story

The test name is the first documentation others read when the test fails. A good name answers three questions: what is being tested, under what condition, and what is expected.

3.1. The methodName_condition_expectedResult pattern

class OrderServiceTest {

    @Test
    void calculateTotal_whenCartIsEmpty_returnsZero() {
        OrderService service = new OrderService();
        Order order = new Order(List.of());

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.ZERO);
    }

    @Test
    void calculateTotal_whenCartHasDiscountedItems_appliesDiscount() {
        OrderService service = new OrderService();
        Order order = new Order(List.of(
                new Item("SKU-1", BigDecimal.valueOf(100), 0.1)
        ));

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.valueOf(90.0));
    }
}

Explanation:

  • The name starts with the method being tested (calculateTotal), then when... states the condition, finally the expected result.
  • When the test fails in a CI report, you know immediately which logic branch has the issue — no need to open the code.

Note: avoid names like test1(), testCalculateTotal(). They say nothing about context and force readers to dig into the test body to understand what’s being checked.

3.2. @DisplayName when method names aren’t expressive enough

With complex business rules, long method names are hard to read. Use @DisplayName to write more natural sentences while keeping method names short for code navigation.

@Test
@DisplayName("When customer is VIP and order exceeds 1 million, apply an extra 5% discount")
void calculateTotal_vipCustomerLargeOrder_extraDiscount() {
    // ...
}

When running with Maven Surefire or your IDE, the output displays the @DisplayName sentence instead of the method name, making it much easier to read in reports.

4. AAA Structure: Arrange – Act – Assert

Each test should have three clear sections, in the correct order, without mixing them. This helps readers scan quickly: which lines prepare data, which lines call the behavior, which lines verify the result.

@Test
void withdraw_whenBalanceIsSufficient_deductsAmount() {
    // Arrange
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));

    // Act
    account.withdraw(BigDecimal.valueOf(200));

    // Assert
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(300));
}

Explanation:

  • Arrange creates an Account object with an initial balance.
  • Act calls exactly one action being tested (withdraw).
  • Assert verifies the result, using AssertJ for assertions that read like natural prose.

4.1. One test, one behavior

The more a test “does”, the harder it is to debug when it fails. Avoid writing tests like this:

@Test
void accountOperations() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));
    account.deposit(BigDecimal.valueOf(100));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(600));

    account.withdraw(BigDecimal.valueOf(200));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));

    account.withdraw(BigDecimal.valueOf(1000)); // error: insufficient funds
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));
}

If the first assertion fails, the two subsequent actions don’t run — you lose information. Split into three separate tests: deposit_..., withdraw_sufficientBalance_..., withdraw_insufficientBalance_throwsException.

4.2. Exception tests must verify both type and message

@Test
void withdraw_whenBalanceIsInsufficient_throwsIllegalStateException() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(100));

    assertThatThrownBy(() -> account.withdraw(BigDecimal.valueOf(500)))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("Insufficient balance");
}

The assertThatThrownBy line calls the action and catches the exception in one step. Don’t use the old @Test(expected = ...) style since it doesn’t allow checking the message or state after the exception.

5. Use @ParameterizedTest to Reduce Code Duplication

When multiple different inputs test the same logic, use parameterized tests instead of copy-pasting multiple methods.

@ParameterizedTest
@CsvSource({
    "0, 0",
    "100, 10",
    "1000, 100",
    "-50, 0"
})
void calculateShippingFee_variousOrderAmounts_returnsExpectedFee(
        BigDecimal orderAmount, BigDecimal expectedFee) {

    ShippingCalculator calculator = new ShippingCalculator();

    BigDecimal fee = calculator.calculateFee(orderAmount);

    assertThat(fee).isEqualTo(expectedFee);
}

Explanation:

  • @CsvSource provides input/output pairs, each row is an independent test case.
  • The report displays each case separately, so you know immediately which case failed without guessing.
calculateShippingFee_variousOrderAmounts_returnsExpectedFee(BigDecimal, BigDecimal)
  ✓ [1] 0, 0
  ✓ [2] 100, 10
  ✗ [3] 1000, 100
  ✓ [4] -50, 0

6. Mock Appropriately, Don’t Over-Mock

Use mocks for external dependencies (database, HTTP client, other services), not the object being tested itself.

@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private PaymentService paymentService;

    @Test
    void processPayment_whenGatewayApproves_marksOrderAsPaid() {
        Order order = new Order("ORD-01", BigDecimal.valueOf(200));
        when(paymentGateway.charge(order.getAmount())).thenReturn(ApprovalResult.approved());

        PaymentResult result = paymentService.processPayment(order);

        assertThat(result.isSuccess()).isTrue();
        verify(paymentGateway).charge(BigDecimal.valueOf(200));
    }
}

Explanation:

  • @Mock creates a fake object for PaymentGateway — something called externally (could be a real API).
  • @InjectMocks injects that mock into the PaymentService being tested.
  • verify confirms charge was called with the correct parameters, not just checking the returned result.

Note: if you need to mock more than 3-4 dependencies for one test, that’s a sign the class is doing too much. Consider refactoring the class before breaking down the test.

7. Coverage: A Reference Metric, Not a Goal

High coverage doesn’t mean good tests. For example, the following test achieves 100% line coverage for the divide method but fails to detect a bug:

public int divide(int a, int b) {
    return a / b;
}
@Test
void divide_returnsQuotient() {
    Calculator calculator = new Calculator();

    int result = calculator.divide(10, 2);

    assertThat(result).isEqualTo(5);
}

This test runs through all lines of code (100% coverage) but doesn’t test the b = 0 case — where ArithmeticException would be thrown in production. Coverage only measures which lines have run, not whether they’ve been tested correctly.

7.1. Use coverage to find blind spots, not to make reports look good

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <execution>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>
    </executions>
</plugin>

Run mvn test, and JaCoCo generates an HTML report at target/site/jacoco/index.html. Use the report to answer “which branches haven’t been tested yet”, then ask yourself “is that branch’s logic important” — don’t follow an absolute % number.

Element           Missed Instructions  Cov.
Calculator        12 of 40             70%
  divide()        4 of 8               50%  ← b=0 case not tested

7.2. Edge cases are more important than the percentage

Add tests for the missed case above:

@Test
void divide_whenDivisorIsZero_throwsArithmeticException() {
    Calculator calculator = new Calculator();

    assertThatThrownBy(() -> calculator.divide(10, 0))
            .isInstanceOf(ArithmeticException.class);
}

One line of test like this is worth far more than writing 5 additional tests repeating normal cases just to push % coverage higher.

8. Self-Review Checklist for Tests

Before creating a pull request, check:

  • Do test names clearly state the condition and expectation, or are they just test1, testX?
  • Does each test have exactly one behavior, one reason to fail?
  • Are there edge cases: null, empty, 0, negative values, upper limits?
  • Are there error cases (exceptions), or just the happy path?
  • Where is coverage low — is it important logic that was missed, or just getters/setters that don’t need testing?

9. Conclusion

A good unit test is one where, when it fails, you immediately know where the problem is without needing to debug. Name tests using the method_condition_expected pattern, maintain clear AAA structure with one behavior per test, and use coverage as a tool to find blind spots rather than a target to achieve — these three simple principles are enough to elevate your test suite quality to the next level. The hardest part isn’t writing assertions, it’s taking the time to think about edge cases your code might encounter in production.

testingjunitjavabest-practicesspring-boot