Theory
중급 2-3시간 이론 + 실습

Spring 08: 테스트 전략

Spring Boot Testing 완전 가이드

Unit TestIntegration TestMockMvc@SpringBootTestTDD

1. 테스트 기초

1.1 테스트의 중요성과 분류

소프트웨어 테스트는 코드의 품질을 보장하고, 버그를 조기에 발견하며, 리팩토링을 안전하게 만드는 핵심 요소입니다. 테스트는 크게 단위 테스트, 통합 테스트, E2E 테스트로 분류됩니다.

단위 테스트 (Unit Test)

  • • 개별 컴포넌트 테스트
  • • 빠른 실행 속도
  • • 외부 의존성 모킹
  • • 높은 격리성

통합 테스트 (Integration Test)

  • • 컴포넌트 간 상호작용
  • • 실제 환경과 유사
  • • DB, 외부 API 연동
  • • 상대적으로 느림

E2E 테스트

  • • 전체 워크플로우
  • • 사용자 관점
  • • UI부터 DB까지
  • • 가장 느림

1.2 JUnit 5 기본 구조

JUnit 5는 JUnit Platform, JUnit Jupiter, JUnit Vintage 세 개의 서브 프로젝트로 구성됩니다. Jupiter는 새로운 프로그래밍 모델과 확장 모델을 제공합니다.

Maven 의존성 설정

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.9.2</version>
    <scope>test</scope>
</dependency>

<!-- Spring Boot Test Starter -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

기본 테스트 클래스 구조

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {
    
    private Calculator calculator;
    
    @BeforeAll
    static void setUpAll() {
        // 모든 테스트 실행 전 한 번 실행
        System.out.println("테스트 클래스 시작");
    }
    
    @BeforeEach
    void setUp() {
        // 각 테스트 메서드 실행 전마다 실행
        calculator = new Calculator();
    }
    
    @Test
    @DisplayName("덧셈 테스트")
    void testAddition() {
        // Given
        int a = 5;
        int b = 3;
        
        // When
        int result = calculator.add(a, b);
        
        // Then
        assertEquals(8, result, "5 + 3은 8이어야 합니다");
    }
    
    @AfterEach
    void tearDown() {
        calculator = null;
    }
    
    @AfterAll
    static void tearDownAll() {
        System.out.println("테스트 클래스 종료");
    }
}

1.3 JUnit 5 주요 어노테이션

라이프사이클 어노테이션

@BeforeAll

클래스 레벨에서 한 번 실행 (static)

@BeforeEach

각 테스트 전에 실행

@AfterEach

각 테스트 후에 실행

@AfterAll

클래스 레벨에서 한 번 실행 (static)

테스트 어노테이션

@Test

기본 테스트 메서드

@DisplayName

테스트 이름 지정

@Disabled

테스트 비활성화

@Timeout

실행 시간 제한

1.4 Assertions 완전 가이드

기본 Assertions

import static org.junit.jupiter.api.Assertions.*;

@Test
void basicAssertions() {
    // 값 비교
    assertEquals(4, 2 + 2);
    assertEquals("Hello", "Hello");
    assertEquals(3.14, Math.PI, 0.01); // delta 허용
    
    // 참/거짓 검증
    assertTrue(5 > 3);
    assertFalse(5 < 3);
    
    // null 검증
    assertNull(null);
    assertNotNull("not null");
    
    // 같은 객체 참조 검증
    String str = "test";
    assertSame(str, str);
    assertNotSame(new String("test"), new String("test"));
    
    // 배열 비교
    int[] expected = {1, 2, 3};
    int[] actual = {1, 2, 3};
    assertArrayEquals(expected, actual);
}

고급 Assertions

@Test
void advancedAssertions() {
    // 예외 검증
    Exception exception = assertThrows(
        IllegalArgumentException.class, 
        () -> {
            throw new IllegalArgumentException("잘못된 인수");
        }
    );
    assertEquals("잘못된 인수", exception.getMessage());
    
    // 예외가 발생하지 않음을 검증
    assertDoesNotThrow(() -> {
        Math.sqrt(4);
    });
    
    // 시간 제한 검증
    assertTimeout(Duration.ofSeconds(2), () -> {
        Thread.sleep(1000);
    });
    
    // 여러 조건 한번에 검증
    assertAll("사용자 정보 검증",
        () -> assertEquals("John", user.getName()),
        () -> assertEquals(25, user.getAge()),
        () -> assertEquals("john@example.com", user.getEmail())
    );
}
Given-When-Then 패턴:

테스트를 구조화하는 가장 일반적인 패턴입니다.

  • Given: 테스트 데이터 준비
  • When: 테스트할 동작 실행
  • Then: 결과 검증

2. Spring Boot 테스트

2.1 Spring Boot Test 개요

Spring Boot는 테스트를 위한 다양한 어노테이션과 도구를 제공합니다. 각각의 테스트 어노테이션은 특정 레이어나 기능에 최적화되어 있어 효율적인 테스트가 가능합니다.

Spring Boot Test Starter

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

// 포함된 라이브러리들:
// - JUnit 5
// - Spring Test & Spring Boot Test
// - AssertJ
// - Hamcrest
// - Mockito
// - JSONassert
// - JsonPath

2.2 @SpringBootTest - 통합 테스트

전체 Spring 애플리케이션 컨텍스트를 로드하여 통합 테스트를 수행합니다. 실제 애플리케이션과 동일한 환경에서 테스트할 수 있습니다.

기본 @SpringBootTest 사용법

@SpringBootTest
class UserServiceIntegrationTest {
    
    @Autowired
    private UserService userService;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void shouldCreateUser() {
        // Given
        CreateUserRequest request = new CreateUserRequest("john@example.com", "John Doe");
        
        // When
        User createdUser = userService.createUser(request);
        
        // Then
        assertThat(createdUser.getId()).isNotNull();
        assertThat(createdUser.getEmail()).isEqualTo("john@example.com");
        assertThat(createdUser.getName()).isEqualTo("John Doe");
        
        // DB에서 실제로 저장되었는지 확인
        Optional<User> savedUser = userRepository.findById(createdUser.getId());
        assertThat(savedUser).isPresent();
    }
}

@SpringBootTest 옵션들

// 1. 웹 환경 설정
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class WebIntegrationTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @LocalServerPort
    private int port;
    
    @Test
    void shouldReturnUserList() {
        String url = "http://localhost:" + port + "/api/users";
        ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
        
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }
}

// 2. 특정 클래스만 로드
@SpringBootTest(classes = {UserService.class, UserRepository.class})
class LimitedContextTest {
    // 필요한 빈만 로드하여 테스트 속도 향상
}

// 3. 프로퍼티 오버라이드
@SpringBootTest(properties = {
    "spring.datasource.url=jdbc:h2:mem:testdb",
    "logging.level.org.springframework.web=DEBUG"
})
class CustomPropertiesTest {
    // 테스트용 설정으로 오버라이드
}

2.3 @WebMvcTest - 웹 레이어 테스트

Spring MVC 컨트롤러만 테스트하는 슬라이스 테스트입니다. 웹 레이어에 집중하여 빠르고 효율적인 테스트가 가능합니다.

@WebMvcTest 기본 사용법

@WebMvcTest(UserController.class)
class UserControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Test
    void shouldReturnUserList() throws Exception {
        // Given
        List<User> users = Arrays.asList(
            new User(1L, "john@example.com", "John"),
            new User(2L, "jane@example.com", "Jane")
        );
        when(userService.findAll()).thenReturn(users);
        
        // When & Then
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", hasSize(2)))
            .andExpect(jsonPath("$[0].email").value("john@example.com"))
            .andExpect(jsonPath("$[1].email").value("jane@example.com"));
    }
    
    @Test
    void shouldCreateUser() throws Exception {
        // Given
        CreateUserRequest request = new CreateUserRequest("new@example.com", "New User");
        User createdUser = new User(3L, "new@example.com", "New User");
        when(userService.createUser(any(CreateUserRequest.class))).thenReturn(createdUser);
        
        // When & Then
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(3))
            .andExpect(jsonPath("$.email").value("new@example.com"));
    }
}

2.4 @DataJpaTest - JPA 레이어 테스트

JPA 관련 컴포넌트만 로드하여 Repository 레이어를 테스트합니다. 인메모리 데이터베이스를 사용하여 빠른 테스트가 가능합니다.

@DataJpaTest 기본 사용법

@DataJpaTest
class UserRepositoryTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void shouldFindByEmail() {
        // Given
        User user = new User("john@example.com", "John Doe");
        entityManager.persistAndFlush(user);
        
        // When
        Optional<User> found = userRepository.findByEmail("john@example.com");
        
        // Then
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("John Doe");
    }
    
    @Test
    void shouldFindActiveUsers() {
        // Given
        User activeUser = new User("active@example.com", "Active User", true);
        User inactiveUser = new User("inactive@example.com", "Inactive User", false);
        entityManager.persist(activeUser);
        entityManager.persist(inactiveUser);
        entityManager.flush();
        
        // When
        List<User> activeUsers = userRepository.findByActiveTrue();
        
        // Then
        assertThat(activeUsers).hasSize(1);
        assertThat(activeUsers.get(0).getEmail()).isEqualTo("active@example.com");
    }
    
    @Test
    void shouldCountUsersByDomain() {
        // Given
        entityManager.persist(new User("user1@gmail.com", "User 1"));
        entityManager.persist(new User("user2@gmail.com", "User 2"));
        entityManager.persist(new User("user3@yahoo.com", "User 3"));
        entityManager.flush();
        
        // When
        long gmailCount = userRepository.countByEmailContaining("gmail.com");
        
        // Then
        assertThat(gmailCount).isEqualTo(2);
    }
}

2.5 기타 슬라이스 테스트 어노테이션

@JsonTest

