본문 바로가기
우아한테크코스 4기/레벨2

[Spring] ATDD 가독성 개선기

by 나는후니 2022. 5. 23.

우아한 테크코스 레벨 2의 핵심 미션인 지하철 미션인수 테스트 주도 개발(atdd)로 진행됩니다.

 

인수 테스트 주도 개발은 시나리오 형태의 요구사항을 먼저 테스트 코드로 작성하여 개발하는 방법론으로 사용자가 특정 api를 호출하는 과정에서 발생하는 모든 시나리오를 테스트 코드로 작성하여 테스트 코드가 성공하면 기능 구현이 종료되는 식의 테스트 방법입니다. 정상적인 시나리오, 예외 발생 시나리오 등 모든 시나리오를 테스트한다면 직접 애플리케이션을 구동하여 일일히 테스트하는 방법보다 그 비용이 훨씬 저렴하겠죠?

 

하지만 인수테스트를 작성하다 보면 웹 요청을 보내는 코드가 굉장히 길고 테스트를 실행하기위해 미리 준비해야하는 다른 협력객체들로 인해 실제 테스트하고자 하는 내용이 무엇인지 헷갈리는 경우가 있습니다.

 

이번 포스팅에서는 인수테스트의 가독성을 높이기 위한 저의 경험을 소개하고자 합니다.

인수테스트 라이브러리

인수테스트 라이브러리로 실제 아파치 텀캣을 구동하여 스프링 부트 서버를 그대로 사용하는 RestAssured 라이브러리를 사용하였습니다.

dependencies {
  testImplementation 'io.rest-assured:rest-assured:4.4.0'
}

실제 웹 환경을 사용하기 때문에 다른 테스트 라이브러리보다는 조금 무겁지만 인수 테스트는 실제 시나리오를 검증하는 테스트이기 때문에 실제 환경과 같은 RestAssured를 사용하게 됐습니다. 물론 MockMvc, WebTestClient등의 다른 라이브러리를 이용할 수 도 있습니다.

가독성 저하 해결 과정

최초에 해당 미션을 clone해오면 예시로 아래와 같은 코드를 볼 수 있습니다.

@DisplayName("지하철역을 조회한다.")
@Test
void getStations() {
  /// given
  Map<String, String> params1 = new HashMap<>();
  params1.put("name", "강남역");
  ExtractableResponse<Response> createResponse1 = RestAssured.given().log().all()
    .body(params1)
    .contentType(MediaType.APPLICATION_JSON_VALUE)
    .when()
    .post("/stations")
    .then().log().all()
    .extract();

  Map<String, String> params2 = new HashMap<>();
  params2.put("name", "역삼역");
  ExtractableResponse<Response> createResponse2 = RestAssured.given().log().all()
    .body(params2)
    .contentType(MediaType.APPLICATION_JSON_VALUE)
    .when()
    .post("/stations")
    .then().log().all()
    .extract();

  // when
  ExtractableResponse<Response> response = RestAssured.given().log().all()
    .when()
    .get("/stations")
    .then().log().all()
    .extract();

  // then
  assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
  List<Long> expectedLineIds = Arrays.asList(createResponse1, createResponse2).stream()
    .map(it -> Long.parseLong(it.header("Location").split("/")[2]))
    .collect(Collectors.toList());
  List<Long> resultLineIds = response.jsonPath().getList(".", StationResponse.class).stream()
    .map(it -> it.getId())
    .collect(Collectors.toList());
  assertThat(resultLineIds).containsAll(expectedLineIds);
}

분명 @DisplayName에 묘사된 테스트 내용은 지하철역을 조회한다인데 이를 테스트하기 위해 역을 저장하는 두번의 웹 요청이 발생하고, 역을 조회하는 지점으로의 요청이 다시 발생합니다. 당연한 부분이지만 웹 요청으로 인해 가독성이 굉장히 떨어지는 것을 볼 수 있었습니다.

 

먼저 가독성을 높이기 위한 첫 번째 노력을 해보겠습니다.

@DisplayName("등록된 강남역과 역삼역을 모두 조회하고 200 OK를 반환한다.")
@Test
void getStations() {
}

@DisplayName을 바꿨을 뿐인데 가독성이 많이 높아졌습니다. 또한 테스트하고자 하는 내용 (강남역, 역삼역 조회, 200 OK 반환)이 명확해졌습니다. 코드는 여전히 길지만 테스트 코드가 돌아간 뒤 그 이름만으로 어떤 일을 하는지 확인할 수 있게됐습니다. 하지만 역시 코드 또한 개선되어야겠죠?

 

