본문 바로가기
가치관 쌓기/개발 돌아보기

[우아한테크코스Pro] 인수 테스트 기반 TDD - 2 [5/9]

by simplify-len 2021. 7. 14.

[목표]

  • 인수테스트 기반 TDD을 도와주는 RestAssured 를 이해할 수 있다.
  • 언제 인수테스트 기반 TDD 를 사용하는 것이 적절한지, 이해할 수 있다.
  • 왜 인수테스트 기반 TDD를 사용하는 것이 적절한지 이해할 수 있다.
  • 어떻게 인수테스트 기반 TDD를 사용하는 것이 적절한지 이해할 수 있다.

인수테스트 기반 TDD을 도와주는 RestAssured 를 이해할 수 있다.

RestAssured공식사이트

RestAssured 는 REST service 를 테스트할 때 도움을 주는 테스트 도구 이다. 우리가 흔히 사용하는 GET, POST, PUT, DELETE 메소드를 지원한다.

만약, GET lotte/5 을 했을 경우 아래와 같은 경우가 나온다면?

아래 내용은 RestAssured 공식 사이트에서 가져온 것이다.

{
   "lotto":{
      "lottoId":5,
      "winning-numbers":[2,45,34,23,7,5,3],
      "winners":[
         {
            "winnerId":23,
            "numbers":[2,45,34,23,3,5]
         },
         {
            "winnerId":54,
            "numbers":[52,3,12,11,18,22]
         }
      ]
   }
}
@Test public void
lotto_resource_returns_200_with_expected_id_and_winners() {

    when().
            get("/lotto/{id}", 5).
    then().
            statusCode(200).
            body("lotto.lottoId", equalTo(5),
                 "lotto.winners.winnerId", hasItems(23, 54));

}

그 외 좀 더 자세한건 공식사이트를 통해서 학습해보자!

언제 인수테스트 기반 TDD를 사용할까?

1. 여러 도메인이 복잡하게 얽혀있어 분리해내기 어려울때

public void deleteStation(Long lineId, Long stationId) {
    Station station = stationDao.findById(stationId);
    Line persistLine = lineDao.findById(lineId);

    List<Edge> oldEdges = persistLine.getEdges();

    Edges edges = persistLine.getEdges();

    List<Edge> replaceEdge = edges.stream()
            .filter(it -> it.hasStation(station))
            .collect(Collectors.toList());

    ...

    Edge newEdge = updatedEdges.getEdges().stream()
            .filter(it -> !oldEdges.contains(it))
            .findFirst().orElseThrow(RuntimeException::new);

    edgeDao.deleteByStationId(stationId);
    edgeDao.save(persistLine.getId(), newEdge);
}

위와 같은 비지니스 로직이 있다고 가정하자.

Station, Line, Edges 등의 여러 도메인이 섞여있어 테스트하기 어려운 코드입니다. 그러한 코드일때는 어떻게 하면 좋을까?

이렇게 복잡하게 되어있다는 것은 설계상 도메인관계가 복잡하게 되어있다는 말과 유사하다. 이를 해결하기 위해서는 ATDD 를 활용할 수 있다.

새로운 방법으로 TDD를 접근

When we’re implementing a feature, we start by writing an acceptance test, which exercises the functionality we want to build. While it’s failing, an acceptance test demonstrates that the system does not yet implement that feature; when it passes, we’re done.

Growing object-oriented software, guided by tests

새로운 방법으로 TDD를 접근

우리는 기능을 구현할 때, 만들고자 하는 기능을 수행하는 인수 테스트를 작성하는 것으로 시작한다. 인수 테스트가 실패하는 동안은 시스템이 아직 그 기능을 구현하지 않고 있다는 것을 보여준다; 인수 테스트가 통과되면, 기능 구현은 끝이다.

​ - 테스트 주도 개발로 배우는 객체 지향 설계와 실천

2. 스프링 프레임워크에 의존하지 않는 테스트를 만들고 싶을 때

우리가 만드는 대부분의 테스트는 스프링 프레임워크에 의존합니다. 예를 들어 @WebMvcTest, @DataJpaTest, @ExtendWith(SpringExtension.class) 등은 스프링에서 제공되는 테스트 도구에 의해서 동작된다.

이러한 의존관계없이, 테스트 해볼 수 있다. 내가 느꼈던 유용한 부분은 Spring security 를 사용하는 경우, 인증이 필요한 메소드를 실행시킬 경우, 코드가 일부 부자연스러운 현상들이 있었다.(어노테이션을 통해서 억지로 Spring security 를 사용하지 않는다는 등) 이런 문제에 대해서 ATDD는 꽤 직관적이고 자연스럽다.

코드의 일부를 소개하면 다음과 같다.

만약 지하철 노선은 오직 인증된 사용자만 생성할 수 있다면?