@JsonTest
class UserJsonTest {
    
    @Autowired
    private JacksonTester<User> json;
    
    @Test
    void shouldSerializeUser() throws Exception {
        User user = new User("john@example.com", "John");
        
        assertThat(json.write(user))
            .extractingJsonPathStringValue("$.email")
            .isEqualTo("john@example.com");
    }
}

@WebFluxTest

@WebFluxTest(UserController.class)
class UserWebFluxTest {
    
    @Autowired
    private WebTestClient webClient;
    
    @MockBean
    private UserService userService;
    
    @Test
    void shouldGetUser() {
        when(userService.findById(1L))
            .thenReturn(Mono.just(new User("john@example.com")));
        
        webClient.get().uri("/api/users/1")
            .exchange()
            .expectStatus().isOk()
            .expectBody(User.class)
            .value(user -> assertThat(user.getEmail())
                .isEqualTo("john@example.com"));
    }
}
슬라이스 테스트 선택 가이드:
  • @WebMvcTest: 컨트롤러 로직 테스트
  • @DataJpaTest: Repository 쿼리 테스트
  • @JsonTest: JSON 직렬화/역직렬화 테스트
  • @SpringBootTest: 전체 통합 테스트

테스트 프로파일 설정

// application-test.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
  logging:
    level:
      org.springframework.web: DEBUG

// 테스트에서 프로파일 활성화
@SpringBootTest
@ActiveProfiles("test")
class IntegrationTest {
    // test 프로파일로 실행
}

3. Mockito 완전 가이드

3.1 Mockito 개요

Mockito는 Java에서 가장 인기 있는 모킹 프레임워크입니다. 외부 의존성을 모킹하여 단위 테스트를 격리시키고, 테스트하고자 하는 로직에만 집중할 수 있게 해줍니다.

Mockito 의존성

<!-- Spring Boot Test Starter에 포함됨 -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

3.2 @Mock과 @InjectMocks

@Mock은 모킹할 객체를 생성하고, @InjectMocks는 모킹된 객체들을 주입받을 테스트 대상 객체를 생성합니다.

기본 사용법

@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @Mock
    private EmailService emailService;
    
    @InjectMocks
    private UserService userService;
    
    @Test
    void shouldCreateUser() {
        // Given
        CreateUserRequest request = new CreateUserRequest("john@example.com", "John Doe");
        User savedUser = new User(1L, "john@example.com", "John Doe");
        
        when(userRepository.save(any(User.class))).thenReturn(savedUser);
        
        // When
        User result = userService.createUser(request);
        
        // Then
        assertThat(result.getId()).isEqualTo(1L);
        assertThat(result.getEmail()).isEqualTo("john@example.com");
        
        // 메서드 호출 검증
        verify(userRepository).save(any(User.class));
        verify(emailService).sendWelcomeEmail("john@example.com");
    }
}

수동 Mock 생성

class UserServiceManualMockTest {
    
    private UserRepository userRepository;
    private EmailService emailService;
    private UserService userService;
    
    @BeforeEach
    void setUp() {
        userRepository = mock(UserRepository.class);
        emailService = mock(EmailService.class);
        userService = new UserService(userRepository, emailService);
    }
    
    @Test
    void shouldFindUserById() {
        // Given
        Long userId = 1L;
        User expectedUser = new User(userId, "john@example.com", "John");
        when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));
        
        // When
        User result = userService.findById(userId);
        
        // Then
        assertThat(result).isEqualTo(expectedUser);
    }
}

3.3 @MockBean - Spring Boot 통합

Spring Boot 테스트에서 Spring 컨텍스트의 빈을 모킹할 때 사용합니다. 실제 빈을 모킹된 빈으로 교체합니다.

@MockBean 사용법

@SpringBootTest
class UserServiceIntegrationTest {
    
    @Autowired
    private UserService userService;
    
    @MockBean
    private EmailService emailService;
    
    @MockBean
    private PaymentService paymentService;
    
    @Test
    void shouldProcessUserRegistration() {
        // Given
        CreateUserRequest request = new CreateUserRequest("john@example.com", "John Doe");
        
        // EmailService 모킹
        doNothing().when(emailService).sendWelcomeEmail(anyString());
        
        // PaymentService 모킹
        when(paymentService.createFreeAccount(anyString()))
            .thenReturn(new PaymentAccount("john@example.com", AccountType.FREE));
        
        // When
        User result = userService.registerUser(request);
        
        // Then
        assertThat(result.getEmail()).isEqualTo("john@example.com");
        
        // 모킹된 서비스들이 호출되었는지 검증
        verify(emailService).sendWelcomeEmail("john@example.com");
        verify(paymentService).createFreeAccount("john@example.com");
    }
}

3.4 when/thenReturn - 동작 정의

모킹된 객체의 메서드가 호출될 때 어떤 값을 반환할지 정의합니다.

다양한 when/then 패턴

@Test
void whenThenPatterns() {
    // 1. 기본 반환값 설정
    when(userRepository.findById(1L))
        .thenReturn(Optional.of(new User("john@example.com")));
    
    // 2. 예외 던지기
    when(userRepository.findById(999L))
        .thenThrow(new UserNotFoundException("User not found"));
    
    // 3. 여러 번 호출 시 다른 값 반환
    when(userRepository.count())
        .thenReturn(10L)
        .thenReturn(11L)
        .thenReturn(12L);
    
    // 4. 조건부 반환
    when(userRepository.findByEmail(anyString()))
        .thenAnswer(invocation -> {
            String email = invocation.getArgument(0);
            if (email.contains("admin")) {
                return Optional.of(new User(email, UserRole.ADMIN));
            }
            return Optional.of(new User(email, UserRole.USER));
        });
    
    // 5. void 메서드 예외 설정
    doThrow(new EmailSendException("SMTP error"))
        .when(emailService).sendEmail(anyString(), anyString());
    
    // 6. void 메서드 정상 동작
    doNothing().when(emailService).sendEmail(anyString(), anyString());
    
    // 7. 실제 메서드 호출
    doCallRealMethod().when(userService).validateEmail(anyString());
}

ArgumentMatchers 활용

import static org.mockito.ArgumentMatchers.*;

@Test
void argumentMatchersExample() {
    // 1. 정확한 값 매칭
    when(userRepository.findById(eq(1L)))
        .thenReturn(Optional.of(new User("john@example.com")));
    
    // 2. 타입 매칭
    when(userRepository.save(any(User.class)))
        .thenReturn(new User(1L, "saved@example.com", "Saved User"));
    
    // 3. 문자열 패턴 매칭
    when(userRepository.findByEmail(contains("gmail")))
        .thenReturn(Optional.of(new User("gmail-user@gmail.com")));
    
    when(userRepository.findByEmail(startsWith("admin")))
        .thenReturn(Optional.of(new User("admin@example.com", UserRole.ADMIN)));
    
    // 4. 컬렉션 매칭
    when(userRepository.findAllById(anyList()))
        .thenReturn(Arrays.asList(new User("user1@example.com"), new User("user2@example.com")));
    
    // 5. 커스텀 매처
    when(userRepository.findByAge(argThat(age -> age >= 18 && age <= 65)))
        .thenReturn(Arrays.asList(new User("adult@example.com")));
    
    // 6. null 값 매칭
    when(userRepository.findByEmail(isNull()))
        .thenThrow(new IllegalArgumentException("Email cannot be null"));
}

3.5 verify - 호출 검증

모킹된 객체의 메서드가 예상대로 호출되었는지 검증합니다.

다양한 verify 패턴

@Test
void verifyPatterns() {
    // Given
    User user = new User("john@example.com", "John");
    when(userRepository.save(any(User.class))).thenReturn(user);
    
    // When
    userService.createUser(new CreateUserRequest("john@example.com", "John"));
    
    // Then - 다양한 검증 방법
    
    // 1. 기본 호출 검증 (1번 호출)
    verify(userRepository).save(any(User.class));
    
    // 2. 호출 횟수 검증
    verify(userRepository, times(1)).save(any(User.class));
    verify(emailService, never()).sendEmail(anyString(), anyString());
    verify(userRepository, atLeastOnce()).save(any(User.class));
    verify(userRepository, atMost(2)).save(any(User.class));
    
    // 3. 정확한 인수로 호출되었는지 검증
    verify(emailService).sendWelcomeEmail("john@example.com");
    
    // 4. 호출 순서 검증
    InOrder inOrder = inOrder(userRepository, emailService);
    inOrder.verify(userRepository).save(any(User.class));
    inOrder.verify(emailService).sendWelcomeEmail(anyString());
    
    // 5. 시간 제한 내 호출 검증
    verify(emailService, timeout(1000)).sendWelcomeEmail(anyString());
    
    // 6. 추가 상호작용이 없음을 검증
    verifyNoMoreInteractions(userRepository);
    verifyNoInteractions(paymentService);
}

ArgumentCaptor 사용

@Test
void argumentCaptorExample() {
    // Given
    ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
    ArgumentCaptor<String> emailCaptor = ArgumentCaptor.forClass(String.class);
    
    // When
    userService.createUser(new CreateUserRequest("john@example.com", "John Doe"));
    
    // Then
    verify(userRepository).save(userCaptor.capture());
    verify(emailService).sendWelcomeEmail(emailCaptor.capture());
    
    // 캡처된 인수 검증
    User capturedUser = userCaptor.getValue();
    assertThat(capturedUser.getEmail()).isEqualTo("john@example.com");
    assertThat(capturedUser.getName()).isEqualTo("John Doe");
    
    String capturedEmail = emailCaptor.getValue();
    assertThat(capturedEmail).isEqualTo("john@example.com");
    
    // 여러 번 호출된 경우 모든 값 캡처
    List<User> allCapturedUsers = userCaptor.getAllValues();
    assertThat(allCapturedUsers).hasSize(1);
}
Mockito 모범 사례:
  • • 과도한 모킹 피하기 - 실제 객체 사용 고려
  • • verify보다는 상태 기반 테스트 선호
  • • ArgumentMatchers 일관성 있게 사용
  • • 모킹된 객체의 동작을 명확히 정의

