테스트 코드 작성의 중요성과 Mockito를 통한 테스트
서론
필자는 예전 프로젝트를 진행할 때부터 ‘테스트 코드’에 대해 관심이 있었다. 또한, 소프트웨어 아키텍처에 대해 관심이 생겨 이에 관한 내용들을 찾아보던 중 TDD(Test Driven Design)에 대해 듣게 되어 테스트가 중요하다는 것을 어림짐작으로 알고 있었다. 그러나, 하나의 아키텍처를 적용하는 것에 대한 부담감때문인지 사실 테스트 코드를 실제로 잘 작성해본 적은 없었던 것 같다.
예전부터 Junit과 Mockito를 이용한 테스트 코드들을 꽤 봤었지만, “어차피 Mock 객체를 삽입해서 특정 메서드의 응답값을 다 지정해주는데 테스트 코드를 작성하는게 의미가 있나?”라는 생각을 했었다. 그러나 이는 테스트의 범위와 의미에 대해 잘 몰랐기 때문에 가지고 있던 의문이었다.
대학교 소프트웨어 설계, 소프트웨어 공학 과목에서도 테스트 관련 내용을 많이 배웠었다. 여러 테스트 케이스를 이용하여 블랙박스 테스트 또는 화이트박스 테스트를 진행한다는 것은 알고 있었지만, 테스트를 통하여 정상이나 예외적인 상황에 대한 예방 및 검증 수행이라는 가장 중요한 개념이자 테스트의 이유를 제대로 인지하지 못하였던 것 같다.
이번 프로젝트를 시작하며, 작업에 대한 테스트 코드를 작성하고 수행하는 것을 하나의 규칙으로 정했다. 그러던 도중, 내가 올린 PR에 대한 같이 백엔드 파트를 담당하는 팀원 분의 카톡을 받게 되었다.
위 연락을 보고 테스트를 진행하여야하는 이유가 번뜩 떠올랐다.
본 포스팅은 이 경험을 바탕으로 테스트에 대한 가장 기본적이자 중요한 개념을 설명한다. 특히 단위 테스트와 Mockito를 통하여 테스트를 진행하며, 계층형 아키텍처(Layerd Architecture)의 MVC 패턴에 포함되는 Controller, Service, Repository 관점에서 진행하는 테스트(Slice Test)를 서술할 것이다.
Testing
소프트웨어 개발이 진행될수록 시스템의
TDD라는 거창한 이름을 가진 아키텍처의 적용이 필수라기 보다는, 시스템의 안정성을 보장하고 예상치 못한 버그나 에러를 예방하기 위해서 테스트는 필수이다.
테스트는 크게 아래와 같이 2가지로 구분할 수 있다.
- 단위 테스트(Unit Test) - 클래스 또는 메서드 수준의 단위로 진행하는 테스트
- 통합 테스트(Integration Test) - 여러 모듈들이 의도대로 협력하여 동작하는지 확인하는 테스트
개발 중에는 일반적으로 단위 테스트를 중점적으로 진행하게 된다. 스프링에서는 @SpringBootTest
라는 어노테이션을 통하여 ApplicationContext를 가져와 애플리케이션의 등록된 모든 실제 Bean 들을 대상으로 통합 테스트를 진행할 수도 있다. 또한, 스프링의 경우에는 테스트 시에 JUnit, Mockito 프레임워크를 이용한다.
스프링을 통한 개발을 진행할 때는 Controller, Service, Repository 계층에 대한 테스트를 중점적으로 시행하며, 각 레이어를 분리하여 테스트를 진행하는 것을 슬라이스 테스트(Slice Test)라 한다. 슬라이스 테스트는 단위 테스트와 통합 테스트의 중간 성격이라 볼 수 있다.
먼저, 단위 테스트와 통합 테스트의 관심 범위에 대해 잠깐 알아보고자 한다.
해당 코드에서 통합 테스트는 AuthService
와 AuthService
가 의존하고 있는 다른 클래스들 간의 상호작용이 잘 이루어지는지, 협력을 통해 의도한대로 코드가 잘 동작하는지를 점검한다.
단위 테스트는 reissueToken(String refreshToken)
메서드가 잘 동작하는 지를 점검하는 것을 목표로 한다. 그런데, 해당 메서드 내부를 보면 AuthService
가 의존하고 있는 다른 객체들의 메서드를 호출하고 있다. 그렇기 때문에 “결국에 다른 객체를 불러와야되는게 아닌가?”라 생각할 수 있다. 그러나, 단위 테스트는 해당 메서드 내의 코드가 잘 동작하는 지 검증하는 것만이 관심 범위이다.
따라서, 이 경우에 다른 모듈의 영향을 받지 않고, 해당 메서드가 의도한대로 잘 동작하는지 확인하기 위해 기타 객체들은 Mock(가짜) 객체를 주입해 개발자가 직접 반환값 등을 지정해주어 메서드 동작과는 무관하도록 설정하여 테스트의 독립성을 보장하는 것이다. 이것이 Mock 객체를 사용하는 이유이다.
또한, 가짜 객체(dummy)가 실제처럼 동작하도록 응답값 등을 미리 설정해놓은 것은 스텁(Stub)이라 칭한다
Mock 객체를 통한 Test
Mock 객체는 테스트를 하고자하는 관심 범위에만 집중하기 위해, 관심 범위 외의 다른 영역을 격리하기 위해 사용하는 모의(가짜) 객체이다.
서론에서 언급한 “어차피 Mock 객체를 사용하면 리턴값과 같은 시나리오를 다 정해주는데 굳이 사용하는 이유가 무엇인가?”라는 질문은 Mock 객체가 왜, 어디에 사용되는지를 제대로 인지 못하고 있었기 때문에 생각했던 것이었다.
@Service
@RequiredArgsConstructor
public class AuthService {
private final RefreshTokenService refreshTokenService;
private final UserService userService;
private final JwtService jwtService;
/**
* 토큰 재발급 메서드
*
* @param refreshToken 리프레시 토큰
* @return 신규 발급 토큰(accessToken, refreshToken)
*/
public TokenDto reissueToken(String refreshToken) {
Long userId = jwtService.extractUserId(refreshToken);
if (!refreshTokenService.exists(userId)) {
throw new CustomException(AuthErrorType.REFRESH_TOKEN_EXPIRED);
}
User user = userService.readById(userId)
.orElseThrow(() -> new CustomException(UserErrorType.NOT_FOUND));
TokenDto reissuedToken = jwtService.generateToken(user.getUsername(), user.getNickname(), user.getId());
refreshTokenService.save(userId, reissuedToken.refreshToken());
return reissuedToken;
}
}
예를 들어, 토큰 재발급 유즈케이스를 나타내는 reissueToken(String refreshToken)
메서드를 테스트하고자 한다면 AuthService
가 필요할 것이며, AuthService
가 의존하고 있는 객체들도 필요할 것이다. 또한, reissueToken(String refreshToken)
내에서 호출되는 jwtService.extractUserId(refreshToken)
등이 실행이 되어야 해당 메서드의 로직을 테스트할 수 있다.
그러나, 우리는 reissueToken(String refreshToken) 메서드를 테스트하고자 하는 것이지 jwtService.extractUserId(refreshToken)을 테스트하고자 하는 것이 아니다. 해당 단위 테스트의 관심 범위 밖의 요소들은 모두 Mock 객체를 사용하여 토큰 재발급 테스트에 영향을 미치지 않도록 하여야 한다.
@ExtendWith(MockitoExtension.class)
public class AuthServiceTest {
@Mock
private RefreshTokenService refreshTokenService;
@Mock
private UserService userService;
@Mock
private JwtService jwtService;
@InjectMocks
private AuthService authService;
private final Long userId = 1L;
private final User user = User.builder()
.nickname("test")
.username("test")
.password("test")
.email("test@test.com")
.role(Role.USER)
.build();
private final TokenDto tokenDto = TokenDto.builder()
.accessToken("test-access-token")
.refreshToken("test-refresh-token")
.build();
@Test
@DisplayName("토큰 재발급 - 성공")
void successReissueToken() {
// given
when(jwtService.extractUserId(any())).thenReturn(userId);
when(refreshTokenService.exists(userId)).thenReturn(true);
when(userService.readById(userId)).thenReturn(Optional.ofNullable(user));
when(jwtService.generateToken(any(), any(), any())).thenReturn(tokenDto);
doNothing().when(refreshTokenService).save(userId, tokenDto.refreshToken());
// when
TokenDto reissuedToken = authService.reissueToken("old-refresh-token");
// then
verify(refreshTokenService).save(userId, reissuedToken.refreshToken());
}
}
해당 테스트 클래스에서는 AuthService
가 의존하고 있는 객체인 RefreshTokenService
, UserService
, JwtService
를 Mock 객체로 가져온다. 또한, @InjectMocks
어노테이션을 통해 AuthService
가 의존하고 있는 객체의 Mock 객체를 주입받는다.
Mock 객체들은 일명 ‘빈 껍데기’ 객체이므로 각 단위 테스트에서 해당 메서드에 호출하는 Mock 객체의 메서드들의 응답값이나 동작들을 개발자가 직접 설정해주어야 한다.
토큰 재발급 성공 테스트 케이스를 나타내는 void successReissueToken()
테스트는 authService.reissueToken("old-refresh-token")
메서드에 대한 단위 테스트를 진행하고자 한다. 따라서, given 파트에서 해당 메서드 안에서 호출되는 기타 메서드의 응답값을 설정(stub)한다.
given-when-then
given-when-then 패턴은 준비-실행-검증 과정으로 이루어진 테스트 패턴이다.
준비-실행-검증 단계는 테스트에 기본적으로 사용되는 순서이다. 다른 테스트 패턴인 AAA(Arrange-Act-Assert) 패턴의 경우에도 동일하게 준비-실행-검증 단계를 가진다.
given 절에는 주로 테스트에 사용되는 데이터를 정의하거나, Mock 객체의 동작을 정하는 설정 작업을 진행한다.
when 절은 실제 테스트를 수행하는 부분이다. 테스트를 하고자하는 범위를 실행하며, 단위 테스트에서는 테스트하고자하는 메서드가 될 것이다. 또한, then 절에서 테스트의 결과를 검증하기 위해서는 when 절에서 실행한 메서드의 결과값을 저장해야하기도 한다.
then 절은 테스트의 결과를 검증하는 부분이다. 사용자가 의도한대로 테스트케이스에 맞는 결과값이 잘 응답되는 것을 검증한다. 단순히 결과값이 예상한 결과값이랑 일치하는지 여부나, 메서드의 실행 횟수 여부, 상태 코드 등과 같은 결과에 대해서도 검증이 가능하다.
FIRST Principle
좋은 단위 테스트를 위해서는 아래와 같은 5가지 규칙을 지키야한다.
- Fast
- Isolated
- Repeatable
- Self-Validating
- Timely/Trough
1. Fast
테스트는 빠르게 실행되어야 한다. 언제든지 테스트를 실행할 수 있고, 여러 개의 단위 테스트가 이루어지더라도 빠르게 실행되어 결과를 응답할 수 있도록 설계되어야 한다.
2. Isolated
각 테스트는 독립적으로 실행되어야 한다. 다른 테스트에 영향을 받거나 의존하여서는 안 된다.
3. Repeatable
테스트는 어떠한 환경에서 몇 번이고 반복되어도 동일한 결과를 제공해야 한다. 이를 위해서 테스트는 자체적으로 필요한 데이터를 설정하고 외부 요인에 의존하지 않아야 한다. 이를 지키기 위해 Stub을 진행한다.
4. Self-Validation
테스트는 로그나 출력 구문을 보고 수동으로 확인하는 것이 아니라 자동으로 성공 여부를 판단할 수 있어야 한다.
5. Timely/Through
적시에 테스트를 적절하고, 철처하게 작성하여야 한다.
Slice Test
슬라이스 테스트는 레이어별로 구분하여 진행하는 테스트이다. 스프링에서는 MVC 패턴에 사용되는 Controller, Service, Repository 클래스 별로 테스트를 진행한다.
Controller
// AuthController.java
@GetMapping("/sign-out")
public ResponseEntity<?> signOut(
@RequestHeader(name = "device") Device device,
@RequestHeader(name = "refreshToken", required = false) String refreshTokenInHeader,
@CookieValue(name = "refreshToken", required = false) String refreshTokenInCookie
) {
if (device.equals(Device.WEB) && refreshTokenInCookie != null) {
authService.signOut(refreshTokenInCookie);
}
if (device.equals(Device.MOBILE) && refreshTokenInHeader != null) {
authService.signOut(refreshTokenInHeader);
}
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, CookieUtil.removeCookie(REFRESH_TOKEN_PREFIX).toString())
.body(SuccessResponse.ok());
}
@WebMvcTest(
controllers = AuthController.class,
excludeFilters = {
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = {SecurityConfig.class, JwtAuthenticationFilter.class})
}
)
public class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private AuthService authService;
private final Cookie refreshTokenInCookie = new Cookie("refreshToken", "test-refresh-token");
private final String refreshTokenInHeader = "test-refresh-token";
@BeforeEach
void setUp(WebApplicationContext webApplicationContext) {
this.mockMvc = MockMvcBuilders
.webAppContextSetup(webApplicationContext)
.alwaysDo(print())
.build();
}
@Test
@DisplayName("로그아웃 성공 - 웹")
void successSignOutInWeb() throws Exception {
// given
doNothing().when(authService).signOut(refreshTokenInCookie.getValue());
// when
ResultActions resultActions = mockMvc.perform(
get("/api/v1/auth/sign-out")
.cookie(refreshTokenInCookie)
.header("device", Device.WEB)
);
// then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(SuccessResponse.ok().message()));
}
}
Controller 레이어 테스트는 @WebMvcTest
어노테이션을 이용한다. 해당 어노테이션에서 테스트를 진행할 컨트롤러 클래스를 지정해줄 수 있다.
또한, @WebMvcTest
에서는 Presentation Layer에 속하는 Bean들을 테스트용으로 자동으로 등록해주는 기능을 제공한다.
@WebMvcTest

