‘TWTW’의 테스트는 크게 Controller, Service, Repository Layer에서 진행되었다. 우리의 목표는 단위 테스트 적용이었다. 하지만 문제점을 발견하였다.
전략 패턴을 사용한 전체 구조
객체들이 할 수 있는 행위 각각에 대해 전략 클래스를 생성하고, 유사한 행위들을 캡슐화 하는 인터페이스를 정의하여,
객체의 행위를 동적으로 바꾸고 싶은 경우 직접 행위를 수정하지 않고 전략을 바꿔주기만 함으로써 행위를 유연하게 확장하는 방법
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 테스트 시 실제 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();
}
}
서비스 로직 테스트를 위해 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();
}
}
컨트롤러 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()));
}
다시 문제 상황으로 돌아가서 프로젝트에서 Test Double을 사용해야 하는 곳은 Repository이다. 근본적으로 Repository란 무엇인지 생각해봤을 때 DAO의 역할을 한다. 데이터 베이스와 상호작용하며 데이터를 가지고 온다.
- StubRepository를 적용하는 곳은 Service 계층이다. 즉 데이터 베이스로부터 데이터를 읽고 가져오는 과정을 검증하는 것이 아닌 데이터를 가져와 서비스의 로직을 검증하는 것이다.
- Service 테스트 로직에 집중하기 위해 Test Double을 사용하는 것이 좋을 것 같아 선택하였다.
Stub과 Mock은 사실 크게 다른 차이점을 보이지 않는다. 둘다 Test Double로서 ‘실제 호출될 테스트에 대한 미리 예상된 결과를 제공한다’라는 의미를 지닌다.
테스트 로직에 있어 Stub을 선택하게 된 이유로는 크게 2가지가 있다.
Mock을 사용할 경우 설정한 값만 계속해서 반환을 하게 된다. 하지만 Stub을 이용할 경우 Repository를 HashMap으로 관리하기 때문에 테스트 중 저장한 값에 따라 다른 반환을 유도할 수 있기 때문에 동적 테스트가 가능하다.