해당 문서는 카카오 헤어와 우아한 마켓 모두에게 적용되기 때문에 한 번에 정리하였습니다. 코드는 아래에서 확인하실 수 있습니다.
카카오 헤어샵
우아한 테크 마켓

안녕하세요. 이번 포스팅에서는 개발 프로세스를 어떻게 가져갈 것 인지에 대해 작성 해 보겠습니다. 어떤 순서로 어떻게 개발을 할 것 인가에 대한 내용이며 '이런 방법도 있구나' 라고 참고하시면 좋을 것 같습니다.

일반적으로 서버를 개발할 때 Production 코드를 먼저 작성하고, 이 코드가 정상적으로 동작한다는 것을 증명하기 위해 테스트 코드 를 작성합니다. 여기서 인수 테스트를 추가한 방식을 개인적으로 선호하는데요! 이를 그림으로 표현하면 아래와 같습니다.

1. 인수 테스트(Acceptance Test)

인수 테스트에서는 실제로 서버에 요청을 보내서 필요한 메소드들이 정상적으로 동작하는지 테스트 합니다. 따라서 MockMvc 가 아닌 RestAssured 를 사용하였습니다. (참고 : MockMvc는 실제 서버에 요청을 보내는 것이 아니라 동일한 형태의 서버를 임시로 만들어 요청을 하기 때문에 부적절하다고 생각했습니다.)

인수 테스트에서 테스트하는 항목은 아래와 같습니다.

  • 실제 요청에 정상적으로 응답한다.
  • 권한 및 로그인과 관련된 부분을 실제로 테스트한다.
    • 타 테스트에서는 Mocking 해서 사용
    • Interceptor / MethodArgumentResolver 는 추가적으로 단위테스트 작성
  • CRUD를 모두 테스트 하며 하나의 도메인이 정상 작동하는지 확인한다.
/*
    Scenario : 회원을 관리한다.
        when : 회원을 만든다.
        then : 회원이 생성된다.

        when : 회원 전체 정보를 조회한다.
        then : 회원 전체 정보가 조회되었다.

        when : 회원을 변경했을 때
        then : 회원 정보를 읽어온다.
        then : 회원 정보가 변경 정보와 일치한다.

        when : 회원을 삭제한다.
        then: 기존 회원이 삭제되었다.
     */
    @Test
    void manageMember() {
        TokenResponse token = createMemberAndRetrieveToken();
        MemberResponse findMember = fetchMember(token);

        fetchUpdate(token);
        MemberResponse updatedMember = fetchMember(token);
        assertThat(findMember).isEqualToIgnoringGivenFields(updatedMember, "name");

        fetchDelete(token);
        fetchNotExistMember(token);
    }

		private void fetchNotExistMember(TokenResponse token) {
        given()
            .auth().oauth2(token.getAccessToken())
            .when()
            .get(RESOURCE)
            .then()
            .log().all()
            .statusCode(HttpStatus.NOT_FOUND.value());
    }

    private void fetchDelete(TokenResponse token) {
        given()
            .auth().oauth2(token.getAccessToken())
            .when()
            .delete(RESOURCE)
            .then()
            .log().all()
            .statusCode(HttpStatus.NO_CONTENT.value());
    }

    private String fetchUpdate(TokenResponse token) {
        MemberUpdateRequest request = MemberFixture.updateDto();

        return given()
            .auth().oauth2(token.getAccessToken())
            .contentType(MediaType.APPLICATION_JSON_VALUE)
            .body(request)
            .when()
            .put(RESOURCE)
            .then()
            .log().all()
            .statusCode(HttpStatus.OK.value())
            .extract()
            .header("Location");
    }

위와 같이 실제로 RestAssured 를 통해 요청하고 응답을 테스트하는 형태로 되어 있습니다. (일부 생략된 코드도 있습니다.)

2. 컨트롤러 테스트

컨트롤러 테스트에서는 특정 요청에 대해서 HandlerMapping 이 정상적으로 되어 있는지, 그리고 응답 상태 코드와 같은 정보 등이 제대로 응답 하는지에 대해서 주로 테스트합니다. 컨트롤러 테스트는 실제로 동작을 확인하는 것이 아닌, 컨트롤러 만을 테스트하기 때문에 MockMvc 를 사용했습니다. 추가적으로 일종의 단위 테스트처럼 작동하기 때문에 의존 관계인 Servicemocking 하여 진행합니다. (전체 테스트는 1번 인수테스트에서 확인하기 때문에)

