Spring 08: 테스트 전략
Spring Boot Testing 완전 가이드
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: 결과 검증
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
// - JsonPath2.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);
}- • 과도한 모킹 피하기 - 실제 객체 사용 고려
- • 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();
}
}- • 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());
}
}- • 외부 의존성은 모킹하여 격리된 테스트 작성
- • 비즈니스 로직의 모든 분기 조건 테스트
- • 예외 상황과 에러 처리 로직 검증
- • 트랜잭션 경계와 롤백 동작 확인
- • 비동기 처리의 완료와 예외 처리 검증
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("빈 파일은 업로드할 수 없습니다"));
}- • @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 엔드포인트 테스트
- □ 데이터베이스 연동 테스트
- □ 인증/권한 테스트
- □ 에러 응답 테스트
핵심 개념
- • 테스트 피라미드 구조
- • 슬라이스 테스트 활용
- • 모킹과 스텁 전략
- • TDD 개발 방법론
실무 적용
- • 레이어별 테스트 전략
- • 커버리지 관리
- • 성능 최적화
- • CI/CD 통합