Split View: Java 테스트 완전 정복: JUnit 5, Mockito, WireMock 실전 가이드
Java 테스트 완전 정복: JUnit 5, Mockito, WireMock 실전 가이드
Java 테스트 완전 정복: JUnit 5, Mockito, WireMock 실전 가이드
테스트는 소프트웨어 품질의 근간입니다. 잘 작성된 테스트는 리팩토링을 자신있게 할 수 있게 해주고, 버그를 빠르게 발견하게 해줍니다. 이 가이드에서는 Java 테스트의 핵심 도구들을 실전 예시와 함께 완전 정복합니다.
1. JUnit 5 완전 정복
1.1 JUnit 5 아키텍처
JUnit 5는 세 개의 서브프로젝트로 구성됩니다:
- JUnit Platform: 테스트 프레임워크 실행 기반 (Launcher API)
- JUnit Jupiter: 새로운 프로그래밍 모델 및 확장 모델 (Jupiter API)
- JUnit Vintage: JUnit 3/4 테스트 하위 호환성
<!-- build.gradle.kts (Kotlin DSL) -->
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 기본 어노테이션
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private Calculator calculator;
@BeforeAll
static void initAll() {
// 클래스 전체에서 한 번만 실행 (static)
System.out.println("테스트 클래스 초기화");
}
@BeforeEach
void setUp() {
// 각 테스트 메서드 전에 실행
calculator = new Calculator();
}
@AfterEach
void tearDown() {
// 각 테스트 메서드 후에 실행
calculator = null;
}
@AfterAll
static void tearDownAll() {
// 클래스 전체에서 마지막에 한 번만 실행 (static)
System.out.println("테스트 클래스 종료");
}
@Test
@DisplayName("두 수의 합이 올바르게 계산되어야 한다")
void add_shouldReturnCorrectSum() {
// given
int a = 3, b = 5;
// when
int result = calculator.add(a, b);
// then
assertEquals(8, result, "3 + 5 = 8 이어야 합니다");
}
@Test
@Disabled("미구현 기능")
void notImplementedYet() {
// 이 테스트는 건너뜀
}
@Test
@Tag("slow")
void slowTest() {
// 태그로 그룹화, ./gradlew test -Dtests.groups=slow
}
}
1.3 @Nested로 테스트 구조화
@DisplayName("주문 서비스 테스트")
class OrderServiceTest {
@Nested
@DisplayName("주문 생성 시")
class WhenCreatingOrder {
@Test
@DisplayName("재고가 충분하면 성공해야 한다")
void shouldSucceedWhenStockIsSufficient() { /* ... */ }
@Test
@DisplayName("재고가 부족하면 예외를 던져야 한다")
void shouldThrowWhenStockIsInsufficient() { /* ... */ }
}
@Nested
@DisplayName("주문 취소 시")
class WhenCancellingOrder {
@Test
@DisplayName("배송 전이면 취소 가능해야 한다")
void shouldAllowCancellationBeforeShipping() { /* ... */ }
@Test
@DisplayName("배송 후에는 취소 불가능해야 한다")
void shouldNotAllowCancellationAfterShipping() { /* ... */ }
}
}
1.4 파라미터화 테스트
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
class ParameterizedTests {
// @ValueSource: 단일 타입 값 목록
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
@DisplayName("공백 문자열은 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: 여러 컬럼 데이터
@ParameterizedTest
@CsvSource({
"apple, 5",
"banana, 6",
"cherry, 6"
})
@DisplayName("과일 이름의 길이가 올바르게 반환되어야 한다")
void fruitLengthShouldMatch(String fruit, int expectedLength) {
assertEquals(expectedLength, fruit.length());
}
// @CsvFileSource: CSV 파일에서 읽기
@ParameterizedTest
@CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1)
void fromCsvFile(String input, int expected) { /* ... */ }
// @MethodSource: 메서드에서 인자 제공
@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
void isBlank_ShouldReturnTrue(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: Enum 값으로 테스트
@ParameterizedTest
@EnumSource(value = DayOfWeek.class, names = {"SATURDAY", "SUNDAY"})
void weekendsAreNotWorkdays(DayOfWeek day) {
assertFalse(isWorkday(day));
}
}
1.5 Assertions 심화
class AssertionsTest {
@Test
void groupAssertions() {
// assertAll: 모든 검증 수행 후 실패 목록 출력
assertAll("person",
() -> assertEquals("Alice", person.getName()),
() -> assertEquals(30, person.getAge()),
() -> assertEquals("alice@example.com", person.getEmail())
);
}
@Test
void exceptionTesting() {
// assertThrows: 예외 타입 검증
Exception exception = assertThrows(
ArithmeticException.class,
() -> calculator.divide(10, 0)
);
assertEquals("Division by zero", exception.getMessage());
}
@Test
void timeoutTesting() {
// assertTimeout: 시간 초과 검증
assertTimeout(Duration.ofSeconds(1), () -> {
// 1초 내에 완료되어야 함
Thread.sleep(500);
});
// assertTimeoutPreemptively: 시간 초과 시 즉시 중단
assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {
someBlockingOperation();
});
}
@Test
void assumptionsTest() {
// 조건이 false이면 테스트 건너뜀 (실패 아님)
assumeTrue("CI".equals(System.getenv("ENV")),
"CI 환경에서만 실행");
// CI 환경일 때만 아래 코드 실행
heavyIntegrationTest();
}
}
1.6 @TestFactory (동적 테스트)
@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 + "는 과일이다",
() -> assertTrue(isFruit(fruit))
));
}
2. Mockito 완전 정복
2.1 기본 설정
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService; // @Mock 필드들이 자동 주입
@Spy
private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// @Spy: 실제 객체이지만 일부 메서드만 모의 가능
@Captor
private ArgumentCaptor<User> userCaptor;
// ArgumentCaptor: 메서드 호출 시 전달된 인자를 캡처
}
2.2 when().thenReturn() / thenThrow() / thenAnswer()
@Test
void stubbing_examples() {
// 기본 스텁: 특정 값 반환
when(userRepository.findById("user1"))
.thenReturn(Optional.of(new User("user1", "Alice")));
// null 또는 비어있는 경우
when(userRepository.findById("nonexistent"))
.thenReturn(Optional.empty());
// 예외 발생
when(userRepository.findById("error"))
.thenThrow(new DatabaseException("Connection refused"));
// 연속 반환 (순서대로)
when(userRepository.findAll())
.thenReturn(List.of(user1))
.thenReturn(List.of(user1, user2))
.thenReturn(Collections.emptyList());
// thenAnswer: 인자에 따른 동적 반환
when(userRepository.findByName(anyString()))
.thenAnswer(invocation -> {
String name = invocation.getArgument(0);
return Optional.of(new User(name.toLowerCase(), name));
});
// doReturn: void 메서드나 spy에서 유용
doReturn(Optional.of(mockUser)).when(userRepository).findById("spy-test");
// doThrow: void 메서드에서 예외 발생
doThrow(new MailException("SMTP error")).when(emailService).sendWelcomeEmail(any());
// doNothing: void 메서드를 아무 동작 없이
doNothing().when(emailService).sendNotification(anyString());
// doCallRealMethod: spy에서 실제 메서드 호출
doCallRealMethod().when(passwordEncoder).encode(anyString());
}
2.3 verify()로 인터랙션 검증
@Test
void verify_examples() {
// 기본 검증: 정확히 1번 호출되었는가
verify(userRepository).findById("user1");
verify(userRepository, times(1)).findById("user1");
// 호출 횟수 검증
verify(emailService, times(3)).sendEmail(anyString());
verify(userRepository, never()).delete(any());
verify(emailService, atLeast(2)).sendEmail(anyString());
verify(emailService, atMost(5)).sendEmail(anyString());
// 호출 순서 검증
InOrder inOrder = inOrder(userRepository, emailService);
inOrder.verify(userRepository).save(any(User.class));
inOrder.verify(emailService).sendWelcomeEmail(anyString());
// 특정 인터랙션 외 다른 호출 없음 검증
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 - 저장된 User 객체 캡처
verify(userRepository).save(userCaptor.capture());
User savedUser = userCaptor.getValue();
assertAll("저장된 사용자 검증",
() -> assertEquals(name, savedUser.getName()),
() -> assertEquals(email.toLowerCase(), savedUser.getEmail()),
() -> assertNotNull(savedUser.getCreatedAt()),
() -> assertEquals(UserStatus.PENDING, savedUser.getStatus())
);
// 여러 호출의 모든 캡처된 값
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(): 모든 타입 (null 포함)
when(repo.findAll(any())).thenReturn(list);
// any(SpecificClass.class): 특정 타입
when(repo.findByExample(any(User.class))).thenReturn(list);
// eq(): 정확히 같은 값
when(repo.findById(eq("specific-id"))).thenReturn(Optional.of(user));
// anyString(), anyInt(), anyList(), anyMap() 등
when(service.process(anyString(), anyInt())).thenReturn(result);
// argThat(): 커스텀 조건
when(repo.save(argThat(u -> u.getName().startsWith("A")))).thenReturn(savedUser);
// 주의: ArgumentMatchers 사용 시 모든 인자를 matcher로 사용해야 함
// Bad: verify(service).method("literal", anyInt());
// Good: verify(service).method(eq("literal"), anyInt());
// assertj와 함께 사용
verify(repo).save(argThat(user -> {
assertThat(user.getName()).isNotBlank();
assertThat(user.getEmail()).contains("@");
return true; // true 반환 필수
}));
}
2.6 Strict Stubbings
// MockitoExtension은 기본적으로 strict stubbing 사용
// 불필요한 스텁(실제로 호출되지 않은 스텁)이 있으면 UnnecessaryStubbingException 발생
@ExtendWith(MockitoExtension.class)
class StrictStubbingTest {
@Test
void unnecessaryStubbingCausesFailure() {
// 이 스텁이 테스트 내에서 사용되지 않으면 예외 발생
when(userRepository.findById("unused")).thenReturn(Optional.of(user));
// 실제 테스트 코드에서 "unused"로 조회하지 않으면 실패
}
// Lenient 모드: 불필요한 스텁 허용 (공유 fixture에서 유용)
@Test
void lenientStubbing() {
lenient().when(userRepository.findById("maybe-unused"))
.thenReturn(Optional.of(user));
// 사용되지 않아도 예외 없음
}
}
3. Stub 패턴 구현
3.1 인메모리 Repository Stub
// 인터페이스 기반 수동 Stub
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()));
}
// 테스트 헬퍼 메서드
public void clear() { store.clear(); }
public int count() { return store.size(); }
}
// 시간 Stub (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
assertEquals(expected, order.getCreatedAt());
}
}
4. WireMock으로 HTTP 모의 서버
4.1 WireMock 설정
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;
// 방법 1: 어노테이션 방식 (간단)
@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\"}")));
// HTTP 요청 수행
String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
// WebClient나 RestTemplate으로 baseUrl + "/api/users/1" 호출
}
}
// 방법 2: Extension 방식 (더 많은 제어)
@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"))); // src/test/resources/wiremock/__files/
}
}
4.2 요청 매칭과 응답 설정
@Test
void requestMatching_examples() {
// URL 매칭
stubFor(get(urlEqualTo("/exact/path"))); // 정확히 일치
stubFor(get(urlPathEqualTo("/path"))); // 쿼리 파라미터 무시
stubFor(get(urlPathMatching("/api/users/[0-9]+")));// 정규식
// 요청 바디 매칭 (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)));
// 응답 지연 시뮬레이션
stubFor(get("/slow-endpoint")
.willReturn(aResponse()
.withStatus(200)
.withFixedDelay(2000) // 2초 고정 지연
.withBody("Slow response")));
stubFor(get("/random-delay")
.willReturn(aResponse()
.withStatus(200)
.withUniformRandomDelay(100, 500))); // 100~500ms 랜덤
// 네트워크 오류 시뮬레이션
stubFor(get("/network-error")
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
// 헤더 응답
stubFor(get("/with-headers")
.willReturn(aResponse()
.withStatus(200)
.withHeader("X-Request-Id", "$(randomValue)")
.withHeader("Cache-Control", "max-age=3600")
.withBody("response body")));
}
4.3 시나리오 (Stateful Mock)
@Test
void statefulMock_scenario() {
// 시나리오: 재시도 패턴 테스트
// 1번째 호출: 503 오류
// 2번째 호출: 200 성공
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")));
// 재시도 로직을 가진 클라이언트 테스트
String result = retryableClient.get("/flaky-endpoint");
assertEquals("Success on retry", result);
// 요청 검증
verify(exactly(2), getRequestedFor(urlEqualTo("/flaky-endpoint")));
}
4.4 WebClient 테스트 예시
@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(WireMockRuntimeInfo wm) {
// 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());
// WireMock 요청 검증
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 테스트 슬라이스 비교
// @SpringBootTest: 전체 애플리케이션 컨텍스트 로드 (통합 테스트)
@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: Web 레이어만 (Controller, Filter, Interceptor)
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean // Spring Context에 Mock 등록
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("사용자 생성 API는 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_whenRequestIsInvalid() throws Exception {
CreateUserRequest invalidRequest = new CreateUserRequest("", "invalid-email", -1);
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray());
}
}
// @DataJpaTest: JPA 관련 컴포넌트만 (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 - 테스트 데이터 직접 저장
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;
// 완전히 모의 객체로 교체 (모든 메서드 기본값 반환)
@SpyBean
private NotificationService notificationService;
// 실제 빈을 spy로 감싸기 (실제 메서드 호출, 일부만 스텁)
@Test
void paymentWithSpyNotification() {
// PaymentGateway는 완전 Mock
when(paymentGateway.charge(any(), any())).thenReturn(PaymentResult.success("txn-123"));
// NotificationService는 실제 동작하되 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 @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 실제 DB로 통합 테스트
@SpringBootTest
@Testcontainers
class UserServiceIntegrationTest {
@Container
@ServiceConnection // Spring Boot 3.1+: 자동 설정
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 - 실제 PostgreSQL에서 조회
Optional<User> persisted = userRepository.findById(response.id());
assertTrue(persisted.isPresent());
assertEquals("Alice", persisted.get().getName());
}
}
// 재사용 가능한 컨테이너 (속도 향상)
public abstract class AbstractIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:16")
.withReuse(true); // 컨테이너 재사용
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 통합 테스트
@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;
@Autowired
private KafkaTemplate<String, OrderEvent> kafkaTemplate;
@Test
void orderCreation_shouldPublishEvent() throws Exception {
// given
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<OrderEvent> receivedEvent = new AtomicReference<>();
// Kafka Consumer 설정
KafkaConsumer<String, OrderEvent> consumer = createTestConsumer();
consumer.subscribe(List.of("order-events"));
// when
orderService.createOrder(userId, items);
// then - Kafka 이벤트 수신 확인
assertTrue(latch.await(10, TimeUnit.SECONDS));
OrderEvent event = receivedEvent.get();
assertNotNull(event);
assertEquals(OrderEventType.CREATED, event.getType());
}
}
7. TDD 실전 예시 (Spring Boot)
7.1 주문 서비스 TDD
// Step 1: 실패하는 테스트 작성 (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", "노트북", 1_500_000, 10));
productRepository.save(new Product("prod-2", "마우스", 50_000, 5));
}
@Test
@DisplayName("주문 생성: 재고가 충분할 때 주문이 생성된다")
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("주문 생성: 재고 부족 시 예외 발생")
void createOrder_shouldThrow_whenStockInsufficient() {
List<OrderItem> items = List.of(new OrderItem("prod-2", 10)); // 재고: 5
assertThrows(InsufficientStockException.class,
() -> orderService.createOrder("user-1", items));
}
@Test
@DisplayName("주문 취소: 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()); // 재고 복구
}
}
// Step 2: 최소한의 구현 (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) {
// 재고 확인 및 차감
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());
}
}
// 재고 차감 (도메인 로직)
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");
// 재고 복구
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. 아키텍처 테스트 (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. 퀴즈
퀴즈 1: JUnit 5에서 @BeforeAll 메서드에 반드시 필요한 조건은?
보기:
- A) 반드시 public이어야 한다
- B) 반드시 static이어야 한다 (기본적으로)
- C) 반드시 void를 반환해야 한다
- D) 반드시 파라미터가 없어야 한다
정답: B (static이어야 한다)
설명: @BeforeAll 메서드는 기본적으로 static이어야 합니다. 클래스 전체에서 한 번만 실행되어야 하기 때문입니다. 단, @TestInstance(Lifecycle.PER_CLASS)를 사용하면 non-static @BeforeAll도 가능합니다. 반환 타입은 void여야 하고, @BeforeAll 메서드는 모든 접근 제어자(public, protected, package-private 등)를 사용할 수 있습니다.
퀴즈 2: Mockito의 @Spy와 @Mock의 차이는?
보기:
- A) @Spy는 인터페이스에만 사용 가능하다
- B) @Mock은 실제 메서드를 호출하지만 @Spy는 그렇지 않다
- C) @Spy는 실제 객체를 감싸서 일부 메서드만 스텁할 수 있다
- D) @Spy와 @Mock은 기능적으로 동일하다
정답: C
설명: @Mock은 완전한 모의 객체로 모든 메서드가 기본값을 반환합니다. @Spy는 실제 객체를 spy로 감싸기 때문에 기본적으로 실제 메서드가 호출되고, 일부 메서드만 스텁할 수 있습니다. @Spy는 구체적인 클래스 인스턴스가 필요합니다.
퀴즈 3: WireMock에서 시나리오(Scenario)를 사용하는 이유는?
보기:
- A) 응답 속도를 높이기 위해
- B) 상태에 따라 다른 응답을 반환하는 Stateful Mock을 구현하기 위해
- C) 여러 테스트에서 동일한 스텁을 재사용하기 위해
- D) HTTP 인증을 시뮬레이션하기 위해
정답: B
설명: WireMock 시나리오는 상태 기계(state machine)를 구현하여 동일한 요청에 대해 호출 순서에 따라 다른 응답을 반환합니다. 예를 들어 재시도 패턴(첫 호출은 503, 두 번째는 200) 또는 장바구니 상태 변화 테스트에 유용합니다.
퀴즈 4: @WebMvcTest와 @SpringBootTest의 주요 차이는?
보기:
- A) @WebMvcTest는 실제 HTTP 요청을 사용하고 @SpringBootTest는 MockMvc를 사용한다
- B) @WebMvcTest는 Web 레이어만 로드하여 빠르고, @SpringBootTest는 전체 컨텍스트를 로드한다
- C) @SpringBootTest는 단위 테스트이고 @WebMvcTest는 통합 테스트이다
- D) @WebMvcTest에서는 @MockBean을 사용할 수 없다
정답: B
설명: @WebMvcTest는 Controller, ControllerAdvice, Filter, WebMvcConfigurer 등 Web 레이어 관련 Bean만 로드하여 빠릅니다. @SpringBootTest는 전체 Spring 컨텍스트를 로드하므로 느리지만 실제 환경에 더 가깝습니다. 두 경우 모두 @MockBean 사용 가능합니다.
퀴즈 5: TestContainers의 주요 장점은?
보기:
- A) 테스트 속도가 H2 in-memory DB보다 빠르다
- B) 실제 데이터베이스 벤더의 동작을 정확히 재현하여 이식성이 높다
- C) 외부 인프라 없이 테스트할 수 있어 CI/CD 파이프라인에 불필요하다
- D) Spring Boot 설정 없이 사용할 수 있다
정답: B
설명: TestContainers의 주요 장점은 실제 PostgreSQL, MySQL, MongoDB, Kafka 등을 Docker 컨테이너로 구동하여 H2 같은 인메모리 DB로는 재현하기 어려운 벤더 특유의 동작(특수 SQL 문법, 트랜잭션 특성 등)을 정확히 테스트할 수 있다는 것입니다. 속도는 H2보다 느리지만 CI/CD 파이프라인에서도 잘 동작합니다.
마무리
효과적인 Java 테스트 전략:
- 단위 테스트: JUnit 5 + Mockito로 비즈니스 로직 집중 테스트
- 웹 레이어 테스트: @WebMvcTest + MockMvc로 빠른 API 테스트
- 통합 테스트: TestContainers로 실제 인프라와 동일한 환경
- HTTP 클라이언트 테스트: WireMock으로 외부 API 의존성 제거
- 아키텍처 테스트: ArchUnit으로 설계 원칙 자동 검증
테스트 피라미드를 지키면서 각 레이어에 적합한 도구를 선택하면 빠르고 신뢰할 수 있는 테스트 스위트를 구축할 수 있습니다.
Java Testing Complete Guide: JUnit 5, Mockito & WireMock in Practice
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.