4. Repository 테스트

4.1 @DataJpaTest 심화

@DataJpaTest는 JPA Repository 테스트에 특화된 어노테이션입니다. 인메모리 데이터베이스를 사용하여 빠르고 격리된 테스트 환경을 제공합니다.

@DataJpaTest 설정

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 실제 DB 사용
@TestPropertySource(properties = {
    "spring.jpa.hibernate.ddl-auto=create-drop",
    "spring.jpa.show-sql=true",
    "spring.jpa.properties.hibernate.format_sql=true"
})
class UserRepositoryTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void contextLoads() {
        assertThat(entityManager).isNotNull();
        assertThat(userRepository).isNotNull();
    }
}

4.2 TestEntityManager 활용

TestEntityManager는 테스트에서 엔티티를 관리하기 위한 유틸리티입니다. 데이터 준비와 검증에 유용합니다.

TestEntityManager 기본 사용법

@DataJpaTest
class UserRepositoryAdvancedTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void shouldFindUsersByStatus() {
        // Given - 테스트 데이터 준비
        User activeUser1 = new User("active1@example.com", "Active User 1", UserStatus.ACTIVE);
        User activeUser2 = new User("active2@example.com", "Active User 2", UserStatus.ACTIVE);
        User inactiveUser = new User("inactive@example.com", "Inactive User", UserStatus.INACTIVE);
        
        // persist: 영속성 컨텍스트에 저장 (아직 DB에 반영 안됨)
        entityManager.persist(activeUser1);
        entityManager.persist(activeUser2);
        entityManager.persist(inactiveUser);
        
        // flush: 변경사항을 DB에 즉시 반영
        entityManager.flush();
        
        // When
        List<User> activeUsers = userRepository.findByStatus(UserStatus.ACTIVE);
        
        // Then
        assertThat(activeUsers).hasSize(2);
        assertThat(activeUsers)
            .extracting(User::getEmail)
            .containsExactlyInAnyOrder("active1@example.com", "active2@example.com");
    }
    
    @Test
    void shouldPersistAndFlush() {
        // Given
        User user = new User("test@example.com", "Test User");
        
        // When
        User savedUser = entityManager.persistAndFlush(user);
        
        // Then
        assertThat(savedUser.getId()).isNotNull();
        
        // 영속성 컨텍스트에서 분리하여 실제 DB 조회 확인
        entityManager.detach(savedUser);
        User foundUser = entityManager.find(User.class, savedUser.getId());
        assertThat(foundUser.getEmail()).isEqualTo("test@example.com");
    }
    
    @Test
    void shouldClearPersistenceContext() {
        // Given
        User user = entityManager.persistAndFlush(new User("test@example.com", "Test"));
        
        // When - 영속성 컨텍스트 초기화
        entityManager.clear();
        
        // Then - 다시 조회하면 새로운 인스턴스
        User foundUser = entityManager.find(User.class, user.getId());
        assertThat(foundUser).isNotSameAs(user);
        assertThat(foundUser.getEmail()).isEqualTo("test@example.com");
    }
}

4.3 쿼리 메서드 테스트

Spring Data JPA의 쿼리 메서드들을 체계적으로 테스트하는 방법을 알아봅시다.

기본 쿼리 메서드 테스트

@DataJpaTest
class UserRepositoryQueryTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @BeforeEach
    void setUp() {
        // 테스트 데이터 준비
        entityManager.persist(new User("john@gmail.com", "John Doe", 25, UserStatus.ACTIVE));
        entityManager.persist(new User("jane@yahoo.com", "Jane Smith", 30, UserStatus.ACTIVE));
        entityManager.persist(new User("bob@gmail.com", "Bob Johnson", 35, UserStatus.INACTIVE));
        entityManager.persist(new User("alice@hotmail.com", "Alice Brown", 28, UserStatus.ACTIVE));
        entityManager.flush();
    }
    
    @Test
    void shouldFindByEmail() {
        // When
        Optional<User> user = userRepository.findByEmail("john@gmail.com");
        
        // Then
        assertThat(user).isPresent();
        assertThat(user.get().getName()).isEqualTo("John Doe");
    }
    
    @Test
    void shouldFindByEmailContaining() {
        // When
        List<User> gmailUsers = userRepository.findByEmailContaining("gmail");
        
        // Then
        assertThat(gmailUsers).hasSize(2);
        assertThat(gmailUsers)
            .extracting(User::getEmail)
            .containsExactlyInAnyOrder("john@gmail.com", "bob@gmail.com");
    }
    
    @Test
    void shouldFindByAgeGreaterThan() {
        // When
        List<User> users = userRepository.findByAgeGreaterThan(28);
        
        // Then
        assertThat(users).hasSize(2);
        assertThat(users)
            .extracting(User::getName)
            .containsExactlyInAnyOrder("Jane Smith", "Bob Johnson");
    }
    
    @Test
    void shouldFindByStatusAndAgeGreaterThan() {
        // When
        List<User> activeAdults = userRepository.findByStatusAndAgeGreaterThan(UserStatus.ACTIVE, 25);
        
        // Then
        assertThat(activeAdults).hasSize(2);
        assertThat(activeAdults)
            .extracting(User::getName)
            .containsExactlyInAnyOrder("Jane Smith", "Alice Brown");
    }
    
    @Test
    void shouldCountByStatus() {
        // When
        long activeCount = userRepository.countByStatus(UserStatus.ACTIVE);
        long inactiveCount = userRepository.countByStatus(UserStatus.INACTIVE);
        
        // Then
        assertThat(activeCount).isEqualTo(3);
        assertThat(inactiveCount).isEqualTo(1);
    }
    
    @Test
    void shouldExistsByEmail() {
        // When & Then
        assertThat(userRepository.existsByEmail("john@gmail.com")).isTrue();
        assertThat(userRepository.existsByEmail("nonexistent@example.com")).isFalse();
    }
}

4.4 커스텀 쿼리 테스트

@Query 어노테이션을 사용한 커스텀 쿼리들을 테스트하는 방법입니다.

JPQL 쿼리 테스트

// Repository 인터페이스
public interface UserRepository extends JpaRepository<User, Long> {
    
    @Query("SELECT u FROM User u WHERE u.email LIKE %:domain%")
    List<User> findByEmailDomain(@Param("domain") String domain);
    
    @Query("SELECT u FROM User u WHERE u.age BETWEEN :minAge AND :maxAge")
    List<User> findByAgeBetween(@Param("minAge") int minAge, @Param("maxAge") int maxAge);
    
    @Query("SELECT COUNT(u) FROM User u WHERE u.createdAt >= :date")
    long countUsersCreatedAfter(@Param("date") LocalDateTime date);
    
    @Modifying
    @Query("UPDATE User u SET u.status = :status WHERE u.lastLoginAt < :date")
    int updateInactiveUsers(@Param("status") UserStatus status, @Param("date") LocalDateTime date);
}

// 테스트 클래스
@DataJpaTest
class UserRepositoryCustomQueryTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void shouldFindByEmailDomain() {
        // Given
        entityManager.persist(new User("user1@gmail.com", "User 1"));
        entityManager.persist(new User("user2@gmail.com", "User 2"));
        entityManager.persist(new User("user3@yahoo.com", "User 3"));
        entityManager.flush();
        
        // When
        List<User> gmailUsers = userRepository.findByEmailDomain("gmail");
        
        // Then
        assertThat(gmailUsers).hasSize(2);
        assertThat(gmailUsers)
            .extracting(User::getEmail)
            .allMatch(email -> email.contains("gmail"));
    }
    
    @Test
    void shouldFindByAgeBetween() {
        // Given
        entityManager.persist(new User("young@example.com", "Young", 20));
        entityManager.persist(new User("adult1@example.com", "Adult1", 25));
        entityManager.persist(new User("adult2@example.com", "Adult2", 30));
        entityManager.persist(new User("senior@example.com", "Senior", 40));
        entityManager.flush();
        
        // When
        List<User> adults = userRepository.findByAgeBetween(25, 35);
        
        // Then
        assertThat(adults).hasSize(2);
        assertThat(adults)
            .extracting(User::getName)
            .containsExactlyInAnyOrder("Adult1", "Adult2");
    }
    
    @Test
    @Transactional
    void shouldUpdateInactiveUsers() {
        // Given
        LocalDateTime cutoffDate = LocalDateTime.now().minusDays(30);
        LocalDateTime oldDate = cutoffDate.minusDays(10);
        LocalDateTime recentDate = cutoffDate.plusDays(10);
        
        User oldUser = new User("old@example.com", "Old User");
        oldUser.setLastLoginAt(oldDate);
        oldUser.setStatus(UserStatus.ACTIVE);
        
        User recentUser = new User("recent@example.com", "Recent User");
        recentUser.setLastLoginAt(recentDate);
        recentUser.setStatus(UserStatus.ACTIVE);
        
        entityManager.persist(oldUser);
        entityManager.persist(recentUser);
        entityManager.flush();
        
        // When
        int updatedCount = userRepository.updateInactiveUsers(UserStatus.INACTIVE, cutoffDate);
        entityManager.flush();
        entityManager.clear();
        
        // Then
        assertThat(updatedCount).isEqualTo(1);
        
        User updatedOldUser = entityManager.find(User.class, oldUser.getId());
        User unchangedRecentUser = entityManager.find(User.class, recentUser.getId());
        
        assertThat(updatedOldUser.getStatus()).isEqualTo(UserStatus.INACTIVE);
        assertThat(unchangedRecentUser.getStatus()).isEqualTo(UserStatus.ACTIVE);
    }
}

