통합테스트와 단위테스트를 작성하고 차이점을 비교해 보자
📌 서론
통합테스트 코드를 작성했는데 이런 생각이 들었다. "이걸 단위테스트 코드로 바꿔서 작성하면 기존 코드와 어떤 차이가 있을까? 그래서 당장 각 테스트 방법으로 코드를 작성하고 테스트를 해봤다. 지금부터 그 내용을 공유한다.
테스트를 하고자 하는 컨트롤러는 다음과 같다. 간단히 레시피를 생성하는 컨트롤러 메서드다.
@RequestMapping("/recipe")
@RequiredArgsConstructor
@RestController
public class RecipeController {
private final CreateRecipeUseCase createRecipeUseCase;
@PostMapping("/createRecipe")
public ResponseEntity<ResponseDto<Long>> createRecipe(@Valid @RequestBody RecipeRequestDto recipeRequestDto) {
Recipe recipe = RecipeConverter.dtoToDomain(recipeRequestDto);
Long savedRecipeId = createRecipeUseCase.createRecipe(recipe);
return ResponseEntity.ok(ResponseDto.success(savedRecipeId));
}
}
1. 간단히 알아보는 통합테스트와 단위테스트의 차이
테스트 환경 설정의 차이
- 통합 테스트는 애플리케이션의 전체 컨텍스트를 로드하여 실제 운영 환경과 유사한 조건에서 테스트를 수행한다. 이 방식은 애플리케이션의 모든 부분이 서로 어떻게 상호작용하는지를 파악하는 것이 목적이다. 반면에, 단위 테스트는 격리된 환경에서 진행되며, 필요한 부분만 모의 객체를 이용해 테스트를 진행하여 각 기능이나 메서드가 독립적으로 올바르게 동작하는지 빠르고 효율적으로 검증하는 것이 목적이다.
의존성 관리 방식
- 통합 테스트에서는 @MockBean을 사용하여 실제 컴포넌트와의 상호작용을 모의한다. 이를 통해 실제 환경에서 발생할 수 있는 다양한 상황들을 시뮬레이션할 수 있다. 반면, 단위 테스트에서는 @Mock과 @InjectMocks 어노테이션을 활용해 필요한 의존성만을 주입하고, 외부 요소와의 상호작용을 최소화한다. 이것은 테스트의 범위를 좁혀서 특정 기능 또는 메서드에만 집중할 수 있게 해 준다.
테스트 실행 방식
- 통합 테스트에서는 MockMvc를 이용해 HTTP 요청과 응답을 모의하며, 이를 통해 컨트롤러의 전체적인 행동을 검증한다. 이 접근 방식은 웹 애플리케이션의 통신 흐름을 실제와 유사하게 테스트할 수 있게 해 준다. 반면에, 단위 테스트에서는 RecipeController의 메서드를 직접 호출하여 각 메서드가 기대한 대로 동작하는지 집중적으로 검증한다.
목적과 효율성
- 통합 테스트는 애플리케이션의 전반적인 동작과 컴포넌트 간의 상호작용을 포괄적으로 검증하는 것에 목적을 둔다. 이는 애플리케이션의 안정성을 확보하는 데 중요한 역할을 한다. 단위 테스트는 각 기능이나 메서드의 정확성과 신뢰성을 빠르고 효율적으로 검증하는 것에 초점을 맞춘다. 이는 개발 과정 중에 발생할 수 있는 문제를 조기에 발견하고 수정하는 데 도움을 준다.
2. 통합테스트 코드 작성하기
코드 이해하기
- 먼저 @DisplayName 어노테이션을 사용해서 테스트 클래스와 메소드에 이름을 지정해 줬다. 이 이름은 테스트 실행 시 콘솔이나 리포트에 표시되어, 어떤 테스트가 실행되는지 쉽게 알 수 있게 해 준다.
@DisplayName("레시피 컨트롤러")
@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureMockMvc // @AutoConfigureMockMvc를 사용하여 MockMvc를 이용해 HTTP 요청을 모의로 보낸다.
class RecipeControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private CreateRecipeUseCase createRecipeUseCase;
@DisplayName("유저가 레시피 생성을 요청하면 정상적으로 저장되고 성공 응답을 반환한다")
@Test
void ifUserCreateRecipeShouldComplete() throws Exception {
// Given
RecipeRequestDto requestDto = createRecipeRequestDto();
Recipe recipe = RecipeConverter.dtoToDomain(requestDto);
recipe.change(1L, "진안");
// When
given(createRecipeUseCase.createRecipe(recipe)).willReturn(1L);
// Then
mockMvc.perform(post("/recipe/createRecipe")
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk()) // 응답 상태 코드가 200 OK인지 확인한다.
.andExpect(jsonPath("$.resultCode").value("SUCCESS"));
}
private RecipeRequestDto createRecipeRequestDto() {
return RecipeRequestDto.of("레시피", "레시피 설명", 20, "감자", "고구마", "{당: 많음}", "N");
}
}
코드분석 1 - 클래스 레벨 어노테이션
- @ActiveProfiles("test") 어노테이션은 'test' 프로파일을 사용하도록 지정하는데, 이는 테스트 환경에 특화된 설정을 적용할 때 유용하다. @SpringBootTest 어노테이션은 스프링 부트 애플리케이션의 전체 컨텍스트를 로드해서, 실제 애플리케이션과 유사한 환경에서 테스트를 수행할 수 있게 해 준다.
- @AutoConfigureMockMvc 어노테이션은 테스트에서 MockMvc 인스턴스를 사용할 수 있도록 설정한다. 여기서 말하는 MockMvc는 웹 애플리케이션의 HTTP 요청과 응답을 테스트할 때 사용되는 도구다. MockMvc를 통해 컨트롤러가 HTTP 요청을 어떻게 처리하는지 모의 테스트를 해볼 수 있다.
@DisplayName("레시피 컨트롤러")
@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureMockMvc // @AutoConfigureMockMvc를 사용하여 MockMvc를 이용해 HTTP 요청을 모의로 보낸다.
class RecipeControllerTest {
}
코드분석 2 - 전역변수
- 아래 코드를 보면 @Autowired로 MockMvc 객체를 주입받아서, 실제 HTTP 요청을 모의로 보내는 테스트를 구현하고 있다. @MockBean을 사용하여 CreateRecipeUseCase의 Mock 객체를 생성하고 있어서, 실제 의존성(스프링 빈) 대신 가짜 객체를 사용해 테스트를 단순화시키고 있다.
class RecipeControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private CreateRecipeUseCase createRecipeUseCase;
}
코드분석 3 - 메서드
- 테스트 메서드로 작성한 ifUserCreateRecipeShouldComplete는 레시피 생성 요청이 성공적으로 처리되는지 확인하기 위한 것이다. RecipeRequestDto 객체를 생성하고 이를 Recipe 도메인 객체로 변환한 다음, Mock 객체인 createRecipeUseCase가 특정 값을 반환하도록 설정했다.
- 그리고 MockMvc를 사용하여 실제 HTTP POST 요청을 모의로 보내고, 응답 상태 코드와 결과가 기대한 대로 나오는지 검증한다.
@DisplayName("유저가 레시피 생성을 요청하면 정상적으로 저장되고 성공 응답을 반환한다")
@Test
void ifUserCreateRecipeShouldComplete() throws Exception {
// Given
RecipeRequestDto requestDto = createRecipeRequestDto();
Recipe recipe = RecipeConverter.dtoToDomain(requestDto);
recipe.change(1L, "진안");
// When
given(createRecipeUseCase.createRecipe(recipe)).willReturn(1L);
// Then
mockMvc.perform(post("/recipe/createRecipe")
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk()) // 응답 상태 코드가 200 OK인지 확인한다.
.andExpect(jsonPath("$.resultCode").value("SUCCESS"));
}
private RecipeRequestDto createRecipeRequestDto() {
return RecipeRequestDto.of("레시피", "레시피 설명", 20, "감자", "고구마", "{당: 많음}", "N");
}
3. 통합테스트 코드를 단위테스트로 바꾸기
변경한 코드 이해하기
- 이 코드는 레시피 컨트롤러의 통합 테스트를 단위 테스트를 구현으로 변경한 것이다. 여기서는 Mockito 프레임워크를 활용해 레시피 생성 기능을 테스트한다.
class RecipeControllerTest {
@Mock
private CreateRecipeUseCase createRecipeUseCase;
@InjectMocks
private RecipeController recipeController;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@DisplayName("유저가 레시피 생성 요청 시 정상적으로 저장되고 성공 응답을 반환한다")
@Test
void ifUserCreateRecipeShouldComplete() {
// Given
RecipeRequestDto requestDto = createRecipeRequestDto();
when(createRecipeUseCase.createRecipe(any(Recipe.class))).thenReturn(1L);
// When
ResponseEntity<?> response = recipeController.createRecipe(requestDto);
// Then
assertEquals(response.getStatusCodeValue(), 200);
verify(createRecipeUseCase, times(1)).createRecipe(any(Recipe.class));
}
private RecipeRequestDto createRecipeRequestDto() {
return RecipeRequestDto.of("레시피", "레시피 설명", 20, "감자", "고구마", "{당: 많음}", "N");
}
}
코드분석 1 - 전역변수
- 단위 테스트에서 우리가 사용하는 @Mock과 @InjectMocks 어노테이션은 테스트하려는 컴포넌트를 완전히 독립적으로 만들어 준다. 예를 들어, @Mock 어노테이션은 CreateRecipeUseCase 인터페이스에 대한 가짜 구현체, 즉 모의 객체를 생성한다.
- 이 모의 객체는 실제 로직을 가지고 있지 않기 때문에, 우리가 지정한 동작만 수행하도록 할 수 있다. 그다음에 @InjectMocks를 사용해서 이 모의 객체를 RecipeController 클래스에 주입한다.
- 이렇게 함으로써, RecipeController는 실제 CreateRecipeUseCase 서비스 대신에 우리가 만든 모의 객체와 상호작용한다. 이 과정을 통해, 컨트롤러의 로직을 외부 요소로부터 독립적으로 테스트한다.
- @BeforeEach 어노테이션이 적힌 setUp 메서드가 테스트가 실행되기 전에 호출되어 Mock 객체를 초기화한다. 이렇게 해주면 각 테스트는 독립적인 환경에서 실행되도록 보장된다.
class RecipeControllerTest {
@Mock
private CreateRecipeUseCase createRecipeUseCase;
@InjectMocks
private RecipeController recipeController;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
}
코드분석 2 - 메서드
- 테스트 메서드로 작성한 ifUserCreateRecipeShouldComplete는 레시피 생성 요청이 정상적으로 처리되는지 검증한다. 예를 들어, '유저가 레시피 생성 요청을 할 때 정상적으로 처리되는지'를 확인하기 위해, 먼저 RecipeRequestDto 객체를 생성하고, when().thenReturn()을 사용해 createRecipeUseCase.createRecipe() 메서드가 호출될 때 원하는 값을 반환하도록 설정한다.
- 이렇게 given이 준비된 상태에서 recipeController.createRecipe(requestDto) 메서드를 호출하면, 실제 메서드의 실행 결과를 확인할 수 있다. 그리고 assertEquals()를 사용해서 응답의 상태 코드가 예상과 일치하는지 검증하고, verify() 메서드로 createRecipeUseCase.createRecipe() 메서드가 예상대로 한 번만 호출되었는지 확인한다.
@DisplayName("유저가 레시피 생성 요청 시 정상적으로 저장되고 성공 응답을 반환한다")
@Test
void ifUserCreateRecipeShouldComplete() {
// Given
RecipeRequestDto requestDto = createRecipeRequestDto();
when(createRecipeUseCase.createRecipe(any(Recipe.class))).thenReturn(1L);
// When
ResponseEntity<?> response = recipeController.createRecipe(requestDto);
// Then
assertEquals(response.getStatusCodeValue(), 200);
verify(createRecipeUseCase, times(1)).createRecipe(any(Recipe.class));
}
private RecipeRequestDto createRecipeRequestDto() {
return RecipeRequestDto.of("레시피", "레시피 설명", 20, "감자", "고구마", "{당: 많음}", "N");
}
📌 잠깐! 단위테스트 코드는 클래스 상단에 아무것도 적어준게 없는데 원래 이런가요?
그렇다. 단위 테스트에서는 클래스의 상단에 복잡한 설정이나 어노테이션이 필요하지 않은 경우가 많다. 그 이유는 단위 테스트의 목적이 특정 함수나 메서드를 외부 환경으로부터 독립적으로 검증하는 데 있기 때문이다.
단위 테스트에서는 주로 테스트하고자 하는 기능이나 메서드만을 초점을 맞추고, 이를 위해 필요한 의존성은 Mockito와 같은 도구를 사용해 모의(Mock) 객체로 대체한다. 이렇게 하면 실제 데이터베이스나 외부 서비스에 의존하지 않고도 해당 기능을 효율적으로 테스트할 수 있다.
예를 들어, 네트워크 호출이 필요한 서비스 메서드를 테스트할 때, 실제 네트워크 호출 대신 모의 객체를 사용해 네트워크 호출의 결과를 모의하는 식이다.
4. 테스트 실행시간 비교 분석
테스트 실행시간 비교하기
- 통합 테스트는 스프링 부트가 실행되기 때문에 좌측에 적히는 테스트 자체의 시간만을 측정해서는 의미가 없다. 그래서 로그에 남아있는 톰캣 실행부터 테스트 종료까지의 시간을 측정했다.
📏 톰켓 로그
RecipeControllerTest : Started RecipeControllerTest in 3.918 seconds (process running for 4.397):
- 이 로그는 테스트 실행 시간과 관련된 주요 정보를 담고 있다. RecipeControllerTest 클래스의 테스트 실행이 완료되는 데 3.918초가 걸렸으며, 전체 프로세스는 4.397초 동안 실행되었다는 것을 나타낸다. 여기서 "전체 프로세스"란 테스트 환경 설정, 스프링 컨텍스트 로딩, 그리고 실제 테스트 실행까지의 전체 시간을 의미한다. 이는 테스트 클래스 실행에 필요한 전체 시간을 포함하는 것이다.
- 요약하자면, 이 로그는 테스트 클래스 실행에 걸린 전체 시간을 포함하며, 이 시간에는 스프링 부트 애플리케이션 컨텍스트 및 톰캣 서버의 로딩 시간도 포함되어 있다. 따라서 이는 톰캣이 시작되고 테스트가 실행되기까지의 전체 시간을 대략적으로 나타내는 것으로 볼 수 있다.
통합 테스트 vs 단위 테스트 실행시간 비교 결과
회차 | 통합테스트 (seconds) | 단위테스트 (ms) |
1 | 4.397 | 250 |
2 | 4.45 | 248 |
3 | 4.416 | 256 |
4 | 4.42 | 251 |
5 | 4.42 | 255 |
6 | 4.404 | 252 |
7 | 4.422 | 250 |
8 | 4.457 | 260 |
9 | 4.403 | 244 |
10 | 4.376 | 249 |
통합 테스트의 일관성
- 통합 테스트의 실행 시간을 살펴보면 대략 4.4초 정도로 일관되게 나타난다. 이는 통합 테스트가 애플리케이션의 전체 또는 상당 부분을 로드하기 때문이다. 이 과정에는 스프링 컨텍스트 초기화, 데이터베이스 연결, 외부 서비스와의 통합 등이 포함되기 때문에 시간이 오래 걸린다.
단위 테스트의 빠른 실행
- 단위 테스트는 평균적으로 250ms 내외로 실행되어 통합 테스트에 비해 훨씬 빠르게 완료된다. 이는 단위 테스트가 애플리케이션의 매우 작은 부분만을 대상으로 하고, 종속적인 외부 시스템이나 스프링 컨텍스트 로딩 없이 실행되기 때문이다.
테스트 방식의 특성
- 이 데이터를 통해 알 수 있는 것은 통합 테스트가 보다 포괄적이고 실제 애플리케이션 환경에 가깝게 테스트하는 반면, 단위 테스트는 특정 기능이나 메소드의 정확성을 빠르고 효율적으로 검증한다는 점이다. 통합 테스트는 애플리케이션의 전체적인 동작을 검증하는 데 유용하지만, 시간이 더 많이 소요되고, 단위 테스트는 빠른 피드백을 제공하며 특정 기능의 오류를 신속하게 찾아낼 수 있다.
5. 마무리 (최종 정리)
1. 통합테스트
- 통합 테스트는 전체 스프링 부트 애플리케이션 컨텍스트를 로드하여 실제 환경과 유사한 조건에서 컨트롤러의 동작을 테스트한다. 목적은 모듈 간의 인터페이스, 데이터 흐름, 외부 시스템과의 연동 등을 검증하는 것이다. MockMvc를 사용하여 HTTP 요청을 모의로 보내고, 응답을 검증한다.
적용 상황
- 기능 통합: 여러 모듈이나 서비스가 통합될 때, 통합 테스트는 이들 간의 상호작용이 제대로 이루어지는지 확인한다.
- 시스템 확장: 애플리케이션이나 시스템에 새로운 기능이나 서비스를 추가할 때, 통합 테스트는 새로운 요소가 전체 시스템과 잘 호환되는지 검증해 준다.
- 배포 전 검증: 실제 사용 환경으로의 배포 전, 통합 테스트는 애플리케이션이 실제 운영 환경에서 예상대로 작동할 것인지 확인하는 데 중요하다.
주요 구성요소
- @SpringBootTest: 전체 애플리케이션 컨텍스트를 로드.
- @AutoConfigureMockMvc: MockMvc 인스턴스를 자동 구성.
- @MockBean: 스프링 컨텍스트에 포함된 빈을 모의 객체로 대체.
- 실제 HTTP 요청을 모의로 보내고 결과를 검증하는 방식으로 테스트 진행.
2. 단위테스트
- 단위 테스트는 컨트롤러의 메서드를 격리된 상태에서 테스트한다. Mockito를 사용하여 필요한 의존성을 모의 객체로 대체한다.
적용 상황
- 개발 초기 단계: 새로운 기능을 개발할 때, 단위 테스트는 코드가 올바르게 작동하는지 신속하게 확인해 주고, 버그를 조기에 발견하는 데 도움을 준다.
- 지속적 통합(CI): 개발 중에 코드 변경이 자주 발생할 때, 단위 테스트는 빠른 피드백을 제공해 변경 사항이 기존 기능에 부정적인 영향을 미치지 않도록 보장해 준다.
- 리팩토링: 코드 구조를 개선하거나 최적화할 때, 단위 테스트는 코드 변경으로 인한 예상치 못한 문제를 감지하는 데 유용하다.
주요 구성요소
- @Mock: 필요한 의존성을 모의 객체로 생성.
- @InjectMocks: 모의 객체를 컨트롤러에 주입.
- 각 테스트 전에 MockitoAnnotations.openMocks(this)를 호출하여 목 객체 초기화.
📌 단위 테스트 vs 통합 테스트
통합 테스트와 단위 테스트는 소프트웨어 개발의 두 중요한 측면을 다루며, 각각의 방식은 테스트의 목적과 범위에 따라 구별된다. 통합 테스트는 전체 시스템이나 주요 컴포넌트들이 함께 어떻게 작동하는지를 검증하는 데 초점을 맞추며, 이는 전체 시스템의 행동을 테스트하며, 실제 환경과 가까운 조건에서 여러 컴포넌트의 상호작용을 검증한다. 그래서 설정이 복잡하고, 실행 시간이 길다.
반면, 단위 테스트는 개별 메서드나 작은 기능 단위를 대상으로 하며, 간단한 설정과 빠른 실행을 통해 코드의 특정 부분이 정확하게 동작하는지 빠르게 확인할 수 있다.
@MockBean을 사용하여 스프링 이벤트 리스너를 테스트하는 방법을 알아보자👇🏻👇🏻
'Spring 테스트 코드' 카테고리의 다른 글
[Spring] 테스트 코드: @SpyBean으로 @EventListener 검증하기 (40) | 2023.12.23 |
---|---|
[Spring] 테스트 코드: @MockBean으로 @EventListener 검증하기 (4) | 2023.12.23 |
주니어 개발자의 테스트 코드 이해하기 (2) | 2023.12.22 |
[스프링, 스프링 부트] Spring test - when()에서 발생한 에러 (0) | 2023.08.22 |
[스프링, 스프링부트] Spring test - 스프링 시큐리티의 authentication객체를 어떻게 사용해야 할까? (0) | 2023.08.22 |