- Authors

- Name
- Youngju Kim
- @fjvbn20031
Java Testing Complete Guide: JUnit 5, Mockito & WireMock in Practice
Testing is the foundation of software quality. Well-written tests let you refactor with confidence and catch bugs early. This guide gives you a complete mastery of Java's core testing tools with hands-on examples.
1. JUnit 5 Complete Guide
1.1 JUnit 5 Architecture
JUnit 5 is composed of three sub-projects:
- JUnit Platform: Test framework execution foundation (Launcher API)
- JUnit Jupiter: New programming model and extension model (Jupiter API)
- JUnit Vintage: Backward compatibility for JUnit 3/4 tests
<!-- build.gradle.kts -->
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("org.mockito:mockito-junit-jupiter:5.11.0")
testImplementation("com.github.tomakehurst:wiremock-jre8:3.0.1")
testImplementation("org.testcontainers:junit-jupiter:1.19.7")
testImplementation("org.assertj:assertj-core:3.25.3")
}
tasks.test {
useJUnitPlatform()
}
1.2 Core Annotations
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private Calculator calculator;
@BeforeAll
static void initAll() {
// Runs once for the entire class (must be static)
System.out.println("Test class setup");
}
@BeforeEach
void setUp() {
// Runs before each test method
calculator = new Calculator();
}
@AfterEach
void tearDown() {
// Runs after each test method
calculator = null;
}
@AfterAll
static void tearDownAll() {
// Runs once after all tests in the class (must be static)
System.out.println("Test class cleanup");
}
@Test
@DisplayName("The sum of two numbers should be calculated correctly")
void add_shouldReturnCorrectSum() {
// given
int a = 3, b = 5;
// when
int result = calculator.add(a, b);
// then
assertEquals(8, result, "3 + 5 should equal 8");
}
@Test
@Disabled("Feature not yet implemented")
void notImplementedYet() {
// This test is skipped
}
@Test
@Tag("slow")
void slowTest() {
// Tag for grouping: ./gradlew test -Dtests.groups=slow
}
}
1.3 @Nested for Structured Tests
@DisplayName("Order Service Tests")
class OrderServiceTest {
@Nested
@DisplayName("When creating an order")
class WhenCreatingOrder {
@Test
@DisplayName("Should succeed when stock is sufficient")
void shouldSucceedWhenStockIsSufficient() { /* ... */ }
@Test
@DisplayName("Should throw when stock is insufficient")
void shouldThrowWhenStockIsInsufficient() { /* ... */ }
}
@Nested
@DisplayName("When cancelling an order")
class WhenCancellingOrder {
@Test
@DisplayName("Should allow cancellation before shipping")
void shouldAllowCancellationBeforeShipping() { /* ... */ }
@Test
@DisplayName("Should not allow cancellation after shipping")
void shouldNotAllowCancellationAfterShipping() { /* ... */ }
}
}
1.4 Parameterized Tests
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
class ParameterizedTests {
// @ValueSource: single-type value list
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
@DisplayName("Blank strings should be detected as blank")
void blankStringsShouldBeBlank(String input) {
assertTrue(input.isBlank());
}
@ParameterizedTest
@ValueSource(ints = {1, 3, 5, 7, 9})
void shouldBeOdd(int number) {
assertEquals(1, number % 2);
}
// @CsvSource: multi-column data
@ParameterizedTest
@CsvSource({
"apple, 5",
"banana, 6",
"cherry, 6"
})
@DisplayName("Fruit name length should match")
void fruitLengthShouldMatch(String fruit, int expectedLength) {
assertEquals(expectedLength, fruit.length());
}
// @CsvFileSource: read from CSV file
@ParameterizedTest
@CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1)
void fromCsvFile(String input, int expected) { /* ... */ }
// @MethodSource: provide arguments from a method
@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
void isBlank_ShouldReturnExpected(String input, boolean expected) {
assertEquals(expected, input == null || input.isBlank());
}
static Stream<Arguments> provideStringsForIsBlank() {
return Stream.of(
Arguments.of(null, true),
Arguments.of("", true),
Arguments.of(" ", true),
Arguments.of("not blank", false)
);
}
// @EnumSource: test with enum values
@ParameterizedTest
@EnumSource(value = DayOfWeek.class, names = {"SATURDAY", "SUNDAY"})
void weekendsAreNotWorkdays(DayOfWeek day) {
assertFalse(isWorkday(day));
}
}
1.5 Assertions In Depth
class AssertionsTest {
@Test
void groupAssertions() {
// assertAll: run all assertions, then report all failures
assertAll("person",
() -> assertEquals("Alice", person.getName()),
() -> assertEquals(30, person.getAge()),
() -> assertEquals("alice@example.com", person.getEmail())
);
}
@Test
void exceptionTesting() {
// assertThrows: verify exception type and message
Exception exception = assertThrows(
ArithmeticException.class,
() -> calculator.divide(10, 0)
);
assertEquals("Division by zero", exception.getMessage());
}
@Test
void timeoutTesting() {
// assertTimeout: verify execution time
assertTimeout(Duration.ofSeconds(1), () -> {
Thread.sleep(500); // Must complete within 1 second
});
// assertTimeoutPreemptively: abort immediately on timeout
assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {
someBlockingOperation();
});
}
@Test
void assumptionsTest() {
// If condition is false, test is skipped (not failed)
assumeTrue("CI".equals(System.getenv("ENV")),
"Only runs in CI environment");
// Code below only runs in CI
heavyIntegrationTest();
}
}
1.6 @TestFactory (Dynamic Tests)
@TestFactory
Collection<DynamicTest> dynamicTests() {
return List.of(
DynamicTest.dynamicTest("1 + 1 = 2", () -> assertEquals(2, 1 + 1)),
DynamicTest.dynamicTest("2 + 3 = 5", () -> assertEquals(5, 2 + 3))
);
}
@TestFactory
Stream<DynamicTest> dynamicTestsFromStream() {
return Stream.of("apple", "banana", "cherry")
.map(fruit -> DynamicTest.dynamicTest(
fruit + " is a fruit",
() -> assertTrue(isFruit(fruit))
));
}
2. Mockito Complete Guide
2.1 Basic Setup
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService; // @Mock fields are auto-injected
@Spy
private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// @Spy: real object but selected methods can be stubbed
@Captor
private ArgumentCaptor<User> userCaptor;
// ArgumentCaptor: captures arguments passed to method calls
}
2.2 Stubbing Methods
@Test
void stubbing_examples() {
// Basic stub: return a specific value
when(userRepository.findById("user1"))
.thenReturn(Optional.of(new User("user1", "Alice")));
// Empty or null result
when(userRepository.findById("nonexistent"))
.thenReturn(Optional.empty());
// Throw an exception
when(userRepository.findById("error"))
.thenThrow(new DatabaseException("Connection refused"));
// Consecutive returns (in order)
when(userRepository.findAll())
.thenReturn(List.of(user1))
.thenReturn(List.of(user1, user2))
.thenReturn(Collections.emptyList());
// thenAnswer: dynamic return based on arguments
when(userRepository.findByName(anyString()))
.thenAnswer(invocation -> {
String name = invocation.getArgument(0);
return Optional.of(new User(name.toLowerCase(), name));
});
// doReturn: useful for void methods and spies
doReturn(Optional.of(mockUser)).when(userRepository).findById("spy-test");
// doThrow: throw on void method
doThrow(new MailException("SMTP error")).when(emailService).sendWelcomeEmail(any());
// doNothing: make void method do nothing
doNothing().when(emailService).sendNotification(anyString());
// doCallRealMethod: call real method on spy
doCallRealMethod().when(passwordEncoder).encode(anyString());
}
2.3 Verifying Interactions
@Test
void verify_examples() {
// Basic: was it called exactly once?
verify(userRepository).findById("user1");
verify(userRepository, times(1)).findById("user1");
// Number of invocations
verify(emailService, times(3)).sendEmail(anyString());
verify(userRepository, never()).delete(any());
verify(emailService, atLeast(2)).sendEmail(anyString());
verify(emailService, atMost(5)).sendEmail(anyString());
// Order of invocations
InOrder inOrder = inOrder(userRepository, emailService);
inOrder.verify(userRepository).save(any(User.class));
inOrder.verify(emailService).sendWelcomeEmail(anyString());
// No interactions beyond the verified ones
verifyNoMoreInteractions(emailService);
verifyNoInteractions(auditService);
}
2.4 ArgumentCaptor
@Test
void argumentCaptor_example() {
// given
String name = "Alice";
String email = "alice@example.com";
// when
userService.registerUser(name, email);
// then - capture the saved User object
verify(userRepository).save(userCaptor.capture());
User savedUser = userCaptor.getValue();
assertAll("Verify saved user",
() -> assertEquals(name, savedUser.getName()),
() -> assertEquals(email.toLowerCase(), savedUser.getEmail()),
() -> assertNotNull(savedUser.getCreatedAt()),
() -> assertEquals(UserStatus.PENDING, savedUser.getStatus())
);
// All captured values across multiple calls
verify(emailService, times(3)).sendEmail(emailCaptor.capture());
List<String> sentEmails = emailCaptor.getAllValues();
assertThat(sentEmails).containsExactly(
"admin@example.com", "alice@example.com", "support@example.com"
);
}
2.5 ArgumentMatchers
@Test
void matchers_examples() {
// any(): any type including null
when(repo.findAll(any())).thenReturn(list);
// any(SpecificClass.class): specific type
when(repo.findByExample(any(User.class))).thenReturn(list);
// eq(): exact value match
when(repo.findById(eq("specific-id"))).thenReturn(Optional.of(user));
// anyString(), anyInt(), anyList(), anyMap(), etc.
when(service.process(anyString(), anyInt())).thenReturn(result);
// argThat(): custom condition
when(repo.save(argThat(u -> u.getName().startsWith("A")))).thenReturn(savedUser);
// Important: when using matchers, ALL arguments must use matchers
// Bad: verify(service).method("literal", anyInt());
// Good: verify(service).method(eq("literal"), anyInt());
// AssertJ-style verification with argThat
verify(repo).save(argThat(user -> {
assertThat(user.getName()).isNotBlank();
assertThat(user.getEmail()).contains("@");
return true; // must return true
}));
}
2.6 Strict Stubbings
// MockitoExtension uses strict stubbing by default
// UnnecessaryStubbingException is thrown for stubs that are never called
@ExtendWith(MockitoExtension.class)
class StrictStubbingTest {
@Test
void unnecessaryStubbingCausesFailure() {
// If this stub is not used in the test, the test fails
when(userRepository.findById("unused")).thenReturn(Optional.of(user));
// If "unused" is never queried in the test, it will fail
}
// Lenient mode: allow unused stubs (useful in shared fixtures)
@Test
void lenientStubbing() {
lenient().when(userRepository.findById("maybe-unused"))
.thenReturn(Optional.of(user));
// No exception even if not used
}
}
3. Implementing Stub Patterns
3.1 In-Memory Repository Stub
// Manual stub based on an interface
public class InMemoryUserRepository implements UserRepository {
private final Map<String, User> store = new HashMap<>();
private long idSequence = 1;
@Override
public User save(User user) {
if (user.getId() == null) {
user = user.withId(String.valueOf(idSequence++));
}
store.put(user.getId(), user);
return user;
}
@Override
public Optional<User> findById(String id) {
return Optional.ofNullable(store.get(id));
}
@Override
public List<User> findAll() {
return new ArrayList<>(store.values());
}
@Override
public void deleteById(String id) {
store.remove(id);
}
@Override
public boolean existsByEmail(String email) {
return store.values().stream()
.anyMatch(u -> email.equals(u.getEmail()));
}
// Test helper methods
public void clear() { store.clear(); }
public int count() { return store.size(); }
}
// Time Stub using Clock.fixed()
public class OrderServiceTest {
private final Clock fixedClock = Clock.fixed(
Instant.parse("2026-03-17T10:00:00Z"),
ZoneId.of("Asia/Seoul")
);
@Test
void orderShouldHaveCreationTime() {
OrderService service = new OrderService(orderRepo, fixedClock);
Order order = service.createOrder(userId, items);
LocalDateTime expected = LocalDateTime.of(2026, 3, 17, 19, 0, 0); // KST (+9)
assertEquals(expected, order.getCreatedAt());
}
}
4. HTTP Mocking with WireMock
4.1 WireMock Setup
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
// Option 1: Annotation-based (simple)
@WireMockTest(httpPort = 8089)
class SimpleWireMockTest {
@Test
void testWithWireMock(WireMockRuntimeInfo wmRuntimeInfo) {
stubFor(get("/api/users/1")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\": \"1\", \"name\": \"Alice\"}")));
String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
// Use WebClient or RestTemplate to call baseUrl + "/api/users/1"
}
}
// Option 2: Extension-based (more control)
@ExtendWith(WireMockExtension.class)
class AdvancedWireMockTest {
@RegisterExtension
static WireMockExtension wm = WireMockExtension.newInstance()
.options(wireMockConfig()
.dynamicPort()
.dynamicHttpsPort()
.usingFilesUnderClasspath("wiremock"))
.build();
@Test
void advancedTest() {
wm.stubFor(get(urlPathEqualTo("/api/users"))
.withQueryParam("page", equalTo("1"))
.withHeader("Authorization", matching("Bearer .+"))
.willReturn(aResponse()
.withStatus(200)
.withBodyFile("users-response.json"))); // from src/test/resources/wiremock/__files/
}
}
4.2 Request Matching and Response Configuration
@Test
void requestMatching_examples() {
// URL matching
stubFor(get(urlEqualTo("/exact/path"))); // exact match
stubFor(get(urlPathEqualTo("/path"))); // ignores query params
stubFor(get(urlPathMatching("/api/users/[0-9]+"))); // regex
// Request body matching (POST/PUT)
stubFor(post(urlPathEqualTo("/api/users"))
.withRequestBody(equalToJson("""
{"name": "Alice", "email": "alice@example.com"}
""", true, true)) // ignoreArrayOrder, ignoreExtraElements
.withRequestBody(matchingJsonPath("$.email", containing("@")))
.willReturn(aResponse().withStatus(201)));
// Fixed delay simulation
stubFor(get("/slow-endpoint")
.willReturn(aResponse()
.withStatus(200)
.withFixedDelay(2000) // 2-second fixed delay
.withBody("Slow response")));
// Random delay
stubFor(get("/random-delay")
.willReturn(aResponse()
.withStatus(200)
.withUniformRandomDelay(100, 500))); // 100-500ms random
// Network fault simulation
stubFor(get("/network-error")
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
// Headers in response
stubFor(get("/with-headers")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Cache-Control", "max-age=3600")
.withBody("response body")));
}
4.3 Scenarios (Stateful Mocks)
@Test
void statefulMock_scenario() {
// Scenario: retry pattern test
// 1st call: 503 error
// 2nd call: 200 success
stubFor(get("/flaky-endpoint")
.inScenario("Retry Scenario")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("First call failed"));
stubFor(get("/flaky-endpoint")
.inScenario("Retry Scenario")
.whenScenarioStateIs("First call failed")
.willReturn(aResponse()
.withStatus(200)
.withBody("Success on retry")));
// Test a client with retry logic
String result = retryableClient.get("/flaky-endpoint");
assertEquals("Success on retry", result);
// Verify both calls were made
verify(exactly(2), getRequestedFor(urlEqualTo("/flaky-endpoint")));
}
4.4 Complete WebClient Test Example
@WireMockTest
class UserApiClientTest {
private UserApiClient client;
@BeforeEach
void setUp(WireMockRuntimeInfo wmRuntimeInfo) {
WebClient webClient = WebClient.builder()
.baseUrl(wmRuntimeInfo.getHttpBaseUrl())
.build();
client = new UserApiClient(webClient);
}
@Test
void getUser_shouldReturnUser_whenFound() {
// given
stubFor(get(urlPathEqualTo("/api/users/alice"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"id": "alice",
"name": "Alice",
"email": "alice@example.com"
}
""")));
// when
User user = client.getUser("alice").block();
// then
assertNotNull(user);
assertEquals("alice", user.getId());
assertEquals("Alice", user.getName());
// Verify WireMock received the correct request
verify(getRequestedFor(urlPathEqualTo("/api/users/alice"))
.withHeader("Accept", equalTo("application/json")));
}
@Test
void getUser_shouldThrow_whenNotFound() {
// given
stubFor(get(urlPathEqualTo("/api/users/nonexistent"))
.willReturn(aResponse().withStatus(404)));
// when / then
assertThrows(UserNotFoundException.class,
() -> client.getUser("nonexistent").block());
}
}
5. Spring Boot Test
5.1 Test Slice Comparison
// @SpringBootTest: loads the full application context (integration tests)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class FullIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void fullFlowTest() {
ResponseEntity<UserResponse> response = restTemplate.postForEntity(
"/api/users",
new CreateUserRequest("Alice", "alice@example.com", 30),
UserResponse.class
);
assertEquals(HttpStatus.CREATED, response.getStatusCode());
}
}
// @WebMvcTest: loads only the Web layer (Controller, Filter, Interceptor)
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean // Registers a mock in the Spring Context
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("POST /api/users should return 201")
void createUser_shouldReturn201() throws Exception {
// given
CreateUserRequest request = new CreateUserRequest("Alice", "alice@example.com", 30);
UserResponse response = new UserResponse("user-1", "Alice", "alice@example.com",
LocalDateTime.now());
when(userService.createUser(any())).thenReturn(response);
// when / then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("user-1"))
.andExpect(jsonPath("$.name").value("Alice"))
.andExpect(jsonPath("$.email").value("alice@example.com"))
.andDo(print());
}
@Test
void createUser_shouldReturn400_whenInvalid() throws Exception {
CreateUserRequest invalidRequest = new CreateUserRequest("", "not-an-email", -1);
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray());
}
}
// @DataJpaTest: JPA components only (Repository, Entity)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
@Transactional
void findByEmail_shouldReturnUser_whenExists() {
// given - persist test data directly
User user = entityManager.persist(new User(null, "Alice", "alice@example.com"));
entityManager.flush();
// when
Optional<User> found = userRepository.findByEmail("alice@example.com");
// then
assertTrue(found.isPresent());
assertEquals("Alice", found.get().getName());
}
}
5.2 @MockBean vs @SpyBean
@SpringBootTest
class MockBeanVsSpyBeanTest {
@MockBean
private PaymentGateway paymentGateway;
// Completely replaces the bean with a mock (all methods return defaults)
@SpyBean
private NotificationService notificationService;
// Wraps the real bean with a spy (real methods called, some can be stubbed)
@Test
void paymentWithSpyNotification() {
// PaymentGateway is a full mock
when(paymentGateway.charge(any(), any())).thenReturn(PaymentResult.success("txn-123"));
// NotificationService uses real behavior except sendSms
doNothing().when(notificationService).sendSms(anyString(), anyString());
orderService.placeOrder(userId, cartId);
verify(paymentGateway).charge(eq(userId), any(Money.class));
verify(notificationService).sendEmail(eq(userId), anyString());
verify(notificationService).sendSms(eq(userId), anyString());
}
}
5.3 Managing Test Data with @Sql
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Test
@Sql("/test-data/insert-orders.sql")
@Sql(scripts = "/test-data/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
void findActiveOrders_shouldReturnCorrectCount() {
List<Order> activeOrders = orderRepository.findByStatus(OrderStatus.ACTIVE);
assertEquals(3, activeOrders.size());
}
}
6. TestContainers
6.1 Integration Tests with Real Databases
@SpringBootTest
@Testcontainers
class UserServiceIntegrationTest {
@Container
@ServiceConnection // Spring Boot 3.1+: auto-configures datasource
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Container
static RedisContainer redis = new RedisContainer("redis:7")
.withExposedPorts(6379);
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
@Transactional
void createUser_shouldPersistToDatabase() {
// given
CreateUserRequest request = new CreateUserRequest("Alice", "alice@example.com", 30);
// when
UserResponse response = userService.createUser(request);
// then - query from actual PostgreSQL
Optional<User> persisted = userRepository.findById(response.id());
assertTrue(persisted.isPresent());
assertEquals("Alice", persisted.get().getName());
}
}
// Reusable containers (for speed)
public abstract class AbstractIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:16")
.withReuse(true); // Reuse container between test runs
POSTGRES.start();
}
@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);
}
}
6.2 Kafka Integration Tests
@SpringBootTest
@Testcontainers
class OrderEventPublisherTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);
@DynamicPropertySource
static void kafkaProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Autowired
private OrderService orderService;
@Test
void orderCreation_shouldPublishEvent() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<OrderEvent> receivedEvent = new AtomicReference<>();
// Set up Kafka consumer
KafkaConsumer<String, OrderEvent> consumer = createTestConsumer();
consumer.subscribe(List.of("order-events"));
// when
orderService.createOrder(userId, items);
// then - verify Kafka event received
assertTrue(latch.await(10, TimeUnit.SECONDS));
OrderEvent event = receivedEvent.get();
assertNotNull(event);
assertEquals(OrderEventType.CREATED, event.getType());
}
}
7. TDD in Practice (Spring Boot)
7.1 Order Service with TDD
// Step 1: Write a failing test (RED)
class OrderServiceTest {
private final OrderRepository orderRepository = new InMemoryOrderRepository();
private final ProductRepository productRepository = new InMemoryProductRepository();
private final OrderService orderService = new OrderService(orderRepository, productRepository);
@BeforeEach
void setUp() {
productRepository.save(new Product("prod-1", "Laptop", 1_500_000, 10));
productRepository.save(new Product("prod-2", "Mouse", 50_000, 5));
}
@Test
@DisplayName("createOrder: should succeed when stock is sufficient")
void createOrder_shouldSucceed_whenStockIsSufficient() {
// given
List<OrderItem> items = List.of(new OrderItem("prod-1", 2));
// when
Order order = orderService.createOrder("user-1", items);
// then
assertNotNull(order.getId());
assertEquals(OrderStatus.PENDING, order.getStatus());
assertEquals(3_000_000, order.getTotalAmount());
assertEquals(8, productRepository.findById("prod-1").get().getStock());
}
@Test
@DisplayName("createOrder: should throw when stock is insufficient")
void createOrder_shouldThrow_whenStockInsufficient() {
List<OrderItem> items = List.of(new OrderItem("prod-2", 10)); // stock: 5
assertThrows(InsufficientStockException.class,
() -> orderService.createOrder("user-1", items));
}
@Test
@DisplayName("cancelOrder: should succeed when status is PENDING")
void cancelOrder_shouldSucceed_whenPending() {
Order order = orderService.createOrder("user-1",
List.of(new OrderItem("prod-1", 1)));
orderService.cancelOrder(order.getId(), "user-1");
Order cancelled = orderRepository.findById(order.getId()).orElseThrow();
assertEquals(OrderStatus.CANCELLED, cancelled.getStatus());
assertEquals(10, productRepository.findById("prod-1").get().getStock()); // stock restored
}
}
// Step 2: Minimal implementation (GREEN)
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
public OrderService(OrderRepository orderRepository, ProductRepository productRepository) {
this.orderRepository = orderRepository;
this.productRepository = productRepository;
}
public Order createOrder(String userId, List<OrderItem> items) {
// Validate and reserve stock
for (OrderItem item : items) {
Product product = productRepository.findById(item.productId())
.orElseThrow(() -> new ResourceNotFoundException("Product", item.productId()));
if (product.getStock() < item.quantity()) {
throw new InsufficientStockException(
item.productId(), item.quantity(), product.getStock());
}
}
// Deduct stock and compute total
int totalAmount = 0;
for (OrderItem item : items) {
Product product = productRepository.findById(item.productId()).orElseThrow();
productRepository.save(product.decreaseStock(item.quantity()));
totalAmount += product.getPrice() * item.quantity();
}
Order order = new Order(null, userId, items, totalAmount,
OrderStatus.PENDING, LocalDateTime.now());
return orderRepository.save(order);
}
public void cancelOrder(String orderId, String userId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new ResourceNotFoundException("Order", orderId));
if (!order.getUserId().equals(userId))
throw new UnauthorizedException("Not your order");
if (order.getStatus() != OrderStatus.PENDING)
throw new InvalidOrderStateException("Cannot cancel order in state: " + order.getStatus());
// Restore stock
for (OrderItem item : order.getItems()) {
Product product = productRepository.findById(item.productId()).orElseThrow();
productRepository.save(product.increaseStock(item.quantity()));
}
orderRepository.save(order.withStatus(OrderStatus.CANCELLED));
}
}
8. Architecture Testing with ArchUnit
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
@AnalyzeClasses(packages = "com.example.app")
class ArchitectureTest {
@ArchTest
static final ArchRule layerDependencies = layeredArchitecture()
.consideringAllDependencies()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Repository").definedBy("..repository..")
.layer("Domain").definedBy("..domain..")
.whereLayer("Controller").mayOnlyBeAccessedByLayers("Controller")
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller", "Service")
.whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Controller", "Service", "Repository");
@ArchTest
static final ArchRule servicesShouldNotDependOnControllers =
noClasses().that().resideInAPackage("..service..")
.should().dependOnClassesThat().resideInAPackage("..controller..");
@ArchTest
static final ArchRule repositoriesShouldBeInterfaces =
classes().that().resideInAPackage("..repository..")
.and().haveSimpleNameEndingWith("Repository")
.should().beInterfaces();
@ArchTest
static final ArchRule servicesShouldBeAnnotated =
classes().that().resideInAPackage("..service.impl..")
.should().beAnnotatedWith(Service.class);
@ArchTest
static final ArchRule noCyclicDependencies =
slices().matching("com.example.app.(*)..")
.should().beFreeOfCycles();
}
9. Quizzes
Quiz 1: What condition must @BeforeAll methods satisfy in JUnit 5?
Options:
- A) Must be public
- B) Must be static (by default)
- C) Must return void
- D) Must have no parameters
Answer: B (must be static)
Explanation: @BeforeAll methods must be static by default because they run once for the entire test class. Exception: with @TestInstance(Lifecycle.PER_CLASS), @BeforeAll can be non-static. The return type must be void. Any access modifier is allowed (public, protected, package-private).
Quiz 2: What is the key difference between @Spy and @Mock in Mockito?
Options:
- A) @Spy can only be used on interfaces
- B) @Mock calls real methods while @Spy does not
- C) @Spy wraps a real object and allows stubbing selected methods
- D) @Spy and @Mock are functionally identical
Answer: C
Explanation: @Mock creates a complete mock where all methods return default values. @Spy wraps a real instance — real methods are called by default, and only selected methods can be stubbed. @Spy requires a concrete class instance, while @Mock works with both interfaces and classes.
Quiz 3: Why are Scenarios used in WireMock?
Options:
- A) To improve response speed
- B) To implement stateful mocks that return different responses based on call order
- C) To reuse the same stub across multiple tests
- D) To simulate HTTP authentication
Answer: B
Explanation: WireMock scenarios implement a state machine, allowing the same endpoint to return different responses depending on which invocation it is. Classic use cases include testing retry patterns (503 on first call, 200 on second), shopping cart state changes, and any interaction where order matters.
Quiz 4: What is the main difference between @WebMvcTest and @SpringBootTest?
Options:
- A) @WebMvcTest uses real HTTP requests and @SpringBootTest uses MockMvc
- B) @WebMvcTest loads only the Web layer (faster); @SpringBootTest loads the full context
- C) @SpringBootTest is a unit test and @WebMvcTest is an integration test
- D) @MockBean cannot be used inside @WebMvcTest
Answer: B
Explanation: @WebMvcTest loads only web-layer components (Controllers, ControllerAdvice, Filters, WebMvcConfigurer) and is fast. @SpringBootTest loads the full Spring context, which is slower but closer to production. Both support @MockBean.
Quiz 5: What is the main advantage of TestContainers?
Options:
- A) Tests run faster than with H2 in-memory databases
- B) Reproduces exact vendor-specific behavior, providing high fidelity
- C) Eliminates the need for any infrastructure in CI/CD pipelines
- D) Can be used without any Spring Boot configuration
Answer: B
Explanation: TestContainers' primary advantage is running actual Docker containers of PostgreSQL, MySQL, MongoDB, Kafka, etc., reproducing vendor-specific behaviors (SQL syntax, transaction semantics, data types) that H2 in-memory databases cannot replicate. They are slower than H2 but run reliably in CI/CD environments.
Summary
An effective Java testing strategy:
- Unit tests: JUnit 5 + Mockito for focused business logic testing
- Web layer tests: @WebMvcTest + MockMvc for fast API testing
- Integration tests: TestContainers for parity with production infrastructure
- HTTP client tests: WireMock to eliminate external API dependencies
- Architecture tests: ArchUnit to automate enforcement of design principles
Following the testing pyramid and choosing the right tool for each layer lets you build a test suite that is fast, reliable, and maintainable.