4.5 네이티브 쿼리 테스트

복잡한 SQL 쿼리나 데이터베이스 특화 기능을 테스트할 때 사용합니다.

네이티브 쿼리 테스트

// Repository 인터페이스
public interface UserRepository extends JpaRepository<User, Long> {
    
    @Query(value = "SELECT * FROM users WHERE email LIKE CONCAT('%', :domain, '%')", 
           nativeQuery = true)
    List<User> findByEmailDomainNative(@Param("domain") String domain);
    
    @Query(value = "SELECT DATE(created_at) as date, COUNT(*) as count " +
                   "FROM users WHERE created_at >= :startDate " +
                   "GROUP BY DATE(created_at) ORDER BY date", 
           nativeQuery = true)
    List<Object[]> getUserRegistrationStats(@Param("startDate") LocalDateTime startDate);
}

@DataJpaTest
class UserRepositoryNativeQueryTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void shouldFindByEmailDomainNative() {
        // Given
        entityManager.persist(new User("test1@gmail.com", "Test 1"));
        entityManager.persist(new User("test2@gmail.com", "Test 2"));
        entityManager.persist(new User("test3@yahoo.com", "Test 3"));
        entityManager.flush();
        
        // When
        List<User> gmailUsers = userRepository.findByEmailDomainNative("gmail");
        
        // Then
        assertThat(gmailUsers).hasSize(2);
        assertThat(gmailUsers)
            .extracting(User::getEmail)
            .allMatch(email -> email.contains("gmail"));
    }
    
    @Test
    void shouldGetUserRegistrationStats() {
        // Given
        LocalDateTime baseDate = LocalDateTime.of(2023, 1, 1, 0, 0);
        
        User user1 = new User("user1@example.com", "User 1");
        user1.setCreatedAt(baseDate);
        
        User user2 = new User("user2@example.com", "User 2");
        user2.setCreatedAt(baseDate);
        
        User user3 = new User("user3@example.com", "User 3");
        user3.setCreatedAt(baseDate.plusDays(1));
        
        entityManager.persist(user1);
        entityManager.persist(user2);
        entityManager.persist(user3);
        entityManager.flush();
        
        // When
        List<Object[]> stats = userRepository.getUserRegistrationStats(baseDate.minusDays(1));
        
        // Then
        assertThat(stats).hasSize(2);
        
        // 첫 번째 날짜의 통계
        Object[] firstDayStats = stats.get(0);
        assertThat(firstDayStats[1]).isEqualTo(2L); // 2명 등록
        
        // 두 번째 날짜의 통계
        Object[] secondDayStats = stats.get(1);
        assertThat(secondDayStats[1]).isEqualTo(1L); // 1명 등록
    }
}

4.6 페이징과 정렬 테스트

Pageable 테스트

@DataJpaTest
class UserRepositoryPagingTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @BeforeEach
    void setUp() {
        // 10명의 사용자 생성
        for (int i = 1; i <= 10; i++) {
            User user = new User("user" + i + "@example.com", "User " + i, 20 + i);
            entityManager.persist(user);
        }
        entityManager.flush();
    }
    
    @Test
    void shouldSupportPaging() {
        // Given
        Pageable pageable = PageRequest.of(0, 3); // 첫 번째 페이지, 3개씩
        
        // When
        Page<User> firstPage = userRepository.findAll(pageable);
        
        // Then
        assertThat(firstPage.getContent()).hasSize(3);
        assertThat(firstPage.getTotalElements()).isEqualTo(10);
        assertThat(firstPage.getTotalPages()).isEqualTo(4);
        assertThat(firstPage.isFirst()).isTrue();
        assertThat(firstPage.hasNext()).isTrue();
    }
    
    @Test
    void shouldSupportSorting() {
        // Given
        Pageable pageable = PageRequest.of(0, 5, Sort.by("age").descending());
        
        // When
        Page<User> page = userRepository.findAll(pageable);
        
        // Then
        List<User> users = page.getContent();
        assertThat(users).hasSize(5);
        
        // 나이 순으로 내림차순 정렬 확인
        for (int i = 0; i < users.size() - 1; i++) {
            assertThat(users.get(i).getAge()).isGreaterThanOrEqualTo(users.get(i + 1).getAge());
        }
    }
    
    @Test
    void shouldSupportCustomPageableQuery() {
        // Given
        Pageable pageable = PageRequest.of(0, 3, Sort.by("name"));
        
        // When
        Page<User> activePage = userRepository.findByStatus(UserStatus.ACTIVE, pageable);
        
        // Then
        assertThat(activePage.getContent()).hasSize(3);
        assertThat(activePage.getContent())
            .extracting(User::getName)
            .isSorted();
    }
}
Repository 테스트 모범 사례:
  • • TestEntityManager로 테스트 데이터 준비
  • • flush()로 즉시 DB 반영 확인
  • • clear()로 영속성 컨텍스트 초기화
  • • 경계값과 예외 상황 테스트
  • • 페이징과 정렬 동작 검증

5. Service 테스트

5.1 Service 레이어 테스트 개요

Service 레이어는 비즈니스 로직의 핵심입니다. 외부 의존성을 모킹하여 비즈니스 로직에만 집중한 단위 테스트를 작성해야 합니다.

Service 테스트 기본 구조

@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @Mock
    private EmailService emailService;
    
    @Mock
    private PasswordEncoder passwordEncoder;
    
    @InjectMocks
    private UserService userService;
    
    @Test
    @DisplayName("사용자 생성 시 이메일 중복 검사를 수행해야 한다")
    void shouldCheckEmailDuplicationWhenCreatingUser() {
        // Given
        String email = "john@example.com";
        CreateUserRequest request = new CreateUserRequest(email, "John Doe", "password123");
        
        when(userRepository.existsByEmail(email)).thenReturn(false);
        when(passwordEncoder.encode("password123")).thenReturn("encoded-password");
        when(userRepository.save(any(User.class))).thenReturn(new User(1L, email, "John Doe"));
        
        // When
        User result = userService.createUser(request);
        
        // Then
        assertThat(result.getEmail()).isEqualTo(email);
        verify(userRepository).existsByEmail(email);
        verify(passwordEncoder).encode("password123");
        verify(emailService).sendWelcomeEmail(email);
    }
}

5.2 비즈니스 로직 테스트

복잡한 비즈니스 규칙과 조건들을 체계적으로 테스트하는 방법을 알아봅시다.

복잡한 비즈니스 로직 테스트

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    
    @Mock
    private OrderRepository orderRepository;
    
    @Mock
    private ProductRepository productRepository;
    
    @Mock
    private UserRepository userRepository;
    
    @Mock
    private PaymentService paymentService;
    
    @Mock
    private InventoryService inventoryService;
    
    @InjectMocks
    private OrderService orderService;
    
    @Test
    @DisplayName("주문 생성 시 재고 확인 후 결제를 진행해야 한다")
    void shouldCheckInventoryAndProcessPaymentWhenCreatingOrder() {
        // Given
        Long userId = 1L;
        Long productId = 100L;
        int quantity = 3;
        
        User user = new User(userId, "john@example.com", "John Doe");
        Product product = new Product(productId, "노트북", BigDecimal.valueOf(1000000), 10);
        CreateOrderRequest request = new CreateOrderRequest(userId, productId, quantity);
        
        when(userRepository.findById(userId)).thenReturn(Optional.of(user));
        when(productRepository.findById(productId)).thenReturn(Optional.of(product));
        when(inventoryService.checkAvailability(productId, quantity)).thenReturn(true);
        when(paymentService.processPayment(any(PaymentRequest.class)))
            .thenReturn(new PaymentResult("payment-123", PaymentStatus.SUCCESS));
        when(orderRepository.save(any(Order.class)))
            .thenReturn(new Order(1L, user, product, quantity, OrderStatus.CONFIRMED));
        
        // When
        Order result = orderService.createOrder(request);
        
        // Then
        assertThat(result.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
        assertThat(result.getQuantity()).isEqualTo(quantity);
        
        // 비즈니스 로직 순서 검증
        InOrder inOrder = inOrder(inventoryService, paymentService, inventoryService, orderRepository);
        inOrder.verify(inventoryService).checkAvailability(productId, quantity);
        inOrder.verify(paymentService).processPayment(any(PaymentRequest.class));
        inOrder.verify(inventoryService).reserveStock(productId, quantity);
        inOrder.verify(orderRepository).save(any(Order.class));
    }
    
    @Test
    @DisplayName("재고 부족 시 주문 생성이 실패해야 한다")
    void shouldFailOrderCreationWhenInsufficientStock() {
        // Given
        Long userId = 1L;
        Long productId = 100L;
        int quantity = 15; // 재고(10)보다 많은 수량
        
        User user = new User(userId, "john@example.com", "John Doe");
        Product product = new Product(productId, "노트북", BigDecimal.valueOf(1000000), 10);
        CreateOrderRequest request = new CreateOrderRequest(userId, productId, quantity);
        
        when(userRepository.findById(userId)).thenReturn(Optional.of(user));
        when(productRepository.findById(productId)).thenReturn(Optional.of(product));
        when(inventoryService.checkAvailability(productId, quantity)).thenReturn(false);
        
        // When & Then
        assertThatThrownBy(() -> orderService.createOrder(request))
            .isInstanceOf(InsufficientStockException.class)
            .hasMessage("재고가 부족합니다. 요청 수량: 15, 가용 재고: 10");
        
        // 결제나 재고 예약이 호출되지 않았는지 확인
        verify(paymentService, never()).processPayment(any());
        verify(inventoryService, never()).reserveStock(anyLong(), anyInt());
        verify(orderRepository, never()).save(any());
    }
    
    @Test
    @DisplayName("결제 실패 시 예약된 재고를 해제해야 한다")
    void shouldReleaseReservedStockWhenPaymentFails() {
        // Given
        Long userId = 1L;
        Long productId = 100L;
        int quantity = 3;
        
        User user = new User(userId, "john@example.com", "John Doe");
        Product product = new Product(productId, "노트북", BigDecimal.valueOf(1000000), 10);
        CreateOrderRequest request = new CreateOrderRequest(userId, productId, quantity);
        
        when(userRepository.findById(userId)).thenReturn(Optional.of(user));
        when(productRepository.findById(productId)).thenReturn(Optional.of(product));
        when(inventoryService.checkAvailability(productId, quantity)).thenReturn(true);
        when(paymentService.processPayment(any(PaymentRequest.class)))
            .thenReturn(new PaymentResult("payment-123", PaymentStatus.FAILED));
        
        // When & Then
        assertThatThrownBy(() -> orderService.createOrder(request))
            .isInstanceOf(PaymentFailedException.class);
        
        // 재고 해제가 호출되었는지 확인
        verify(inventoryService).reserveStock(productId, quantity);
        verify(inventoryService).releaseReservedStock(productId, quantity);
        verify(orderRepository, never()).save(any());
    }
}

