yeonuel-tech

단위 테스트를 적용하지 못했을 때의 문제점과 단위 테스트의 중요성 본문

backend-framework/Spring

단위 테스트를 적용하지 못했을 때의 문제점과 단위 테스트의 중요성

여늘(yeonuel) 2024. 9. 1. 18:41

이전까지는 단위 테스트에 대해 명확히 이해하지 못하고 프로젝트를 했던 것 같습니다. 그렇기에 제가 작성한 테스트 코드에 어려운 점들이 있었습니다

첫 번째로 제가 작성하고 있는 테스트가 필요 이상으로 복잡했습니다. 두 번째로 프로젝트 외부 환경 예를 들어서 DB 테이블의 데이터가 변경됨에 따라 테스트 코드의 결과가 달라졌습니다. 이 부분이 가장 골칫거리였습니다.

첫 번째 부분을 살펴보겠습니다. 밑에 코드를 참고하면 좋을것 같습니다. 이 테스트 코드는 단위 테스트에 대해 이해하지 못하고 있었을 때 작성한 부분입니다. 일단, 테스트 초기화 부분을 보면 많은 작업을 해야하는 것을 볼 수 있습니다.

(코드)

제가 개발한 부분은 문의글입니다. 문의글에는 문의글과 카테고리, 문의글 상태 등이 있고 이를 다루기 위한 dao가 정의되어 있습니다. 서비스를 테스트할 때 해당 dao를 통해서 테스트 환경을 조정해줘야 했습니다. 그렇기에 초기화 작업에서는 모든 데이터를 정리하고 필요한 데이터를 미리 등록해 놓는 작업을 하고 있습니다.

그리고 다른 테스트 부분들을 보면 여러 dao를 사용함으로써 로직이 복잡해지는 것을 알 수 있습니다. 위와 같이 테스트 코드를 작성하는데에 시간이 많이 걸리고 복잡해지는 문제가 발생하는 것을 알 수 있습니다.

(코드)

다음으로 두 번째 부분을 살펴보겠습니다. 일단, 밑에 부분에는 서비스에 대해 테스트를 작성한 코드입니다. 여기서 이상한 점은 dao를 주입받아서 해당 테스트 코드에서 사용한다는 점 입니다. 제가 원하는 것은 서비스에 대해서 테스트를 작성하는 것입니다. 즉, 서비스 로직만 검증하면되지 dao 까지 검증하거나 사용할 필요가 없습니다. 아니 엄밀히 말하면 사용하면 안됩니다.

(코드)

단위 테스트에서 중요한 것은 작은 부분에 대해서만 테스트를 해야한다는 것입니다. 또한, 테스트 대상 이외의 것들에 종속 되어버리면 안됩니다. 이 말은 테스트 대상이 의존하고 있는 객체도 포함하고 있는 말이기도 합니다.

따라서, 테스트 대상이 의존하고 있는 객체를 테스트용 객체로 바꿔서 테스트를 작성해야 합니다. 이를 통해서 테스트 대상에 대해서만 검증할 수 있고 테스트 결과가 외부 환경에 종속되지 않아서 테스트 결과의 일관성을 유지할 수 있습니다.

테스트용 객체를 사용하기 위한 라이브러리가 있습니다. 그것은 mokito이다. 다음은 mokito를 활용해서 단위 테스트를 적용해본 코드입니다.

package com.example.shop2.service.base;

import static org.junit.jupiter.api.Assertions.*;

import com.example.shop2.constant.Role;
import com.example.shop2.dto.MemberFormDto;
import com.example.shop2.entity.Member;
import com.example.shop2.exception.member.DuplicatedEmailException;
import com.example.shop2.repository.MemberRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.transaction.TransactionSystemException;

import static org.mockito.Mockito.*;


@ExtendWith(MockitoExtension.class)
class MemberServiceBaseTest {

    @Mock
    private MemberRepository memberRepository;

    @InjectMocks
    private MemberServiceBase memberServiceBase;


    @BeforeEach
    public void setUp() {
        assertNotNull(memberRepository);
        assertNotNull(memberServiceBase);
    }

