| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- 추천시스템steam
- LLM기반 콘텐츠 추천
- dialtimeout
- 스프링단위테스트방법
- 존폴쥬얼리
- staem algorithm
- 존폴결혼반지
- 자바스크립트 깨질 때
- steam game score
- jqx워터마크제거
- LLM기반CBF
- integrationtest
- jqxWidget워터마크
- 존폴결혼예물
- 외부 톰캣 특수문자 깨질 때
- 스팀 게임 스코어 알고리즘
- 한글 특수문자 자바스크립트
- spring unit test
- set-cookie 안만들어짐
- Akamai 연구결과
- tomcat튜닝
- 분산파일
- cookie refreshToke
- dial timeout
- 존폴반지
- 동시 요청 처리
- jsp 예외 permission
- jsp permission denied
- 비개인화추천모델
- 분산파일시스템
- Today
- Total
hola 개발
[ Test ] 제대로 익히는 Spring Boot 테스트 방법 #2. 각 계층 단위 테스트 또는 슬라이스 테스트 본문
# 예제 코드들은 글 하단에 있습니다.
# 테스트에 대해 처음 접하는 분들은 아래 글을 읽고 오시는 것을 추천합니다.
https://do-it-zero.tistory.com/57
[ Test ] 제대로 익히는 Spring 테스트 방법 #1. 기본
## 스프링 애플리케이션 테스트 코드는 글 마지막에 있습니다 ##- 왜 써야하는가? 개발을 하면서 어떤 기술이나 방법론을 배울 때는 왜 써야하는지? 에 대한 질문이 중요한 것 같습니다. 왜냐하면
do-it-zero.tistory.com
[ 들어가며 ]
지난 글에서 테스트 유형, 테스트 프레임워크 등에 대한 글을 썼습니다.
이번 글에서는 Spring 애플리케이션의 각 계층인 Controller, Service, Repository 차례대로 어떻게 테스트를 진행할지에 대한 내용입니다.
[ 명명 규칙 ]
테스트 메서드 이름은 그 의도를 명확하게 전달해야 합니다.
왜냐하면 다른 사람들이 테스트를 이해하는 데 도움을 주며, 추후 다시 방문할 때 유용하기 때문입니다.
회사마다 명명 규칙은 다르겠지만, 이번 글에서 명명 규칙은 다음과 같이 하겠습니다.
should[Action]When[Condition]
예를 들어, 회원가입 시 회원 id를 반환하는 테스트를 진행한다면
-> shouldReturnUserIdWhenJoinSuccess() 라고 테스트 이름을 짓도록 하겠습니다.
[ 각 계층 테스트 ]
1.Controller 단위 테스트 -> 슬라이스 테스트
단위 테스트는 다른 프레임워크를 사용하지 않고 순수 자바로 로직을 검증하는 것입니다.
다음은 Controller를 순수 자바로 로직을 검증하는 코드입니다.
@Test
void 컨트롤러_단위_테스트() {
MemberService mockService = mock(MemberService.class);
when(mockService.join(any())).thenReturn(1L);
MemberController controller = new MemberController(mockService);
ResponseEntity<?> response = controller.join(new MemberRequest("test"));
assertEquals(HttpStatus.CREATED, response.getStatusCode());
}
위와 같이 다른 프레임워크 없이 Controller 단위 테스트를 진행할 수 있습니다.
하지만 위와 같은 테스트는 Spring의 DispatcherServlet의 흐름을 거치지 않는다는 문제가 있습니다.
Controller라는 특성 상 DispatcherServlet에 탑재되어 동작하기 때문에 Spring의 실제 동작 흐름에 맞춰서 테스틀 할 수 있어야 합니다. 또한 테스트 특성 상 HTTP 요청을 만들어야 하기도 합니다.
이를 도와주는 도구들을 알아보고 해당 도구를 활용한 테스트 코드를 작성해보겠습니다..
- @WebMvcTest
@WebMvcTest는 Spring MVC 구성 요소 중에서 컨트롤러 계층만 테스트할 수 있도록 설정된 슬라이스 테스트입니다.
즉, 전체 애플리케이션 컨텍스트를 띄우는 것이 아니라, 웹 계층(Spring MVC)에 관련된 Bean들만 로드합니다.
| 대상 | @Controller, @ControllerAdvice 등 |
| 목적 | 웹 요청/응답 흐름 테스트 |
| 로딩 Bean | Spring MVC 관련 Bean만 (예: Controller, @JsonComponent, @ControllerAdvice 등) |
| 제외 대상 | @Service, @Repository, @Component 등은 자동으로 로드되지 않음 |
| Mock | 필요한 Bean은 @MockBean을 이용해 주입 |
- MockMvc
MockMvc는 Spring에서 제공하는 웹 계층 테스트 도구로, 실제로 서버를 실행하지 않고도 컨트롤러에 HTTP 요청을 보내고 응답을 검증할 수 있게 해줍니다.
| 목적 | 서버 없이 Controller 테스트 |
| 테스트 대상 | DispatcherServlet → Filter → Controller → Response 까지의 흐름 |
| 실제 요청 X | 네트워크 없이 내부적으로 요청 시뮬레이션 |
| Http 요청 | get(), post(), put(), delete() 등 사용 가능 |
| 응답 검증 | status(), jsonPath(), content() 등으로 가능 |
-@MockitoBean
Spring Test 컨텍스트에 가짜 Bean을 등록해줍니다.
기존 빈을 **대체(override)**하여 테스트에서 원하는 동작을 지정할 수 있게 합니다.
주로 @WebMvcTest, @SpringBootTest 등에서 의존성을 주입해야 하지만 실제 빈을 사용하고 싶지 않을 때 사용합니다.
복잡한 의존성을 가진 클래스에서도 일부만 목 처리하여 부분 통합 테스트 가능하게 합니다.
예를 들어 컨트롤러 테스트 시, 서비스 레이어를 MockBean으로 대체해 독립적인 테스트를 하거나, 실제 DB 연결 없이도 레포지토리 계층 MockBean으로 대체해 테스트를 가능하게 합니다.
이 때 MockBean으로 대체된 경우 그 결과값을 명시해줘야 합니다.(아래의 예시에 memberService의 결과값을 명시한 부분 참고)
이러한 도구들을 사용하여 MemberController의 join 메서드를 슬라이스 테스트 예시 입니다.
@WebMvcTest(MemberController.class) // Spring Mvc Test Context에 등록
class MemberControllerTest {
/** MockMvc 사용이유
* 실제 서버를 띄우지 않고도 HTTP 요청과 응답을 시뮬레이션할 수 있게 해주기 때문
* @WebMvcTest(MemberController.class) 으로 등록된 MemberController에 HTTP 요청 시물레이션
* */
@Autowired
private MockMvc mockMvc;
/** @MockitoBean 사용이유
* MemberService 타입의 mock을 만들어서 MemberController에 주입
* 이렇게 하는 이유는 단위 테스트는 외부 의존성을 제거하고 로직을 검증해야 하기 때문
* */
@MockitoBean
private MemberService memberService;
/** Jackson의 JSON 직렬화 하는 이유
* 컨트롤러에서 @RequestBody에 해당하는 요청본문을 역질렬화 하기 때문
*/
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("MemberController join 테스트")
void shouldReturnUserIdWhenJoinSuccess() throws Exception {
// given
MemberDto memberDto = new MemberDto("test");
Member member = memberDto.toEntity();
String json = objectMapper.writeValueAsString(memberDto);
// mock 으로 구현된 memberService의 join시의 결과를 명시해줌
Mockito.when(memberService.join(Mockito.any(MemberDto.class)))
.thenReturn(member.getId());
mockMvc.perform(post("/members")
.contentType(MediaType.APPLICATION_JSON)
.content(json)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(member.getId()));
}
}
2. Service 단위 테스트
현재 예제 코드의 Service는 Controller 와 달리 HTTP 환경이 필요하지 않습니다.
join 로직에 memberRepository가 사용됩니다.
따라서 join 단위 테스트시에는 memberRepository는 mock 객체로 만들고 save시 어떤 타입을 리턴할지 명시해주면 됩니다.
public String join(MemberDto memberDto) {
validateDuplicateMember(memberDto);
return memberRepository.save(memberDto.toEntity()).getId();
}
private void validateDuplicateMember(MemberDto memberDto) {
memberRepository.findByName(memberDto.getName())
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
-@Mock
테스트 대상 클래스가 Repository, Service, HttpClient, 외부 API 등을 의존할 때 이 의존 객체들을 실제로 만들지 않고, @Mock으로 대체할 때 사용됩니다.
| 정의 | 가짜 객체(Mock)를 만들어주는 어노테이션 |
| 동작 | Mockito가 memberRepository의 동작을 흉내냄 |
| 효과 | 의존성 격리, 단위 테스트에 집중 |
| 주의 | @ExtendWith(MockitoExtension.class)와 함께 사용해야 동작 |
-@InjectMocks
@Mock 객체들을 주입해서 테스트 대상 객체 생성해줍니다. 즉, 테스트 할 대상 클래스에 사용되는 어노테이션입니다.
지금 테스트에서는 memberServie가 대상 객체이고 memberRepository가 의존 객체입니다.
-@ExtendWith(MockitoExtension.class)
JUnit 5에서 Mockito 기능을 활성화해주는 어노테이션입니다.
테스트 클래스에 붙이면 Mockito 관련 어노테이션(@Mock, @InjectMocks, 등)이 자동으로 동작하게 됩니다.
해당 도구들을 활용하여 memberService의 join 단위 테스트입니다.
join시 기존 회원이 존재하는지 확인하는 기능이 있으므로 , 해당 기능이 작동하는지에 대한 테스트는 따로 진행하였습니다.
@ExtendWith(MockitoExtension.class)
class MemberServiceTest {
@Mock
private MemberRepositoryJpa memberRepository;
@InjectMocks
private MemberService memberService;
@Test
@DisplayName("join시 name 중복 테스트")
void validateDuplicateJoin(){
// given
MemberDto memberDto = new MemberDto("test");
Member member = memberDto.toEntity();
when(memberRepository.findByName(member.getName())).thenReturn(Optional.of(member));
// when & then
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> memberService.join(memberDto));
// 예외 메세지 검증
assertEquals("이미 존재하는 회원입니다.",exception.getMessage());
// save() 호출 되지 않았는지 검증
Mockito.verify(memberRepository,Mockito.never()).save(any());
}
@Test
@DisplayName("join 테스트")
void join() {
// given
MemberDto memberDto = new MemberDto("test1");
Member member = memberDto.toEntity();
when(memberRepository.save(any(Member.class))).thenReturn(member);
// when
String savedId = memberService.join(memberDto);
// then
Assertions.assertNotNull(savedId);
}
}
3. Repository 단위 테스트 -> 슬라이스 테스트
이 쯤에서 다시 한번 단위 테스트의 주요 목적을 생각해봐야 합니다.
단위 테스트의 목적은 메서드나 클래스와 같은 단일 "단위"의 작업에 집중하고, 외부 시스템(데이터베이스 등) 과는 독립적으로 그 로직을 테스트 하는 것입니다.
그렇기에 외부 DB와 연동된 Repository를 정확하게 테스트 하기 위해서는 단위 테스트 보다는 슬라이스 테스트나 통합 테스트를 진행하는 것이 합리적이라고 생각합니다.
-@DataJpaTest
Spring Boot에서 JPA Repository만 슬라이스 테스트(slice test) 하도록 도와주는 어노테이션입니다.
실제 DB가 아니라 보통 H2 인메모리 DB로 테스트되며, JPA Repository의 동작(쿼리, 저장, 조회 등)을 검증할 때 사용합니다.
@DataJpaTest
class MemoryMemberRepositoryTest {
@Autowired
private MemberRepositoryJpa memberRepository;
@Test
void shouldReturnMemberWhenSaveSuccess() {
// given
MemberDto memberDto = new MemberDto("test");
Member member = new Member(memberDto.getName());
// when
Member savedMember = memberRepository.save(member);
// then
assertEquals(member.getName(),savedMember.getName());
}
}
[ 정리 ]
각 계층에 대한 테스트 방법을 정리하자면 다음과 같습니다.
| 계층 | 테스트 방법 | 목적 | Spring Context |
| Controller | @WebMvcTest + MockMvc | HTTP 흐름 테스트 | 일부 (슬라이스) |
| Service | Mockito로 단위 테스트 | 비즈니스 로직 검증 | ❌ 필요 없음 |
| Repository | @DataJpaTest | DB 연동 검증 | ✅ 필요 (슬라이스) |
[ 예제 코드 ]
java : 17
spring boot : 3.5.3
build tool : maven
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.4.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
</dependencies>
- dto
public class MemberDto {
private String name;
public MemberDto(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Member toEntity() {
return new Member(this.name);
}
}
-entity
@Entity
@Table(name = "members")
public class Member {
@Id
private String id;
@Column
private String name;
public Member() {
}
// 생성자, getter
public Member(String name) {
this.id = IdGenerator.generateId();
this.name = name;
}
public String getId() { return id; }
public String getName() { return name; }
}
-controller
@RestController
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@PostMapping
public ResponseEntity<?> join(@RequestBody MemberDto memberDto) {
String id = memberService.join(memberDto);
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("id", id));
}
@GetMapping("/{id}")
public ResponseEntity<Member> get(@PathVariable String id) {
return memberService.findOne(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
-service
@Service
public class MemberService {
private final MemberRepositoryJpa memberRepository;
public MemberService(MemberRepositoryJpa memberRepository) {
this.memberRepository = memberRepository;
}
public String join(MemberDto memberDto) {
validateDuplicateMember(memberDto);
return memberRepository.save(memberDto.toEntity()).getId();
}
private void validateDuplicateMember(MemberDto memberDto) {
memberRepository.findByName(memberDto.getName())
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
public Optional<Member> findOne(String id) {
return memberRepository.findById(id);
}
public List<Member> findAll() {
return memberRepository.findAll();
}
}
-repository
@Repository
public interface MemberRepositoryJpa extends JpaRepository<Member,String> {
Optional<Member> findById(String id);
Optional<Member> findByName(String name);
List<Member> findAll();
}
'프레임워크 > 스프링' 카테고리의 다른 글
| [ Test ] 제대로 익히는 Spring Boot Test 방법 #3. 통합 테스트 (0) | 2025.07.15 |
|---|---|
| [ Test ] 제대로 익히는 Spring Boot 테스트 방법 #1. 기본 (1) | 2025.07.07 |
| [ 문제 해결 ] XLSTransformer java.lang.ClassNotFoundException 과 NoClassDefFoundError 발생 시(feat. ChatGpt도 한계가 있음을) (0) | 2025.06.23 |
| [Spring] 서블릿 필터와 인터셉터는 왜 필요하고 어떻게 쓰는걸까? (3) | 2024.10.14 |
| [ Spring ] 설정과 사용의 분리의 효과는 어마어마해! - 커넥션 풀과 DataSource (1) | 2024.10.14 |