내가 알고 있던 테스트에 관하여
기존에 유닛테스트가 중요하다는 것은 전공에서도 배웠고 여기저기서 주워들은 건 있었지만 정작 프로젝트를 진행하다보면 유닛테스트가 정말 의미가 있나 싶은 순간들이 많았다.
아래의 회원가입 코드의 경우, 스프링 DI로 부터 주입받은 다른 컴포넌트 로직의 동작을 제외하면 해당 메서드의 온전한 로직은 겨우 파라미터로 변수를 전달하거나 return 값을 던져주는 것 뿐이다.
@Transactional
public JoinDto.Response join(@Valid JoinDto.Request request) {
Member member = Member.builder()
.email(request.getEmail())
.nickname(request.getNickname())
.password(passwordEncoder.encode(request.getPassword()))
.build();
return JoinDto.Response.fromEntity(memberRepository.save(member));
}
실제 로직의 코드에 비해 테스트 코드 사이즈는 아래와 같이 비대해진다.
@ExtendWith(MockitoExtension.class)
class TestTest {
@Mock
private MemberRepository memberRepository;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private AuthService authService;
@Test
void joinTestSuccess() {
// given
JoinDto.Request request = JoinDto.Request.builder()
.email("test@example.com")
.nickname("test")
.password("test123")
.build();
String encodedPassword = "encodedPassword123";
Member savedMember = Member.builder()
.email(request.getEmail())
.nickname(request.getNickname())
.password(encodedPassword)
.build();
when(passwordEncoder.encode(request.getPassword()))
.thenReturn(encodedPassword);
when(memberRepository.save(any(Member.class)))
.thenReturn(savedMember);
// when
JoinDto.Response response = authService.join(request);
// then
assertEquals(request.getEmail(), response.getEmail());
assertEquals(request.getNickname(), response.getNickname());
}
}
모든 의존성을 전부 Mocking 하다보면 Java의 OOP 특성이 많이 드러나는 Spring에선 특히 테스트 코드 작성에 들이는 시간적인 오버헤드가 크다.
많은 모듈과 컴포넌트가 계층적으로 나눠져 의존성 주입을 통해 많은 것이 동작하는 스프링부트 아키텍처 특성 상 일일이 모든 모듈에 대한 테스트 코드를 작성하는 것은 배보다 배꼽이 더 큰 꼴이었기에 의문이 꽤 많이 들었다.
Martin Fowler의 유닛 테스트
그래서 검색을 하던 중 ‘마틴 파울러’ 라는 사람의 테스트 피라미드 모델을 보게 되었고, 유닛 테스트에 대한 회의감이 심해지던 와중에 유닛 테스트의 비중이 가장 크다는 그림을 보고 더 혼란스러워졌다.
아무리 봐도 자바는 체계적인 언어여서 통합 테스트만 진행하더라도 탄탄한 예외처리를 통해 충분히 빠른 디버깅이 가능할텐데 유닛 테스트의 중요성을 재차 강조하다니.
그러나 마틴 파울러가 말하는 유닛 테스트는 내가 알던 것과 약간 달랐다.
마틴 파울러가 말하는 유닛 테스트에는 Sociable(협동) Unit Test, Solitary(단일) Unit Test 두 가지가 존재했는데, 전자가 보통 흔히 생각하는 통합 테스트, 후자가 우리가 익히 배워왔던 고전적인 유닛 테스트였던 것이다.
즉, Solitary Unit Test는 mock/stub을 이용하여 해당 컴포넌트의 의존성을 배제하고 로직만을 테스트하는 것, Sociable Unit Test는 한 컴포넌트의 비즈니스 로직에 대해 다른 협력 컴포넌트들과 함께 테스트 한다는 것이다.
그렇다면 Sociable Test와 Integration Test의 차이는 무엇인가?
Sociable Test는 Unit test인 만큼 여전히 특정 컴포넌트나 모듈, 클래스에 대한 테스트라면, 통합 테스트는 아예 다른 시스템과의 상호작용이나, 다른 기술 스택 간의 상호작용, 외부 API, 네트워크 등의 영향을 받는 조건에 대해 모두 테스트 하는 것이다.
또한 Unit test 케이스는 개발자가 직접 작성한다면, Integration test 케이스는 개발자가 아닌 타인이 작성한다는 것이 차이점이다.
그렇다면 내게 필요한 것은 비즈니스 로직의 핵심적이고 복잡한 모듈에 대해선 Solitary Unit Test가 필요할 것이고 (예를 들자면 JWT 토큰을 생성하거나 Validation 하는 로직), 어느 정도 흐름이 필요한 모듈엔 Sociable Unit Test를 통해 해당 컴포넌트와 협력하는 다른 컴포넌트들과의 흐름을 테스트해야 할 것이다. (특정 Util 컴포넌트를 사용한 사용자 Validation 등)
Solitary(단독) Unit Test
흔히 생각할 수 있는 고전적인 방식의 Unit Test, 테스트더블(Mock, Stub)을 이용해 의존성을 배제하여 다른 컴포넌트의 영향을 받지 않고 오로지 해당 컴포넌트의 로직만을 테스트하는 것
Sociable(협동) Unit Test
특정 컴포넌트를 테스트 한다는 것은 고전적인 Unit Test와 동일하지만 의존성을 배제하지 않고 팀 또는 개발자가 지정한 Unit의 범위에 맞게 다른 컴포넌트와 함께 협동으로 테스트하는 것, 흐름 단위로 Unit의 범위를 지정할 수도 있음
통합 테스트와의 차이로는 통합테스트는 다른 시스템과의 상호작용이나, 다른 기술 스택 간의 상호작용, 외부 API, 네트워크 등의 영향을 받는 조건에 대해 모두 테스트 하는 것, 또한 Unit test 케이스는 주로 개발자가 직접 작성한다면, Integration test 케이스는 개발자가 아닌 타인이 작성할 수 있다는 것이 차이점
Sociable Unit Test
테스트 하고자 하는 Unit 범위 내에 있는 Bean은 실제 Bean을 사용하고, 범위를 벗어나는 컴포넌트는 Mocking을 하여 Unit Test를 진행할 수 있다
아래 코드는 JWT를 적용한 회원가입 성공 케이스에 대한 테스트이다. AuthService의 join() 메서드에 대한 전반적인 로직의 흐름을 테스트 하며 DB 접근에 대한 부분은 Mocking 하였다
- @TestPropertySource를 통해 application.properties를 Mocking 한다
- @ContextConfiguration을 통해 해당 테스트 Context 내에서 사용할 Bean의 형태를 지정한다
--> AuthService.class는 @Service를 통해 정의되었기에 Spring Context를 통해 의존성이 주입되므로 별도로 지정한다
--> 나머지는 생성자를 통해 객체를 생성할 수 있는 @Component 또는 @Bean 이므로
- 테스트 클래스 내에 @TestConfiguration 어노테이션을 사용하여 어느 Bean을 Mocking하고 어느 Bean을 실제 Bean으로 사용할 지에 대한 의존성 주입 생성자를 지정한다
--> @TestConfiguration은 @Configuration과 유사하나 Spring Test 전용으로 사용한다
@ExtendWith(SpringExtension.class)
@TestPropertySource(properties = {
"jwt.secret=test-secret",
"jwt.exp=3600"
})
@ContextConfiguration(classes = {AuthService.class, AuthServiceTest.TestConfig.class})
class AuthServiceTest {
@Autowired
private AuthService authService;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private MemberRepository memberRepository; // mock임
@Test
void joinTestSuccess() {
// given
String rawPassword = "test1234";
String encodedPassword = passwordEncoder.encode(rawPassword);
JoinDto.Request request = JoinDto.Request.builder()
.email("user@example.com")
.nickname("tester")
.password(rawPassword)
.build();
Member memberToReturn = Member.builder()
.email(request.getEmail())
.nickname(request.getNickname())
.password(encodedPassword)
.build();
// mocking
Mockito.when(memberRepository.save(Mockito.any(Member.class)))
.thenReturn(memberToReturn);
// when
JoinDto.Response response = authService.join(request);
// then
assertEquals(request.getEmail(), response.getEmail());
assertEquals(request.getNickname(), response.getNickname());
Mockito.verify(memberRepository).save(Mockito.any(Member.class));
}
@TestConfiguration
static class TestConfig {
@Bean
public MemberRepository memberRepository() {
return Mockito.mock(MemberRepository.class);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 실제 컴포넌트 사용
}
@Bean
public JwtProperties jwtProperties() {
return new JwtProperties();
}
@Bean
public JwtUtil jwtUtil() {
return new JwtUtil(jwtProperties());
}
@Bean
public JwtBlacklistService jwtBlacklistService() {
return Mockito.mock(JwtBlacklistService.class);
}
@Bean
public AuthenticationManager authenticationManager() {
return Mockito.mock(AuthenticationManager.class);
}
}
}
'IT > Spring Boot' 카테고리의 다른 글
[Spring boot] 사용자의 데이터 사용 권한에 대해 (0) | 2025.04.10 |
---|---|
[Spring Boot] 스프링부트 리팩토링 팁 (0) | 2024.09.27 |
[Spring Boot] 스프링 부트 SpEL(Spring Expression Language) (0) | 2024.09.27 |
[Spring Boot] 스프링부트 Spring Resource (0) | 2024.09.27 |
[Spring Boot] 스프링 부트 Validation (0) | 2024.09.27 |