Skip to content

필사 모드: Java 테스트 완전 정복: JUnit 5, Mockito, WireMock 실전 가이드

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

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 기본 어노테이션

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 파라미터화 테스트

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 설정

// 방법 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)

@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. 퀴즈

**보기:**

- A) 반드시 public이어야 한다

- B) 반드시 static이어야 한다 (기본적으로)

- C) 반드시 void를 반환해야 한다

- D) 반드시 파라미터가 없어야 한다

**정답**: B (static이어야 한다)

**설명**: @BeforeAll 메서드는 기본적으로 static이어야 합니다. 클래스 전체에서 한 번만 실행되어야 하기 때문입니다. 단, @TestInstance(Lifecycle.PER_CLASS)를 사용하면 non-static @BeforeAll도 가능합니다. 반환 타입은 void여야 하고, @BeforeAll 메서드는 모든 접근 제어자(public, protected, package-private 등)를 사용할 수 있습니다.

**보기:**

- A) @Spy는 인터페이스에만 사용 가능하다

- B) @Mock은 실제 메서드를 호출하지만 @Spy는 그렇지 않다

- C) @Spy는 실제 객체를 감싸서 일부 메서드만 스텁할 수 있다

- D) @Spy와 @Mock은 기능적으로 동일하다

**정답**: C

**설명**: @Mock은 완전한 모의 객체로 모든 메서드가 기본값을 반환합니다. @Spy는 실제 객체를 spy로 감싸기 때문에 기본적으로 실제 메서드가 호출되고, 일부 메서드만 스텁할 수 있습니다. @Spy는 구체적인 클래스 인스턴스가 필요합니다.

**보기:**

- A) 응답 속도를 높이기 위해

- B) 상태에 따라 다른 응답을 반환하는 Stateful Mock을 구현하기 위해

- C) 여러 테스트에서 동일한 스텁을 재사용하기 위해

- D) HTTP 인증을 시뮬레이션하기 위해

**정답**: B

**설명**: WireMock 시나리오는 상태 기계(state machine)를 구현하여 동일한 요청에 대해 호출 순서에 따라 다른 응답을 반환합니다. 예를 들어 재시도 패턴(첫 호출은 503, 두 번째는 200) 또는 장바구니 상태 변화 테스트에 유용합니다.

**보기:**

- 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 사용 가능합니다.

**보기:**

- 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 테스트 전략:

1. **단위 테스트**: JUnit 5 + Mockito로 비즈니스 로직 집중 테스트

2. **웹 레이어 테스트**: @WebMvcTest + MockMvc로 빠른 API 테스트

3. **통합 테스트**: TestContainers로 실제 인프라와 동일한 환경

4. **HTTP 클라이언트 테스트**: WireMock으로 외부 API 의존성 제거

5. **아키텍처 테스트**: ArchUnit으로 설계 원칙 자동 검증

테스트 피라미드를 지키면서 각 레이어에 적합한 도구를 선택하면 빠르고 신뢰할 수 있는 테스트 스위트를 구축할 수 있습니다.

현재 단락 (1/829)

테스트는 소프트웨어 품질의 근간입니다. 잘 작성된 테스트는 리팩토링을 자신있게 할 수 있게 해주고, 버그를 빠르게 발견하게 해줍니다. 이 가이드에서는 Java 테스트의 핵심 도구들...

작성 글자: 0원문 글자: 25,290작성 단락: 0/829