- Authors

- Name
- Youngju Kim
- @fjvbn20031
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으로 설계 원칙 자동 검증
테스트 피라미드를 지키면서 각 레이어에 적합한 도구를 선택하면 빠르고 신뢰할 수 있는 테스트 스위트를 구축할 수 있습니다.