@Controller
, @ControllerAdvice
, @JsonComponent
와 같은 Spring MVC 관련 설정들을 자동으로 설정(auto-configuration)한다. 또한, Filter
나 SecurityFilterChain
등과 같은 인터페이스를 구현한 클래스들도 테스트 시 Bean으로 등록된다.
따라서, Spring Security Filter 단에서 JWT 인증을 위해 사용한 JwtAuthenticationFilter
나 JwtExceptionFilter
가 Bean으로 등록이 되고, 해당 Bean을 등록하기 위해서는 의존성들을 또 주입해줘야하기 때문에 설정이 복잡하다. 따라서, Spring Secuirty를 테스트 로직에서 제외하기 위해 @WebMvcTest
의 excludeFilter
필드를 통하여 Spring Security 관련 Bean들은 등록되지 않도록 설정한다.
또한, Controller를 테스트하기 위해서는 HTTP 요청을 보내야한다. 그러나, 테스트 과정에서 매번 애플리케이션 외부에서 요청을 보내는 것은 무리가 있다. Spring에서는 MockMvc
클래스를 통해 HTTP 요청을 지원한다. @Autowired
어노테이션을 통하여 MockMvc 객체가 자동 주입되도록 한다.
successSingOutInWeb()
메서드는 ‘웹 디바이스 사용자의 로그아웃 요청 성공 시나리오’에 대한 테스트이다.
즉, GET/api/v1/auth/sign-out
요청에 대한 테스트이다. 따라서, AuthConroller
의 signOut(...)
메서드에 대한 테스트를 진행하는 것이다.
따라서, given절에서 해당 메서드 내부에서 호출하는 authService.signOut(refreshTokenInXXX)
에 대한 Stubbing을 진행하여야 한다.
when절에서는 MockMvc를 통해 요청을 보내에서 테스트를 실행한다. MockMvc.perform(...)
메서드는 ResultActions
객체를 리턴한다. 해당 객체에 테스트 실행 결과가 저장된다.
then절에서는 요청 수행 결과를 저장하고있는 객체인 resultActions
를 통해 검증을 진행해서 의도한대로 동작하는지 확인한다.
Service
@Service
@RequiredArgsConstructor
public class AuthService {
private final RefreshTokenService refreshTokenService;
private final UserService userService;
private final JwtService jwtService;
/**
* 토큰 재발급 메서드
*
* @param refreshToken 리프레시 토큰
* @return 신규 발급 토큰(accessToken, refreshToken)
*/
public TokenDto reissueToken(String refreshToken) {
Long userId = jwtService.extractUserId(refreshToken);
if (!refreshTokenService.exists(userId)) {
throw new CustomException(AuthErrorType.REFRESH_TOKEN_EXPIRED);
}
User user = userService.readById(userId)
.orElseThrow(() -> new CustomException(UserErrorType.NOT_FOUND));
TokenDto reissuedToken = jwtService.generateToken(user.getUsername(), user.getNickname(), user.getId());
refreshTokenService.save(userId, reissuedToken.refreshToken());
return reissuedToken;
}
}
@ExtendWith(MockitoExtension.class)
public class AuthServiceTest {
@Mock
private RefreshTokenService refreshTokenService;
@Mock
private UserService userService;
@Mock
private JwtService jwtService;
@InjectMocks
private AuthService authService;
private final Long userId = 1L;
private final User user = User.builder()
.nickname("test")
.username("test")
.password("test")
.email("test@test.com")
.role(Role.USER)
.build();
private final TokenDto tokenDto = TokenDto.builder()
.accessToken("test-access-token")
.refreshToken("test-refresh-token")
.build();
@Test
@DisplayName("토큰 재발급 - 실패: 리프레시 토큰 만료")
void failReissueToken() {
// given
when(jwtService.extractUserId(any())).thenReturn(userId);
when(refreshTokenService.exists(userId)).thenReturn(false); // refresh token 만료
// when
CustomException exception = assertThrows(CustomException.class, () ->
authService.reissueToken("old-refresh-token")
);
// then
assertEquals(AuthErrorType.REFRESH_TOKEN_EXPIRED, exception.getErrorType());
}
}
AuthService
테스트는 각 메서드에 대한 단위테스트이다. @ExtendWith(MockitoExtenstion.class)
어노테이션을 통하여 Junit과 Mockito 기능을 테스트 클래스와 자동으로 연결해준다.
Service 레이어에 대한 테스트는 테스트 대상인 Service 클래스가 의존하고 있는 객체들을 Mock 객체로 등록하고 @InjectMocks
를 통하여 모의 객체를 주입받는다. 또한, 각 메서드에서 Stub을 정의하여 각 메서드가 의도한 로직대로 잘 동작하는지를 확인한다.
Repository
@DataJpaTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(locations = "classpath:application-test.yml")
@Import(JpaConfig.class)
public class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private OAuthRepository oAuthRepository;
@Test
@DisplayName("일반 User 생성")
void 일반_User_생성() {
// given
User user = User.builder()
.email("test@gmail.com")
.username("tester")
.nickname("test")
.password("testpw")
.role(Role.USER)
.build();
// when
User savedUser = userRepository.save(user);
// then
assertThat(savedUser.getId()).isEqualTo(user.getId());
}
}
Repository 테스트에서는 @DataJpaTest
어노테이션을 사용한다. 해당 어노테이션은 트랙잭션이나 자동 롤백 기능과 같은 설정을 자동으로 적용해준다.
Repository는 외부 요소인 데이터베이스와 연결이 진행되어야한다. 따라서, 테스트에 시간이 많이 소요된다. 이는 FIRST 원칙을 잘 지키지 못하는 상황으로 이어질 수 있다. 해당 프로젝트의 메인 데이터베이스로는 MySQL을 사용하였다. 그러나, 운영용 데이터베이스와 테스트용 데이터베이스를 모두 MySQL로 사용할 경우 구분을 위해 생기는 비용이나 CI 과정에서 빌드 테스트 진행 시 MySQL 데이터베이스 구성을 위하여 CI의 시간이 많이 소요되는 문제가 생겨 테스트용 데이터베이스를 H2 database를 사용하는 것으로 결정하였다.
@AutoConfigureTestDatabase
나 @TestPropertySource
는 H2 database를 사용하기 위한 설정에 이용되는 어노테이션이다.
Repository에 대한 단위 테스트?
테스트에 대한 많은 자료들을 찾아보면 Repository에 대한 테스트 코드 작성은 생략한 경우가 많다. 이는 Repository에서는 단순히 엔티티를 CRUD하는 메서드만 포함하고 있으며, 데이터베이스와 연결 등에 소요되는 비용이 FIRST 원칙과는 부합하지 않기 때문에 진행하지 않는 것으로 생각한다.
또한, Persistence 레이어는 데이터베이스 설정 등의 비용으로 인하여 테스트 코드 작성이 불가능하거나 어려운 경우가 많다. 이렇듯 테스트 코드 작성에 제한이 되는 영역을 Black Box 영역이라 한다. 만약, 이 Block Box 영역이 테스트 코드에 직접적으로 침투하게 된다면 이를 의존하고 있는 모든 구간들로 비용이 전파될 것이다. 이러한 Black Box 영역을 테스트 코드에 직접적으로 침투시키지 않으면서 테스트가 이루어지도록 하는 방법이 Mock 객체를 이용하는 것이다.
결론
개발에서 테스트는 뺴놓을 수 없는 영역이다. 테스트를 통하여 예상치 못한 오류를 찾아내고, 프로그램이 의도한대로 잘 동작하는지 확인하여야 한다.
Spring에서는 테스트를 위하여 JUnit, Mockito와 같은 프레임워크를 통해 강력한 테스트 기능을 지원한다. 이번 프로젝트를 진행하며 느끼는 점 중 하나는 테스트 코드를 잘 작성하는 것을 위해서는 어떠한 것을 검증해야하는지와 JUnit과 Mockito는 어떠한 메서드를 제공하여 테스트를 지원하는지를 알아야한다는 것이다.
특히, 개발 중에는 단위 테스트 위주로 이루어지기 때문에 각 기능에 대한 여러가지 테스트 케이스를 검증해야 한다. 해당 메서드에 이러한 인자가 전해졌을 때 내부에서 이 인자가 특정 메서드에 사용이 되는지, 메서드의 응답값이 null이 아닌지, 이러한 입력값이 주어졌을 때 에러가 발생하는지 등과 같이 정말 여러가지의 시나리오가 생길 수 있다.
개인적으로 혹은 동아리 단위에서 진행하는 테스트에서 시나리오(테스트 케이스)를 생각하는 것은 오롯이 개발자의 몫이다. 결국 테스트 케이스를 고민하는 것이 중요하며, 내가 작성한 메서드를 검증할 수있는 가장 좋은 방법이다. 또한, 해당 테스트 케이스를 검증하기 위해서는 사용할 수 있는 명령어들을 충분히 숙지하여야 한다.
해당 포스팅에서 언급하지 않은 스프링 시큐리티 설정 후 테스트나 Captor
를 통한 인자값 검증 등 정말 많은 테스트 관련 기술이 존재한다. 필자도 계속해서 좋은 테스트 케이스를 생각하고, 이를 검증하기 위해 계속해서 자료들을 찾아볼 예정이다.