참고

  • SliceTest(WebMvcTest) 를 사용하지 않은 이유는 권한 관리 및 MethodArgumentResolver 등 Import 를 추가적으로 해야 하는 대상이 너무 많아져서 SpringbootTest 로 대체하였습니다.
  • 문서화와 관련된 부분은 다른 포스팅에서 추가적으로 작성하였습니다.

테스트하는 항목은 아래와 같습니다.

  • 정상적인 요청
  • 정상적인 응답
  • 부적절한 응답
  • 부적절한 요청
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MemberControllerTest {
    private static final String REDIRECT_URL = "https:/kauth.kakao.com/oauth/authorize?response_type=code&client_id=AAA&redirect_uri=BBB";

    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private MemberService memberService;

    @MockBean
    private LoginService loginService;

    @BeforeEach
    void setUp(WebApplicationContext context) {
        mockMvc = MockMvcBuilders.webAppContextSetup(context)
            .addFilters(new CharacterEncodingFilter("UTF-8", true))
            .alwaysDo(print())
            .build();
    }

    @DisplayName("회원을 생성하는 요청을 정상적으로 처리한다.")
    @Test
    void create() throws Exception {
        when(memberService.create(any(Member.class))).thenReturn(TokenResponse.of(anyString()));
        when(memberService.findBySocialId(anyString())).thenReturn(MemberFixture.memberWithId());

        final MemberCreateRequest request = MemberFixture.createDto();

        mockMvc.perform(post("/api/members")
            .header("Authorization", "VALID_TOKEN")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsBytes(request))
        )
            .andExpect(status().isOk())
            .andExpect(jsonPath("accessToken").isNotEmpty());
    }

		@DisplayName("회원을 생성 요청의 body 가 잘못된 경우 Invalid 된다.")
    @Test
    void createBadRequest() throws Exception {
        MemberCreateRequest request = MemberFixture.createWrongDto();
        when(memberService.create(any(Member.class))).thenReturn(TokenResponse.of(anyString()));
        mockMvc.perform(post("/api/members")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsBytes(request))
        )
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("code").value(ErrorCode.INVALID_REQUEST.getCode()))
            .andExpect(jsonPath("errors").exists());
    }

위와 같이 요청에 대해서 정상적인 응답을 내려 주는지 테스트하는 클래스입니다.

3. 컨트롤러 구현

이제 테스트가 정상적으로 동작하기 위해서 컨트롤러를 구현 해보겠습니다. 아래와 같이 코드를 작성하면, Service가 존재하지 않아 컴파일 에러가 발생합니다. Service로 넘어가 보겠습니다.

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/members")
public class MemberController {
    private final MemberService memberService;
    private final LoginService loginService;

    @PostMapping
    public ResponseEntity<TokenResponse> create(@Valid @RequestBody MemberCreateRequest request) {
        final TokenResponse token = memberService.create(request.toMember());

        return ResponseEntity.ok(token);
    }

    @GetMapping
    public ResponseEntity<MemberResponse> findByMemberId(@LoginMember Member member) {
        return ResponseEntity.ok(MemberResponse.from(member));
    }

    @PutMapping
    public ResponseEntity<Void> updateByMemberId(@LoginMember Member member,
        @RequestBody @Valid MemberUpdateRequest request) {
        memberService.update(member, request);

        return ResponseEntity.ok()
            .header("Location", "/api/members/" + member.getId())
            .build();
    }

4. 서비스 테스트

서비스는 도메인간의 순서를 보장해주며 Repository에 접근하여 필요한 정보를 가져오는 레이어입니다. 하지만 컨트롤러 테스트처럼 외부에 의존적인 부분을 제외하고 자신의 메소드가 정상 동작함을 테스트해야하기 때문에 외부 모듈은 Mocking 하여 테스트합니다.

테스트하는 항목은 아래와 같습니다.

