Write Effective Tests
Problem
Tests provide confidence that code works correctly, but poorly written tests create false confidence or become maintenance burdens. Common problems include unclear test structure, inadequate assertions, over-mocking, and testing implementation instead of behavior.
This guide shows how to write effective tests in Java using JUnit 5 (Jupiter).
Test Structure and Organization
Use the AAA Pattern
Structure tests with Arrange, Act, Assert for clarity.
// ✅ Clear AAA structure
@Test
void shouldCalculateTotalPriceWithTax() {
// Arrange - Set up test data
Order order = new Order();
order.addItem(new OrderItem("Widget", new BigDecimal("100.00"), 2));
order.addItem(new OrderItem("Gadget", new BigDecimal("50.00"), 1));
BigDecimal taxRate = new BigDecimal("0.10");
// Act - Execute the code under test
BigDecimal total = order.calculateTotalWithTax(taxRate);
// Assert - Verify the outcome
BigDecimal expected = new BigDecimal("275.00");
assertEquals(0, expected.compareTo(total));
}
// ❌ Unclear structure - everything mixed
@Test
void testOrder() {
Order order = new Order();
BigDecimal total = order.calculateTotalWithTax(new BigDecimal("0.10"));
order.addItem(new OrderItem("Widget", new BigDecimal("100.00"), 2));
assertEquals(0, new BigDecimal("275.00").compareTo(total)); // Wrong order!
}Why it matters: AAA pattern makes tests readable like documentation. Anyone can understand what’s being tested, what action triggers behavior, and what outcome is expected. Tests become specifications that explain how code should behave.
Name Tests Descriptively
Test names should describe the scenario and expected behavior.
// ✅ Descriptive test names
@Test
void shouldThrowExceptionWhenWithdrawingMoreThanBalance() {
Account account = new Account(new BigDecimal("100.00"));
assertThrows(InsufficientFundsException.class, () -> {
account.withdraw(new BigDecimal("150.00"));
});
}
@Test
void shouldApplyDiscountWhenOrderExceedsMinimumAmount() {
Order order = new Order();
order.addItem(new OrderItem("Widget", new BigDecimal("100.00"), 1));
BigDecimal total = order.calculateTotal();
assertTrue(total.compareTo(new BigDecimal("90.00")) == 0);
}
// ❌ Unclear names
@Test
void test1() { /* ... */ }
@Test
void testAccount() { /* ... */ }
@Test
void withdrawTest() { /* ... */ }Naming conventions:
should[ExpectedBehavior]When[Condition]given[Condition]When[Action]Then[Outcome][method]_[scenario]_[expectedResult]
Organize Tests by Class
// ✅ Test class organization
class AccountTest {
@Nested
@DisplayName("Deposit operations")
class DepositTests {
@Test
void shouldIncreaseBalanceWhenDepositingPositiveAmount() {
Account account = new Account(new BigDecimal("100.00"));
account.deposit(new BigDecimal("50.00"));
assertEquals(0, new BigDecimal("150.00").compareTo(account.getBalance()));
}
@Test
void shouldThrowExceptionWhenDepositingNegativeAmount() {
Account account = new Account(new BigDecimal("100.00"));
assertThrows(IllegalArgumentException.class, () -> {
account.deposit(new BigDecimal("-10.00"));
});
}
}
@Nested
@DisplayName("Withdrawal operations")
class WithdrawalTests {
@Test
void shouldDecreaseBalanceWhenWithdrawingValidAmount() {
Account account = new Account(new BigDecimal("100.00"));
account.withdraw(new BigDecimal("30.00"));
assertEquals(0, new BigDecimal("70.00").compareTo(account.getBalance()));
}
@Test
void shouldThrowExceptionWhenWithdrawingMoreThanBalance() {
Account account = new Account(new BigDecimal("100.00"));
assertThrows(InsufficientFundsException.class, () -> {
account.withdraw(new BigDecimal("150.00"));
});
}
}
}JUnit 5 Basics
Test Lifecycle Annotations
class LifecycleTest {
@BeforeAll
static void setupAll() {
// Runs once before all tests in class
// Use for expensive setup (database connection, etc.)
System.out.println("Setup before all tests");
}
@AfterAll
static void teardownAll() {
// Runs once after all tests in class
// Use for cleanup (close connections, etc.)
System.out.println("Cleanup after all tests");
}
@BeforeEach
void setupEach() {
// Runs before each test method
// Use for common test setup
System.out.println("Setup before each test");
}
@AfterEach
void teardownEach() {
// Runs after each test method
// Use for cleanup after each test
System.out.println("Cleanup after each test");
}
@Test
void firstTest() {
System.out.println("Running first test");
}
@Test
void secondTest() {
System.out.println("Running second test");
}
}
// Output:
// Setup before all tests
// Setup before each test
// Running first test
// Cleanup after each test
// Setup before each test
// Running second test
// Cleanup after each test
// Cleanup after all testsAssertions
class AssertionTest {
@Test
void demonstrateBasicAssertions() {
// Equality assertions
assertEquals(5, 2 + 3);
assertEquals("hello", "hel" + "lo");
assertNotEquals(5, 2 + 2);
// Boolean assertions
assertTrue(5 > 3);
assertFalse(5 < 3);
// Null assertions
assertNull(null);
assertNotNull(new Object());
// Same/Not Same (reference equality)
String str1 = new String("test");
String str2 = new String("test");
assertEquals(str1, str2); // Content equal
assertNotSame(str1, str2); // Different objects
String str3 = "test";
String str4 = "test";
assertSame(str3, str4); // Same object (string interning)
}
@Test
void demonstrateCollectionAssertions() {
List<String> names = List.of("Alice", "Bob", "Charlie");
// Collection size
assertEquals(3, names.size());
// Contains
assertTrue(names.contains("Bob"));
assertFalse(names.contains("Dave"));
// Array equality
int[] expected = {1, 2, 3};
int[] actual = {1, 2, 3};
assertArrayEquals(expected, actual);
}
@Test
void demonstrateExceptionAssertions() {
// Assert that exception is thrown
assertThrows(IllegalArgumentException.class, () -> {
throw new IllegalArgumentException("Invalid");
});
// Assert specific exception with message verification
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> new Account(-100)
);
assertTrue(exception.getMessage().contains("balance cannot be negative"));
// Assert no exception thrown
assertDoesNotThrow(() -> {
new Account(100);
});
}
@Test
void demonstrateAssertionsWithMessages() {
// ✅ Custom failure messages
assertEquals(5, actualValue(), "Expected value to be 5");
// ✅ Lazy message evaluation (only computed on failure)
assertEquals(5, actualValue(), () ->
"Expected value to be 5, but got " + actualValue()
);
}
@Test
void demonstrateAssertAll() {
// ✅ Assert multiple conditions (all evaluated even if some fail)
User user = new User("Alice", 30, "alice@example.com");
assertAll("User properties",
() -> assertEquals("Alice", user.getName()),
() -> assertEquals(30, user.getAge()),
() -> assertEquals("alice@example.com", user.getEmail())
);
// If any assertion fails, all results are reported
}
}Parameterized Tests
Test the same logic with different inputs.
class ParameterizedTestExample {
@ParameterizedTest
@ValueSource(ints = {1, 3, 5, 7, 9})
void shouldReturnTrueForOddNumbers(int number) {
assertTrue(isOdd(number));
}
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
void shouldReturnTrueForBlankStrings(String input) {
assertTrue(input.isBlank());
}
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"5, 5, 10",
"10, -5, 5"
})
void shouldAddTwoNumbers(int a, int b, int expected) {
assertEquals(expected, a + b);
}
@ParameterizedTest
@CsvSource({
"apple, APPLE",
"hello world, HELLO WORLD",
"JUnit, JUNIT"
})
void shouldConvertToUpperCase(String input, String expected) {
assertEquals(expected, input.toUpperCase());
}
@ParameterizedTest
@MethodSource("emailProvider")
void shouldValidateEmailFormat(String email, boolean valid) {
assertEquals(valid, EmailValidator.isValid(email));
}
static Stream<Arguments> emailProvider() {
return Stream.of(
Arguments.of("test@example.com", true),
Arguments.of("invalid.email", false),
Arguments.of("", false),
Arguments.of("test@", false),
Arguments.of("@example.com", false)
);
}
@ParameterizedTest
@EnumSource(Month.class)
void shouldHaveValidMonthName(Month month) {
assertNotNull(month.name());
assertTrue(month.name().length() > 2);
}
}Why it matters: Parameterized tests eliminate duplication when testing similar scenarios with different data. One test method covers many cases, making tests more maintainable and comprehensive.
Mocking with Mockito
Basic Mocking
class UserServiceTest {
@Test
void shouldReturnUserWhenFound() {
// Arrange - Create mock
UserRepository mockRepository = mock(UserRepository.class);
User expectedUser = new User("123", "Alice");
// Configure mock behavior
when(mockRepository.findById("123")).thenReturn(Optional.of(expectedUser));
UserService service = new UserService(mockRepository);
// Act
User actualUser = service.getUser("123");
// Assert
assertEquals(expectedUser, actualUser);
verify(mockRepository).findById("123"); // Verify method was called
}
@Test
void shouldThrowExceptionWhenUserNotFound() {
UserRepository mockRepository = mock(UserRepository.class);
when(mockRepository.findById("999")).thenReturn(Optional.empty());
UserService service = new UserService(mockRepository);
assertThrows(UserNotFoundException.class, () -> {
service.getUser("999");
});
verify(mockRepository).findById("999");
}
}Mock Annotations
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentService paymentService;
@InjectMocks
private OrderService orderService; // Mocks injected automatically
@Test
void shouldProcessOrderSuccessfully() {
Order order = new Order("order-123", new BigDecimal("100.00"));
when(orderRepository.save(any(Order.class))).thenReturn(order);
when(paymentService.processPayment(any(), any())).thenReturn(true);
OrderResult result = orderService.processOrder(order);
assertTrue(result.isSuccess());
verify(orderRepository).save(order);
verify(paymentService).processPayment(eq(order.getId()), any());
}
}Argument Matchers
class ArgumentMatcherTest {
@Test
void demonstrateArgumentMatchers() {
UserRepository mock = mock(UserRepository.class);
// Any matcher
when(mock.findById(any(String.class))).thenReturn(Optional.of(new User()));
// Specific value
when(mock.findByEmail(eq("test@example.com"))).thenReturn(Optional.of(new User()));
// Custom matcher
when(mock.findById(argThat(id -> id.length() > 5))).thenReturn(Optional.of(new User()));
// Verify with matchers
UserService service = new UserService(mock);
service.getUser("user-123");
verify(mock).findById(startsWith("user-"));
verify(mock, times(1)).findById(anyString());
verify(mock, never()).delete(any());
}
}Stubbing Multiple Calls
@Test
void shouldHandleMultipleCalls() {
EmailService mock = mock(EmailService.class);
// Different returns for consecutive calls
when(mock.send(anyString()))
.thenReturn(true) // First call
.thenReturn(false) // Second call
.thenThrow(new RuntimeException()); // Third call
assertTrue(mock.send("test1"));
assertFalse(mock.send("test2"));
assertThrows(RuntimeException.class, () -> mock.send("test3"));
}Avoiding Over-Mocking
// ❌ Over-mocking - testing implementation details
@Test
void badTest() {
List<String> mockList = mock(List.class);
when(mockList.size()).thenReturn(5);
when(mockList.get(0)).thenReturn("first");
// Testing mock behavior, not real code!
assertEquals(5, mockList.size());
}
// ✅ Mock only external dependencies
@Test
void goodTest() {
// Use real objects when possible
List<String> list = new ArrayList<>();
list.add("first");
list.add("second");
assertEquals(2, list.size());
assertEquals("first", list.get(0));
}
// ✅ Mock external services
@Test
void mockExternalDependencies() {
// Mock external API client
ApiClient mockClient = mock(ApiClient.class);
when(mockClient.fetchData()).thenReturn(testData);
// Real business logic
DataProcessor processor = new DataProcessor(mockClient);
Result result = processor.process();
assertNotNull(result);
}When NOT to Test
// ❌ Don't test trivial getters/setters
@Test
void testGettersAndSetters() {
User user = new User();
user.setName("Alice");
assertEquals("Alice", user.getName()); // Waste of time
}
// ❌ Don't test framework code
@Test
void testSpringConfiguration() {
// Testing that Spring autowiring works
// Trust the framework!
}
// ❌ Don't test private methods directly
@Test
void testPrivateMethod() throws Exception {
Method method = MyClass.class.getDeclaredMethod("privateHelper");
method.setAccessible(true);
// If it's important, test through public API
}
// ✅ Test public behavior
@Test
void testPublicBehavior() {
MyClass instance = new MyClass();
// Public method that uses private helper internally
String result = instance.processData("input");
assertEquals("expected", result);
// Private methods tested indirectly
}
// ✅ Test business logic
@Test
void shouldCalculateDiscountCorrectly() {
PricingService service = new PricingService();
BigDecimal price = new BigDecimal("100.00");
BigDecimal discount = service.calculateDiscount(price, CustomerType.PREMIUM);
assertEquals(0, new BigDecimal("20.00").compareTo(discount));
}Test Coverage and TDD Basics
Meaningful Coverage
// ✅ Cover important paths
class PricingServiceTest {
@Test
void shouldApplyNoDiscountForRegularCustomers() {
// Happy path
}
@Test
void shouldApply10PercentDiscountForPremiumCustomers() {
// Alternative path
}
@Test
void shouldApply20PercentDiscountForVIPCustomers() {
// Another alternative
}
@Test
void shouldThrowExceptionForNullPrice() {
// Error path
}
@Test
void shouldThrowExceptionForNegativePrice() {
// Edge case
}
}TDD Workflow
// ✅ Red-Green-Refactor cycle
// 1. RED - Write failing test first
@Test
void shouldCalculateTotalPrice() {
ShoppingCart cart = new ShoppingCart();
cart.addItem(new Item("Widget", 10.00, 2));
cart.addItem(new Item("Gadget", 5.00, 1));
assertEquals(25.00, cart.calculateTotal());
// Test fails - calculateTotal() doesn't exist yet
}
// 2. GREEN - Write minimum code to pass
public class ShoppingCart {
private List<Item> items = new ArrayList<>();
public void addItem(Item item) {
items.add(item);
}
public double calculateTotal() {
return items.stream()
.mapToDouble(item -> item.getPrice() * item.getQuantity())
.sum();
}
}
// Test passes!
// 3. REFACTOR - Improve code while keeping tests green
public double calculateTotal() {
return items.stream()
.map(Item::getSubtotal)
.reduce(0.0, Double::sum);
}
// Tests still pass, code is cleanerTesting Best Practices
Test One Thing
// ❌ Testing multiple things
@Test
void testEverything() {
User user = new User("Alice", "alice@example.com");
user.setAge(25);
assertTrue(user.isValid());
assertEquals("ALICE", user.getUppercaseName());
assertTrue(user.canVote());
// Too many assertions
}
// ✅ One test per behavior
@Test
void shouldReturnTrueWhenUserDataIsValid() {
User user = new User("Alice", "alice@example.com");
assertTrue(user.isValid());
}
@Test
void shouldReturnUppercaseNameWhenRequested() {
User user = new User("Alice", "alice@example.com");
assertEquals("ALICE", user.getUppercaseName());
}
@Test
void shouldAllowVotingWhenAgeIs18OrAbove() {
User user = new User("Alice", "alice@example.com");
user.setAge(25);
assertTrue(user.canVote());
}Fast Tests
// ✅ Fast unit tests
@Test
void fastTest() {
Calculator calculator = new Calculator();
assertEquals(5, calculator.add(2, 3));
// Runs in milliseconds
}
// ❌ Slow tests (save for integration tests)
@Test
void slowTest() throws InterruptedException {
Thread.sleep(5000); // Don't do this in unit tests!
// Use @Tag("integration") for slow tests
}Independent Tests
// ❌ Tests depend on execution order
static User sharedUser;
@Test
void test1_CreateUser() {
sharedUser = new User("Alice");
// BAD: Next test depends on this
}
@Test
void test2_UpdateUser() {
sharedUser.setAge(25); // Fails if test1 doesn't run first!
}
// ✅ Independent tests
@Test
void shouldCreateUser() {
User user = new User("Alice");
assertNotNull(user);
}
@Test
void shouldUpdateUserAge() {
User user = new User("Alice");
user.setAge(25);
assertEquals(25, user.getAge());
}Summary
Effective testing in Java begins with clear test structure using the AAA pattern - Arrange, Act, Assert. Each test sets up data, executes the code under test, and verifies outcomes. Descriptive test names document expected behavior, turning tests into specifications that explain how code should work.
JUnit 5 provides lifecycle annotations for setup and teardown. Use @BeforeAll for expensive one-time setup, @BeforeEach for common setup before each test, and corresponding @After annotations for cleanup. Organize related tests with @Nested classes to group tests logically and improve readability.
Assertions verify expected outcomes. JUnit 5 provides assertEquals for equality, assertTrue/assertFalse for boolean conditions, assertThrows for exceptions, and assertAll for validating multiple conditions together. Custom failure messages clarify what went wrong when tests fail.
Parameterized tests eliminate duplication when testing the same logic with different data. @ValueSource supplies simple values, @CsvSource handles multiple parameters, @MethodSource provides complex arguments, and @EnumSource tests all enum values. One test method covers many scenarios.
Mockito handles external dependencies without requiring real databases, APIs, or services. Mock only external dependencies - use real objects for business logic. Configure mocks with when/thenReturn, verify method calls with verify(), and use argument matchers for flexible matching. Avoid over-mocking which tests mock behavior instead of real code.
Write tests for public behavior, not implementation details. Don’t test trivial getters/setters, framework code, or private methods directly. Focus on business logic and important code paths. Test coverage measures how much code runs during tests, but meaningful coverage tests important behaviors, edge cases, and error paths.
TDD follows Red-Green-Refactor cycles. Write a failing test first (red), implement minimum code to pass (green), then refactor while keeping tests passing. This rhythm produces testable code with built-in test coverage.
Keep tests fast, independent, and focused. Each test should verify one behavior and run in milliseconds. Tests shouldn’t depend on execution order or shared state. Fast, reliable tests encourage running them frequently, catching bugs early.