가독성을 저하하는 핵심 요인인 웹요청을 메서드로 분리해보겠습니다.

public class Request {

    protected ExtractableResponse<Response> get(String url) {
        return RestAssured.given().log().all()
                .when()
                .get(url)
                .then().log().all()
                .extract();
    }

    protected ExtractableResponse<Response> post(Object params, String url) {
        return RestAssured.given().log().all()
                .body(params)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .when()
                .post(url)
                .then().log().all()
                .extract();
    }

    protected ExtractableResponse<Response> put(Object params, String url) {
        return RestAssured.given().log().all()
                .body(params)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .when()
                .put(url)
                .then().log().all()
                .extract();
    }

    protected ExtractableResponse<Response> delete(String url) {
        return RestAssured.given().log().all()
                .when()
                .delete(url)
                .then().log().all()
                .extract();
    }
}

앞으로 많은 인수테스트를 작성해야 하기에 웹 요청을 담당하는 클래스를 생성하여 주로 사용하는 http 요청인 get, post, put, delete에 대한 요청을 메서드로 분리하였습니다.

 

이제 이를 활용하여 지하철 노선 조회를 개선해보겠습니다.

@DisplayName("등록된 강남역과 역삼역을 모두 조회하고 200 OK를 반환한다.")
@Test
void getStations() {
  /// given
  StationRequest stationRequest1 = new StationRequest("신당역");
  ExtractableResponse<Response> createResponse1 = post(stationRequest1, "/stations");

  StationRequest stationRequest2 = new StationRequest("동묘앞역");
  ExtractableResponse<Response> createResponse2 = post(stationRequest2, "/stations");

  // when
  ExtractableResponse<Response> response = get("/stations")

  // then
  assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
  List<Long> expectedLineIds = Arrays.asList(createResponse1, createResponse2).stream()
    .map(it -> Long.parseLong(it.header("Location").split("/")[2]))
    .collect(Collectors.toList());
  List<Long> resultLineIds = response.jsonPath().getList(".", StationResponse.class).stream()
    .map(it -> it.getId())
    .collect(Collectors.toList());
  assertThat(resultLineIds).containsAll(expectedLineIds);
}

웹 요청하는 지점의 가독성이 굉장히 올라갔습니다. 하지만 만약 개발자가 아닌 누군가가 이 내용을 인수 받고자 했을 때, 이 메서드의 내용을 보고 어떤 테스트를 하는지 알 수 있을까요? 저는 절대 불가하다 생각합니다.

 

이제 인수라는 의미에 집중하여 이 테스트의 가독성을 더 높여보겠습니다.

StationRequest stationRequest1 = new StationRequest("신당역");
ExtractableResponse<Response> createResponse1 = post(stationRequest1, "/stations");

위 두 줄의 내용을 해석하자면 아래와 같습니다.

"신당역을 등록하고 응답을 확인한다." 이 응답에는 db에 잘 저장되었는지 id값을 확인할 수 있어야하고, 그 내용도 저장 내용과 동일한지 확인할 수 있어야 합니다. 그럼 심플하게 해석한 내용을 그대로 메서드로 분리해보겠습니다.

public enum TStation {

    SINDANG("신당역"),
    DONGMYO("동묘앞역"),
    CHANGSIN("창신역"),
    BOMUN("보문역"),
    SANGWANGSIMNI("상왕십리역"),
    WANGSIMNI("왕십리역");

    private final String name;
    private Long id;

    TStation(String name) {
        this.name = name;
    }
}

먼저 주로 사용하는 역의 이름들을 enum클래스로 분리해보았습니다. 이제 이 역을 저장하고, 이 역들의 응답값을 Station으로 받아보겠습니다. (StationResponse)로 받아보는 것도 무방합니다.

// TStation enum class
public Station saveStation() {
  StationResponse stationResponse = request(new StationRequest(name));
  this.id = stationResponse.getId(); // id에 값이 할당되면, 더이상 웹 요청을 보내지 않고 재사용하도록 수정할 수 있습니다.
  return new Station(id, stationResponse.getName());
}

private StationResponse request(final StationRequest stationRequest) {
  return requestStation(stationRequest).as(StationResponse.class);
}

private ExtractableResponse<Response> requestStation(final StationRequest stationRequest) {
  return RestAssured.given().log().all()
    .body(stationRequest)
    .contentType(MediaType.APPLICATION_JSON_VALUE)
    .when()
    .post("/stations")
    .then().log().all()
    .extract();
}

