전략패턴을 이용한 단위 테스트 적용기

진주원(JooWon Jin)·2024년 4월 20일
0

TWTW

목록 보기
5/8
post-thumbnail

문제상황


‘TWTW’의 테스트는 크게 Controller, Service, Repository Layer에서 진행되었다. 우리의 목표는 단위 테스트 적용이었다. 하지만 문제점을 발견하였다.

첫 번째 문제점

  • Service 테스트 코드 내에서 Repository를 통한 실제 DB 접근이 이루어져 완벽한 단위 테스트를 수행할 수 없었다.

두 번째 문제점

  • 첫 번째 문제점을 해결하기 위해 Service 테스트 내에서 Mocking 처리를 하였다. 하지만 Repository와의 상호작용마다 Mocking 처리를 함으로서 Mock의 사용량이 많이 증가했다.

접근 방식


  • Repository Test
    • DB를 통한 접근이 수행되는가에 초점을 맞추어 테스트 코드 작성
  • Service Test
    • Stub을 활용하여 DB 접근을 하지 않고 서비스 로직에만 초점을 맞추어 테스트 코드 작성
  • Controller Test
    • mock을 활용하여 Service 로직을 타지 않고 테스트 수행
    • 테스트를 수행하면서 자동으로 rest docs 생성

테스트용 Repository 분리


전략 패턴을 사용한 전체 구조

전략 패턴이란 ?

객체들이 할 수 있는 행위 각각에 대해 전략 클래스를 생성하고, 유사한 행위들을 캡슐화 하는 인터페이스를 정의하여,

객체의 행위를 동적으로 바꾸고 싶은 경우 직접 행위를 수정하지 않고 전략을 바꿔주기만 함으로써 행위를 유연하게 확장하는 방법

Repository에 적용

  • 행위들을 정의한 Repository 인터페이스를 정의
    • 실제 서비스 로직에서는 JpaMemberRepository 즉 운영 레포를 사용
    • 테스트 로직에서는 StubMemberRepository 즉 테스트 레포를 사용

img

Repository의 추상화

@Repository
public interface MemberRepository {
    List<Member> findAllByNickname(final String nickname);

    List<Member> findAllByNicknameContainingIgnoreCase(final String nickname);

    Optional<Member> findByOAuthIdAndAuthType(final String oAuthId, final AuthType authType);

    boolean existsByNickname(final String nickname);

    Member save(final Member member);

    Optional<Member> findById(final UUID id);

    List<Member> findAllByIds(final List<UUID> friendMemberIds);

    void deleteById(final UUID memberId);
}

실제 서비스용 JpaRepository

@Repository
public interface JpaMemberRepository extends JpaRepository<Member, UUID>, MemberRepository {

    @Query(
            value =
                    "SELECT * FROM member m WHERE MATCH (m.nickname) AGAINST(:nickname IN BOOLEAN"
                        + " MODE)",
            nativeQuery = true)
    List<Member> findAllByNickname(@Param("nickname") String nickname);

    @Query(
            "SELECT m FROM Member m WHERE m.oauthInfo.clientId = :oAuthId AND"
                    + " m.oauthInfo.authType = :authType")
    Optional<Member> findByOAuthIdAndAuthType(
            @Param("oAuthId") String oAuthId, @Param("authType") AuthType authType);

    @Query("SELECT m FROM Member m WHERE m.id in :friendMemberIds")
    List<Member> findAllByIds(@Param("friendMemberIds") final List<UUID> friendMemberIds);
}

테스트용 StubRepository

public class StubMemberRepository implements MemberRepository {

    private final Map<UUID, Member> map = new HashMap<>();

    @Override
    public List<Member> findAllByNickname(final String nickname) {
        return map.values().stream()
                .filter(
                        member ->
                                member.getNickname().toUpperCase().contains(nickname.toUpperCase()))
                .toList();
    }

    @Override
    public List<Member> findAllByNicknameContainingIgnoreCase(final String nickname) {
        return map.values().stream()
                .filter(
                        member ->
                                member.getNickname().toUpperCase().contains(nickname.toUpperCase()))
                .toList();
    }

