Java Testing
A collection of test-writing best practices for JUnit 5, AssertJ, Mockito, and Testcontainers. Designed for AI agents and LLMs to write readable, maintainable, and trustworthy tests.
Categories
Mockito [HIGH]
Mock only architectural boundaries, with strict, verifiable stubbing.
| Rule | Description |
|---|---|
| mockito-argument-captor | Use ArgumentCaptor to inspect call arguments |
| mockito-avoid-mocking-value-objects | Never mock value objects |
| mockito-mock-boundaries-only | Mock only architectural boundaries |
| mockito-spy-sparingly | Use @Spy sparingly |
| mockito-strict-stubbing | Rely on Mockito's strict stubbing |
| mockito-verify-behavior-not-implementation | Verify behavior, not implementation |
JUnit 5 Structure [HIGH]
Structure JUnit 5 tests for clarity: one behavior per test, explicit lifecycle, readable names.
| Rule | Description |
|---|---|
| junit5-structure-aaa-pattern | Structure tests with arrange-act-assert |
| junit5-structure-display-name | Use @DisplayName for human-readable test names |
| junit5-structure-lifecycle-annotations | Use lifecycle annotations for shared setup and teardown |
| junit5-structure-nested-test-classes | Group related tests with @Nested |
| junit5-structure-one-assertion-concept | Assert one behavioral concept per test |
AssertJ Assertions [HIGH]
Write fluent, complete AssertJ assertions instead of terse JUnit asserts.
| Rule | Description |
|---|---|
| assertj-assertthatthrownby | Assert exceptions with assertThatThrownBy |
| assertj-extracting-for-collections | Use extracting to assert on collection elements' fields |
| assertj-fluent-over-junit-asserts | Prefer AssertJ's fluent assertions over JUnit's assertEquals |
| assertj-soft-assertions | Use SoftAssertions to report every failure in one run |
Fixtures [HIGH]
Build and share test data and setup without hidden shared mutable state.
| Rule | Description |
|---|---|
| fixtures-avoid-shared-mutable-fixtures | Avoid shared mutable fixtures |
| fixtures-builder-for-test-data | Use a test-data builder instead of telescoping constructors |
| fixtures-extension-for-shared-setup | Extract cross-cutting setup into a JUnit extension |
| fixtures-test-instance-per-method-default | Leave test instance lifecycle at per-method default |
Testcontainers [HIGH]
Run real dependencies in tests with correctly scoped container lifecycle and explicit wait strategies.
| Rule | Description |
|---|---|
| testcontainers-container-reuse | Reuse containers across a test run instead of restarting per class |
| testcontainers-lifecycle-scope | Match container lifecycle scope to what the container represents |
| testcontainers-wait-strategy-explicit | Declare an explicit wait strategy for every container |
Parameterized Tests [MEDIUM]
Replace duplicated test methods with parameterized sources and readable case names.
| Rule | Description |
|---|---|
| parametrized-csv-source | Use @CsvSource for compact scalar argument tables |
| parametrized-enum-source | Use @EnumSource to cover a whole enum's cases |
| parametrized-method-source | Use @MethodSource for complex parameterized arguments |
| parametrized-readable-display-names | Give parameterized tests readable display names |
Test Doubles [MEDIUM]
Choose the right test double for the job and avoid mocking types you don't own.
| Rule | Description |
|---|---|
| test-doubles-dummy-stub-mock-distinction | Know the difference between dummy, stub, and mock |
| test-doubles-fake-over-mock | Prefer a fake over a mock for stateful collaborators |
| test-doubles-no-mocking-what-you-dont-own | Don't mock types you don't own |
Quick Reference
Mockito
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock PaymentGateway paymentGateway; // real boundary: external HTTP call
@InjectMocks OrderService service;
@Test
void appliesDiscountToRealOrder() {
Order order = new Order(List.of(new LineItem("sku-1", Money.of("100.00"))));
Order discounted = service.applyDiscount(order, Percentage.of(10));
assertThat(discounted.total()).isEqualTo(Money.of("90.00"));
}
}
JUnit 5 Structure
@Test
void discountAppliesToEligibleOrder() {
Order order = new Order(customerId, List.of(item1, item2));
order.applyDiscount(discountPolicy);
assertThat(order.total()).isEqualByComparingTo("90.00");
}
AssertJ Assertions
@Test
void orderTotalsCorrectly() {
assertThat(order.items()).hasSize(2).extracting(LineItem::sku).contains("sku-1");
assertThat(order.total()).isEqualByComparingTo("90.00");
}
Fixtures
@Test
void appliesDiscountToEligibleOrder() {
Order order = anOrder().withTotal("100.00").withCustomerTier(Tier.GOLD).build();
Order discounted = service.applyDiscount(order);
assertThat(discounted.total()).isEqualByComparingTo("90.00");
}
Testcontainers
GenericContainer<?> app = new GenericContainer<>("my-app:latest")
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/health")
.forStatusCode(200)
.withStartupTimeout(Duration.ofSeconds(60)));
app.start(); // blocks until /health actually returns 200
Parameterized Tests
@ParameterizedTest
@CsvSource({
"100.00, 0.20, 120.00",
"50.00, 0.10, 55.00"
})
void computesVat(BigDecimal net, BigDecimal rate, BigDecimal expected) {
assertThat(service.applyVat(net, rate)).isEqualByComparingTo(expected);
}
Test Doubles
class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> store = new HashMap<>();
@Override public void save(Order order) { store.put(order.id(), order); }
@Override public Optional<Order> findById(String id) { return Optional.ofNullable(store.get(id)); }
}
See Also
- java-coding-standards - General Java coding standards and best practices
- java-tooling - Build, static analysis, CI quality gate, dependency management, and formatting rules