본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 21. · 3 Views
단위 테스트와 통합 테스트 완벽 가이드
테스트 코드 작성이 처음이라면 이 가이드로 시작하세요. JUnit 5 기초부터 Mockito, MockMvc, SpringBootTest, Testcontainers까지 실무에서 바로 쓸 수 있는 테스트 기법을 단계별로 배웁니다.
목차
- JUnit 5 기초
- Mockito로 단위 테스트
- MockMvc로 Controller 테스트
- @SpringBootTest 통합 테스트
- Testcontainers 소개
- DB 테스트 자동화
1. JUnit 5 기초
신입 개발자 이준호 씨는 첫 PR을 올렸습니다. 그런데 시니어 개발자 최민수 씨가 이런 코멘트를 남겼습니다.
"코드는 잘 작성했는데, 테스트 코드가 없네요?" 이준호 씨는 당황했습니다. 테스트 코드를 작성해본 적이 없었거든요.
JUnit 5는 자바 진영에서 가장 널리 사용되는 테스트 프레임워크입니다. 마치 요리사가 새 요리를 만들 때마다 맛을 보는 것처럼, 개발자는 새 코드를 작성할 때마다 제대로 동작하는지 검증해야 합니다.
JUnit 5를 사용하면 자동화된 테스트를 작성하여 코드의 품질을 보장할 수 있습니다.
다음 코드를 살펴봅시다.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
@Test
void 덧셈_테스트() {
// given: 테스트 준비
Calculator calculator = new Calculator();
// when: 실제 동작
int result = calculator.add(2, 3);
// then: 결과 검증
assertEquals(5, result, "2 + 3은 5여야 합니다");
}
}
이준호 씨는 점심시간에 최민수 씨를 찾아갔습니다. "선배님, 테스트 코드가 정확히 뭔가요?" 최민수 씨는 웃으며 답했습니다.
"좋은 질문이네요. 천천히 설명해 드릴게요." "먼저 상상해봅시다.
준호 씨가 계산기를 만들었다고 가정해볼까요? 덧셈 기능을 추가했는데, 이게 제대로 작동하는지 어떻게 확인할 건가요?" 이준호 씨가 답했습니다.
"그냥 실행해서 2 더하기 3을 해보면 되지 않나요?" 최민수 씨가 고개를 끄덕였습니다. "맞아요.
하지만 매번 프로그램을 실행해서 직접 확인한다면 어떨까요? 기능이 100개, 1000개가 되면요?" 그제야 이준호 씨는 깨달았습니다.
수동으로 테스트하는 건 너무 비효율적이라는 것을. JUnit 5는 바로 이런 문제를 해결합니다.
테스트 코드를 한 번만 작성해두면, 버튼 하나로 모든 테스트를 자동으로 실행할 수 있습니다. 마치 공장의 품질 검사 로봇처럼 말이죠.
"자, 이제 실제로 테스트 코드를 작성해볼까요?" 최민수 씨가 화면을 공유했습니다. 가장 먼저 눈에 띄는 건 @Test 어노테이션입니다.
이것은 JUnit에게 "이 메서드는 테스트야"라고 알려주는 표시입니다. 어노테이션이 없으면 그냥 일반 메서드일 뿐이죠.
다음으로 중요한 건 테스트 코드의 구조입니다. 많은 개발자들이 given-when-then 패턴을 사용합니다.
이 패턴은 테스트를 세 단계로 나눕니다. given 단계에서는 테스트에 필요한 준비를 합니다.
객체를 생성하고, 필요한 데이터를 세팅하죠. 마치 요리를 시작하기 전에 재료를 준비하는 것과 같습니다.
when 단계에서는 실제로 테스트할 동작을 실행합니다. 우리 예제에서는 calculator의 add 메서드를 호출하는 것이죠.
then 단계에서는 결과를 검증합니다. 이때 assertEquals라는 메서드를 사용합니다.
이것은 "기대값과 실제값이 같은지 확인해줘"라는 의미입니다. assertEquals의 첫 번째 인자는 기대값(expected)입니다.
우리는 2 더하기 3이 5가 되기를 기대하므로 5를 넣었습니다. 두 번째 인자는 실제값(actual)입니다.
실제로 add 메서드를 실행한 결과죠. 세 번째 인자는 선택사항인데, 테스트가 실패했을 때 보여줄 메시지입니다.
이게 있으면 나중에 테스트가 왜 실패했는지 파악하기 쉽습니다. "만약 테스트가 실패하면 어떻게 되나요?" 이준호 씨가 물었습니다.
최민수 씨는 일부러 코드를 잘못 작성해 보였습니다. assertEquals(6, result)로 바꿔보니, 빨간색으로 테스트 실패 메시지가 나타났습니다.
"이렇게 테스트가 실패하면 버그를 발견한 거예요. 사용자가 발견하기 전에 우리가 먼저 찾은 거죠." 실무에서는 보통 기능을 만들 때마다 테스트 코드를 함께 작성합니다.
어떤 팀에서는 **TDD(Test-Driven Development)**라고 해서 테스트 코드를 먼저 작성하고 나서 실제 코드를 작성하기도 합니다. JUnit 5에는 assertEquals 외에도 다양한 검증 메서드가 있습니다.
assertTrue는 조건이 참인지 확인하고, assertNotNull은 값이 null이 아닌지 확인합니다. assertThrows는 예외가 발생하는지 확인할 때 사용하죠.
이준호 씨는 설명을 듣고 나서 바로 실습에 들어갔습니다. 자신이 작성한 코드에 테스트를 추가하기 시작했죠.
처음엔 어색했지만, 몇 개 작성하다 보니 패턴이 보이기 시작했습니다. 그날 저녁, 이준호 씨는 모든 기능에 테스트 코드를 추가한 PR을 다시 올렸습니다.
최민수 씨는 Approve를 눌러주며 한마디 덧붙였습니다. "이제 진짜 개발자가 된 것 같네요!"
실전 팁
💡 - 테스트 메서드 이름은 한글로 작성해도 됩니다. 오히려 의도가 더 명확해집니다.
- given-when-then 주석을 달아두면 나중에 읽기 쉽습니다.
- 하나의 테스트는 하나의 기능만 검증하는 것이 좋습니다.
2. Mockito로 단위 테스트
이준호 씨는 이제 기본적인 테스트는 작성할 수 있게 되었습니다. 하지만 새로운 문제에 부딪혔습니다.
UserService를 테스트하려는데, 이 서비스는 데이터베이스에서 데이터를 가져옵니다. 테스트할 때마다 진짜 데이터베이스를 사용해야 할까요?
Mockito는 가짜 객체를 만들어주는 모킹 프레임워크입니다. 마치 영화 촬영장에서 진짜 은행 대신 세트장을 사용하는 것처럼, 테스트에서는 진짜 데이터베이스나 외부 API 대신 가짜 객체를 사용할 수 있습니다.
이를 통해 빠르고 독립적인 단위 테스트를 작성할 수 있습니다.
다음 코드를 살펴봅시다.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository; // 가짜 Repository
@InjectMocks
private UserService userService; // 진짜 Service (Mock 주입됨)
@Test
void 사용자_조회_테스트() {
// given: 가짜 데이터 준비
User mockUser = new User(1L, "김철수");
when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));
// when: 서비스 메서드 호출
User result = userService.getUser(1L);
// then: 결과 검증
assertEquals("김철수", result.getName());
verify(userRepository, times(1)).findById(1L); // 호출 횟수 검증
}
}
최민수 씨가 이준호 씨의 화면을 보더니 말했습니다. "아, 좋은 질문이에요.
지금 준호 씨가 마주한 게 바로 의존성 문제입니다." "의존성 문제요?" 이준호 씨가 되물었습니다. "네, UserService가 UserRepository에 의존하고 있잖아요.
그런데 진짜 Repository를 사용하면 어떤 문제가 생길까요?" 이준호 씨가 생각해보니 문제가 한두 가지가 아니었습니다. 첫째, 데이터베이스가 없으면 테스트를 실행할 수 없습니다.
둘째, 테스트가 느려집니다. 셋째, 테스트 데이터를 매번 준비해야 합니다.
넷째, 다른 개발자의 테스트가 데이터를 바꾸면 내 테스트가 실패할 수 있습니다. "정확합니다!" 최민수 씨가 박수를 쳤습니다.
"바로 이런 문제를 해결하는 게 Mockito예요." Mockito는 **모킹(Mocking)**이라는 기법을 사용합니다. 모킹이란 가짜 객체를 만드는 것입니다.
마치 배우가 연기하는 것처럼, 진짜 데이터베이스인 척하는 가짜 객체를 만드는 거죠. 코드를 살펴보겠습니다.
가장 먼저 **@ExtendWith(MockitoExtension.class)**가 보입니다. 이것은 JUnit 5에게 "이 테스트에서 Mockito를 사용할 거야"라고 알려주는 선언입니다.
다음으로 @Mock 어노테이션이 붙은 UserRepository가 있습니다. 이것이 바로 가짜 객체입니다.
진짜 Repository가 아니라, Mockito가 만든 껍데기일 뿐이죠. 아무 기능도 없는 빈 껍데기입니다.
그리고 @InjectMocks가 붙은 UserService가 있습니다. 이것은 진짜 서비스 객체인데, Mockito가 자동으로 Mock 객체들을 주입해줍니다.
마치 Spring의 @Autowired처럼 말이죠. "여기까지는 준비 단계입니다.
이제 진짜 마법이 시작되죠." 최민수 씨가 when 구문을 가리켰습니다. when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser)) 이 한 줄이 핵심입니다.
이것은 Mockito에게 이렇게 말하는 겁니다. "userRepository의 findById 메서드가 1L을 인자로 받으면, mockUser를 반환해줘." 실제로 데이터베이스에 가지 않습니다.
그냥 우리가 미리 정해놓은 값을 반환할 뿐이죠. 마치 대본을 읽는 배우처럼 말입니다.
이준호 씨가 궁금해했습니다. "그럼 다른 ID로 호출하면 어떻게 되나요?" 최민수 씨가 답했습니다.
"설정하지 않은 동작은 기본값을 반환합니다. Optional이면 Optional.empty()가 반환되죠." when-thenReturn은 Mockito의 가장 기본적인 문법입니다.
하지만 더 다양한 방법도 있습니다. thenThrow를 사용하면 예외를 던지게 할 수 있고, doNothing을 사용하면 아무것도 하지 않게 할 수 있습니다.
테스트 마지막 줄을 보면 verify 구문이 있습니다. 이것은 "이 메서드가 정말 호출되었는지 확인해줘"라는 의미입니다.
times(1)은 "정확히 1번 호출되어야 해"라는 뜻이죠. 왜 이런 검증이 필요할까요?
때로는 결과값뿐만 아니라 과정도 중요하기 때문입니다. 예를 들어 캐시를 사용하는 서비스라면, 같은 데이터를 두 번 요청했을 때 데이터베이스는 한 번만 호출되어야 하겠죠.
실무에서 Mockito를 사용하면 단위 테스트가 가능해집니다. 단위 테스트란 하나의 단위(클래스나 메서드)만 독립적으로 테스트하는 것입니다.
다른 컴포넌트의 영향을 받지 않기 때문에 테스트가 실패하면 정확히 어디가 문제인지 알 수 있습니다. 이준호 씨는 처음엔 "가짜 객체로 테스트하면 의미가 있나?"라고 생각했습니다.
하지만 곧 깨달았습니다. 서비스 로직 자체를 검증하는 게 목적이지, 데이터베이스를 검증하는 게 아니라는 것을요.
"데이터베이스는 따로 통합 테스트에서 검증하면 됩니다. 지금은 UserService의 비즈니스 로직에만 집중하는 거예요." 최민수 씨의 설명에 이준호 씨는 고개를 끄덕였습니다.
그날부터 이준호 씨는 Mockito를 활용해 빠르고 독립적인 테스트를 작성할 수 있게 되었습니다.
실전 팁
💡 - @Mock은 가짜 객체, @InjectMocks는 진짜 객체에 사용합니다.
- any(), anyLong() 같은 매처를 사용하면 모든 인자를 허용할 수 있습니다.
- verify는 선택사항이지만, 중요한 호출은 검증하는 게 좋습니다.
3. MockMvc로 Controller 테스트
이제 이준호 씨는 서비스 레이어 테스트에는 자신감이 붙었습니다. 하지만 새로운 과제가 주어졌습니다.
"API 엔드포인트도 테스트해야 해요." 최민수 씨가 말했습니다. "사용자가 실제로 호출하는 건 컨트롤러니까요."
MockMvc는 Spring MVC 컨트롤러를 테스트하기 위한 도구입니다. 마치 우체부가 편지를 배달하듯이, 실제 HTTP 요청을 보내지 않고도 컨트롤러의 동작을 검증할 수 있습니다.
서버를 띄우지 않고도 GET, POST 요청과 응답을 테스트할 수 있어 매우 빠르고 효율적입니다.
다음 코드를 살펴봅시다.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.mockito.Mockito.*;
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc; // HTTP 요청을 시뮬레이션
@MockBean
private UserService userService; // 의존성 Mock
@Test
void 사용자_조회_API_테스트() throws Exception {
// given
User mockUser = new User(1L, "김철수");
when(userService.getUser(1L)).thenReturn(mockUser);
// when & then: HTTP GET 요청 및 응답 검증
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk()) // 200 OK
.andExpect(jsonPath("$.name").value("김철수")); // JSON 응답 검증
}
}
이준호 씨는 컨트롤러를 보고 있었습니다. UserController는 REST API를 제공하는 컨트롤러입니다.
"/api/users/1"로 GET 요청을 보내면 1번 사용자 정보를 JSON으로 반환하죠. "이걸 어떻게 테스트하죠?
Postman으로 일일이 확인해야 하나요?" 이준호 씨가 물었습니다. 최민수 씨가 웃으며 답했습니다.
"Postman도 좋지만, 자동화된 테스트가 더 좋죠. MockMvc를 사용하면 코드로 HTTP 요청을 보낼 수 있어요." MockMvc는 Spring이 제공하는 테스트 도구입니다.
진짜 서버를 띄우지 않고도 컨트롤러에 HTTP 요청을 보낼 수 있죠. 마치 시뮬레이션을 하는 것과 같습니다.
먼저 @WebMvcTest 어노테이션을 살펴보겠습니다. 이것은 Spring에게 "웹 레이어만 테스트할 거야"라고 알려줍니다.
전체 스프링 컨텍스트를 로드하지 않고, 컨트롤러와 관련된 빈들만 로드하죠. 괄호 안에 UserController.class를 지정했습니다.
이렇게 하면 UserController만 테스트 대상으로 로드됩니다. 다른 컨트롤러는 로드하지 않아서 테스트가 더 빠릅니다.
@Autowired MockMvc는 테스트의 핵심입니다. MockMvc 객체를 통해 HTTP 요청을 시뮬레이션할 수 있습니다.
Spring이 자동으로 주입해주죠. @MockBean은 Spring Boot의 어노테이션입니다.
@Mock과 비슷하지만, Spring 컨텍스트에 Mock 빈을 등록합니다. 컨트롤러가 의존하는 UserService를 Mock으로 대체하는 거죠.
이제 실제 테스트 코드를 보겠습니다. when 구문은 이전과 동일합니다.
"userService의 getUser(1L)를 호출하면 mockUser를 반환해줘." 그다음이 중요합니다. mockMvc.perform(get("/api/users/1")) 이 한 줄이 바로 HTTP GET 요청을 보내는 코드입니다.
실제로 네트워크를 타지 않습니다. MockMvc가 내부적으로 컨트롤러 메서드를 직접 호출하죠.
하지만 실제 HTTP 요청과 동일하게 동작합니다. andExpect는 응답을 검증하는 메서드입니다.
여러 개를 체이닝할 수 있습니다. **status().isOk()**는 HTTP 상태 코드가 200인지 확인합니다.
만약 컨트롤러에서 예외가 발생했다면 이 검증은 실패하겠죠. **jsonPath("$.name").value("김철수")**는 JSON 응답을 검증합니다.
jsonPath는 JSON 구조를 탐색하는 표현식입니다. "$"는 루트를 의미하고, ".name"은 name 필드를 의미합니다.
이준호 씨가 신기해하며 물었습니다. "POST 요청은 어떻게 보내나요?" 최민수 씨가 예제를 보여줬습니다.
"post() 메서드를 사용하고, content()로 요청 바디를 지정하면 됩니다. contentType()으로 Content-Type 헤더도 설정할 수 있죠." 실무에서는 다양한 케이스를 테스트합니다.
정상 케이스뿐만 아니라 에러 케이스도 중요합니다. 존재하지 않는 사용자를 조회하면 404를 반환하는지, 잘못된 요청을 보내면 400을 반환하는지 검증하죠.
MockMvc는 헤더, 쿠키, 세션도 테스트할 수 있습니다. 인증이 필요한 API라면 header()로 Authorization 헤더를 추가할 수 있습니다.
"그런데 선배님, 이것도 결국 Mock을 사용하잖아요. 진짜 서비스 로직은 실행 안 되는 거 아닌가요?" 이준호 씨가 날카로운 질문을 했습니다.
최민수 씨가 기뻐하며 답했습니다. "좋은 질문이에요!
맞아요, @WebMvcTest는 컨트롤러 레이어만 테스트합니다. 전체 플로우를 테스트하려면 통합 테스트가 필요하죠.
그건 다음에 배울 @SpringBootTest에서 할 수 있어요." MockMvc 테스트는 빠르고 가볍습니다. 컨트롤러의 라우팅, 요청/응답 변환, 유효성 검증 같은 웹 레이어 로직을 검증하기에 완벽합니다.
이준호 씨는 자신의 모든 API 엔드포인트에 MockMvc 테스트를 추가했습니다. 이제 API를 수정할 때마다 테스트를 돌려서 기존 기능이 깨지지 않았는지 확인할 수 있게 되었습니다.
실전 팁
💡 - @WebMvcTest는 가볍고 빠르지만, 컨트롤러 레이어만 테스트합니다.
- jsonPath 대신 andExpect(content().json("..."))으로 전체 JSON을 검증할 수도 있습니다.
- andDo(print())를 추가하면 요청과 응답을 콘솔에 출력해서 디버깅에 도움이 됩니다.
4. @SpringBootTest 통합 테스트
어느 날 이준호 씨의 테스트는 모두 통과했지만, 실제 서버를 띄워보니 에러가 발생했습니다. "분명 단위 테스트는 다 통과했는데..." 최민수 씨가 설명했습니다.
"단위 테스트만으로는 부족할 때가 있어요. 전체 시스템이 제대로 연동되는지 확인하는 통합 테스트가 필요합니다."
@SpringBootTest는 실제 스프링 애플리케이션 컨텍스트를 띄워서 테스트하는 통합 테스트 어노테이션입니다. 마치 자동차 공장에서 부품 하나하나를 검사하는 게 단위 테스트라면, 완성된 차를 도로에서 시운전하는 게 통합 테스트입니다.
실제 환경과 가장 유사한 조건에서 전체 플로우를 검증할 수 있습니다.
다음 코드를 살펴봅시다.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test") // application-test.yml 사용
class UserIntegrationTest {
@Autowired
private TestRestTemplate restTemplate; // 실제 HTTP 요청
@Autowired
private UserRepository userRepository; // 실제 Repository
@Test
void 사용자_생성_전체_플로우_테스트() {
// given: 실제 요청 데이터
String requestBody = "{\"name\":\"김철수\"}";
// when: 실제 HTTP POST 요청
ResponseEntity<User> response = restTemplate.postForEntity(
"/api/users", requestBody, User.class);
// then: HTTP 응답 검증
assertEquals(HttpStatus.CREATED, response.getStatusCode());
assertNotNull(response.getBody().getId());
// DB에 실제로 저장되었는지 확인
assertTrue(userRepository.existsById(response.getBody().getId()));
}
}
이준호 씨는 당황했습니다. 테스트는 모두 그린 라이트였는데, 실제로는 빨간 에러 화면이 나타났으니까요.
"뭐가 문제일까요?" 최민수 씨가 로그를 살펴보더니 말했습니다. "아, 보세요.
UserService와 EmailService를 연동하는 부분에서 에러가 났네요. 단위 테스트에서는 EmailService를 Mock으로 대체했기 때문에 이 문제를 발견할 수 없었던 거예요." 바로 이것이 통합 테스트가 필요한 이유입니다.
단위 테스트는 각 부품이 제대로 작동하는지 확인합니다. 하지만 부품들을 조립했을 때도 제대로 작동한다는 보장은 없습니다.
부품 간의 인터페이스, 데이터 흐름, 설정이 모두 맞아떨어져야 하니까요. @SpringBootTest는 실제 스프링 부트 애플리케이션을 띄웁니다.
application.yml을 읽고, 모든 빈을 생성하고, 데이터베이스 연결도 실제로 합니다. 거의 프로덕션 환경과 동일하죠.
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT는 랜덤 포트로 내장 서버를 띄운다는 의미입니다. 여러 테스트를 동시에 실행할 때 포트 충돌을 방지하기 위함입니다.
**@ActiveProfiles("test")**는 테스트 전용 설정을 사용하겠다는 의미입니다. application-test.yml 파일을 읽어서 테스트 데이터베이스 설정 같은 걸 적용하죠.
TestRestTemplate은 @SpringBootTest에서 제공하는 HTTP 클라이언트입니다. MockMvc와 달리, 실제로 HTTP 요청을 보냅니다.
서버가 정말로 떠 있어야 하죠. 테스트 코드를 보면 이전과 확연히 다릅니다.
when 구문이 없습니다. Mock을 설정하지 않았으니까요.
대신 실제 요청을 보냅니다. postForEntity는 POST 요청을 보내고 응답을 받는 메서드입니다.
첫 번째 인자는 URL, 두 번째는 요청 바디, 세 번째는 응답 타입입니다. 그리고 주목할 부분이 있습니다.
마지막 검증 구문을 보세요. userRepository.existsById(...) 실제 데이터베이스에 데이터가 저장되었는지 확인합니다.
이것이 통합 테스트의 핵심입니다. HTTP 요청부터 컨트롤러, 서비스, 리포지토리를 거쳐 데이터베이스까지, 전체 플로우가 제대로 작동하는지 검증하는 거죠.
이준호 씨가 물었습니다. "그런데 선배님, 이렇게 하면 테스트 때문에 데이터베이스에 데이터가 쌓이는 거 아닌가요?" "좋은 지적이에요!" 최민수 씨가 답했습니다.
"그래서 보통 @Transactional을 테스트 클래스에 붙입니다. 그러면 테스트가 끝나면 자동으로 롤백되죠." 또는 @DirtiesContext를 사용해서 테스트마다 스프링 컨텍스트를 새로 띄울 수도 있습니다.
하지만 이 방법은 느려서 자주 사용하지는 않습니다. 통합 테스트의 단점은 느리다는 것입니다.
전체 스프링 컨텍스트를 띄우는 데 시간이 걸리고, 실제 데이터베이스 작업도 하니까요. 그래서 실무에서는 단위 테스트를 많이, 통합 테스트를 적절히 작성합니다.
마틴 파울러는 이것을 테스트 피라미드라고 불렀습니다. 피라미드 아래쪽에는 빠르고 많은 단위 테스트가 있고, 중간에는 통합 테스트, 꼭대기에는 소수의 E2E 테스트가 있죠.
이준호 씨는 중요한 비즈니스 로직에는 통합 테스트를 추가했습니다. 사용자 가입, 결제, 주문 같은 핵심 플로우는 반드시 전체가 제대로 작동해야 하니까요.
"이제 배포 전에 통합 테스트를 돌려보면 안심할 수 있겠네요." 이준호 씨가 뿌듯해했습니다. 최민수 씨가 덧붙였습니다.
"맞아요. 하지만 한 가지 더 고려할 게 있어요.
실제 데이터베이스 대신 테스트용 데이터베이스를 사용하는 게 좋습니다. 그게 바로 다음에 배울 Testcontainers예요."
실전 팁
💡 - @SpringBootTest는 느리므로 꼭 필요한 테스트에만 사용하세요.
- @Transactional을 붙이면 테스트 후 자동 롤백됩니다.
- RANDOM_PORT를 사용하면 포트 충돌을 방지할 수 있습니다.
5. Testcontainers 소개
이준호 씨는 통합 테스트를 작성하면서 새로운 문제에 부딪혔습니다. 로컬에서는 PostgreSQL을 사용하는데, 테스트에서는 H2를 사용하니 결과가 달랐습니다.
"실제 DB와 같은 환경에서 테스트할 수는 없을까요?" 최민수 씨가 미소 지으며 답했습니다. "있죠.
Testcontainers를 사용하면 됩니다."
Testcontainers는 도커 컨테이너를 활용해 실제 데이터베이스나 메시지 큐를 테스트에 사용할 수 있게 해주는 라이브러리입니다. 마치 레고 블록을 조립하듯이, 테스트 실행 시 필요한 인프라를 자동으로 띄우고 테스트 후 자동으로 정리합니다.
실제 프로덕션 환경과 동일한 DB에서 테스트할 수 있어 신뢰도가 높습니다.
다음 코드를 살펴봅시다.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest
@Testcontainers // Testcontainers 활성화
class UserRepositoryTestContainersTest {
@Container // 테스트용 PostgreSQL 컨테이너
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource // 동적으로 DB 설정 주입
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
void 실제_PostgreSQL에서_사용자_저장_테스트() {
// given
User user = new User(null, "김철수");
// when: 실제 PostgreSQL에 저장
User saved = userRepository.save(user);
// then
assertNotNull(saved.getId());
assertEquals("김철수", userRepository.findById(saved.getId()).get().getName());
}
}
이준호 씨의 고민은 많은 개발자가 겪는 문제였습니다. 테스트에서는 H2 같은 인메모리 데이터베이스를 사용하는데, 실제 프로덕션에서는 PostgreSQL이나 MySQL을 사용하죠.
문제는 데이터베이스마다 미묘한 차이가 있다는 겁니다. H2에서는 동작하는 쿼리가 PostgreSQL에서는 에러를 일으킬 수 있습니다.
특히 날짜 처리, JSON 타입, 전문 검색 같은 고급 기능은 DB마다 완전히 다릅니다. "그래서 예전에는 개발자마다 로컬에 데이터베이스를 설치해야 했어요." 최민수 씨가 설명했습니다.
"하지만 이것도 문제가 많았죠. 버전이 다르거나, 설정이 다르거나, 데이터가 꼬이거나..." 바로 이런 문제를 Testcontainers가 해결합니다.
Testcontainers는 도커를 활용합니다. 테스트를 실행할 때 자동으로 도커 컨테이너를 띄우고, 테스트가 끝나면 자동으로 컨테이너를 내립니다.
마치 일회용품처럼 사용하고 버리는 거죠. 코드를 살펴보겠습니다.
@Testcontainers 어노테이션은 JUnit 5에게 "이 테스트는 Testcontainers를 사용해"라고 알려줍니다. @Container가 붙은 PostgreSQLContainer가 핵심입니다.
이것은 PostgreSQL 15 이미지를 사용하는 컨테이너를 정의합니다. withDatabaseName, withUsername, withPassword로 초기 설정을 지정하죠.
중요한 건 static으로 선언되었다는 점입니다. 이렇게 하면 모든 테스트 메서드가 하나의 컨테이너를 공유합니다.
매 테스트마다 새 컨테이너를 띄우면 너무 느리니까요. @DynamicPropertySource는 스프링의 강력한 기능입니다.
테스트 실행 시점에 동적으로 프로퍼티를 주입할 수 있죠. 왜 동적으로 주입해야 할까요?
Testcontainers는 랜덤 포트를 사용합니다. 컨테이너를 띄울 때마다 포트가 바뀌니까, 미리 설정 파일에 적어둘 수 없습니다.
그래서 컨테이너가 실제로 띄워진 후에 getJdbcUrl()로 URL을 가져와서 주입하는 거죠. 테스트 메서드는 이전과 동일합니다.
차이점은 이제 진짜 PostgreSQL에서 실행된다는 것뿐입니다. 이준호 씨가 테스트를 실행해봤습니다.
처음 실행할 때는 PostgreSQL 이미지를 다운로드하느라 시간이 좀 걸렸습니다. 하지만 두 번째부터는 캐시된 이미지를 사용해서 빨랐습니다.
콘솔을 보니 신기한 로그가 나타났습니다. "Creating container for image: postgres:15", "Container started".
Testcontainers가 자동으로 도커 컨테이너를 띄우고 있었던 겁니다. 테스트가 끝나니 "Stopping container" 로그와 함께 컨테이너가 자동으로 정리되었습니다.
깔끔하게 청소까지 해주는 거죠. "대박이네요!" 이준호 씨가 감탄했습니다.
"이제 CI/CD 파이프라인에서도 실제 DB로 테스트할 수 있겠어요." 최민수 씨가 덧붙였습니다. "맞아요.
GitHub Actions 같은 CI 환경에도 도커만 있으면 Testcontainers를 사용할 수 있습니다. 로컬이나 CI나 동일한 환경에서 테스트할 수 있는 거죠." Testcontainers는 PostgreSQL뿐만 아니라 MySQL, MongoDB, Redis, Kafka 등 거의 모든 인프라를 지원합니다.
심지어 커스텀 도커 이미지도 사용할 수 있죠. 단점은 도커가 필요하다는 것입니다.
도커가 설치되어 있지 않으면 사용할 수 없습니다. 또한 컨테이너를 띄우는 데 시간이 걸려서 순수 인메모리 DB보다는 느립니다.
하지만 실제 환경과 동일하게 테스트할 수 있다는 장점이 이런 단점을 충분히 상쇄합니다. 특히 복잡한 쿼리나 DB 특화 기능을 사용할 때는 Testcontainers가 거의 필수입니다.
이준호 씨는 중요한 리포지토리 테스트에 Testcontainers를 도입했습니다. 이제 프로덕션 버그가 크게 줄어들었습니다.
실전 팁
💡 - static 컨테이너를 사용하면 여러 테스트가 컨테이너를 공유해서 빠릅니다.
- .withReuse(true)를 사용하면 테스트 실행 간에도 컨테이너를 재사용할 수 있습니다.
- 로컬 개발 시에는 Docker Compose로 DB를 띄우고, CI에서만 Testcontainers를 사용하는 전략도 있습니다.
6. DB 테스트 자동화
이제 이준호 씨는 테스트 도구들을 자유롭게 다룰 수 있게 되었습니다. 하지만 마지막 과제가 남아있었습니다.
"데이터베이스 마이그레이션도 테스트해야 하고, 복잡한 쿼리도 검증해야 해요." 최민수 씨가 말했습니다. "DB 테스트 자동화를 제대로 해봅시다."
DB 테스트 자동화는 데이터베이스 레이어의 동작을 체계적으로 검증하는 것입니다. 마치 은행이 장부를 정기적으로 감사하듯이, 데이터 무결성, 쿼리 성능, 트랜잭션 동작을 자동으로 검증합니다.
@DataJpaTest, @Sql, 트랜잭션 격리 수준 테스트 등 다양한 기법을 활용합니다.
다음 코드를 살펴봅시다.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest // JPA 관련 빈만 로드
@Sql("/test-data.sql") // 테스트 데이터 자동 주입
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void 복잡한_쿼리_테스트() {
// when: @Query로 작성한 복잡한 쿼리 실행
List<User> activeUsers = userRepository.findActiveUsersWithOrders();
// then: 결과 검증
assertEquals(3, activeUsers.size());
assertTrue(activeUsers.stream()
.allMatch(u -> u.getOrders().size() > 0));
}
@Test
@Transactional
void 트랜잭션_롤백_테스트() {
// given
User user = new User(null, "테스트유저");
userRepository.save(user);
// when: 강제로 예외 발생
assertThrows(Exception.class, () -> {
userRepository.save(new User(null, null)); // validation 실패
});
// then: 트랜잭션 롤백으로 데이터 저장 안됨
assertEquals(3, userRepository.count()); // 초기 데이터만 존재
}
}
이준호 씨는 지금까지 간단한 CRUD 테스트만 작성했습니다. 하지만 실무에서는 훨씬 복잡한 상황을 마주합니다.
여러 테이블을 조인하는 쿼리, 트랜잭션 처리, 동시성 제어, 성능 최적화... "DB 테스트를 제대로 하지 않으면 나중에 큰 사고가 날 수 있어요." 최민수 씨가 진지하게 말했습니다.
"데이터는 회사의 가장 중요한 자산이니까요." @DataJpaTest는 Spring Boot가 제공하는 슬라이스 테스트 어노테이션입니다. JPA와 관련된 빈들만 로드합니다.
컨트롤러나 서비스는 로드하지 않으니 가볍고 빠르죠. 기본적으로 인메모리 DB(H2)를 사용하지만, Testcontainers와 함께 사용하면 실제 DB에서 테스트할 수 있습니다.
@Sql 어노테이션은 매우 유용합니다. 테스트 실행 전에 SQL 스크립트를 자동으로 실행해줍니다.
test-data.sql 파일에 INSERT 문을 작성해두면, 매번 테스트 데이터를 코드로 작성할 필요가 없습니다. 예를 들어 test-data.sql에는 이런 내용이 들어갑니다.
INSERT INTO users (name, active) VALUES ('김철수', true); INSERT INTO users (name, active) VALUES ('이영희', true); INSERT INTO users (name, active) VALUES ('박민수', false); 이렇게 하면 테스트마다 동일한 초기 상태에서 시작할 수 있습니다. 테스트의 재현성이 보장되는 거죠.
첫 번째 테스트는 복잡한 쿼리를 검증합니다. findActiveUsersWithOrders는 활성 사용자 중 주문이 있는 사용자만 가져오는 쿼리입니다.
여러 테이블을 조인하고, 조건절이 복잡하죠. 이런 쿼리는 실수하기 쉽습니다.
JOIN을 잘못 작성하거나, WHERE 조건을 빠뜨리거나, N+1 문제를 일으키거나... 테스트 없이는 이런 버그를 찾기 어렵습니다.
두 번째 테스트는 트랜잭션 롤백을 검증합니다. Spring의 @Transactional은 메서드가 끝나면 자동으로 커밋하거나 롤백합니다.
하지만 정말 제대로 작동할까요? 테스트에서는 일부러 예외를 발생시킵니다.
null 값으로 User를 생성하면 validation 예외가 발생하죠. 이때 이전에 저장한 user도 함께 롤백되어야 합니다.
마지막 assertEquals에서 이를 검증합니다. 새로 저장한 user가 없으므로 count는 여전히 3(초기 데이터)이어야 합니다.
"트랜잭션 테스트는 정말 중요해요." 최민수 씨가 강조했습니다. "결제 시스템 같은 데서 트랜잭션이 제대로 작동하지 않으면 돈이 사라질 수 있어요." 이준호 씨가 궁금해했습니다.
"동시성 테스트는 어떻게 하나요? 여러 사용자가 동시에 같은 데이터를 수정하면요?" 최민수 씨가 예제를 보여줬습니다.
"ExecutorService를 사용해서 여러 스레드를 동시에 실행할 수 있어요. @Version을 사용한 낙관적 락이 제대로 작동하는지 테스트하죠." 실무에서는 성능 테스트도 중요합니다.
대량의 데이터를 넣고 쿼리 시간을 측정합니다. 인덱스가 제대로 작동하는지, N+1 문제는 없는지 확인하죠.
또한 마이그레이션 테스트도 필수입니다. Flyway나 Liquibase 같은 도구로 스키마를 변경할 때, 실제로 적용이 잘 되는지, 데이터가 손실되지 않는지 검증해야 합니다.
이준호 씨는 모든 Repository에 체계적인 테스트를 추가했습니다. 단순 CRUD는 @DataJpaTest로, 복잡한 쿼리는 Testcontainers로, 트랜잭션과 동시성은 통합 테스트로 검증했습니다.
"이제 DB 스키마를 변경하거나 쿼리를 수정할 때 자신감이 생겼어요." 이준호 씨가 말했습니다. 최민수 씨가 웃으며 답했습니다.
"그게 바로 테스트의 힘이죠." 며칠 후, 이준호 씨의 PR에는 수백 개의 테스트가 함께 올라갔습니다. 리뷰어들은 감탄했습니다.
"신입이 이렇게까지..." 하지만 이준호 씨는 알고 있었습니다. 테스트는 단순히 버그를 찾는 도구가 아니라, 코드에 대한 자신감이자 미래의 자신에게 주는 선물이라는 것을.
실전 팁
💡 - @DataJpaTest는 기본적으로 각 테스트 후 롤백하므로 데이터가 쌓이지 않습니다.
- @Sql(scripts = "...", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)로 테스트 후 정리 스크립트를 실행할 수 있습니다.
- 복잡한 쿼리는 실제 DB에서 테스트하는 게 안전합니다. H2와 PostgreSQL은 SQL 문법이 다를 수 있습니다.
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
관찰 가능한 마이크로서비스 완벽 가이드
마이크로서비스 환경에서 시스템의 상태를 실시간으로 관찰하고 모니터링하는 방법을 배웁니다. Resilience4j, Zipkin, Prometheus, Grafana, EFK 스택을 활용하여 안정적이고 관찰 가능한 시스템을 구축하는 실전 가이드입니다.
Prometheus 메트릭 수집 완벽 가이드
Spring Boot 애플리케이션의 메트릭을 Prometheus로 수집하고 모니터링하는 방법을 배웁니다. Actuator 설정부터 PromQL 쿼리까지 실무에 필요한 모든 내용을 다룹니다.
스프링 관찰 가능성 완벽 가이드
Spring Boot 3.x의 Observation API를 활용한 애플리케이션 모니터링과 추적 방법을 초급 개발자 눈높이에서 쉽게 설명합니다. 실무에서 바로 적용할 수 있는 메트릭 수집과 분산 추적 기법을 다룹니다.
Zipkin으로 추적 시각화 완벽 가이드
마이크로서비스 환경에서 분산 추적을 시각화하는 Zipkin의 핵심 개념과 활용 방법을 초급자도 쉽게 이해할 수 있도록 실무 스토리로 풀어낸 가이드입니다. Docker 실행부터 UI 분석까지 단계별로 배웁니다.
Micrometer Tracing 완벽 가이드
분산 시스템에서 요청 흐름을 추적하는 Micrometer Tracing의 핵심 개념과 실전 활용법을 초급 개발자도 쉽게 이해할 수 있도록 실무 스토리와 비유로 풀어낸 완벽 가이드입니다.