Skip to content

Testing in Java

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.
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());
}
}
}
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
}
}
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

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 is the dominant mocking framework for Java. It creates mock objects (proxies) that return Configurable values and verify interactions.

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);
}
}
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"));
}
}
// thenReturn — fixed return value
when(mock.process()).thenReturn("result");
// thenReturn — chain of return values
when(mock.nextId()).thenReturn(1L, 2L, 3L); // 1st call returns 1, 2nd returns 2, etc.
// thenThrow
when(mock.process()).thenThrow(new RuntimeException("failure"));
// thenAnswer — dynamic return value based on arguments
when(mock.process(anyString())).thenAnswer(invocation -> {
String arg = invocation.getArgument(0);
return arg.toUpperCase();
});
// doThrow — for void methods
doThrow(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 methods
doNothing().when(mock).log(anyString());
// lenient — allow unnecessary stubbing (default strict mode reports unused stubs)
lenient().when(mock.process()).thenReturn("result");
// Mock — all methods are stubbed, real code is NOT executed
List<String> mockList = mock(List.class);
// Spy — wraps a real object, real methods are called unless stubbed
List<String> realList = new ArrayList<>();
List<String> spyList = spy(realList);
spyList.add("real"); // calls the real add method
when(spyList.size()).thenReturn(100); // overrides the real size method
System.out.println(spyList.size()); // 100 (stubbed)
System.out.println(spyList.get(0)); // "real" (real method called)
## Integration Testing
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 test
class 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 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;
@Testcontainers
class 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));
}
}
}
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class 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());
}
}

The most widely used test structure. Each test method follows three clear phases:

@Test
void 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);
}

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);
}

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);
}
}
// Object Mother — factory methods for test data
public 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
@Test
void shouldActivateUser() {
User user = TestUsers.aValidUser();
userService.activate(user);
assertEquals(UserStatus.ACTIVE, user.getStatus());
}

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>
MetricDescription
Line coveragePercentage of source lines exercised by tests
Branch coveragePercentage of if/else branches taken
Method coveragePercentage of methods called
Class coveragePercentage of classes with at least one method called
Instruction coveragePercentage of bytecode instructions executed