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), thenwhen...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:
Arrangecreates anAccountobject with an initial balance.Actcalls exactly one action being tested (withdraw).Assertverifies 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:
@CsvSourceprovides 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:
@Mockcreates a fake object forPaymentGateway— something called externally (could be a real API).@InjectMocksinjects that mock into thePaymentServicebeing tested.verifyconfirmschargewas 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.