Testing in Java - Wyatt's Notes
import Citations from ‘@components/Citations.astro’
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
Section titled “Parameterized Tests”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)Integration Testing
Section titled “Integration Testing”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 |
Correctness. A test that calls a method with wrong inputs and asserts wrong values still contributes To coverage. Focus on meaningful tests, not the coverage number. Use coverage as a tool to find Untested code, not as a target to gamify.
Flaky Tests
Section titled “Flaky Tests”Causes of Flakiness
Section titled “Causes of Flakiness”- Non-deterministic order. Tests that depend on execution order, hash map iteration order, or
HashSetordering. - Time-dependent behavior. Tests that depend on wall-clock time, sleep durations, or timeout thresholds.
- Concurrency. Tests that use threads,
CompletableFutureOr async operations without proper synchronization. - External dependencies. Tests that depend on databases, network services, or file system state.
- Non-deterministic random. Tests that use
Math.random()orThreadLocalRandomwithout seeds.
Strategies for Reliable Tests
Section titled “Strategies for Reliable Tests”// 1. Use seeded random@Testvoid shouldShuffleDeterministically() { Random random = new Random(42); // fixed seed List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4, 5)); Collections.shuffle(list, random); // Always produces the same order with seed 42 assertEquals(List.of(3, 4, 2, 1, 5), list);}
// 2. Use fake time@Testvoid shouldExpireAfterTimeout() { Clock fixedClock = Clock.fixed(Instant.parse("2024-01-01T00:00:00Z"), ZoneOffset.UTC); Session session = new Session(fixedClock, Duration.ofMinutes(30)); assertTrue(session.isValid());
Clock advancedClock = Clock.offset(fixedClock, Duration.ofMinutes(31)); Session advancedSession = new Session(advancedClock, Duration.ofMinutes(30)); assertFalse(advancedSession.isValid());}
// 3. Use Awaitility for async assertions@Testvoid shouldProcessMessageAsync() { messageQueue.send("test-message");
Awaitility.await() .atMost(Duration.ofSeconds(5)) .until(() -> processor.getProcessedCount() == 1);}
// 4. Isolate tests — don"t share mutable stateclass IsolatedTest { @BeforeEach void freshState() { // Reset singletons, clear static state TestContext.reset(); }}Retry Mechanism
Section titled “Retry Mechanism”JUnit 5 does not have a built-in retry mechanism. Use the junit-pioneer library or implement a Custom extension:
// Using junit-pioneer@RepeatedIfExceptionsTest(repeats = 3)void flakyNetworkTest() { // Retries up to 3 times on failure webClient.fetchData();}Common Pitfalls
Section titled “Common Pitfalls”Testing Implementation Details
Section titled “Testing Implementation Details”// BAD — testing internal implementation, not behavior@Testvoid shouldSortInternally() { processor.process(data); verify(processor).sort(anyList()); // testing that sort is called, not the result}
// GOOD — testing observable behavior@Testvoid shouldReturnSortedResults() { List<String> result = processor.process(data); assertEquals(expectedSorted, result);}Over-Mocking
Section titled “Over-Mocking”// BAD — mocking everything makes the test fragile and meaningless@Testvoid badTest() { when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(userRepository.save(any())).thenReturn(user); when(emailService.send(any())).thenReturn(true); when(auditLog.log(any())).thenReturn(null); // The test doesn't verify real behavior — it verifies the mock setup}
// GOOD — mock only external dependencies, test real logic@Testvoid goodTest() { when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(emailService.send(any())).thenReturn(true);
userService.deactivateUser(1L);
verify(emailService).send(eq(user.getEmail())); assertEquals(UserStatus.INACTIVE, user.getStatus());}Ignoring Test Failures
Section titled “Ignoring Test Failures”## BAD — ignoring test failures in CImvn package -DskipTests
## GOOD — fix the failing test or temporarily disable it with @Disabled and a reason// @Disabled("Fix: issue #1234 — race condition in concurrent cache")Tests That Depend on Order
Section titled “Tests That Depend on Order”// BAD — testTwo depends on testOne's side effectsclass OrderDependentTest { private static int counter = 0;
@Test void testOne() { counter++; } @Test void testTwo() { assertEquals(2, counter); } // FAILS if testTwo runs first}
// GOOD — each test sets up its own stateclass IndependentTest { @Test void testOne() { int counter = 1; assertEquals(1, counter); } @Test void testTwo() { int counter = 2; assertEquals(2, counter); }}Using Thread.sleep in Tests
Section titled “Using Thread.sleep in Tests”// BAD — slow and flaky@Testvoid shouldProcessAsync() throws InterruptedException { asyncProcessor.submit(task); Thread.sleep(5000); // hope it finishes in time assertEquals(1, result.get());}
// GOOD — use Awaitility or CountDownLatch@Testvoid shouldProcessAsync() { asyncProcessor.submit(task); Awaitility.await() .atMost(Duration.ofSeconds(5)) .pollInterval(Duration.ofMillis(100)) .until(() -> result.get() == 1);}Not Cleaning Up Resources in Tests
Section titled “Not Cleaning Up Resources in Tests”// BAD — resources leak, locks held, temp files not deleted@Testvoid shouldWriteToFile() throws IOException { FileOutputStream fos = new FileOutputStream("test.txt"); fos.write(data); // fos never closed — file handle leaked}
// GOOD — use try-with-resources@Testvoid shouldWriteToFile() throws IOException { try (FileOutputStream fos = new FileOutputStream("test.txt")) { fos.write(data); } // fos is automatically closed
// Or use @TempDir for temporary files (JUnit 5)}
@TempDir Path tempDir;
@Testvoid shouldWriteToTempFile() throws IOException { Path file = tempDir.resolve("test.txt"); Files.writeString(file, "data"); assertTrue(Files.exists(file)); // tempDir is automatically cleaned up after the test}flowchart TD
A[Java Testing] --> B[Unit Testing]
A --> C[Integration Testing]
A --> D[E2E Testing]
B --> E[JUnit 5]
B --> F[Mockito]
B --> G[AssertJ]
E --> H[@Test annotation]
E --> I[BeforeEach/AfterEach]
E --> J[Assertions]
F --> K[mock() / when() / verify()]
F --> L[doReturn / doThrow]
C --> M[Selenium]
C --> N[Testcontainers]
C --> O[WireMock]
D --> P[Playwright]
D --> Q[Cypress]
R[Best Practices] --> S[Test isolation]
R --> T[Arrange-Act-Assert]
R --> U[One assertion per test]
R --> V[Fast feedback loop]<Citations sources={[ {title=“Effective Java”, author=“Bloch”, year=“2018”, type=“book”}, {title=“JUnit in Action”, author=“Cortina, Turcotte and Baruch”, year=“2020”, type=“book”}, ]} />
Summary
Section titled “Summary”This topic covers the core concepts of testing in java, including underlying theory, practical implementation, and key applications.
Key concepts include:
- relational databases and SQL
- normalisation (1NF, 2NF, 3NF)
- entity-relationship diagrams
- transaction processing (ACID)
- NoSQL and distributed databases
Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Intuition
Section titled “Intuition”Testing is the practice of verifying that code behaves as expected. Unit tests check individual methods in isolation, integration tests verify how components work together, and end-to-end tests validate complete user workflows. JUnit 5 provides annotations like @Test and @BeforeEach to structure tests, while assertions like assertEquals verify outcomes. Mocking isolates the unit under test by replacing collaborators with controlled fakes.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above. :::## Cross-References
- Site Home: Main landing page for Java notes.
- Java Basics: Fundamental Java concepts including types and control flow.
- Concurrency: Multithreading and concurrent programming.
- Practice: Practice problems for revision.