2023. 12. 4. 20:58ㆍSpringboot
0. Test code를 작성하는 이유?
test code를 작성하지 않고 결과를 검증하는 시간을 줄여준다.
테스트 코드 작성을 통하여 기존에 검증 과정인 Spring boot application 실행 및 swagger 또는
Postman과 같은 검증 방법을 시행하지만 테스트 코드를 작성하면 해당 src/test/ 디렉터리에서
해당 코드를 실행하면 코드를 검증할 수 있어 시간 단축 및 코드 리뷰하기 편리하다.
1. Test code 작성 전
실제 데이터베이스를 Mysql을 사용하고 테스트 H2 데이터베이스를 사용합니다.
1) build.gradle
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.assertj:assertj-core:3.24.2'
}
2) src/test/resources/application-test.propreties
# application-test.properties
spring.datasource.url=jdbc:h2:mem:testdb;MODE=MYSQL
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true
spring.config.activate.on-profile=test
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
# H2DB 선택 사항
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
2. 단위 테스트와 통합 테스트
1) 단위 테스트
하나의 모듈, 메서드를 기준으로 진행되는 가장 작은 단위 테스트입니다.
장점 : 단위 테스트는 함수, 모듈 하나의 기준으로 진행하는 테스트이기에 유지보수 비용이 적습니다.
단점 : 독립적인 테스트임으로 통합된 테스트 실행 시에 다른 결과가 나올 수 있습니다.
2) 통합 테스트
통합된 모듈의 상호작용을 확인하는 테스트입니다.
장점 : 통합된 모듈의 상호작용을 테스트 하기에 결과가 실제 환경과 같다고 생각할 수 있습니다.
단점 : 어디 부분에서 상호작용이 안되는지 쉽게 파악하기 힘들고 유지 보수 비용이 많이 듭니다.
3. Controller, Service, Repository Test code
1) Controller test code
Controller test code에서의 특징으로 @WebMvcTest 어노테이션을 활용하여 작성합니다.
@WebMvcTest는 주로 웹 계층(컨트롤러, 필터, 에러 핸들러 등)을 테스트하는 데 사용됩니다.
웹 계층 테스트 시에는 나머지 부분을 mocking하여 가볍게 유닛 테스트를 할 수 있습니다.
ex) MemberControllerTest.java
package com.pjh.newsfeedtest.member.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pjh.newsfeedtest.member.domain.Member;
import com.pjh.newsfeedtest.member.dto.MemberResponseDTO;
import com.pjh.newsfeedtest.member.dto.RequestProfileUpdateDto;
import com.pjh.newsfeedtest.member.service.MemberService;
import com.pjh.newsfeedtest.security.config.WebSecurityConfig;
import com.pjh.newsfeedtest.security.service.MemberDetailsImpl;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import static org.mockito.Mockito.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
@WebMvcTest(
controllers = {MemberRestController.class},
excludeFilters = {
@ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = WebSecurityConfig.class
)
}
)
class MemberRestControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private MemberService memberService;
@InjectMocks
private MemberRestController memberRestController;
@Autowired
private ObjectMapper objectMapper;
@Test
@WithMockUser(username = "testUser")
@DisplayName("[Member] [Controller] readUserInfoTest")
void readUserInfoTest() throws Exception {
// Given
String username = "testUser";
String content = "content1";
MemberResponseDTO mockResponse = MemberResponseDTO.builder()
.username(username)
.content(content)
.build();
when(memberService.readMemberInfo(username)).thenReturn(mockResponse);
// When, Then
mockMvc.perform(MockMvcRequestBuilders.get("/api/user/{username}", username))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(jsonPath("$.memberInfo.username").value(username));
}
@Test
@WithMockUser(username = "testUser", password = "testPassword")
@DisplayName("[Member] [Controller] modifyContentTest")
void modifyContentTest() throws Exception {
// Given
String username = "testUser";
String password = "testPassword";
String content = "update content";
RequestProfileUpdateDto updateDto = new RequestProfileUpdateDto(password, content, password);
// 가상 사용자를 생성하는 부분을 업데이트
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(new UsernamePasswordAuthenticationToken(username, password));
SecurityContextHolder.setContext(securityContext);
MemberDetailsImpl mockMemberDetails = new MemberDetailsImpl(Member.builder()
.username(username)
.password(password)
.build());
doNothing().when(memberService).updateMember(updateDto, mockMemberDetails);
// When, Then
mockMvc.perform(MockMvcRequestBuilders.put("/api/user/{username}", username)
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateDto))
.with(request -> {
request.setRemoteUser(username);
return request;
}))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(jsonPath("$.result").value("success"));
}
}
2) Service test code
Service test code에서의 특징으로 @ExtendWith(MockitoExtension.class) 활용하여 작성합니다.
@ExtendWith(MockitoExtension.class)는 Mockito를 사용한 테스트를 위한 확장자입니다.
Mockito를 사용하는 테스트 클래스에 이 어노테이션을 추가하여 Mockito 기능을 활성화합니다.
ex) MemberServiceTest.java
package com.pjh.newsfeedtest.member.service;
import com.pjh.newsfeedtest.member.domain.Member;
import com.pjh.newsfeedtest.member.dto.MemberResponseDTO;
import com.pjh.newsfeedtest.member.dto.RequestProfileUpdateDto;
import com.pjh.newsfeedtest.member.dto.SignupDto;
import com.pjh.newsfeedtest.member.repository.MemberRepository;
import com.pjh.newsfeedtest.security.service.MemberDetailsImpl;
import org.junit.jupiter.api.DisplayName;
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.modelmapper.ModelMapper;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import java.util.NoSuchElementException;
import java.util.Optional;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ActiveProfiles("test")
@ExtendWith(MockitoExtension.class)
public class MemberServiceTest {
@Mock
private MemberRepository memberRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private ModelMapper modelMapper;
@InjectMocks
private MemberService memberService;
@Test
@DisplayName("[Member] [Service] [MemberService] signup Seccuess")
void signupSeccuess() {
// given
String username = "testUser";
String rawPassword = "password123";
String content = "my Content";
SignupDto signupDto = new SignupDto(username,rawPassword,content);
// SignupDto에 들어가는 데이터 입력
when(passwordEncoder.encode(any())).thenReturn("encodedPassword");
// when() -> "password123"이라는 입력값을 받으면 passwordEncoder.encode()를 하고
// thenReturn() -> 해당 입력값에 대해서 encodedPassword로 반환
when(memberRepository.findByUsername(username)).thenReturn(Optional.empty());
// when() -> "testUser"라는 입력값을 findByUsername()메서드에서 받으면
// thenReturn() -> 해당 입력값에 대해서 Optional.empty() 반환, 해당 사용자명은 DB에 존재하지 않음
// when
assertDoesNotThrow(() -> memberService.signup(signupDto));
// 예외처리 검사 memberService.signup(signupDto); 시행
// then
verify(passwordEncoder, times(1)).encode(rawPassword);
verify(memberRepository, times(1)).findByUsername("testUser");
// memberRepository에서 findByUsername("testUser")를 증명하기 위해
// signup 메서드 내부에서 사용자명 중복체크가 이루어지고 있는지 확인
verify(memberRepository, times(1)).save(any(Member.class));
// membeerRepository에서 save함수 호출 시
// 메서드가 Member.class의 인자를 받아 한 번 호출되었는지?
}
@Test
@DisplayName("[Member] [Service] [MemberService] signup duplicate Username")
public void signupDuplicateUsername(){
// given
String duplicateUsername = "existingUsername";
SignupDto signupDto1 = new SignupDto(duplicateUsername, "testPassword", "testContent");
SignupDto signupDto2 = new SignupDto(duplicateUsername, "anotherPassword", "anotherContent");
when(passwordEncoder.encode(any())).thenReturn("encodedPassword");
// existingUser라는 사용자명을 이미 사용중이라고 가정, Optional.of()로 해당 파라미터는 null이 아닐 것을 명시
when(memberRepository.findByUsername(duplicateUsername)).thenReturn(Optional.of(new Member()));
// when, then
assertThrows(IllegalArgumentException.class, () -> memberService.signup(signupDto1));
// 중복 사용자명으로 가입 시도 시 IllegalArgumentException이 발생하는지 확인
verify(memberRepository, times(1)).findByUsername(duplicateUsername);
verify(memberRepository, never()).save(any(Member.class));
// 중복 사용자명이면 저장되지 않아야 함
// 추가로 두 번째 사용자명으로 가입 시도도 예외를 던져야 함
assertThrows(IllegalArgumentException.class, () -> memberService.signup(signupDto2));
}
@Test
@DisplayName("[Member] [Service] [MemberService] updateMember success")
public void UpdateMemberSuccess(){
// Given
Member member = Member.builder()
.username("testUser")
.password("encodedPassword")
.content("my Content").build();
// 해당 사용자는 회원가입을 한 상태임으로 encodedPassword를 가지고 있는게 맞다
MemberDetailsImpl memberDetails = new MemberDetailsImpl(member);
RequestProfileUpdateDto updateProfileDto = new RequestProfileUpdateDto("newPassword","change Content","newPasswordConfirm");
when(passwordEncoder.matches(any(), any())).thenReturn(true);
when(passwordEncoder.encode(any())).thenReturn("encodedNewPassword");
// When
assertDoesNotThrow(() -> memberService.updateMember(updateProfileDto, memberDetails));
// Then
verify(passwordEncoder, times(1)).matches("newPassword", "encodedPassword");
verify(passwordEncoder, times(1)).encode("newPasswordConfirm");
verify(memberRepository, times(1)).save(any(Member.class));
}
@Test
@DisplayName("[Member] [Service] [MemberService] updateMember password != passwordConfirm")
void updateMemberPasswordMismatch() {
// Given
Member member = Member.builder()
.username("testUser")
.password("encodedPassword")
.content("myContent").build();
MemberDetailsImpl memberDetails = new MemberDetailsImpl(member);
RequestProfileUpdateDto updateProfileDto = new RequestProfileUpdateDto();
// When, Then
assertThrows(IllegalArgumentException.class,
() -> memberService.updateMember(updateProfileDto, memberDetails));
}
@Test
@DisplayName("[Member] [Service] [MemberService] readMemberInfo() success")
public void readMemberInfoSuccess() {
// given
String username = "testUser";
Member testMember = new Member();
when(memberRepository.findByUsername(username)).thenReturn(Optional.of(testMember));
when(modelMapper.map(testMember, MemberResponseDTO.class)).thenReturn(new MemberResponseDTO());
// when
MemberResponseDTO result = memberService.readMemberInfo(username);
// then
assertNotNull(result);
verify(memberRepository, times(1)).findByUsername(username);
verify(modelMapper, times(1)).map(testMember, MemberResponseDTO.class);
}
@Test
@DisplayName("[Member] [Service] [MemberService] readMemberInfo() not Found username")
void readMemberInfoUserNotFound() {
// Given
String username = "nonExistingUser";
when(memberRepository.findByUsername(username)).thenReturn(Optional.empty());
// When, Then
assertThrows(NoSuchElementException.class, () -> memberService.readMemberInfo(username));
// Verification
verify(memberRepository, times(1)).findByUsername(username);
verify(modelMapper, never()).map(any(), eq(MemberResponseDTO.class));
}
}
3) Repository test code
Repository 테스트 코드 작성 시에는 Spring Data JPA의 기능을 활용하여
데이터베이스와의 상호작용을 테스트하는 것이 일반적입니다.
@DataJpaTest는 JPA 관련 빈만 등록하며, 실제 데이터베이스를 사용하지 않고
내장된 데이터베이스를 사용하는 테스트를 지원하는 어노테이션입니다.
ex) MemberRepositoryTest.java
package com.pjh.newsfeedtest.member.repository;
import com.pjh.newsfeedtest.member.domain.Member;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@DataJpaTest
@TestPropertySource(locations = "classpath:application-test.properties")
public class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Test
@DisplayName("[Member] [Repository] findByUsernameTest")
@Transactional
public void memberRepositoryFindByUsernameTest() {
// given
Member member = Member.builder()
.username("testUser")
.password("encodedPassword")
.content("my Biography").build();
// when
memberRepository.save(member);
Optional<Member> foundMember = memberRepository.findByUsername("testUser");
// then
assertTrue(foundMember.isPresent());
assertEquals("testUser", foundMember.get().getUsername());
}
}
'Springboot' 카테고리의 다른 글
Spring boot OAuth 로그인(1) (1) | 2023.12.07 |
---|---|
Spring boot 환경 변수 설정 (1) | 2023.12.05 |
Spring boot 중복된 Bean 에러 (0) | 2023.11.30 |
Spring HTTP 요청과 응답 (0) | 2023.11.13 |
Spring boot의 인증 방식 (0) | 2023.11.10 |