@DisplayName("지하철 노선을 생성한다.")
    @Test
    void createLine() {

        ExtractableResponse<Response> response = 토큰을_요청한다(request);
        정상적으로_동작됨(response);
        정상적으로_로그인됨(토큰으로_로그인_요청함(response.as(TokenResponse.class)));

        // when
        ExtractableResponse<Response> response = 지하철_노선_생성_요청(lineRequest1);

        // then
        지하철_노선_생성됨(response);
    }

이러한 방식으로, 로그인을 먼저 시도한 뒤 노선을 생성한다. 주관적인 생각으로는 꽤 자연스럽다고 생각한다.

인수테스트 기반 TDD 를 사용하면 좋을까?

인수테스트 기반 TDD를 사용하면 사용자 관점에서 테스트 코드를 이해할 수 있게 된다.

흔히 말하는 UserCase 에 대해서도 100% 까지 커버할 수 있다. 라는 관점보다는 구현과정에 있어 해피케이스에 대한 도움을 적극 받을 수 있다.

마찬가지로 한가지 테스트 코드를 아래에 첨부한다.

@DisplayName("회원 정보를 관리한다.")
    @Test
    void manageMember() {
        // when
        ExtractableResponse<Response> createResponse = 회원_생성을_요청(EMAIL, PASSWORD, AGE);
        // then
        회원_생성됨(createResponse);

        // when
        ExtractableResponse<Response> findResponse = 회원_정보_조회_요청(createResponse);
        // then
        회원_정보_조회됨(findResponse, EMAIL, AGE);

        // when
        ExtractableResponse<Response> updateResponse = 회원_정보_수정_요청(createResponse, NEW_EMAIL, NEW_PASSWORD, NEW_AGE);
        // then
        회원_정보_수정됨(updateResponse);

        // when
        ExtractableResponse<Response> deleteResponse = 회원_삭제_요청(createResponse);
        // then
        회원_삭제됨(deleteResponse);
    }

    @DisplayName("나의 정보를 관리한다.")
    @Test
    void manageMyInfo() {
        회원_생성됨(회원_생성을_요청(EMAIL, PASSWORD, AGE));

        ExtractableResponse<Response> response = AuthAcceptanceTest.토큰을_요청한다(new TokenRequest(EMAIL, PASSWORD));

        ExtractableResponse<Response> findResponse = 나의_정보_조회_요청(response.as(TokenResponse.class));
        회원_정보_조회됨(findResponse, EMAIL, AGE);

        ExtractableResponse<Response> updateResponse = 나의_정보_수정_요청(response.as(TokenResponse.class), NEW_EMAIL, NEW_PASSWORD, NEW_AGE);
        회원_정보_수정됨(updateResponse);

        ExtractableResponse<Response> refindResponse = 나의_정보_조회_요청(response.as(TokenResponse.class));
        회원_정보_조회됨(refindResponse, NEW_EMAIL, NEW_AGE);

        ExtractableResponse<Response> deleteResponse = 나의_정보_삭제_요청(response.as(TokenResponse.class));
        회원_삭제됨(deleteResponse);

    }

이러한 유저케이스를 커버하는 테스트 코드가 하나 존재한다면, 우린 Member 클래스에 대한 모든 행복케이스에 대해서는 테스트를 완료한 것이다. 각각의 인수테스트를 만들고 통과시키면서 우리는 원했던 기능구현을 이어갈 수 있게 되었다.

어떻게 인수테스트 기반 TDD를 사용하는 것이 적절한지 이해할 수 있다.

그러면 마지막으로 어떻게 인수테스트를 작성하면 좋을까?

이미 위 메소드를 보면서 눈치챈 사람들도 있을 수 있다.

대부분 하나의 컨트롤러를 기반으로 만들지만, 한가지 명심해야 될 부분이 있다. 만약 GET /members/me 을 인수테스트로 한다면, 내가 무엇을 테스트 할 것인가에 대해서 명확히 의도를 노출할 수 있도록 메소드 명을 작성해야 한다. GET /members/me 할 경우, 나의_정보_조회_요청() 이라는 의도를 노출하는 이름으로 테스트명을 작성해야 한다.

 private ExtractableResponse<Response> 나의_정보_조회_요청(TokenResponse tokenResponse) {
   return RestAssured.given().log().all()
     .auth().oauth2(tokenResponse.getAccessToken())
     .contentType(MediaType.APPLICATION_JSON_VALUE)
     .when().get("/members/me")
     .then().log().all().extract();
 }

결론

테스트하기 쉽고, 스프링에 의존하지 않는 테스트라는 관점에서 꽤 매력적인 도구이다. 그러나, 반대로 작성해야될 테스트 코드가 늘어다는 단점도 있다.

댓글