5.3 트랜잭션 테스트

@Transactional 어노테이션의 동작과 트랜잭션 경계를 테스트하는 방법입니다.

트랜잭션 롤백 테스트

@SpringBootTest
@Transactional
class UserServiceTransactionTest {
    
    @Autowired
    private UserService userService;
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Test
    @DisplayName("사용자 생성 중 예외 발생 시 트랜잭션이 롤백되어야 한다")
    void shouldRollbackTransactionWhenExceptionOccurs() {
        // Given
        CreateUserRequest request = new CreateUserRequest("test@example.com", "Test User", "password");
        
        // 이메일 서비스에서 예외가 발생하도록 설정 (실제 구현에서는 모킹 필요)
        // 여기서는 이미 존재하는 이메일로 테스트
        userRepository.save(new User("test@example.com", "Existing User"));
        entityManager.flush();
        
        long initialCount = userRepository.count();
        
        // When & Then
        assertThatThrownBy(() -> userService.createUser(request))
            .isInstanceOf(DuplicateEmailException.class);
        
        // 트랜잭션 롤백으로 인해 사용자 수가 변하지 않았는지 확인
        entityManager.clear(); // 영속성 컨텍스트 초기화
        long finalCount = userRepository.count();
        assertThat(finalCount).isEqualTo(initialCount);
    }
    
    @Test
    @DisplayName("@Transactional(readOnly = true) 메서드에서 수정 시도 시 예외가 발생해야 한다")
    void shouldThrowExceptionWhenModifyingInReadOnlyTransaction() {
        // Given
        User user = userRepository.save(new User("readonly@example.com", "ReadOnly User"));
        entityManager.flush();
        entityManager.clear();
        
        // When & Then
        assertThatThrownBy(() -> userService.updateUserInReadOnlyMethod(user.getId(), "New Name"))
            .isInstanceOf(TransactionSystemException.class);
    }
    
    @Test
    @DisplayName("새로운 트랜잭션에서 실행되는 메서드는 독립적으로 커밋되어야 한다")
    void shouldCommitIndependentlyInNewTransaction() {
        // Given
        CreateUserRequest request = new CreateUserRequest("independent@example.com", "Independent User", "password");
        
        // When
        try {
            userService.createUserWithIndependentAuditLog(request);
        } catch (Exception e) {
            // 메인 로직에서 예외가 발생해도 감사 로그는 커밋되어야 함
        }
        
        // Then
        // 감사 로그가 저장되었는지 확인 (새로운 트랜잭션으로 실행됨)
        entityManager.clear();
        boolean auditLogExists = auditLogRepository.existsByAction("USER_CREATION_ATTEMPTED");
        assertThat(auditLogExists).isTrue();
    }
}

5.4 예외 처리 테스트

다양한 예외 상황과 에러 처리 로직을 체계적으로 테스트합니다.

예외 시나리오 테스트

@ExtendWith(MockitoExtension.class)
class UserServiceExceptionTest {
    
    @Mock
    private UserRepository userRepository;
    
    @Mock
    private EmailService emailService;
    
    @InjectMocks
    private UserService userService;
    
    @Test
    @DisplayName("존재하지 않는 사용자 조회 시 UserNotFoundException이 발생해야 한다")
    void shouldThrowUserNotFoundExceptionWhenUserDoesNotExist() {
        // Given
        Long nonExistentUserId = 999L;
        when(userRepository.findById(nonExistentUserId)).thenReturn(Optional.empty());
        
        // When & Then
        assertThatThrownBy(() -> userService.findById(nonExistentUserId))
            .isInstanceOf(UserNotFoundException.class)
            .hasMessage("사용자를 찾을 수 없습니다. ID: 999")
            .satisfies(exception -> {
                UserNotFoundException ex = (UserNotFoundException) exception;
                assertThat(ex.getUserId()).isEqualTo(nonExistentUserId);
                assertThat(ex.getErrorCode()).isEqualTo("USER_NOT_FOUND");
            });
    }
    
    @Test
    @DisplayName("이메일 중복 시 DuplicateEmailException이 발생해야 한다")
    void shouldThrowDuplicateEmailExceptionWhenEmailAlreadyExists() {
        // Given
        String duplicateEmail = "duplicate@example.com";
        CreateUserRequest request = new CreateUserRequest(duplicateEmail, "Test User", "password");
        
        when(userRepository.existsByEmail(duplicateEmail)).thenReturn(true);
        
        // When & Then
        assertThatThrownBy(() -> userService.createUser(request))
            .isInstanceOf(DuplicateEmailException.class)
            .hasMessage("이미 사용 중인 이메일입니다: duplicate@example.com")
            .hasFieldOrPropertyWithValue("email", duplicateEmail);
    }
    
    @Test
    @DisplayName("이메일 전송 실패 시 적절한 예외 처리를 해야 한다")
    void shouldHandleEmailSendingFailureGracefully() {
        // Given
        CreateUserRequest request = new CreateUserRequest("test@example.com", "Test User", "password");
        User savedUser = new User(1L, "test@example.com", "Test User");
        
        when(userRepository.existsByEmail("test@example.com")).thenReturn(false);
        when(userRepository.save(any(User.class))).thenReturn(savedUser);
        doThrow(new EmailSendException("SMTP 서버 연결 실패"))
            .when(emailService).sendWelcomeEmail("test@example.com");
        
        // When & Then
        assertThatThrownBy(() -> userService.createUser(request))
            .isInstanceOf(UserCreationException.class)
            .hasMessage("사용자 생성 중 오류가 발생했습니다")
            .hasCauseInstanceOf(EmailSendException.class);
        
        // 사용자는 저장되었지만 이메일 전송 실패로 인한 예외 발생 확인
        verify(userRepository).save(any(User.class));
        verify(emailService).sendWelcomeEmail("test@example.com");
    }
    
    @Test
    @DisplayName("잘못된 입력값에 대해 ValidationException이 발생해야 한다")
    void shouldThrowValidationExceptionForInvalidInput() {
        // Given - 잘못된 이메일 형식
        CreateUserRequest invalidRequest = new CreateUserRequest("invalid-email", "Test User", "password");
        
        // When & Then
        assertThatThrownBy(() -> userService.createUser(invalidRequest))
            .isInstanceOf(ValidationException.class)
            .hasMessage("유효하지 않은 이메일 형식입니다")
            .satisfies(exception -> {
                ValidationException ex = (ValidationException) exception;
                assertThat(ex.getFieldErrors()).containsKey("email");
                assertThat(ex.getFieldErrors().get("email")).isEqualTo("올바른 이메일 형식이 아닙니다");
            });
        
        // 검증 실패로 인해 저장소 호출이 없었는지 확인
        verify(userRepository, never()).existsByEmail(anyString());
        verify(userRepository, never()).save(any(User.class));
    }
    
    @Test
    @DisplayName("데이터베이스 연결 실패 시 적절한 예외가 발생해야 한다")
    void shouldHandleDatabaseConnectionFailure() {
        // Given
        CreateUserRequest request = new CreateUserRequest("test@example.com", "Test User", "password");
        
        when(userRepository.existsByEmail("test@example.com"))
            .thenThrow(new DataAccessException("데이터베이스 연결 실패") {});
        
        // When & Then
        assertThatThrownBy(() -> userService.createUser(request))
            .isInstanceOf(ServiceException.class)
            .hasMessage("서비스 처리 중 오류가 발생했습니다")
            .hasCauseInstanceOf(DataAccessException.class);
    }
}

5.5 비동기 처리 테스트

@Async 어노테이션을 사용한 비동기 메서드의 테스트 방법입니다.

비동기 메서드 테스트

@SpringBootTest
@EnableAsync
class UserServiceAsyncTest {
    
    @Autowired
    private UserService userService;
    
    @MockBean
    private EmailService emailService;
    
    @MockBean
    private NotificationService notificationService;
    
    @Test
    @DisplayName("비동기 이메일 전송이 완료되어야 한다")
    void shouldCompleteAsyncEmailSending() throws Exception {
        // Given
        String email = "async@example.com";
        doNothing().when(emailService).sendWelcomeEmailAsync(email);
        
        // When
        CompletableFuture<Void> future = userService.sendWelcomeEmailAsync(email);
        
        // Then
        // 비동기 작업 완료 대기 (최대 5초)
        assertThat(future).succeedsWithin(Duration.ofSeconds(5));
        
        // 이메일 서비스가 호출되었는지 검증 (약간의 지연 후)
        await().atMost(Duration.ofSeconds(2))
               .untilAsserted(() -> verify(emailService).sendWelcomeEmailAsync(email));
    }
    