생성한 테스트 픽스쳐를 바탕으로 테스트 메서드를 조금 더 개선해보겠습니다.

@DisplayName("등록된 강남역과 역삼역을 모두 조회하고 200 OK를 반환한다.")
@Test
void getStations() {
  /// given
  Station sindang = SINDANG.saveStation();
  Station dongmyo = DONGMYO.saveStation();

  // when
  ExtractableResponse<Response> response = get("/stations")

  // then (개선 전)
  assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
  List<Long> expectedLineIds = Arrays.asList(createResponse1, createResponse2).stream()
    .map(it -> Long.parseLong(it.header("Location").split("/")[2]))
    .collect(Collectors.toList());
  List<Long> resultLineIds = response.jsonPath().getList(".", StationResponse.class).stream()
    .map(it -> it.getId())
    .collect(Collectors.toList());
  assertThat(resultLineIds).containsAll(expectedLineIds);
}

하지만 더 개선할 수 있어 보입니다.

특히 역을 등록하고 모든 역을 조회한다 라는 흐름의 시나리오가 존재한다면 높은 가독성의 테스트 코드를 만들 수 있을 것 같습니다.

그래서 저는 아래와 같은 메서드를 생성하게 됐습니다.

public StationAddAndRequest saveStationAnd() {
  return new StationAddAndRequest(this);
}

이 메서드는 StationAddAndRequest 클래스를 반환하여 해당 요청 이후의 행동을 기대할 수 있게끔 만듭니다.

이제 해당 클래스에서 또다른 웹 요청을 보내고 적절한 응답값을 보내도록 변경해봅니다.

public class StationAddAndRequest extends Request {

  private final Station station;

  public StationAddAndRequest(TStation station) {
    this.station = station.saveStation();
  }

  public List<StationResponse> findStations(int status) {
    ExtractableResponse<Response> response = get("/stations");
    assertThat(response.statusCode()).isEqualTo(status);

    return response.jsonPath().getList(".", StationResponse.class);
  }
}

역을 조회함과 동시에, 기대하는 status까지 확인하도록 메서드를 변경해보았습니다.

그리고 이 메서드까지 사용하여 테스트 메서드를 수정해보겠습니다.

@DisplayName("등록된 강남역과 역삼역을 모두 조회하고 200 OK를 반환한다.")
@Test
void getStations() {
  /// given
  Station sindang = SINDANG.saveStation();
  List<StationResponse> response = DONGMYO.saveStationAnd().findStations(HttpStatus.OK.value());

  // then (.station() 메서드는 StationResponse를 생성하여 반환합니다.)
  assertThat(response).containsExactly(SINDANG.station(), DONGMYO.station());
}

이렇게 되면 테스트하고자하는 내용을 모두 반영하면서, 테스트의 가독성을 매우 높일 수 있습니다.

하지만 저는 영어 네이티브가 아닙니다. 모든 메서드를 한글로 변경해보았습니다.

@DisplayName("등록된 강남역과 역삼역을 모두 조회하고 200 OK를 반환한다.")
@Test
void getStations() {
  SINDANG.역을등록한다();
  List<StationResponse> 역조회결과 = DONGMYO.역을등록하고().모든역을조회한다(HttpStatus.OK.value());
  assertThat(역조회결과).containsExactly(SINDANG.역(), DONGMYO.역());
}

어떤가요? 개발자가 아닌 누가 오더라도 이해하기 쉬운 테스트 코드가 작성된것 같나요?

느낀점

이번 테스트 개선기는 인수 테스트의 의미 자체에 집중했습니다. 핵심적으로 집중한 부분은 특정 기능을 사용하기 위해 사용자가 해야하는 모든 시나리오(웹요청)개발자가 아닌 누가 와도 문서로 인수인계 할 수 있는 가독성 높은 코드입니다.

 

한글 메서드를 사용하면 테스트를 읽는 한국 개발자들에게는 인기가 굉장히 많겠지만 IDE에서 자동완성을 제공하지 않기 때문에 코드를 작성하는 입장에서 굉장한 불편함이 있었습니다. 따라서 한글 메서드를 통해 가독성을 개선하자! 가 아닌 적절한 테스트 픽스쳐를 만들어 중복 코드를 제거하고 의미를 명확하게 만들자 라는 결론에 도달하게 되었습니다.

 

이 테스트코드 개선기는 작년 우아한 테크 코스 3기 깃들다 팀의 테스트를 많이 참고하였고, 저의 테스트 코드는 링크에서 확인할 수 있습니다.