    @Override
    public Optional<Member> findByOAuthIdAndAuthType(
            final String oAuthId, final AuthType authType) {
        return map.values().stream()
                .filter(
                        member -> {
                            final OAuth2Info oauthInfo = member.getOauthInfo();
                            return oauthInfo.getClientId().equals(oAuthId)
                                    && oauthInfo.getAuthType().equals(authType);
                        })
                .findFirst();
    }

    @Override
    public boolean existsByNickname(final String nickname) {
        return map.values().stream().anyMatch(member -> member.getNickname().equals(nickname));
    }

    @Override
    public Member save(final Member member) {
        map.put(member.getId(), member);
        return member;
    }

    @Override
    public void deleteById(final UUID memberId) {
        map.remove(memberId);
    }
}

각 기능별 StubRepository를 만든 후 StubConfig를 통해 테스트 시 빈으로 주입되도록 설정

@TestConfiguration
public class StubConfig {

    private final Map<UUID, Friend> map = new HashMap<>();

    @Bean
    @Primary
    public FriendQueryRepository stubFriendQueryRepository() {
        return new StubFriendQueryRepository(map);
    }

    @Bean
    @Primary
    public FriendCommandRepository stubFriendCommandRepository() {
        return new StubFriendCommandRepository(map);
    }

    @Bean
    @Primary
    public MemberRepository memberRepository() {
        return new StubMemberRepository();
    }

    @Bean
    @Primary
    public PlanRepository planRepository() {
        return new StubPlanRepository();
    }
}

Repository Test


Repository 테스트 시 실제 DB와의 상호작용을 테스트하도록 코드 작성

@DisplayName("MemberRepository의")
class MemberRepositoryTest extends RepositoryTest {

    @Autowired private MemberRepository memberRepository;

    @Test
    @DisplayName("PK를 통한 저장/조회가 성공하는가?")
    void saveAndFindId() {
        // given
        final Member member = memberRepository.save(MemberEntityFixture.FIRST_MEMBER.toEntity());

        // when
        final UUID expected = member.getId();
        final Member result = memberRepository.findById(expected).orElseThrow();

        // then
        assertThat(result.getId()).isEqualTo(member.getId());
    }

    @Test
    @DisplayName("soft delete가 수행되는가?")
    void softDelete() {
        // given
        final Member member = MemberEntityFixture.FIRST_MEMBER.toEntity();
        final UUID memberId = memberRepository.save(member).getId();

        // when
        memberRepository.deleteById(memberId);

        // then
        assertThat(memberRepository.findById(memberId)).isEmpty();
    }
}

Service Test


서비스 로직 테스트를 위해 StubRepository를 이용하여 테스트 작성

  • MemberServiceTest의 경우 주입받는 memberRepository는 StubRepository
@DisplayName("MemberService의")
class MemberServiceTest extends LoginTest {
    @Autowired private MemberService memberService;
    @Autowired private MemberRepository memberRepository;

    @Test
    @DisplayName("닉네임 중복 체크가 제대로 동작하는가")
    void checkNickname() {
        // given
        final Member member = memberRepository.save(MemberEntityFixture.FIRST_MEMBER.toEntity());
        // when
        DuplicateNicknameResponse response = memberService.duplicateNickname(member.getNickname());
        // then
        assertTrue(response.getIsPresent());
    }

    @Test
    @DisplayName("UUID를 통해 Member 조회가 되는가")
    void getMemberById() {
        // given
        final Member member = memberRepository.save(MemberEntityFixture.FIRST_MEMBER.toEntity());

        // when
        Member response = memberService.getMemberById(member.getId());

        // then
        assertThat(response.getId()).isEqualTo(member.getId());
    }

    @Test
    @DisplayName("Member가 MemberResponse로 변환이 되는가")
    void getResponseByMember() {
        // given
        final Member member = memberRepository.save(MemberEntityFixture.FIRST_MEMBER.toEntity());

        // when
        MemberResponse memberResponse = memberService.getResponseByMember(member);

        // then
        assertThat(memberResponse.getMemberId()).isEqualTo(member.getId());
    }