    @Test
    @DisplayName("비동기 작업에서 예외 발생 시 적절히 처리되어야 한다")
    void shouldHandleAsyncException() throws Exception {
        // Given
        String email = "error@example.com";
        doThrow(new EmailSendException("비동기 이메일 전송 실패"))
            .when(emailService).sendWelcomeEmailAsync(email);
        
        // When
        CompletableFuture<Void> future = userService.sendWelcomeEmailAsync(email);
        
        // Then
        assertThat(future).failsWithin(Duration.ofSeconds(5))
                         .withThrowableOfType(ExecutionException.class)
                         .withCauseInstanceOf(EmailSendException.class);
    }
    
    @Test
    @DisplayName("여러 비동기 작업이 병렬로 실행되어야 한다")
    void shouldExecuteMultipleAsyncTasksInParallel() throws Exception {
        // Given
        List<String> emails = Arrays.asList("user1@example.com", "user2@example.com", "user3@example.com");
        
        doAnswer(invocation -> {
            // 각 이메일 전송에 1초씩 소요되도록 시뮬레이션
            Thread.sleep(1000);
            return null;
        }).when(emailService).sendWelcomeEmailAsync(anyString());
        
        // When
        long startTime = System.currentTimeMillis();
        
        List<CompletableFuture<Void>> futures = emails.stream()
            .map(userService::sendWelcomeEmailAsync)
            .collect(Collectors.toList());
        
        CompletableFuture<Void> allFutures = CompletableFuture.allOf(
            futures.toArray(new CompletableFuture[0])
        );
        
        // Then
        assertThat(allFutures).succeedsWithin(Duration.ofSeconds(3));
        
        long endTime = System.currentTimeMillis();
        long executionTime = endTime - startTime;
        
        // 병렬 실행으로 인해 전체 실행 시간이 3초보다 작아야 함 (순차 실행 시 3초)
        assertThat(executionTime).isLessThan(2500);
        
        // 모든 이메일이 전송되었는지 확인
        verify(emailService, times(3)).sendWelcomeEmailAsync(anyString());
    }
}
Service 테스트 모범 사례:
  • • 외부 의존성은 모킹하여 격리된 테스트 작성
  • • 비즈니스 로직의 모든 분기 조건 테스트
  • • 예외 상황과 에러 처리 로직 검증
  • • 트랜잭션 경계와 롤백 동작 확인
  • • 비동기 처리의 완료와 예외 처리 검증

6. Controller 테스트

6.1 MockMvc 기본 사용법

MockMvc는 Spring MVC 컨트롤러를 테스트하기 위한 핵심 도구입니다. 실제 서버를 띄우지 않고도 HTTP 요청/응답을 시뮬레이션할 수 있습니다.

기본 MockMvc 설정

@WebMvcTest(UserController.class)
class UserControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @MockBean
    private UserService userService;
    
    @Test
    @DisplayName("사용자 목록 조회 API 테스트")
    void shouldReturnUserList() throws Exception {
        // Given
        List<User> users = Arrays.asList(
            new User(1L, "john@example.com", "John Doe"),
            new User(2L, "jane@example.com", "Jane Smith")
        );
        when(userService.findAll()).thenReturn(users);
        
        // When & Then
        mockMvc.perform(get("/api/users")
                .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$", hasSize(2)))
            .andExpect(jsonPath("$[0].id").value(1))
            .andExpect(jsonPath("$[0].email").value("john@example.com"))
            .andExpect(jsonPath("$[0].name").value("John Doe"))
            .andExpect(jsonPath("$[1].id").value(2))
            .andExpect(jsonPath("$[1].email").value("jane@example.com"))
            .andExpect(jsonPath("$[1].name").value("Jane Smith"));
        
        verify(userService).findAll();
    }
    
    @Test
    @DisplayName("특정 사용자 조회 API 테스트")
    void shouldReturnUserById() throws Exception {
        // Given
        Long userId = 1L;
        User user = new User(userId, "john@example.com", "John Doe");
        when(userService.findById(userId)).thenReturn(user);
        
        // When & Then
        mockMvc.perform(get("/api/users/{id}", userId))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(userId))
            .andExpect(jsonPath("$.email").value("john@example.com"))
            .andExpect(jsonPath("$.name").value("John Doe"));
    }
}

6.2 POST/PUT/DELETE 요청 테스트

다양한 HTTP 메서드와 요청 본문을 포함한 API 테스트 방법입니다.

POST 요청 테스트

