배경
현재 3 Tier Architecture인 Presentation layer ↔ Business layer ↔ Persistence layer 구조를 이루고 있습니다.
CouselController, CounseService, CounselRepository 총 3개에 클래스에 대해 테스트를 작성합니다.
아래 예시 코드를 보면 CounselService의 createCounsel 메서드를 보면 내부적으로 counselRepository의 save 메서드를 호출합니다. 이와 같이 PostService에서 PostRepository 의존성 주입 이후 사용되기에 테스트를 위해서는 범위를 CounselService와 CounselRepository까지 고려해야 합니다.
@Service
@RequiredArgsConstructor
public class CouselService{
private CouselRepository couselRepository;
public createCounsel(){
return CouselRepository.save(new Cousel("제목", "내용");
}
}
실제 동작에서는 스프링 컨텍스트 환경에서 수행되기에 테스트에서도 특정 빈들이 담긴 스프링 컨텍스트를 사용하여 유사한 환경에서 테스트도 중요합니다. 따라서 스프링 컨텍스트를 사용한 통합 테스트를 구성했습니다.
계층에서는 다음과 같은 의미를 가집니다.
- Presentation Layer ↔ Controller
- Business Layer ↔ Service
- Persistence Layer ↔ Repository
각 계층에서는 검증하고자 하는 부분만 실제 값으로 테스트했고 빨간 네모 표시가 해당 부분입니다. 테스트 검증에 대한 범위는 어디까지 mocking할지 각자 지정하면 됩니다.
아래는 작성한 Counsel 도메인에 대한 계층별 테스트 코드입니다.
CounselControllerTest
CounselService는 @MockBean처리하여 Presentation layer만 실제 검증했으며 @WebMvcTest를 통해 웹 상에서 요청과 응답에 대해 테스트를 진행했습니다.
@WebMvcTest(controllers = CounselController.class)
class CounselControllerTest {
@Autowired
MockMvc mockMvc;
**@MockBean**
**CounselService counselService;**
@Autowired
ObjectMapper objectMapper;
@Test
@DisplayName("상담에 대한 결과가 나온다.")
@WithMockUser(username = "123456_APPLE")
void createCounsel() throws Exception {
// given
String socialId = "123456";
SocialType socialType = SocialType.APPLE;
String uniqueValue = socialId + "_" + socialType.getText();
CounselCreateRequest request = CounselCreateRequest.builder()
.analysisId(null)
.question("요즘 가족이 맨날 싸워. 청소 관련 성향의 차이로 다투기 시작했어. 사소한 이유가 너무 커져서 폭언을 하기도 해. 어떻게 해결해야 할까?")
.counselType("가족")
.build();
CounselResultResponse response = new CounselResultResponse(
1L, "counselContent 입니다.", "imageUrl 입니다."
);
// Mockito 설정
/*when(counselService.consult(eq(uniqueValue), eq(request))) // 직접 설정하면 실패함. 정확하게 일치하지 않아서 실패한다고 하는데 이해 부족
.thenReturn(response);*/
when(counselService.consult(anyString(), any(CounselCreateRequest.class)))
.thenReturn(response);
// when & then
mockMvc.perform(
MockMvcRequestBuilders.post("/api/v1/counsel")
.with(csrf())
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/counsel/1"))
.andExpect(jsonPath("$.data.counselId").value(1L))
.andExpect(jsonPath("$.data.counselContent").value("counselContent 입니다."))
.andExpect(jsonPath("$.data.imageUrl").value("imageUrl 입니다." +
""));
}
.....
}
CounselServiceImplTest
외부 API를 호출하는 기능을 가진 WebClientUtil만 @MockBean으로 mocking하고 나머지는 실제 빈을 등록하여 테스트를 진행했습니다. ‘business layer ↔ persistence layer’를 검증했는데 persitence layer 부분을 mocking 해도 됩니다. 각 계층 부분만 실제로 테스트하고 상호 작용하는 다른 계층은 mocking을 하냐 마냐는 상황과 필요성에 따라 판단하시면 됩니다.
@SpringBootTest
@Transactional
@ActiveProfiles("test")
class CounselServiceImplTest {
@Autowired CounselServiceImpl counselService;
@Autowired AnalysisRepository analysisRepository;
@Autowired CounselRepository counselRepository;
@Autowired UserRepository userRepository;
**@MockBean WebClientUtil webClientUtil;**
@Test
@DisplayName("회원의 질문에 대한 상담 답변을 제공한다.")
void consult(){
//given
String socialId = "123456";
User user = User.builder()
.socialId(socialId)
.socialType(SocialType.APPLE)
.build();
User savedUser = userRepository.save(user);
CounselCreateRequest request = new CounselCreateRequest(
"취업진로",
"나는 취업에 대한 고민이 있어. 내가 개발자가 될 수 있을까? 어떤 노력이 필요해?"
);
CounselAiResponse mockResponse = new CounselAiResponse(
"개발자가 되기 위해서는 꾸준한 학습과 프로젝트 경험이 필요합니다.",
"<http://example.com/image.jpg>"
);
String uniqueValue = savedUser.getSocialId() + "_" + savedUser.getSocialType().getText();
when(webClientUtil.callConsult(any(HashMap.class)))
.thenReturn(mockResponse);
//when
CounselResultResponse result = counselService.consult(uniqueValue, request);
//then
assertThat(result).isNotNull()
.extracting("counselContent", "imageUrl")
.containsExactly(mockResponse.getAnswer(), mockResponse.getImageUrl());
assertThat(result.getCounselId()).isNotNull();
}
...
}
CounselRepositoryTest
@ActiveProfiles("test")
@SpringBootTest
@Transactional
//@DataJpaTest
class CounselRepositoryTest {
@Autowired
CounselRepository counselRepository;
@Autowired
AnalysisRepository analysisRepository;
@Autowired
UserRepository userRepository;
@Test
@DisplayName("상담 고유 번호로 분석 정보가 담긴 상담 정보를 조회한다.")
void findWithAnalysisById(){
//given
Analysis analysis = Analysis.builder()
.build();
Counsel counsel = Counsel.builder()
.question("질문1")
.counselType(CounselType.FAMILY)
.build();
counsel.addAnalysis(analysis);
Analysis savedAnalysis = analysisRepository.save(analysis);
Counsel savedCounsel = counselRepository.save(counsel);
//when
Optional<Counsel> findCounsel = counselRepository.findWithAnalysisById(savedCounsel.getId());
//then
Assertions.assertThat(findCounsel.get()).isNotNull()
.extracting("question", "counselType", "id")
.containsExactlyInAnyOrder(
"질문1", CounselType.FAMILY, savedCounsel.getId()
);
Assertions.assertThat(findCounsel.get().getAnalysis().getId()).isEqualTo(savedAnalysis.getId());
}
....
}
이제 작성된 전체 테스트 결과를 확인하겠습니다.
정상적으로 테스트가 성공했습니다. 여기서 확인할 부분은 다음과 같습니다.
스프링 컨텍스트 로드와 함께 스프링 빈 등록, 의존성 주입을 위해 @WebMvcTest 1번, @SpringBootTest 2번을 사용했습니다. 테스트 결과에서 ‘Spring Boot’ 검색하면 총 3개가 나옵니다. 로그를 확인해보면 3개의 스프링 컨텍스트 로드가 된 것을 확인할 수 있습니다.
Test Task 가 수행되면 존재하는 모든 테스트를 실행합니다. 따라서 스프링 컨텍스트를 로드하는 어노테이션(@WebMvcTest, @SpringBootTest 등)으로 인해 테스트 과정에서 컨텍스트가 로드가 3회 발생했습니다.
그렇다면 @WebMvcTest, @SpringBootTest와 같은 어노테이션을 사용하는 테스트 클래스 수가 많을수록 스프링 컨텍스트 로드 횟수도 증가할 것입니다.
문제 상황
스프링 컨텍스트 로드가 반복된다면 무엇이 문제일까요?
인텔리제이와 같은 개발 도구에서 스프링 부트를 실행할때, 정상적으로 실행이 되면 아래와 같은 로그가 나옵니다. 예시에는 스프링 컨텍스트 초기화, Bean 등록 등 모든 설정이 완료하는데 걸린 실행 완료 시간은 4.048초 입니다.
위 테스트에서는 스프링 컨텍스트 로드가 3번 수행됐습니다. 당연히 로드 횟수가 증가하면 그만큼 스프링 애플리케이션 실행 준비가 반복되면서 테스트 수행 시간도 길어질 것입니다. 서비스가 커질수록 테스트 코드 양도 비례할텐데 어쩔 수 없는 부분일까요?
이는 스프링 테스트 환경을 통합해서 스프링 컨텍스트 로드 횟수를 줄일 수 있습니다.
테스트 환경 통합해서 스프링 컨텍스트 로드 빈도 줄이기
각 테스트 클래스마다 @ActiveProfiles("test"), @SpringBootTest, @Transactional 과 같이 각 클래스를 기준으로 실행되거나 각기 다른 설정 환경(@SpringBootTest or @WebMvcTest 등)으로 여러 번 스프링 컨텍스트가 로드되었습니다.
로드 빈도수를 줄이기 위해서는 각 클래스에 대한 테스트를 하나의 추상 클래스를 상속하여 설정 환경에 대해 공통 처리를 하여 통합하면 됩니다.
@SpringBootTest
@Transactional
@ActiveProfiles("test")
class CounselServiceImplTest {
@Autowired CounselServiceImpl counselService;
@Autowired AnalysisRepository analysisRepository;
@Autowired CounselRepository counselRepository;
@Autowired UserRepository userRepository;
@MockBean WebClientUtil webClientUtil;
...
}
@ActiveProfiles("test")
@SpringBootTest
@Transactional
class CounselRepositoryTest {
@Autowired CounselRepository counselRepository;
@Autowired AnalysisRepository analysisRepository;
@Autowired UserRepository userRepository;
...
}
테스트 클래스 예시를 보면 CounselServiceImpTest와 CounselRepositoryTest 클래스 설정 환경이 유사한 것을 알 수 있습니다. 다른 점은 WebClientUtil가 가짜 빈으로 되는 지에 대한 처리입니다.
같은 테스트 상위 추상 클래스를 상속하더라도 하위 클래스에서 @MockBean으로 가짜 빈으로 바꿔 등록한다면 등록된 빈이 다르기에 실제로 따로 컨텍스트가 로드됩니다. 그렇기에 테스트 환경을 통합하기 위해 아래와 같이 수행했습니다.
저의 상황에서 WebClient는 외부 API를 호출하는 기능을 수행하므로 테스트 검증 필요성이 낮고 어려운 영역이기에 가짜 빈을 주입해도 상관없다고 판단했습니다. CounselRepositoryTest 또한 WebClient를 사용하지 않기에 상위 추상 클래스에서 WebClient를 @MockBean 처리하여 같은 설정 환경으로 지정했습니다.
CounselServiceImpTest와 CounselRepositoryTest 에서 해당 추상 클래스를 상속한다면 같은 설정 환경이므로 같은 스프링 컨텍스트를 통해서 동작할 것입니다.
만약 컨트롤러 부분도 통합해서 사용하고 싶다면 아래 그림과 같이 추상 클래스를 만들어 적용하면 됩니다.
검증할 컨트롤러를 추가하고 싶다면 @WebMvcTest에 클래스를 추가하고 클래스 필드에 필요한 의존관계 주입을 하면 됩니다. 상위 클래스에 대한 지정은 꼭 위와 같이 진행하지 않아도 됩니다. 본인이 수행할 테스트 환경에 맞게 진행하면 됩니다. 잊지 말아야 할 점은 상위 클래스를 상속하여 같은 테스트 환경에서 수행한다는 점입니다.
테스트 환경 통합 이후 상황
CounselControllerTest
class CounselControllerTest extends ControllerTestSupport {
.....
}
CounselServiceImplTest
class CounselServiceImplTest extends IntegrationSupportTest {
@Autowired CounselServiceImpl counselService;
@Autowired AnalysisRepository analysisRepository;
@Autowired CounselRepository counselRepository;
@Autowired UserRepository userRepository;
...
}
CounselRepositoryTest
class CounselRepositoryTest extends IntegrationSupportTest{
@Autowired CounselRepository counselRepository;
@Autowired AnalysisRepository analysisRepository;
@Autowired UserRepository userRepository;
....
}
위와 같이 클래스에 대한 어노테이션, 필드에 대한 어노테이션이 변경됐습니다. 현재 ControllerTestSupport를 상속한 테스트 클래스 1개, IntegrationSupportTest를 상속한 테스트 클래스 2개가 있습니다. 컨텍스트 로드 횟수는 어떻게 될까요?
실행 결과를 확인해보겠습니다.
[개선 전]
[개선 후]
현재 CounselControllerTest / (CounselServiceTest, CounselRepositoryTest - IntegrationSupportTest) 와 같이 테스트 환경을 통합했기에 스프링 컨텍스트 로드 횟수가 3회에서 2회로 감소된 것을 확인할 수 있습니다.
테스트 클래스의 환경을 통합하여 스프링 컨텍스트 로드 횟수를 감소할 수 있었습니다. 보여준 예시는 CounselControllerTest, CounselServiceTest, CounselRepositoryTest 클래스밖에 없기에 테스트 비용 감소에 대한 큰 차이는 없었습니다.
만약에 위 구조와 같이 컨트롤러 테스트 클래스 4개, 서비스 테스트 클래스 4개, 리포지토리 테스트 클래스 4개가 있더라면 ControllerTestSupport로 4개의 테스크 클래스에서 스프링 컨텍스트 로드 1회, 8개의 서비스 테스트 클래스, 리포지토리 클래스에서 1회로 총 로드 횟수는 2회로 테스트 비용이 크게 감소될 것입니다.
하지만 테스트 수행 시간이 나타나지 않기에 직관적으로 와닿지 않을 수 있습니다. 테스트 수행 시간도 스프링 컨텍스트 로드를 고려하지 않은 순수 테스트 수행 시간이기에 스프링 컨텍스트 로드에 대한 시간 확인은 어렵습니다.
테스트 수행 시간
- 개선 전 : 1.321초
- 개선 후 : 1.146초
이러한 차이점을 몸소 느끼기 위해 스프링 컨텍스트 관련 시간까지 포함한 시간 측정으로 비교해보겠습니다.
테스트 비용 개선 전/후 비교(전체 테스트 수행 시간 측정)
시간 측정 비교는 위 예시 테스트 상황에서 다른 테스트도 추가됐습니다. 참고해 주세요.
이미 많은 사람들이 사용하고 있는 JetBrains의 IntelliJ에서는 테스트의 수행시간을 화면 상에 표시해줍니다.
하지만, 이와 같은 방법은 몇 가지 한계점이 있습니다.
바로 @SpringBootTest , @DataJpaTest, @WebMvcTest 등 테스트 컨텍스트를 로딩하는 시간이 수행 시간에서 제외된다는 것입니다. 종종 스프링 컨텍스트 관련 테스트를 실행하면 측정되는 시간보다 오래 걸리는 느낌을 받은 적도 있을 것입니다.
사실, 테스트 자체를 수행하는 시간은 1초 이상을 넘기는 일이 잘 없으며 테스트 컨텍스트 로딩 시간이 우리가 체감하는 테스트 수행 시간의 대부분을 차지합니다.
서비스가 커질수록 테스트 코드 또한 커질 것입니다. 이에 따라 스프링 컨텍스트가 로드되는 테스트 코드도 많아지면서 테스트 수행 시간은 비대해질 수 있습니다. 테스트는 비즈니스를 보조하는 도구입니다. 배보다 배꼽이 크면 의미가 없기에 이를 개선할 필요가 있습니다.
이 시간을 줄이기 전에 해당 시간을 정확히 측정해보겠습니다.
컨텍스트 로딩 시간을 포함한 테스트 코드 전체 수행시간을 구하기 위해선 스프링 프레임워크에 존재하는 TestExecutionListener 인터페이스를 직접 구현해주면 됩니다.
위 코드는 테스트 시간을 전체적으로 더한 값입니다.
이처럼 상속해서 사용하면 스프링 컨텍스트가 로드되는 테스트에는 모두 적용됩니다. 이를 적용하기 위해서는 TestExecutionListener 구현 이후, 연동해줘야 합니다. 사진 빨간 박스 안을 보면 META-INF 패키지 하위에 spring.factorites 에 구현한 클래스 경로를 지정해야 스프링에서 인식할 수 있습니다.
org.springframework.test.context.TestExecutionListener
=com.project.doongdoong.module.TestExecutionTimeListener
이제 준비는 끝났습니다. 테스트 소요 시간을 측정해보겠습니다.
[개선 전]
1회 시도 - 스프링 컨텍스트 관련 테스트 소요 시간 : 약 11.5초
2회 시도 - 스프링 컨텍스트 관련 테스트 소요 시간 : 약 10초
[개선 후]
1회 시도 - 스프링 컨텍스트 관련 테스트 소요 시간 : 약 6.2초
2회 시도 - 스프링 컨텍스트 관련 테스트 소요 시간 : 약 5.9초
[결과]
1회 시도 : 개선 전(11.5초) / 개선 후(6.2초) → 약 1.85배 단축
2회 시도 : 개선 전(10.5초) / 개선 후(5.9초) → 약 1.77배 단축
테스트 수행 비용 45% 개선
출력된 ‘지금까지 걸린 시간’ 은 스프링 컨텍스트가 사용되는 테스트 소요 시간입니다. 스프링 컨텍스트가 사용되지 않는 단위/통합 테스트 시간은 미포함됩니다. 따라서 실제로 수행된 테스트 시간을 포함하면 조금 다를 수 있습니다.
정확한 컨텍스트 로드까지 포함한 전체 테스트 시간을 비교하려면 스프링 컨텍스트가 로드되지 않는 테스트의 시간은 따로 추가해야 합니다.
마지막으로 스프링 컨텍스트 로드 횟수를 줄여서 실제로 단축되는 시간을 확인했습니다. 스프링 컨텍스트 로드를 하는 테스트 클래스의 수와 스프링 환경 통합에 따라 이처럼 테스트 시간을 단축시킬 수 있었습니다.
테스트 환경 통합해서 스프링 컨텍스트 로드 빈도 줄이기
'spring' 카테고리의 다른 글
스프링 핵심 원리 - 기본 (0) | 2023.03.06 |
---|---|
객체 지향 설계와 스프링 (0) | 2023.03.05 |