    @Test
    @DisplayName("Nickname을 통한 Member 검색이 수행되는가")
    void searchMemberByNickname() {
        // given
        final Member member = memberRepository.save(MemberEntityFixture.FIRST_MEMBER.toEntity());

        // when
        final List<MemberResponse> responses =
                memberService.getMemberByNickname(member.getNickname().substring(0, 1));

        // then
        assertThat(responses).isNotEmpty();
    }
}

Controller Test


컨트롤러 Layer에서의 Request & Response 테스트를 위해 Service를 mock으로 만들어 테스트 작성

    @Test
    @DisplayName("닉네임이 중복되었는가")
    void duplicate() throws Exception {
        final DuplicateNicknameResponse expected = new DuplicateNicknameResponse(false);
        given(memberService.duplicateNickname(any())).willReturn(expected);

        final ResultActions perform =
                mockMvc.perform(
                        get("/member/duplicate/{name}", "JinJooOne")
                                .contentType(MediaType.APPLICATION_JSON));

        // then
        perform.andExpect(status().isOk()).andExpect(jsonPath("$.isPresent").exists());
        // docs

        perform.andDo(print())
                .andDo(
                        document(
                                "get duplicate nickname",
                                getDocumentRequest(),
                                getDocumentResponse()));
    }

Why Test Double

검증 대상

다시 문제 상황으로 돌아가서 프로젝트에서 Test Double을 사용해야 하는 곳은 Repository이다. 근본적으로 Repository란 무엇인지 생각해봤을 때 DAO의 역할을 한다. 데이터 베이스와 상호작용하며 데이터를 가지고 온다.

  • StubRepository를 적용하는 곳은 Service 계층이다. 즉 데이터 베이스로부터 데이터를 읽고 가져오는 과정을 검증하는 것이 아닌 데이터를 가져와 서비스의 로직을 검증하는 것이다.
  • Service 테스트 로직에 집중하기 위해 Test Double을 사용하는 것이 좋을 것 같아 선택하였다.

Why Stub

Stub과 Mock은 사실 크게 다른 차이점을 보이지 않는다. 둘다 Test Double로서 ‘실제 호출될 테스트에 대한 미리 예상된 결과를 제공한다’라는 의미를 지닌다.

상태 검증 VS 행위 검증

  • 상태 검증 : 메서드가 수행 시 연관된 객체의 상태에 초점을 두고 검증
  • 행위 검증 : 메서드가 수행시 참조하는 객체의 메서드 즉 동작이 제대로 수행되는지에 대해 초점

Stub

테스트 로직에 있어 Stub을 선택하게 된 이유로는 크게 2가지가 있다.

  1. 동적 테스트 실행

Mock을 사용할 경우 설정한 값만 계속해서 반환을 하게 된다. 하지만 Stub을 이용할 경우 Repository를 HashMap으로 관리하기 때문에 테스트 중 저장한 값에 따라 다른 반환을 유도할 수 있기 때문에 동적 테스트가 가능하다.

  1. 무분별한 Mocking 처리 방지

분석


  • Stub을 사용하여 유연한 처리
    • Repository Layer가 JPA에 종속적이지 않고 테스트에 용이한 유연한 구조 가져감
    • Controller 테스트의 경우 하나의 메서드만 mocking하면 되었지만, Service 테스트에서는 많은 의존성 때문에 모두 mock으로 처리하기에 부담, 같은 메서드도 매번 mock 처리하기에도 어려움
  • TestContainer 도입
    • MySQL에서 제공하는 기능을 기존에 사용하던 테스트용 H2 DB에서 지원하지 않음(FULL TEXT INDEX)
    • Redis, RabbitMQ와 같은 외부 시스템과 연동되는 부분을 원활히 테스트

테스트 커버리지


  • Jacoco 도입으로 테스트시와 PR시 커버리지 확인 가능 img
  • Jacoco 커버리지
    img
profile
Young , Wild , Free

0개의 댓글