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' 카테고리의 다른 글