Spring boot Test code(1)

2023. 12. 4. 20:58Springboot

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