Testing Best Practices in Java
Writing tests is easy. Writing good tests — tests that help rather than hinder — takes discipline. This chapter covers the practices that separate fragile test suites from robust ones.
1. One Assertion Concept Per Test
// ❌ BAD — multiple unrelated assertions
@Test
void testUser() {
User user = userService.createUser("Alice", "alice@test.com");
assertNotNull(user);
assertEquals("Alice", user.getName());
assertTrue(user.isActive());
assertNotNull(user.getCreatedAt());
User found = userService.findById(user.getId());
assertEquals("Alice", found.getName());
}
// ✅ GOOD — one concept per test
@Test
void shouldCreateUserWithCorrectName() {
User user = userService.createUser("Alice", "alice@test.com");
assertEquals("Alice", user.getName());
}
@Test
void shouldCreateActiveUser() {
User user = userService.createUser("Alice", "alice@test.com");
assertTrue(user.isActive());
}
@Test
void shouldBeRetrievableAfterCreation() {
User created = userService.createUser("Alice", "alice@test.com");
User found = userService.findById(created.getId());
assertEquals(created.getId(), found.getId());
}
Use
assertAll()to group related assertions about the same object — that’s fine.
2. Naming Conventions
Pattern: should_[ExpectedBehavior]_when_[Condition]
@Test void shouldReturnUser_whenIdExists() {}
@Test void shouldThrowException_whenEmailIsDuplicate() {}
@Test void shouldSendWelcomeEmail_whenUserIsCreated() {}
@Test void shouldReturnEmptyList_whenNoUsersExist() {}
Or simply: shouldDoSomething
@Test void shouldCreateUserWithValidInput() {}
@Test void shouldRejectBlankEmail() {}
@Test void shouldCalculateTotalWithDiscount() {}
With
@DisplayName, method names matter less — but keep them descriptive for CI logs.
3. Arrange-Act-Assert (AAA)
@Test
void shouldApplyDiscount() {
// Arrange
ShoppingCart cart = new ShoppingCart();
cart.addItem(new Product("Laptop", new BigDecimal("1000.00")));
Coupon coupon = new Coupon("SAVE10", 10);
// Act
cart.applyCoupon(coupon);
// Assert
assertEquals(new BigDecimal("900.00"), cart.getTotal());
}
Blank lines between sections improve readability.
4. Tests Must Be Independent
// ❌ BAD — tests depend on execution order
private static User sharedUser;
@Test void step1_createUser() {
sharedUser = userService.createUser("Alice", "alice@test.com");
}
@Test void step2_verifyUser() {
assertNotNull(sharedUser); // fails if step1 didn't run first
}
// ✅ GOOD — each test sets up its own data
@Test void shouldCreateAndVerifyUser() {
User user = userService.createUser("Alice", "alice@test.com");
assertNotNull(user.getId());
}
5. Don’t Test Private Methods
// ❌ BAD — testing private method via reflection
@Test
void testPrivateValidation() throws Exception {
Method method = UserService.class.getDeclaredMethod("validateEmail", String.class);
method.setAccessible(true);
Boolean result = (Boolean) method.invoke(userService, "test@test.com");
assertTrue(result);
}
// ✅ GOOD — test through the public API
@Test
void shouldRejectInvalidEmail() {
assertThrows(IllegalArgumentException.class,
() -> userService.createUser("Alice", "bad-email"));
}
Private methods are implementation details. Test the public behavior.
6. Don’t Test Frameworks
// ❌ BAD — testing that ArrayList works
@Test
void shouldAddToList() {
List<String> list = new ArrayList<>();
list.add("hello");
assertEquals(1, list.size()); // Why are you testing Java Collections?
}
// ❌ BAD — testing that Spring DI works
@Test
void shouldInjectBean() {
assertNotNull(userService); // Spring has its own tests
}
Test your logic, not the framework’s.
7. Keep Tests Fast
| Test Type | Expected Speed |
|---|---|
| Unit test | < 50ms |
| Integration test | < 5s |
| End-to-end test | < 30s |
Slow tests → developers skip them → bugs slip through
Tips:
- Mock external dependencies in unit tests.
- Use shared Testcontainers (singleton pattern) for integration tests.
- Tag slow tests with
@Tag("slow")and exclude from local builds.
8. The Testing Pyramid (and Anti-Pattern)
✅ Correct: Testing Pyramid
/ E2E \ Few, slow, expensive
/─────────\
/ Integration \ Medium amount
/───────────────\
/ Unit Tests \ Many, fast, cheap
/───────────────────\
❌ Anti-Pattern: Ice Cream Cone
/───────────────────\
/ Manual Testing \ Most effort here
/───────────────────────\
\ E2E / UI Tests / Slow, flaky
\───────────────────/
\ Integration / Some
\───────────────/
\ Unit Tests / Few or none
\───────────/
Most teams start as an ice cream cone and evolve toward the pyramid.
9. Avoiding Flaky Tests
Flaky = passes sometimes, fails other times. They destroy trust in the test suite.
| Cause | Fix |
|---|---|
Time-dependent (new Date()) |
Inject a Clock and control it |
| Random data | Use seed-based random or fixed test data |
| Shared state between tests | Use @BeforeEach to reset |
| External service dependency | Mock it |
| Race conditions | Await results, don’t Thread.sleep() |
// ❌ Flaky — depends on system time
@Test
void shouldExpireAfterOneHour() {
Token token = tokenService.generate();
Thread.sleep(3600_000); // 😱 waits 1 hour
assertTrue(token.isExpired());
}
// ✅ Deterministic — inject clock
@Test
void shouldExpireAfterOneHour() {
Clock fixedClock = Clock.fixed(Instant.parse("2024-01-01T12:00:00Z"), ZoneOffset.UTC);
Token token = tokenService.generate(fixedClock);
Clock oneHourLater = Clock.fixed(Instant.parse("2024-01-01T13:00:01Z"), ZoneOffset.UTC);
assertTrue(token.isExpired(oneHourLater));
}
10. Test Data Builders
// ❌ Repetitive
@Test void test1() {
User user = new User("Alice", "alice@test.com", true, LocalDate.now(), Role.ADMIN);
}
@Test void test2() {
User user = new User("Bob", "bob@test.com", true, LocalDate.now(), Role.USER);
}
// ✅ Builder pattern
class TestUserBuilder {
private String name = "TestUser";
private String email = "test@test.com";
private boolean active = true;
private Role role = Role.USER;
static TestUserBuilder aUser() { return new TestUserBuilder(); }
TestUserBuilder withName(String n) { name = n; return this; }
TestUserBuilder withRole(Role r) { role = r; return this; }
TestUserBuilder inactive() { active = false; return this; }
User build() { return new User(name, email, active, LocalDate.now(), role); }
}
@Test void shouldAllowAdminAccess() {
User admin = aUser().withName("Alice").withRole(Role.ADMIN).build();
assertTrue(accessControl.canAccess(admin, Resource.SETTINGS));
}
Summary Checklist
| Practice | ✅ |
|---|---|
| One assertion concept per test | ☐ |
| Descriptive test names | ☐ |
| AAA pattern (Arrange-Act-Assert) | ☐ |
| Tests are independent | ☐ |
| No testing of private methods | ☐ |
| No testing of frameworks | ☐ |
| Tests run fast (< 50ms unit) | ☐ |
| No flaky tests | ☐ |
| Testing pyramid (many unit, few E2E) | ☐ |
| Test data builders for complex objects | ☐ |
Key Takeaways
- Good tests are fast, independent, repeatable, and self-validating.
- Name tests as specifications — they document behavior.
- Test public behavior, not implementation details.
- Follow the testing pyramid — most tests should be unit tests.
- Fix flaky tests immediately — they erode team trust.
Module Complete! 🎉
You’ve covered the entire testing toolkit: JUnit 5, Mockito, TDD, JaCoCo, and Testcontainers. Next module: Build Tools — Maven and Gradle.