@Test
@DisplayName("사용자 생성 API 테스트")
void shouldCreateUser() throws Exception {
    // Given
    CreateUserRequest request = new CreateUserRequest("new@example.com", "New User", "password123");
    User createdUser = new User(3L, "new@example.com", "New User");
    
    when(userService.createUser(any(CreateUserRequest.class))).thenReturn(createdUser);
    
    // When & Then
    mockMvc.perform(post("/api/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(request)))
        .andExpect(status().isCreated())
        .andExpect(header().exists("Location"))
        .andExpect(header().string("Location", containsString("/api/users/3")))
        .andExpect(jsonPath("$.id").value(3))
        .andExpect(jsonPath("$.email").value("new@example.com"))
        .andExpect(jsonPath("$.name").value("New User"));
    
    // ArgumentCaptor로 실제 전달된 요청 검증
    ArgumentCaptor<CreateUserRequest> captor = ArgumentCaptor.forClass(CreateUserRequest.class);
    verify(userService).createUser(captor.capture());
    
    CreateUserRequest capturedRequest = captor.getValue();
    assertThat(capturedRequest.getEmail()).isEqualTo("new@example.com");
    assertThat(capturedRequest.getName()).isEqualTo("New User");
}

@Test
@DisplayName("사용자 수정 API 테스트")
void shouldUpdateUser() throws Exception {
    // Given
    Long userId = 1L;
    UpdateUserRequest request = new UpdateUserRequest("updated@example.com", "Updated User");
    User updatedUser = new User(userId, "updated@example.com", "Updated User");
    
    when(userService.updateUser(eq(userId), any(UpdateUserRequest.class))).thenReturn(updatedUser);
    
    // When & Then
    mockMvc.perform(put("/api/users/{id}", userId)
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(request)))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.id").value(userId))
        .andExpect(jsonPath("$.email").value("updated@example.com"))
        .andExpect(jsonPath("$.name").value("Updated User"));
}

@Test
@DisplayName("사용자 삭제 API 테스트")
void shouldDeleteUser() throws Exception {
    // Given
    Long userId = 1L;
    doNothing().when(userService).deleteUser(userId);
    
    // When & Then
    mockMvc.perform(delete("/api/users/{id}", userId))
        .andExpect(status().isNoContent());
    
    verify(userService).deleteUser(userId);
}

6.3 JSON 검증 심화

복잡한 JSON 응답을 검증하는 다양한 방법들을 알아봅시다.

고급 JSON 검증

@Test
@DisplayName("복잡한 JSON 응답 검증")
void shouldValidateComplexJsonResponse() throws Exception {
    // Given
    User user = User.builder()
        .id(1L)
        .email("john@example.com")
        .name("John Doe")
        .age(30)
        .status(UserStatus.ACTIVE)
        .createdAt(LocalDateTime.of(2023, 1, 1, 10, 0))
        .addresses(Arrays.asList(
            new Address("서울시 강남구", "12345"),
            new Address("서울시 서초구", "67890")
        ))
        .build();
    
    when(userService.findById(1L)).thenReturn(user);
    
    // When & Then
    mockMvc.perform(get("/api/users/1"))
        .andExpect(status().isOk())
        .andExpect(content().contentType(MediaType.APPLICATION_JSON))
        
        // 기본 필드 검증
        .andExpect(jsonPath("$.id").value(1))
        .andExpect(jsonPath("$.email").value("john@example.com"))
        .andExpect(jsonPath("$.name").value("John Doe"))
        .andExpect(jsonPath("$.age").value(30))
        .andExpect(jsonPath("$.status").value("ACTIVE"))
        
        // 날짜 형식 검증
        .andExpect(jsonPath("$.createdAt").value("2023-01-01T10:00:00"))
        
        // 배열 검증
        .andExpect(jsonPath("$.addresses", hasSize(2)))
        .andExpect(jsonPath("$.addresses[0].street").value("서울시 강남구"))
        .andExpect(jsonPath("$.addresses[0].zipCode").value("12345"))
        .andExpect(jsonPath("$.addresses[1].street").value("서울시 서초구"))
        .andExpect(jsonPath("$.addresses[1].zipCode").value("67890"))
        
        // 조건부 검증
        .andExpect(jsonPath("$.age").value(greaterThan(18)))
        .andExpect(jsonPath("$.email").value(containsString("@")))
        .andExpect(jsonPath("$.addresses[*].zipCode").value(everyItem(hasLength(5))));
}

@Test
@DisplayName("페이징된 응답 검증")
void shouldValidatePagedResponse() throws Exception {
    // Given
    List<User> users = Arrays.asList(
        new User(1L, "user1@example.com", "User 1"),
        new User(2L, "user2@example.com", "User 2")
    );
    
    Page<User> userPage = new PageImpl<>(users, PageRequest.of(0, 10), 25);
    when(userService.findAll(any(Pageable.class))).thenReturn(userPage);
    
    // When & Then
    mockMvc.perform(get("/api/users")
            .param("page", "0")
            .param("size", "10")
            .param("sort", "name,asc"))
        .andExpect(status().isOk())
        
        // 페이징 메타데이터 검증
        .andExpect(jsonPath("$.content", hasSize(2)))
        .andExpect(jsonPath("$.totalElements").value(25))
        .andExpect(jsonPath("$.totalPages").value(3))
        .andExpect(jsonPath("$.number").value(0))
        .andExpect(jsonPath("$.size").value(10))
        .andExpect(jsonPath("$.first").value(true))
        .andExpect(jsonPath("$.last").value(false))
        
        // 정렬 정보 검증
        .andExpect(jsonPath("$.sort.sorted").value(true))
        .andExpect(jsonPath("$.sort.orders[0].property").value("name"))
        .andExpect(jsonPath("$.sort.orders[0].direction").value("ASC"));
}

@Test
@DisplayName("에러 응답 JSON 검증")
void shouldValidateErrorResponse() throws Exception {
    // Given
    when(userService.findById(999L))
        .thenThrow(new UserNotFoundException("사용자를 찾을 수 없습니다", "USER_NOT_FOUND"));
    
    // When & Then
    mockMvc.perform(get("/api/users/999"))
        .andExpect(status().isNotFound())
        .andExpect(content().contentType(MediaType.APPLICATION_JSON))
        .andExpect(jsonPath("$.error").value("USER_NOT_FOUND"))
        .andExpect(jsonPath("$.message").value("사용자를 찾을 수 없습니다"))
        .andExpect(jsonPath("$.timestamp").exists())
        .andExpect(jsonPath("$.path").value("/api/users/999"));
}

6.4 요청 검증과 에러 처리

잘못된 요청에 대한 검증과 에러 응답을 테스트합니다.

입력 검증 테스트

@Test
@DisplayName("잘못된 요청 데이터에 대한 검증 에러 테스트")
void shouldReturnValidationErrorForInvalidRequest() throws Exception {
    // Given - 잘못된 요청 데이터
    CreateUserRequest invalidRequest = CreateUserRequest.builder()
        .email("invalid-email") // 잘못된 이메일 형식
        .name("") // 빈 이름
        .password("123") // 너무 짧은 비밀번호
        .build();
    
    // When & Then
    mockMvc.perform(post("/api/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(invalidRequest)))
        .andExpect(status().isBadRequest())
        .andExpect(content().contentType(MediaType.APPLICATION_JSON))
        .andExpect(jsonPath("$.error").value("VALIDATION_FAILED"))
        .andExpect(jsonPath("$.message").value("입력값 검증에 실패했습니다"))
        .andExpect(jsonPath("$.fieldErrors", hasSize(3)))
        .andExpect(jsonPath("$.fieldErrors[*].field").value(containsInAnyOrder("email", "name", "password")))
        .andExpect(jsonPath("$.fieldErrors[?(@.field == 'email')].message").value("올바른 이메일 형식이 아닙니다"))
        .andExpect(jsonPath("$.fieldErrors[?(@.field == 'name')].message").value("이름은 필수입니다"))
        .andExpect(jsonPath("$.fieldErrors[?(@.field == 'password')].message").value("비밀번호는 최소 8자 이상이어야 합니다"));
    
    // 서비스 메서드가 호출되지 않았는지 확인
    verify(userService, never()).createUser(any());
}

@Test
@DisplayName("Content-Type이 잘못된 경우 에러 테스트")
void shouldReturnErrorForWrongContentType() throws Exception {
    // Given
    CreateUserRequest request = new CreateUserRequest("test@example.com", "Test User", "password123");
    
    // When & Then
    mockMvc.perform(post("/api/users")
            .contentType(MediaType.TEXT_PLAIN) // 잘못된 Content-Type
            .content(objectMapper.writeValueAsString(request)))
        .andExpect(status().isUnsupportedMediaType());
}

@Test
@DisplayName("JSON 파싱 에러 테스트")
void shouldReturnErrorForMalformedJson() throws Exception {
    // Given - 잘못된 JSON
    String malformedJson = "{ "email": "test@example.com", "name": }"; // 잘못된 JSON
    
    // When & Then
    mockMvc.perform(post("/api/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content(malformedJson))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.error").value("JSON_PARSE_ERROR"))
        .andExpect(jsonPath("$.message").value(containsString("JSON 파싱 오류")));
}

@Test
@DisplayName("HTTP 메서드가 지원되지 않는 경우 에러 테스트")
void shouldReturnErrorForUnsupportedHttpMethod() throws Exception {
    // When & Then
    mockMvc.perform(patch("/api/users/1")) // PATCH 메서드는 지원하지 않음
        .andExpect(status().isMethodNotAllowed())
        .andExpect(header().string("Allow", containsString("GET")))
        .andExpect(header().string("Allow", containsString("PUT")))
        .andExpect(header().string("Allow", containsString("DELETE")));
}

6.5 인증과 권한 테스트

Spring Security와 함께 인증과 권한 부여를 테스트하는 방법입니다.

Spring Security 테스트

@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerSecurityTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @MockBean
    private JwtTokenProvider jwtTokenProvider;
    
    @Test
    @DisplayName("인증 없이 보호된 엔드포인트 접근 시 401 에러")
    void shouldReturn401WhenAccessingProtectedEndpointWithoutAuth() throws Exception {
        // When & Then
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isUnauthorized());
    }
    
    @Test
    @WithMockUser(username = "user@example.com", roles = "USER")
    @DisplayName("일반 사용자로 사용자 목록 조회")
    void shouldAllowUserToAccessUserList() throws Exception {
        // Given
        List<User> users = Arrays.asList(new User(1L, "user1@example.com", "User 1"));
        when(userService.findAll()).thenReturn(users);
        
        // When & Then
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", hasSize(1)));
    }
    
    @Test
    @WithMockUser(username = "user@example.com", roles = "USER")
    @DisplayName("일반 사용자가 관리자 기능 접근 시 403 에러")
    void shouldReturn403WhenUserAccessesAdminEndpoint() throws Exception {
        // When & Then
        mockMvc.perform(delete("/api/users/1"))
            .andExpect(status().isForbidden());
    }
    
    @Test
    @WithMockUser(username = "admin@example.com", roles = "ADMIN")
    @DisplayName("관리자가 사용자 삭제")
    void shouldAllowAdminToDeleteUser() throws Exception {
        // Given
        doNothing().when(userService).deleteUser(1L);
        
        // When & Then
        mockMvc.perform(delete("/api/users/1"))
            .andExpect(status().isNoContent());
        
        verify(userService).deleteUser(1L);
    }
    
    @Test
    @DisplayName("유효한 JWT 토큰으로 인증")
    void shouldAuthenticateWithValidJwtToken() throws Exception {
        // Given
        String validToken = "valid.jwt.token";
        List<User> users = Arrays.asList(new User(1L, "user@example.com", "User"));
        
        when(jwtTokenProvider.validateToken(validToken)).thenReturn(true);
        when(jwtTokenProvider.getUsernameFromToken(validToken)).thenReturn("user@example.com");
        when(userService.findAll()).thenReturn(users);
        
        // When & Then
        mockMvc.perform(get("/api/users")
                .header("Authorization", "Bearer " + validToken))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", hasSize(1)));
    }
    
    @Test
    @DisplayName("만료된 JWT 토큰으로 접근 시 401 에러")
    void shouldReturn401WithExpiredJwtToken() throws Exception {
        // Given
        String expiredToken = "expired.jwt.token";
        when(jwtTokenProvider.validateToken(expiredToken)).thenReturn(false);
        
        // When & Then
        mockMvc.perform(get("/api/users")
                .header("Authorization", "Bearer " + expiredToken))
            .andExpect(status().isUnauthorized());
    }
    
    @Test
    @WithMockUser(username = "user@example.com")
    @DisplayName("자신의 정보만 수정 가능")
    void shouldAllowUserToUpdateOwnProfile() throws Exception {
        // Given
        Long userId = 1L;
        UpdateUserRequest request = new UpdateUserRequest("updated@example.com", "Updated Name");
        User updatedUser = new User(userId, "updated@example.com", "Updated Name");
        
        // 현재 사용자가 해당 사용자인지 확인하는 로직이 서비스에 있다고 가정
        when(userService.updateUser(eq(userId), any(UpdateUserRequest.class))).thenReturn(updatedUser);
        
        // When & Then
        mockMvc.perform(put("/api/users/{id}", userId)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.email").value("updated@example.com"));
    }
}

6.6 파일 업로드 테스트

MultipartFile 테스트

@Test
@DisplayName("파일 업로드 API 테스트")
void shouldUploadFile() throws Exception {
    // Given
    MockMultipartFile file = new MockMultipartFile(
        "file",
        "test.txt",
        MediaType.TEXT_PLAIN_VALUE,
        "Hello, World!".getBytes()
    );
    
    UploadResult uploadResult = new UploadResult("file-123", "test.txt", 13L);
    when(userService.uploadProfileImage(eq(1L), any(MultipartFile.class))).thenReturn(uploadResult);
    
    // When & Then
    mockMvc.perform(multipart("/api/users/1/profile-image")
            .file(file))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.fileId").value("file-123"))
        .andExpect(jsonPath("$.fileName").value("test.txt"))
        .andExpect(jsonPath("$.fileSize").value(13));
    
    verify(userService).uploadProfileImage(eq(1L), any(MultipartFile.class));
}

@Test
@DisplayName("빈 파일 업로드 시 에러 테스트")
void shouldReturnErrorForEmptyFile() throws Exception {
    // Given
    MockMultipartFile emptyFile = new MockMultipartFile(
        "file",
        "empty.txt",
        MediaType.TEXT_PLAIN_VALUE,
        new byte[0]
    );
    
    // When & Then
    mockMvc.perform(multipart("/api/users/1/profile-image")
            .file(emptyFile))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.error").value("EMPTY_FILE"))
        .andExpect(jsonPath("$.message").value("빈 파일은 업로드할 수 없습니다"));
}
Controller 테스트 모범 사례:
  • • @WebMvcTest로 웹 레이어만 테스트
  • • MockMvc로 HTTP 요청/응답 시뮬레이션
  • • JSONPath로 응답 JSON 구조 검증
  • • 인증/권한 시나리오 포함
  • • 에러 상황과 예외 처리 검증

7. 테스트 전략과 정리

7.1 테스트 전략 수립

효과적인 테스트 전략은 테스트 피라미드를 기반으로 하며, 각 레이어별로 적절한 테스트 비율과 범위를 정의해야 합니다.

단위 테스트 (70%)

  • • 빠른 피드백
  • • 높은 커버리지
  • • 격리된 테스트
  • • 리팩토링 안전성

대상: Service, Repository, Utility 클래스

통합 테스트 (20%)

  • • 컴포넌트 상호작용
  • • 실제 DB 연동
  • • API 계층 검증
  • • 설정 검증

대상: Controller, Repository 통합

E2E 테스트 (10%)

  • • 전체 워크플로우
  • • 사용자 시나리오
  • • 환경 검증
  • • 회귀 테스트

대상: 핵심 비즈니스 플로우

테스트 전략 구현 예시

// 1. 단위 테스트 - Service 레이어
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    @Mock private UserRepository userRepository;
    @InjectMocks private UserService userService;
    
    // 비즈니스 로직의 모든 분기 테스트
}

// 2. 슬라이스 테스트 - Repository 레이어
@DataJpaTest
class UserRepositoryTest {
    @Autowired private TestEntityManager entityManager;
    @Autowired private UserRepository userRepository;
    
    // 쿼리 메서드와 커스텀 쿼리 테스트
}

// 3. 슬라이스 테스트 - Web 레이어
@WebMvcTest(UserController.class)
class UserControllerTest {
    @Autowired private MockMvc mockMvc;
    @MockBean private UserService userService;
    
    // HTTP 요청/응답과 JSON 검증
}

// 4. 통합 테스트 - 전체 컨텍스트
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserIntegrationTest {
    @Autowired private TestRestTemplate restTemplate;
    
    // 실제 HTTP 요청으로 전체 플로우 테스트
}

7.2 TDD (Test-Driven Development)

TDD는 테스트를 먼저 작성하고 구현하는 개발 방법론입니다. Red-Green-Refactor 사이클을 통해 품질 높은 코드를 작성할 수 있습니다.

TDD 사이클 실습

// 1. RED - 실패하는 테스트 작성
@Test
@DisplayName("사용자 이메일 중복 검사")
void shouldThrowExceptionWhenEmailAlreadyExists() {
    // Given
    String duplicateEmail = "duplicate@example.com";
    when(userRepository.existsByEmail(duplicateEmail)).thenReturn(true);
    
    CreateUserRequest request = new CreateUserRequest(duplicateEmail, "Test User", "password");
    
    // When & Then
    assertThatThrownBy(() -> userService.createUser(request))
        .isInstanceOf(DuplicateEmailException.class)
        .hasMessage("이미 사용 중인 이메일입니다: " + duplicateEmail);
}

// 2. GREEN - 테스트를 통과하는 최소한의 코드 작성
@Service
public class UserService {
    
    public User createUser(CreateUserRequest request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new DuplicateEmailException("이미 사용 중인 이메일입니다: " + request.getEmail());
        }
        
        // 최소한의 구현
        User user = new User(request.getEmail(), request.getName());
        return userRepository.save(user);
    }
}