    /**
     * '회원파트' 기능 목록
     * - 1. 회원 등록
     * - 2. 로그인
     * - 3. 로그아웃
     *
     * Service 기능 구현 내용
     * - 1. 회원 등록 -> create
     * - 1-1. 이미 등록된 이메일로 회원을 등록할 경우, 이메일 중복됐다는 예외 던지기
     * - 1-2. 필수값이 누락된 DTO로 회원 등록할 경우, 필수값 누락됐다는 예외 던지기
     * - 1-3. DB상의 문제가 발생해서 재시도 처리,
     * - 1-4. 회원 등록 성공
     *
     * - 2. 로그인 처리 -> login
     * - 2-1. 로그인 실패시 false
     * - 2-2. 로그인 성공시 true
     * - 2-3. 해당 이메일로 회원을 조회하지 못한 경우, 회원을 찾을 수 없다는 예외 던지기
     *
     * - 3. 로그아웃 처리 -> logout, 컨트롤러 단에서 세션만 없애기
     *
     *
     */


    @DisplayName("회원 등록 - 1-1. 이미 등록된 이메일로 회원을 등록할 경우, 이메일 중복됐다는 예외 던지기")
    @Test
    public void testCreatingMemberWithDuplicatedEmail() {
        // given
        var duplicatedEmailMemberFormDto = createMemberFormDto(1);
        when(memberRepository.findByEmail(anyString())).thenReturn(new Member());

        assertThrows(DuplicatedEmailException.class,
                () -> memberServiceBase.create(duplicatedEmailMemberFormDto));
    }

    @DisplayName("회원 등록 - 1-2. 필수값이 누락된 DTO로 회원 등록할 경우, 필수값 누락됐다는 예외 던지기")
    @Test
    public void testCreatingMemberWithMissingRequiredValues() {
        var emptyRequiredValuesMemberFormDto = createMemberFormDto(1);
        emptyRequiredValuesMemberFormDto.setEmail(null);

        when(memberRepository.save(any(Member.class)))
                .thenThrow(TransactionSystemException.class);

        assertThrows(TransactionSystemException.class,
                () -> memberServiceBase.create(emptyRequiredValuesMemberFormDto));
    }


    @DisplayName("회원 등록 - 1-4. 회원 등록 성공")
    @ParameterizedTest
    @ValueSource(ints = {1, 10, 15, 20})
    public void testCreatingMember(int count) {
        for (int i=0; i<count; i++) {
            var memberFormDto = createMemberFormDto(i);
            var member = Member.createMember(memberFormDto);

            // 중복된 이메일 못찾게 만듦
            when(memberRepository.findByEmail(anyString())).thenReturn(null);
            // save 메서드가 호출되면 member를 반환하게 만듦
            when(memberRepository.save(any(Member.class))).thenReturn(member);

            Member savedMember = memberServiceBase.create(memberFormDto);

            assertSameMember(member, savedMember);
        }


    }

    @DisplayName("로그인 - 2-1. 로그인 실패시 false")
    @Test
    public void testLoginFail() {
        // dto, 엔티티 생성
        var memberFormDto = createMemberFormDto(1);
        var member = Member.createMember(memberFormDto);

        // 리포지토리 세팅
        when(memberRepository.findByEmail(anyString())).thenReturn(member);

        // 실행, 결과가 False
        memberFormDto.setPassword("wrongpassword");
        assertFalse(memberServiceBase.isValidUser(memberFormDto));

    }

    @DisplayName("로그인 - 2-2. 로그인 성공시 true")
    @Test
    public void testLoginSuccess() {
        // dto, 엔티티 생성
        var memberFormDto = createMemberFormDto(1);
        var member = Member.createMember(memberFormDto);

        // 리포지토리 세팅
        when(memberRepository.findByEmail(anyString())).thenReturn(member);

        // 실행, 결과가 true
        assertTrue(memberServiceBase.isValidUser(memberFormDto));
    }

    @DisplayName("로그인 - 2-3. 해당 이메일로 회원을 조회하지 못한 경우, 회원을 찾을 수 없다는 예외 던지기")
    @Test
    public void testLoginWithNonExistentEmail() {
        // dto, 엔티티 생성
        var memberFormDto = createMemberFormDto(1);
        var member = Member.createMember(memberFormDto);

        // 리포지토리 세팅
        when(memberRepository.findByEmail(anyString())).thenReturn(null);

        // 실행, 결과가 False
        assertFalse(memberServiceBase.isValidUser(memberFormDto));
    }


    private MemberFormDto createMemberFormDto(int i) {
        var memberFormDto = new MemberFormDto();

        memberFormDto.setEmail("test@gmail.com" + i);
        memberFormDto.setAddress("서울시 강남구");
        memberFormDto.setName("홍길동" + i);
        memberFormDto.setPassword("testpassword" + i);
        memberFormDto.setRole(Role.USER.name());

        return memberFormDto;
    }