  • 도메인의 순서를 보장하는지 테스트
  • 외부 모듈을 제외한 내부 (서비스 자체) 로직이 정상 동작 하는지
@ExtendWith(MockitoExtension.class)
class MemberServiceTest {

    private MemberService memberService;

    @Mock
    private MemberRepository memberRepository;

    @Mock
    private JwtGenerator jwtGenerator;

    @BeforeEach
    void setUp() {
        this.memberService = new MemberService(memberRepository, jwtGenerator);
    }

    @DisplayName("회원이 정상적으로 생성된다.")
    @Test
    void create() {
        final Member expectedMember = MemberFixture.memberWithId();
        when(memberRepository.save(any(Member.class))).thenReturn(expectedMember);
        when(jwtGenerator.createCustomToken(anyString())).thenReturn(TokenResponse.of(anyString()));

        final TokenResponse tokenResponse = memberService.create(memberWithOutId());

        assertThat(tokenResponse.getAccessToken()).isNotNull();
    }

    @DisplayName("회원의 ID로 회원을 조회한다.")
    @Test
    void findByMemberId() {
        final Member expectedMember = MemberFixture.memberWithId();
        when(memberRepository.findById(anyLong())).thenReturn(Optional.of(expectedMember));
        final MemberResponse findMember = memberService.findByMemberId(expectedMember.getId());

        assertThat(findMember).isEqualToIgnoringNullFields(expectedMember);
    }

5. 서비스 및 도메인 구현

서비스는 아래와 같습니다. Member Service 는 특별한 로직 없이 MemberRepository 만 사용하고 있고 Transaction단위만 보장해주고 있습니다.

@RequiredArgsConstructor
@Transactional
@Service
public class MemberService {
    private final MemberRepository memberRepository;
    private final JwtGenerator jwtGenerator;

    public TokenResponse createMemberAndToken(SocialInfo socialInfo) {
        final Member member = memberRepository.findBySocialId(socialInfo.getId())
            .orElseGet(() -> createMember(Member.builder()
                .name(socialInfo.getName())
                .socialId(socialInfo.getId())
                .build()));

        return jwtGenerator.createCustomToken(member.getSocialId());
    }

    @Transactional(readOnly = true)
    public Member findBySocialId(String socialId) {
        return memberRepository.findBySocialId(socialId)
            .orElseThrow(() -> new MemberNotFoundException(socialId));
    }

    public void update(Member member, MemberUpdateRequest request) {
        member.changeInfo(request);
    }

도메인은 아래와 같습니다. 회원의 정보를 수정하는 메소드만 존재합니다.

public void changeInfo(MemberUpdateRequest request) {
        if(request.getName() != null) {
            this.name = request.getName();
        }
    }

이후 과정

  • 현재 Member와 관련해서는 JpaRepository 가 제공하는 메소드 이외의 Custom mehtod를 사용하지 않기 때문에 RepositoryTest 는 존재하지 않습니다.
  • 현재 간단한 리팩토링은 완료한 상태이기에 리팩토링 과정은 생략하겠습니다.

결론

위와 같은 구조로 개발하게 되면 각 레이어 별로 테스트 해야 하는 부분과, 로직들이 명확하게 분리되기 때문에 좋습니다. 다만 문제로는 Production 코드가 구현되기 전에는 Test가 동작하지 않는다는 단점이 있는데요. 이는 Mock Data 를 리턴하는 형태로 임시로 Production code 를 작성하면 독립적인 테스트 및 테스트를 확인하며 개발할 수 있습니다. (저는 혼자 개발하기 때문에, 테스트 코드가 돌지 않더라도 전반적인 틀을 작성하는 형태로만 사용하고 Production은 한 번에 개발하는 형태로 하고 있습니다. 어려운 프로세스는 Mocking을 통해 단위를 쪼개는 형태로 개발하고 있습니다.)

profile
오늘 하루도 좋은 하루 보내세요! 혹시 시간 괜찮으시면 악플이라도 하나,, 어떠세요?🙇‍♂️

2개의 댓글

comment-user-thumbnail
2020년 9월 15일

좋은 내용이네요! 혹시 인수테스트 이후에 Mock Server 구현 후 테스트가 정상동작 하는지 테스트 해봐야 하지 않을까요? 😊

1개의 답글