// 3. REFACTOR - 코드 개선
@Service
@Transactional
public class UserService {
    
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final EmailService emailService;
    
    public User createUser(CreateUserRequest request) {
        validateEmailUniqueness(request.getEmail());
        
        User user = User.builder()
            .email(request.getEmail())
            .name(request.getName())
            .password(passwordEncoder.encode(request.getPassword()))
            .status(UserStatus.ACTIVE)
            .build();
        
        User savedUser = userRepository.save(user);
        emailService.sendWelcomeEmail(savedUser.getEmail());
        
        return savedUser;
    }
    
    private void validateEmailUniqueness(String email) {
        if (userRepository.existsByEmail(email)) {
            throw new DuplicateEmailException("이미 사용 중인 이메일입니다: " + email);
        }
    }
}

7.3 테스트 커버리지

테스트 커버리지는 코드의 어느 부분이 테스트되었는지 측정하는 지표입니다. 높은 커버리지가 반드시 좋은 테스트를 의미하지는 않지만, 중요한 참고 지표입니다.

JaCoCo 설정

<!-- Maven 설정 -->
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.8</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
        <execution>
            <id>check</id>
            <goals>
                <goal>check</goal>
            </goals>
            <configuration>
                <rules>
                    <rule>
                        <element>CLASS</element>
                        <limits>
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

// Gradle 설정
plugins {
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.8"
}

jacocoTestReport {
    reports {
        xml.required = true
        html.required = true
    }
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                counter = 'LINE'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }
        }
    }
}

커버리지 분석과 개선

// 커버리지가 낮은 메서드 예시
public class OrderService {
    
    public OrderResult processOrder(OrderRequest request) {
        // 1. 입력 검증 (테스트됨)
        validateOrderRequest(request);
        
        // 2. 재고 확인 (테스트됨)
        if (!inventoryService.hasStock(request.getProductId(), request.getQuantity())) {
            throw new InsufficientStockException("재고 부족");
        }
        
        // 3. 결제 처리 (테스트 안됨 - 커버리지 낮음)
        PaymentResult paymentResult = paymentService.processPayment(request.getPaymentInfo());
        if (paymentResult.isFailed()) {
            // 이 분기가 테스트되지 않음
            handlePaymentFailure(request, paymentResult);
            return OrderResult.failed("결제 실패");
        }
        
        // 4. 주문 생성 (테스트됨)
        Order order = createOrder(request, paymentResult);
        return OrderResult.success(order);
    }
    
    private void handlePaymentFailure(OrderRequest request, PaymentResult result) {
        // 이 메서드가 테스트되지 않아 커버리지 0%
        auditService.logPaymentFailure(request.getUserId(), result.getErrorCode());
        notificationService.notifyPaymentFailure(request.getUserId());
    }
}

// 커버리지 개선을 위한 테스트 추가
@Test
@DisplayName("결제 실패 시 적절한 처리를 해야 한다")
void shouldHandlePaymentFailureCorrectly() {
    // Given
    OrderRequest request = createOrderRequest();
    PaymentResult failedResult = PaymentResult.failed("CARD_DECLINED");
    
    when(inventoryService.hasStock(anyLong(), anyInt())).thenReturn(true);
    when(paymentService.processPayment(any())).thenReturn(failedResult);
    
    // When
    OrderResult result = orderService.processOrder(request);
    
    // Then
    assertThat(result.isSuccess()).isFalse();
    assertThat(result.getMessage()).isEqualTo("결제 실패");
    
    // 실패 처리 로직 검증
    verify(auditService).logPaymentFailure(request.getUserId(), "CARD_DECLINED");
    verify(notificationService).notifyPaymentFailure(request.getUserId());
}

7.4 테스트 성능 최적화

테스트 실행 시간을 단축하고 효율성을 높이는 방법들을 알아봅시다.

테스트 최적화 기법

// 1. 테스트 컨텍스트 재사용
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserIntegrationTest {
    
    @Autowired
    private UserRepository userRepository;
    
    @BeforeAll
    void setUpAll() {
        // 클래스 레벨에서 한 번만 실행
        // 테스트 데이터 준비
    }
    
    @AfterAll
    void tearDownAll() {
        // 정리 작업
    }
}

// 2. 병렬 테스트 실행
// junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.config.strategy=dynamic

@Execution(ExecutionMode.CONCURRENT)
class ParallelTest {
    // 병렬로 실행되는 테스트들
}

// 3. 테스트 슬라이스 활용
@WebMvcTest(UserController.class) // 웹 레이어만 로드
@DataJpaTest // JPA 레이어만 로드
@JsonTest // JSON 직렬화만 테스트

// 4. 인메모리 데이터베이스 사용
@TestPropertySource(properties = {
    "spring.datasource.url=jdbc:h2:mem:testdb",
    "spring.jpa.hibernate.ddl-auto=create-drop"
})

// 5. 모킹으로 외부 의존성 제거
@MockBean
private EmailService emailService; // 실제 이메일 전송 없이 테스트

// 6. 테스트 프로파일 분리
@ActiveProfiles("test")
class FastTest {
    // 테스트 전용 설정으로 빠른 실행
}

7.5 테스트 모범 사례 정리

DO - 해야 할 것들

  • ✅ Given-When-Then 패턴 사용
  • ✅ 의미 있는 테스트 이름 작성
  • ✅ 하나의 테스트는 하나의 기능만
  • ✅ 외부 의존성 모킹
  • ✅ 경계값과 예외 상황 테스트
  • ✅ 테스트 데이터 빌더 패턴 활용
  • ✅ 적절한 어노테이션 선택
  • ✅ 테스트 격리 보장

DON'T - 피해야 할 것들

  • ❌ 테스트 간 의존성 생성
  • ❌ 하드코딩된 값 사용
  • ❌ 과도한 모킹
  • ❌ 구현 세부사항 테스트
  • ❌ 느린 통합 테스트 남발
  • ❌ 테스트 코드 중복
  • ❌ 불안정한 테스트 방치
  • ❌ 커버리지만을 위한 테스트

테스트 체크리스트

단위 테스트
  • □ 모든 public 메서드 테스트
  • □ 예외 상황 처리 테스트
  • □ 경계값 테스트
  • □ 비즈니스 로직 분기 테스트
통합 테스트
  • □ API 엔드포인트 테스트
  • □ 데이터베이스 연동 테스트
  • □ 인증/권한 테스트
  • □ 에러 응답 테스트
Spring Boot 테스트 마스터 요약:
핵심 개념
  • • 테스트 피라미드 구조
  • • 슬라이스 테스트 활용
  • • 모킹과 스텁 전략
  • • TDD 개발 방법론
실무 적용
  • • 레이어별 테스트 전략
  • • 커버리지 관리
  • • 성능 최적화
  • • CI/CD 통합