    private void assertSameMember(Member member, Member savedMember) {
        assertTrue(member.getName().equals(savedMember.getName()) &&
                member.getEmail().equals(savedMember.getEmail()) &&
                member.getPassword().equals(savedMember.getPassword()) &&
                member.getAddress().equals(savedMember.getAddress()) &&
                member.getRole().equals(savedMember.getRole()));
    }

}

 

package com.example.shop2.service.expand;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

import com.example.shop2.constant.Role;
import com.example.shop2.dto.MemberFormDto;
import com.example.shop2.exception.global.RetryFailedException;
import com.example.shop2.service.base.MemberServiceBase;
import java.time.LocalDateTime;
import org.junit.jupiter.api.BeforeEach;
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.springframework.dao.DataAccessException;

@ExtendWith(MockitoExtension.class)
class MemberServiceWithRetryTest {


    @Mock
    private MemberServiceBase memberServiceBase;

    @InjectMocks
    private MemberServiceWithRetry memberServiceWithRetry;


    private static class CustomDataAccessException extends DataAccessException {

        // 기본 생성자
        public CustomDataAccessException(String msg) {
            super(msg);
        }

        // 원인을 포함하는 생성자
        public CustomDataAccessException(String msg, Throwable cause) {
            super(msg, cause);
        }
    }

    @BeforeEach
    public void setUp() {
        assertNotNull(memberServiceWithRetry);
        this.memberServiceWithRetry = new MemberServiceWithRetry(memberServiceBase);
    }

    /**
     * '회원파트' 기능 목록
     * - 1. 회원 등록
     * - 2. 로그인
     * - 3. 로그아웃
     *
     * Service 기능 구현 내용
     * - 1. 회원 등록 -> create
     * - 1-1. 이미 등록된 이메일로 회원을 등록할 경우, 이메일 중복됐다는 예외 던지기
     * - 1-2. 필수값이 누락된 DTO로 회원 등록할 경우, 필수값 누락됐다는 예외 던지기
     * - 1-3. DB상의 문제가 발생해서 재시도 처리,
     * - 1-4. 회원 등록 성공
     *
     * - 2. 로그인 처리 -> login
     * - 2-1. 로그인 실패시 false
     * - 2-2. 로그인 성공시 true
     * - 2-3. 해당 이메일로 회원을 조회하지 못한 경우, 회원을 찾을 수 없다는 예외 던지기
     *
     * - 3. 로그아웃 처리 -> logout, 컨트롤러 단에서 세션만 없애기
     *
     *
     */


    @DisplayName("회원 등록 - 1-3. DB상의 문제가 발생해서 재시도 처리")
    @Test
    public void testCreatingMemberWithExternalSettingError() {
        LocalDateTime startTime = LocalDateTime.now();
        LocalDateTime expectedEndTime = startTime.plusSeconds(9);

        MemberFormDto memberFormDto = createMemberFormDto(1);
        when(memberServiceBase.create(any(MemberFormDto.class)))
                .thenThrow(CustomDataAccessException.class);

        assertThrows(RetryFailedException.class,
                () -> memberServiceWithRetry.create(memberFormDto)); // 10초 걸리게 만듦

        LocalDateTime actualEndTime = LocalDateTime.now();
        assertTrue(actualEndTime.isAfter(expectedEndTime));

    }


    private MemberFormDto createMemberFormDto(int i) {
        var memberFormDto = new MemberFormDto();

        memberFormDto.setEmail("test@gmail.com" + i);
        memberFormDto.setAddress("서울시 강남구");
        memberFormDto.setName("홍길동" + i);
        memberFormDto.setPassword("testpassword" + i);
        memberFormDto.setRole(Role.USER.name());

        return memberFormDto;
    }


}



이 과정을 통해 내가 경험한 것은 단위 테스트의 중요성입니다. 사실 이전까지 프로젝트를 진행하면서 팀원끼리 코드를 통합하는 과정에서 항상 기존에 성공하던 테스트 코드가 실패하는 경우가 많이 발생했습니다. 이러한 문제점으로 테스트 코드 관리하는데 시간도 많이 걸릴 뿐더러 테스트 코드를 잘 활용하지 못했습니다. 하지만, 단위 테스트를 준수하면서 개발했을 때는 리팩토링 과정에서 단위 테스트를 적극 활용할 수 있었습니다.