자바 스프링에서 서비스 테스트와 컨트롤러 테스트는 각각 비즈니스 로직과 웹 계층을 독립적으로 검증하는 중요한 과정이다. 두 테스트는 구조와 목적에 차이가 있지만, 둘 다 애플리케이션의 안정성을 보장하는 데 중요한 역할을 한다.
서비스 테스트(Service Test)
서비스 레이어는 비즈니스 로직을 처리하는 계층으로, 데이터베이스와 상호작용하거나 외부 API를 호출하는 등의 중요한 역할을 한다. 이 레이어를 테스트하는 것은 비즈니스 로직이 예상대로 동작하는지 검증하는 데 목적이 있다.
테스트 대상 : 주로 비즈니스 로직, 레포지토리와의 상호작용, 외부 API 통신 등을 담당하는 서비스 클래스.
주요 사용하는 방법에는 단위 테스트(Unit Test)에서 서비스 메서드를 작은 단위로 테스트하며, 주로 의존성을 Mock(가짜 객체)객체로 대체하여 특정 메서드의 동작만 검증하고 의존선 주입(Mocking) @Mock, @InjectMocks, 또는 Mockito를 이용해 레포지토리나 다른 서비스의 의존성을 Mock으로 주입한다.
(주로 사용하는 라이브러리로는 Mockito, JUnit, Spring Test가 있다.)
Ex)
AuthService 클래스의 주요 기능인 회원가입(signup)과 로그인(signin) 기능을 검증하는 JUnit 테스트이다. 이 코드에서는 Mockito를 이용해 필요한 의존성을 Mock 객체로 대체하고 있으며, ReflectionTestUtils로 필드 값을 설정해 테스트를 작성한걸 볼 수 있다.
package org.example.expert.domain.auth.authservice;
import org.example.expert.config.JwtUtil;
import org.example.expert.config.PasswordEncoder;
import org.example.expert.domain.auth.dto.request.SigninRequest;
import org.example.expert.domain.auth.dto.request.SignupRequest;
import org.example.expert.domain.auth.dto.response.SigninResponse;
import org.example.expert.domain.auth.dto.response.SignupResponse;
import org.example.expert.domain.auth.service.AuthService;
import org.example.expert.domain.common.exception.InvalidRequestException;
import org.example.expert.domain.user.entity.User;
import org.example.expert.domain.user.enums.UserRole;
import org.example.expert.domain.user.repository.UserRepository;
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 org.springframework.test.util.ReflectionTestUtils;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class AuthServiceTest {
@InjectMocks
private AuthService authService;
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private JwtUtil jwtUtil;
@Test
void signup_정상적인_회원가입_테스트() {
// given
SignupRequest signupRequest = new SignupRequest("test@example.com", "password123", "USER");
User user = new User();
ReflectionTestUtils.setField(user, "id", 1L);
ReflectionTestUtils.setField(user, "email", "test@example.com");
ReflectionTestUtils.setField(user, "password", "encodedPassword");
ReflectionTestUtils.setField(user, "userRole", UserRole.USER);
String bearerToken = "someBearerToken";
// when
// Mock user repository behavior
when(userRepository.existsByEmail(signupRequest.getEmail())).thenReturn(false); // 수정: 새로운 사용자 등록 가능
when(passwordEncoder.encode(signupRequest.getPassword())).thenReturn("encodedPassword"); // 수정: 실제 인코딩된 패스워드 반환
when(userRepository.save(any(User.class))).thenReturn(user); // Mock으로 저장된 사용자 반환
when(jwtUtil.createToken(1L, "test@example.com", UserRole.USER)).thenReturn(bearerToken); // Mock으로 JWT 반환
SignupResponse response = authService.signup(signupRequest);
// then
assertNotNull(response.getBearerToken());
assertEquals(bearerToken, response.getBearerToken());
verify(userRepository).save(any(User.class));
}
@Test
void signin_정상적인_로그인_테스트() {
// given
String email = "test@example.com";
String password = "1q2w3e4r";
UserRole userRole = UserRole.USER;
String encodedPassword = passwordEncoder.encode(password);
String bearerToken = "someBearerToken";
// Create user and mock repository behavior
User user = new User(email, encodedPassword, userRole);
// Mocking UserRepository
when(userRepository.findByEmail(email)).thenReturn(Optional.of(user));
when(passwordEncoder.matches(password, encodedPassword)).thenReturn(true);
when(jwtUtil.createToken(user.getId(), email, userRole)).thenReturn(bearerToken);
// Set up SigninRequest
SigninRequest signinRequest = new SigninRequest(email, password);
// when
SigninResponse response = authService.signin(signinRequest);
// then
assertNotNull(response.getBearerToken());
assertEquals(bearerToken, response.getBearerToken());
}
@Test
void signin_존재하지_않는_유저_로그인_테스트() {
// given
SigninRequest signinRequest = new SigninRequest();
User user = new User();
ReflectionTestUtils.setField(user, "email", "odomarine@gmail.com");
ReflectionTestUtils.setField(user, "password" , "abc1234@");
// when & then
assertThrows(InvalidRequestException.class, () -> authService.signin(signinRequest));
}
@Test
void signin_잘못된_비밀번호_테스트() {
// given
String email = "odomarine@gmail.com";
String password = "1q2w3e4r";
UserRole userRole = UserRole.USER;
// 유저 생성
User user = new User(email, passwordEncoder.encode(password), userRole);
userRepository.save(user);
SigninRequest signinRequest = new SigninRequest();
ReflectionTestUtils.setField(user, "email", "odomarine@gmail.com");
ReflectionTestUtils.setField(user, "password" , "abc1234@");
// when & then
assertThrows(InvalidRequestException.class, () -> authService.signin(signinRequest));
}
}
해당 클래스 구조를 보면
테스트 대상을 보면 AuthService 클래스고 의존성으로는 UserRepository, PasswordEncode, JwtUtil를 의존성 객체 mock으로 만들고 Authservice 클래스의 인스턴스를 자동으로 생성하고 mock객체로 필요한 의존성을 주입하기 위해 @InjectMocks를 넣었다.
클래스 구조
테스트 클래스: AuthServiceTest
테스트 대상: AuthService 클래스
의존성:
- UserRepository: 사용자 정보를 조회하고 저장하는 리포지토리.
- PasswordEncoder: 비밀번호 암호화 및 비교.
- JwtUtil: JWT 토큰 생성.
@InjectMocks: AuthService 클래스의 인스턴스를 자동으로 생성하고 목(mock) 객체로 필요한 의존성을 주입.
@Mock: 테스트 중에 사용할 의존성 객체를 목(mock)으로 만듦.
테스트 설명
회원가입 테스트(signup_정상적인_회원가입_테스트)
정상적인 회원가입 절차를 검증한다.
@Test
void signup_정상적인_회원가입_테스트() {
// given
SignupRequest signupRequest = new SignupRequest("test@example.com", "password123", "USER");
User user = new User();
ReflectionTestUtils.setField(user, "id", 1L);
ReflectionTestUtils.setField(user, "email", "test@example.com");
ReflectionTestUtils.setField(user, "password", "encodedPassword");
ReflectionTestUtils.setField(user, "userRole", UserRole.USER);
String bearerToken = "someBearerToken";
// when
// Mock user repository behavior
when(userRepository.existsByEmail(signupRequest.getEmail())).thenReturn(false); // 수정: 새로운 사용자 등록 가능
when(passwordEncoder.encode(signupRequest.getPassword())).thenReturn("encodedPassword"); // 수정: 실제 인코딩된 패스워드 반환
when(userRepository.save(any(User.class))).thenReturn(user); // Mock으로 저장된 사용자 반환
when(jwtUtil.createToken(1L, "test@example.com", UserRole.USER)).thenReturn(bearerToken); // Mock으로 JWT 반환
SignupResponse response = authService.signup(signupRequest);
// then
assertNotNull(response.getBearerToken());
assertEquals(bearerToken, response.getBearerToken());
verify(userRepository).save(any(User.class));
}
given, when, then으로 나누어 given에는 SignupRequest 객체를 생성하여 이메일, 비밀번호, 역할을 설정하고 User 객체의 필드 값을 ReflectionTestUtils를 사용해 설정하고 when에서는 userRepository.existsByEmail()메서드를 통해 이메일이 이미 존재하는지 확인 후 사용자가 없으면 false로 반환한다.
- passwordEncoder.encode()로 비밀번호를 암호화.
- userRepository.save()로 사용자를 저장하고, 저장된 사용자 객체 반환.
- jwtUtil.createToken()을 호출해 JWT 토큰 생성.
Then에서는 assertNotNull을 통해 SignupResponse가 null인지 아닌지, 토큰이 올바른지 검증하고 verify를 통해 userRepository.save() 메서드가 호출되었는지 검증한다.
그 밑에 있는 테스트 코드들도 given-when-then 순서로 나눠서 테스트 코드를 작성하여 위와 같은 방식대로 하면 service 기본적인 테스트 코드를 짜는 것에 대해 문제 없을것이다!
컨트롤러 테스트(Controller Test)
컨트롤러 레이어는 HTTP 요청과 응답을 처리하는 웹 계층으로, 클라이언트와 서버 간의 상호작용을 담당한다. 컨트롤러 테스트는 이 계층에서 올바르게 요청이 처리되고 적절한 응답이 반환되는지 확인하는 데 목적이 있다.
테스트 대상 : 컨트롤러의 각 메서드, 요청 처리 및 응답의 상태 코드, 데이터 바인딩 등
주요 방법으로는 단위 테스트(Unit Test): @WebMvcTest를 사용해 컨트롤러만 독립적으로 테스트, 서비스나 레포지토리는 mock 객체로 대체하고 통합테스트(Integration Test)는 @SpringBootTest와 MockMvc를 함께 사용해 전체 컨트롤러의 동작을 통합적으로 테스한다.
(주로 사용하는 라이브러리: MockMvc, Mockito, JUnit, Spring Test)
@SpringBootTest와 @WebMvcTest 차이
@SpringBootTest
설명:
- @SpringBootTest는 통합 테스트(Integration Test)를 위한 어노테이션
- 애플리케이션의 전체 컨텍스트(Spring Application Context)를 로드하여 테스트를 수행한다. 즉, 실제로 애플리케이션을 실행하는 것과 같은 환경을 제공한다.
특징:
- 전체 애플리케이션 컨텍스트를 로드하므로, 애플리케이션의 모든 빈(Bean)을 테스트할 수 있다.
- 데이터베이스와의 통신, 서비스 계층, 리포지토리까지 포함한 전체 스택을 대상으로 테스트가 가능하다.
- 성능 측면에서는 비교적 느림. 왜냐하면 전체 애플리케이션이 실행되기 때문에 로드되는 시간이 많이 걸린다.
사용 목적:
- 서비스 로직, 리포지토리, 컨트롤러, 필터, 예외 처리 등 애플리케이션 전체를 통합적으로 테스트할 때 사용된다.
- 애플리케이션이 실제 운영 환경에서 어떻게 동작할지 전체적으로 테스트할 수 있다.
Ex)
@SpringBootTest
public class ExampleIntegrationTest {
@Autowired
private MyService myService;
@Test
public void testServiceMethod() {
// MyService의 실제 로직을 테스트
assertNotNull(myService);
}
}
@WebMvcTest
설명:
- @WebMvcTest는 Spring MVC(웹 레이어)를 테스트하기 위한 어노테이션
- 컨트롤러(Controller)와 관련된 빈만 로드하며, 서비스나 리포지토리 계층과는 분리된 테스트를 수행한다.
- 주로 컨트롤러의 HTTP 요청 및 응답 처리를 테스트할 때 사용된다.
특징:
- 웹과 관련된 컨트롤러, 필터, 예외 처리, 인터셉터 등 웹 레이어만을 로드하여 테스트한다.
- @Service, @Repository 등의 빈은 로드되지 않으며, 이들은 목(mock) 객체로 대체된다.
- 성능 면에서 빠름, 왜냐하면 웹 계층만 로드하므로 전체 애플리케이션 컨텍스트를 로드할 필요가 없다.
사용 목적:
- 컨트롤러의 요청 처리, 유효성 검사, 응답 처리 등을 집중적으로 테스트할 때 사용된다.
- 데이터베이스나 서비스 로직 없이 HTTP 요청/응답 흐름을 테스트할 수 있다.
ex)
@WebMvcTest(MyController.class)
public class MyControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private MyService myService;
@Test
public void testControllerMethod() throws Exception {
when(myService.getData()).thenReturn("Hello World");
mockMvc.perform(get("/hello"))
.andExpect(status().isOk())
.andExpect(content().string("Hello World"));
}
}
항목 | @SpringBootTest | @WebMvcTest |
테스트 대상 | 애플리케이션 전체(통합 테스트) | Spring MVC 레이어 (컨트롤러, 필터, 예외 처리 등) |
로딩 범위 | 전체 애플리케이션 컨텍스트 | 웹 계층 관련 빈만 로드 |
성능 | 느림 (전체 컨텍스트 로딩) | 빠름 (웹 레이어만 로딩) |
사용 목적 | 서비스, 리포지토리, 컨트롤러, 데이터베이스 등 모든 계층 테스트 | 컨트롤러의 HTTP 요청 및 응답 흐름 테스트 |
주요 어노테이션 | @Autowired로 모든 빈을 사용할 수 있음 | @MockBean을 사용해 서비스 계층을 목(mock) 처리 |
요약하자면....
- @SpringBootTest는 애플리케이션의 전체적인 동작을 검증하고, 데이터베이스와의 통신이나 서비스 계층까지 포함한 통합적인 테스트를 수행할 때 유용하다.
- @WebMvcTest는 컨트롤러를 테스트할 때 적합하며, 요청/응답 처리에 집중한 가벼운 테스트를 원할 때 사용한다. 서비스나 리포지토리를 목(mock)으로 대체하여, 웹 레이어만을 빠르게 테스트할 수 있다.
Ex)
이 테스트 코드는 Spring Boot의 컨트롤러 테스트를 위한 코드로, ManagerController와 관련된 API들을 테스트하고 있다. MockMvc를 사용하여 실제 서버를 띄우지 않고 HTTP 요청을 모의하여 API 동작을 검증한다. 이 테스트 코드는 각각 매니저 등록, 매니저 목록 조회, 매니저 삭제 API를 성공적으로 실행하는 경우를 다룬다.
package org.example.expert.domain.manager.service.managecontroller;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.expert.config.JwtUtil;
import org.example.expert.domain.common.dto.AuthUser;
import org.example.expert.domain.manager.dto.request.ManagerSaveRequest;
import org.example.expert.domain.manager.dto.response.ManagerResponse;
import org.example.expert.domain.manager.dto.response.ManagerSaveResponse;
import org.example.expert.domain.user.dto.response.UserResponse;
import org.example.expert.domain.manager.service.ManagerService;
import org.example.expert.domain.user.enums.UserRole;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
public class ManageControllerTest {
@MockBean
private ManagerService managementService;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private ManagerService managerService;
@BeforeEach
void setup() {
MockitoAnnotations.openMocks(this);
}
@Test
public void 매니저_등록_성공() throws Exception {
// given
long todoId = 1L;
UserResponse userResponse = new UserResponse(1L, "user1@example.com");
ManagerSaveRequest request = new ManagerSaveRequest(1L);
ManagerSaveResponse response = new ManagerSaveResponse(1L, userResponse);
AuthUser authUser = new AuthUser(1L, "test@example.com");
String token = jwtUtil.createToken(authUser.getId(), authUser.getEmail(), UserRole.USER);
when(managementService.saveManager(any(AuthUser.class), any(Long.class), any(ManagerSaveRequest.class)))
.thenReturn(response);
// When & then
mockMvc.perform(MockMvcRequestBuilders.post("/todos/{todoId}/managers", todoId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
.header("Authorization", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(response.getId()))
.andExpect(jsonPath("$.user.id").value(response.getUser().getId()))
.andExpect(jsonPath("$.user.email").value(response.getUser().getEmail()));
}
@Test
void 매니저_목록_조회_성공() throws Exception {
// Given
long todoId = 1L;
UserResponse userResponse1 = new UserResponse(1L, "user1@example.com");
UserResponse userResponse2 = new UserResponse(2L, "user1@example.com");
ManagerResponse manager1 = new ManagerResponse(1L, userResponse1);
ManagerResponse manager2 = new ManagerResponse(2L, userResponse2);
List<ManagerResponse> managers = List.of(manager1, manager2);
AuthUser authUser = new AuthUser(1L, "test@example.com");
String token = jwtUtil.createToken(authUser.getId(), authUser.getEmail(), UserRole.USER);
given(managerService.getManagers(todoId)).willReturn(managers);
// When & Then
mockMvc.perform(get("/todos/{todoId}/managers", todoId)
.header("Authorization", token))
.andExpect(status().isOk());
}
@Test
void 매니저_삭제_성공() throws Exception {
// Given
long todoId = 1L;
long managerId = 1L;
AuthUser authUser = new AuthUser(1L, "test@example.com");
String token = jwtUtil.createToken(authUser.getId(), authUser.getEmail(), UserRole.USER);
// When & Then
mockMvc.perform(MockMvcRequestBuilders.delete("/todos/{todoId}/managers/{managerId}", todoId, managerId)
.header("Authorization", token))
.andExpect(status().isOk());
}
}
필드 및 초기 설정
@Autowired
private JwtUtil jwtUtil;
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ManagerService managementService;
@BeforeEach
void setup() {
MockitoAnnotations.openMocks(this);
}
- MockMvc: 스프링 MVC 테스트를 위한 클래스이다. 실제 서블릿 컨테이너 없이 HTTP 요청을 모의하는 데 사용된다.
- ObjectMapper: 객체를 JSON 형식으로 직렬화/역직렬화하는 데 사용됩니다. 요청 데이터를 JSON 문자열로 변환할 때 사용된다.
- @MockBean ManagerService: ManagerService는 @MockBean으로 선언되어, 서비스 계층의 동작을 목(mock)으로 처리한다.
- setup(): 테스트 실행 전에 MockitoAnnotations.openMocks()로 모의 객체 초기화 작업을 진행한다.
여러가지 컨트롤러 테스트 중에 매니저_등록_성공 테스트 메서드에 대해 설명해보겠다.
@Test
public void 매니저_등록_성공() throws Exception {
// given
long todoId = 1L;
UserResponse userResponse = new UserResponse(1L, "user1@example.com");
ManagerSaveRequest request = new ManagerSaveRequest(1L);
ManagerSaveResponse response = new ManagerSaveResponse(1L, userResponse);
AuthUser authUser = new AuthUser(1L, "test@example.com");
String token = jwtUtil.createToken(authUser.getId(), authUser.getEmail(), UserRole.USER);
when(managementService.saveManager(any(AuthUser.class), any(Long.class), any(ManagerSaveRequest.class)))
.thenReturn(response);
// When & then
mockMvc.perform(MockMvcRequestBuilders.post("/todos/{todoId}/managers", todoId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
.header("Authorization", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(response.getId()))
.andExpect(jsonPath("$.user.id").value(response.getUser().getId()))
.andExpect(jsonPath("$.user.email").value(response.getUser().getEmail()));
}
이 테스트 코드는 /todos/{todoId}/managers API를 테스트하며, 매니저 등록 요청이 성공하는지 검증합니다.
given에서 ManagerSaveRequest 객체로 매니저 등록 요청을 준비고 ManagerSaveResponse객체로 매니저 등록 성공 응답을 mock한다. 그리고 JWT 토큰을 생성하여 Authorization 헤더에 추가한다.
when & then에서는 mockMvc를 사용하여 POST 요청을 보내고, 응답을 검증한다. 등록 성공 시 응답이 JSON 형식으로 올바른 필드 값(id, user.id, usesr.email)을 가지고 있는지 확인한다.
그래서 이 컨트롤러 테스트 코드는 MockMvc와 ObjectMapper, 그리고 JwtUtil을 활용하여, 실제 HTTP 요청과 유사한 환경을 모의한다. 각각의 테스트 메서드는 매니저 등록, 조회, 삭제 API가 정상적으로 작동하는지 확인한다.
- 매니저 등록 테스트는 POST 요청을 통해 요청 본문을 JSON으로 직렬화하고, 응답을 JSON 경로(jsonPath)로 검증.
- 매니저 조회 테스트는 GET 요청을 통해 JWT 인증을 거쳐 매니저 목록을 조회하고, 성공적으로 응답을 받았는지 확인.
- 매니저 삭제 테스트는 DELETE 요청을 통해 매니저를 성공적으로 삭제할 수 있는지 확인.
이러한 방식으로 컨트롤러의 각 기능을 테스트하여, API가 예상대로 동작하는지 확인하였다.
'Spring' 카테고리의 다른 글
Fetch Type:LazyLoading vs EagerLoading (0) | 2024.09.30 |
---|---|
Error 메시지(enum 활용) (3) | 2024.09.13 |
AOP(Aspect-Oriented Programming) (0) | 2024.09.12 |
단위 테스트(Unit Testing), JUnit5 (0) | 2024.09.10 |
커스텀 어노테이션 (2) | 2024.09.05 |