Testing in Java
JUnit 5
Section titled “JUnit 5”JUnit 5 (Jupiter) is the standard testing framework for Java. It consists of three sub-projects:
- JUnit Platform — the foundation for launching test frameworks on the JVM.
- JUnit Jupiter — the programming model (annotations, assertions) and extension model.
- JUnit Vintage — backward compatibility for running JUnit 3 and 4 tests.
Core Annotations
Section titled “Core Annotations”import org.junit.jupiter.api.*;import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
@Test void addition() { assertEquals(4, 2 + 2); }
@Test @DisplayName("Division by zero should throw ArithmeticException") void divisionByZero() { Exception exception = assertThrows(ArithmeticException.class, () -> { int result = 1 / 0; }); assertEquals("/ by zero", exception.getMessage()); }
@Test @Disabled("Not implemented yet") void futureFeature() { // skipped }
@Test @Tag("slow") void slowIntegrationTest() { // run with: ./gradlew test --tests "*CalculatorTest*" -Djunit.jupiter.tags.include=slow }
@Nested @DisplayName("When the calculator is initialized") class WhenInitialized {
@Test @DisplayName("should return zero for initial state") void initialState() { assertEquals(0, new Calculator().getValue()); } }}Assertions
Section titled “Assertions”import static org.junit.jupiter.api.Assertions.*;
class AssertionExamples {
@Test void basicAssertions() { assertEquals(42, compute(), "should return 42"); assertNotEquals(0, compute());
assertTrue(isValid(input)); assertFalse(isEmpty(input)); assertNull(optionalInput); assertNotNull(requiredInput);
assertSame(instance, getInstance()); // same reference (==) assertNotSame(new Object(), new Object()); // different references }
@Test void exceptionAssertions() { // Assert that a specific exception is thrown IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validate(-1) ); assertEquals("value must be positive", ex.getMessage());
// Assert that no exception is thrown assertDoesNotThrow(() -> validate(42)); }
@Test void timeoutAssertions() { // Fail if execution takes longer than 1 second assertTimeout(Duration.ofSeconds(1), () -> { performOperation(); });
// Preemptively fail if timeout is exceeded (does not wait for completion) assertTimeoutPreemptively(Duration.ofMillis(500), () -> { longRunningOperation(); }); }
@Test void groupedAssertions() { // All assertions are executed, all failures are reported together Address address = parseAddress(input);
assertAll("address", () -> assertEquals("123 Main St", address.getStreet()), () -> assertEquals("Springfield", address.getCity()), () -> assertEquals("IL", address.getState()), () -> assertEquals("62701", address.getZip()) ); }
@Test void customMessage() { assertEquals(expected, actual, () -> String.format("Expected %d but got %d for input %s", expected, actual, input)); // Use lambda for message — evaluated only on failure }}Test Lifecycle
Section titled “Test Lifecycle”class LifecycleTest {
@BeforeAll static void setUpAll() { // Runs once before ALL tests in this class // Must be static (unless using @TestInstance(Lifecycle.PER_CLASS)) System.out.println("Initializing shared resources"); }
@BeforeEach void setUp() { // Runs before EACH test // Instance method — fresh state for each test System.out.println("Setting up test " + this.hashCode()); }
@Test void testOne() { System.out.println("Running testOne"); }
@Test void testTwo() { System.out.println("Running testTwo"); }
@AfterEach void tearDown() { // Runs after EACH test — cleanup System.out.println("Tearing down test"); }
@AfterAll static void tearDownAll() { // Runs once after ALL tests in this class System.out.println("Cleaning up shared resources"); }}Parameterized tests run the same test logic with different inputs, eliminating test method Proliferation.
import org.junit.jupiter.params.*;import org.junit.jupiter.params.provider.*;
class ParameterizedTestExamples {
@ParameterizedTest @ValueSource(ints = {1, 2, 3, 4, 5}) void shouldBePositive(int value) { assertTrue(value > 0); }
@ParameterizedTest @ValueSource(strings = {"racecar", "madam", "level"}) void shouldBePalindrome(String word) { assertTrue(isPalindrome(word)); }
@ParameterizedTest @NullSource @EmptySource @ValueSource(strings = {" ", "\t", "\n"}) void shouldRejectBlankInputs(String input) { assertFalse(isValid(input)); }
@ParameterizedTest @EnumSource(TimeUnit.class) void shouldSupportAllTimeUnits(TimeUnit unit) { assertNotNull(unit.toString()); }
@ParameterizedTest @EnumSource(value = TimeUnit.class, names = {"DAYS", "HOURS"}) void shouldSupportSpecificTimeUnits(TimeUnit unit) { assertTrue(unit.name().equals("DAYS") || unit.name().equals("HOURS")); }
@ParameterizedTest @MethodSource("provideInvalidEmails") void shouldRejectInvalidEmails(String email) { assertFalse(EmailValidator.isValid(email)); }
static Stream<Arguments> provideInvalidEmails() { return Stream.of( Arguments.of(""), Arguments.of("not-an-email"), Arguments.of("@missing-local.com"), Arguments.of("missing-at-sign.com"), Arguments.of("spaces in@email.com") ); }
@ParameterizedTest @CsvSource({ "1, 2, 3", "0, 0, 0", "-1, 1, 0", "100, 200, 300" }) void addition(int a, int b, int expected) { assertEquals(expected, a + b); }
@ParameterizedTest @CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1) void fromCsvFile(String input, boolean expected) { assertEquals(expected, Validator.validate(input)); }}Mockito
Section titled “Mockito”Mockito is the dominant mocking framework for Java. It creates mock objects (proxies) that return Configurable values and verify interactions.
Mocking and Stubbing
Section titled “Mocking and Stubbing”import static org.mockito.Mockito.*;
class MockitoBasics {
@Test void basicMocking() { // Create a mock List<String> mockList = mock(List.class);
// Stubbing — define behavior when(mockList.get(0)).thenReturn("first"); when(mockList.get(1)).thenThrow(new IndexOutOfBoundsException()); when(mockList.size()).thenReturn(10);
// Use the mock assertEquals("first", mockList.get(0)); assertThrows(IndexOutOfBoundsException.class, () -> mockList.get(1)); assertEquals(10, mockList.size());
// Verification — assert that methods were called verify(mockList).get(0); verify(mockList, never()).get(99); verify(mockList, times(1)).get(0); verify(mockList, atLeast(1)).size(); }
@Test void argumentMatching() { List<String> mockList = mock(List.class);
// Argument matchers when(mockList.get(anyInt())).thenReturn("default"); when(mockList.contains(eq("hello"))).thenReturn(true); when(mockList.contains(startsWith("h"))).thenReturn(true);
assertEquals("default", mockList.get(42));
// Custom argument matcher when(mockList.add(argThat(s -> s != null && s.length() > 5))).thenReturn(true); }}Annotations
Section titled “Annotations”import org.mockito.*;import org.mockito.junit.jupiter.MockitoExtension;import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(MockitoExtension.class)class UserServiceTest {
@Mock private UserRepository userRepository;
@Mock private EmailService emailService;
@InjectMocks private UserService userService;
@Captor private ArgumentCaptor<User> userCaptor;
@Test void shouldCreateUserAndSendEmail() { // Arrange when(userRepository.save(any(User.class))).thenAnswer(invocation -> { User user = invocation.getArgument(0); user.setId(1L); return user; });
// Act userService.createUser("alice@example.com", "Alice");
// Assert — verify interactions verify(userRepository).save(userCaptor.capture()); User savedUser = userCaptor.getValue(); assertEquals("alice@example.com", savedUser.getEmail());
verify(emailService).sendWelcomeEmail(eq("alice@example.com")); }}Stubbing Variations
Section titled “Stubbing Variations”// thenReturn — fixed return valuewhen(mock.process()).thenReturn("result");
// thenReturn — chain of return valueswhen(mock.nextId()).thenReturn(1L, 2L, 3L); // 1st call returns 1, 2nd returns 2, etc.
// thenThrowwhen(mock.process()).thenThrow(new RuntimeException("failure"));
// thenAnswer — dynamic return value based on argumentswhen(mock.process(anyString())).thenAnswer(invocation -> { String arg = invocation.getArgument(0); return arg.toUpperCase();});
// doThrow — for void methodsdoThrow(new IllegalStateException()).when(mock).clear();
// doReturn — when spying (real methods are called by default)List<String> spy = spy(new ArrayList<>());doReturn("mocked").when(spy).get(0); // bypasses the real get(0)
// doNothing — explicit no-op for void methodsdoNothing().when(mock).log(anyString());
// lenient — allow unnecessary stubbing (default strict mode reports unused stubs)lenient().when(mock.process()).thenReturn("result");Spy vs Mock
Section titled “Spy vs Mock”// Mock — all methods are stubbed, real code is NOT executedList<String> mockList = mock(List.class);
// Spy — wraps a real object, real methods are called unless stubbedList<String> realList = new ArrayList<>();List<String> spyList = spy(realList);
spyList.add("real"); // calls the real add methodwhen(spyList.size()).thenReturn(100); // overrides the real size methodSystem.out.println(spyList.size()); // 100 (stubbed)System.out.println(spyList.get(0)); // "real" (real method called)Spring Boot Test Context
Section titled “Spring Boot Test Context”import org.springframework.boot.test.context.SpringBootTest;import org.springframework.test.context.ActiveProfiles;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.transaction.annotation.Transactional;
@SpringBootTest@ActiveProfiles("test")@Transactional // rolls back after each testclass OrderServiceIntegrationTest {
@Autowired private OrderService orderService;
@Autowired private OrderRepository orderRepository;
@Test void shouldCreateOrder() { Order order = orderService.createOrder("product-123", 5); assertNotNull(order.getId()); assertEquals("product-123", order.getProductId()); assertEquals(5, order.getQuantity()); }}Testcontainers
Section titled “Testcontainers”Testcontainers provides lightweight, throwaway database, message broker, and service containers for Integration tests:
import org.testcontainers.containers.PostgreSQLContainer;import org.testcontainers.junit.jupiter.Container;import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainersclass DatabaseIntegrationTest {
@Container private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine") .withDatabaseName("testdb") .withUsername("test") .withPassword("test");
@Test void shouldConnectToDatabase() { String jdbcUrl = postgres.getJdbcUrl(); try (Connection conn = DriverManager.getConnection(jdbcUrl, postgres.getUsername(), postgres.getPassword())) { assertTrue(conn.isValid(5)); } }}Database Testing Patterns
Section titled “Database Testing Patterns”@DataJpaTest@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)@Testcontainersclass UserRepositoryTest {
@Container private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); }
@Autowired private UserRepository userRepository;
@Test void shouldSaveAndFindUser() { User user = new User("alice@example.com", "Alice"); userRepository.save(user);
Optional<User> found = userRepository.findByEmail("alice@example.com"); assertTrue(found.isPresent()); assertEquals("Alice", found.get().getName()); }
@Test void shouldReturnEmptyForUnknownEmail() { Optional<User> found = userRepository.findByEmail("nonexistent@example.com"); assertTrue(found.isEmpty()); }}Test Design Patterns
Section titled “Test Design Patterns”Arrange-Act-Assert (AAA)
Section titled “Arrange-Act-Assert (AAA)”The most widely used test structure. Each test method follows three clear phases:
@Testvoid shouldCalculateTotalPrice() { // Arrange — set up test data and preconditions ShoppingCart cart = new ShoppingCart(); cart.addItem(new Item("Widget", BigDecimal.valueOf(10.00), 2)); cart.addItem(new Item("Gadget", BigDecimal.valueOf(25.00), 1));
// Act — invoke the method under test BigDecimal total = cart.calculateTotal();
// Assert — verify the result assertEquals(new BigDecimal("45.00"), total);}Given-When-Then (BDD Style)
Section titled “Given-When-Then (BDD Style)”BDD-style naming using @DisplayName and descriptive method names:
@Test@DisplayName("given a user with expired subscription, when checking access, then should deny")void expiredSubscriptionDeniesAccess() { // Given User user = new User("alice@example.com"); user.setSubscriptionExpiry(LocalDate.now().minusDays(1));
// When boolean hasAccess = accessControl.checkAccess(user, "premium-content");
// Then assertFalse(hasAccess);}Test Fixtures
Section titled “Test Fixtures”Shared test data and setup using @BeforeAll``@BeforeEachOr test utility classes:
class OrderProcessorTest {
private OrderProcessor processor; private OrderRepository mockRepo; private NotificationService mockNotifier;
@BeforeEach void setUp() { mockRepo = mock(OrderRepository.class); mockNotifier = mock(NotificationService.class); processor = new OrderProcessor(mockRepo, mockNotifier); }
@Test void shouldProcessValidOrder() { // Uses the fresh processor instance from setUp Order order = new Order("product-1", 10, BigDecimal.valueOf(99.99)); processor.process(order); verify(mockRepo).save(order); }}Test Object Mothers and Builders
Section titled “Test Object Mothers and Builders”// Object Mother — factory methods for test datapublic class TestUsers { public static User aValidUser() { return new User("alice@example.com", "Alice", UserStatus.ACTIVE); }
public static User anExpiredUser() { User user = new User("bob@example.com", "Bob", UserStatus.ACTIVE); user.setSubscriptionExpiry(LocalDate.now().minusDays(1)); return user; }}
// Usage in tests@Testvoid shouldActivateUser() { User user = TestUsers.aValidUser(); userService.activate(user); assertEquals(UserStatus.ACTIVE, user.getStatus());}Test Coverage
Section titled “Test Coverage”JaCoCo
Section titled “JaCoCo”JaCoCo (Java Code Coverage) is the standard coverage tool. It instruments bytecode to track which Lines, branches, and methods are exercised by tests.
Gradle configuration:
plugins { jacoco}
jacoco { toolVersion = "0.8.11"}
tasks.jacocoTestReport { dependsOn(tasks.test)
reports { xml.required.set(true) html.required.set(true) }}
tasks.jacocoTestCoverageVerification { violationRules { rule { limit { minimum = "0.80".toBigDecimal() } } }}
tasks.check { dependsOn(tasks.jacocoTestCoverageVerification)}Maven configuration:
<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.11</version> <executions> <execution> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>report</id> <phase>test</phase> <goals> <goal>report</goal> </goals> </execution> <execution> <id>check</id> <goals> <goal>check</goal> </goals> <configuration> <rules> <rule> <element>BUNDLE</element> <limits> <limit> <counter>LINE</counter> <value>COVEREDRATIO</value> <minimum>0.80</minimum> </limit> </limits> </rule> </rules> </configuration> </execution> </executions></plugin>Coverage Metrics
Section titled “Coverage Metrics”| Metric | Description |
|---|---|
| Line coverage | Percentage of source lines exercised by tests |
| Branch coverage | Percentage of if/else branches taken |
| Method coverage | Percentage of methods called |
| Class coverage | Percentage of classes with at least one method called |
| Instruction coverage | Percentage of bytecode instructions executed |