Testing Best Practices in Java

4 minute read

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

  1. Good tests are fast, independent, repeatable, and self-validating.
  2. Name tests as specifications — they document behavior.
  3. Test public behavior, not implementation details.
  4. Follow the testing pyramid — most tests should be unit tests.
  5. 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.

Updated: