728x90

 

Cucumber 이란?


Cucumber는 BDD(Behavioral Driven Development) 프레임워크를 지원하고 Gherkin 언어를 사용하여 명세 기반 시나리오를 만들 수 있는 도구이다.  하지만 보통 이러한 테스트 시나리오를 설계할 때 데이터로 작업해야 하는 상황에 자주 직면하게 된다.

 

 

Cucumber에서는 데이터를 제공하는 여러 가지 방법이 있지만 그중에서 Scenario Outline과 Data Tables에 대해서 알아보자. 예제 코드는 next step의 ATDD 강의를 수강하며 진행했던 미션코드로 예제로 들어보았다. 

 

Data Table: Parameterizing Steps with Specific Data


데이터 테이블은 단계를 매개변수화하고 시나리오를 DRY(Don’t Repeat Yourself)로 유지할 수 있게 해주는 Gherkin의 기본 구성 요소이다. 데이터 테이블의 주요 특징은 다음과 같다.

 

Passing a Table of Data

  • 데이터 테이블은 시나리오의 단계에 구조화된 데이터 테이블을 전달하는 데 사용된다.
  • 해당 테이블은 시나리오 단계가 작업할 입력값 세트를 제공한다.
  • 테스트 데이터를 정의하기 위해 별도의 키워드가 필요하지 않는다.
  • 데이터 테이블의 헤더 행은 선택적으로 사용할 수 있다.

 

Tight Coupling

  • 데이터 테이블은 특정 작업과 밀접하게 연관된 데이터 세트를 사용할 때 특히 유용하다.
  • 이는 데이터가 수행하려는 동작과 명확히 관련되어 있다는 것을 의미한다.

 

Directly in Scenario

  • 데이터 테이블은 일반적으로 시나리오 내에서 특정 단계 바로 아래에 작성된다.
  • 이를 통해 시나리오를 깔끔하고 이해하기 쉽게 유지할 수 있다.

 

Multiple Rows of Data

  • 동일한 시나리오 내에서 여러 행의 데이터를 전달할 수 있다.
  • 이를 통해 동일한 단계를 다양한 데이터 입력값으로 테스트할 수 있다.

 

Enclosed Within Triple Pipes

  • 데이터 테이블은 삼중 파이프(|||)로 감싸져 있으며, 여러 열로 구성된다.

 

Data Table 예제

 

데이터 테이블의 사용 예제를 알아보자.  지하철의 경로를 찾는 테스트 시나리오를 생각해 보자.

Feature: 지하철역 경로 관련 기능

#  /**
#  * 교대역   ---  2호선, 10, 1  ----   강남역
#  * |                               |
#  * 3호선, 2, 20                 신분당선, 10, 1
#  * |                               |
#  * 남부터미널역 -- 3호선, 3, 30 ----   양재
#  */
  Background: 이호선, 삼호선, 신분당선 노선의 구간
    Given 지하철역들을 생성 요청하고
      | name   |
      | 교대역    |
      | 강남역    |
      | 양재역    |
      | 남부터미널역 |
    And 지하철 노선들을 생성 요청하고
      | name | color  | upStation | downStation | distance | duration |
      | 2호선  | green  | 교대역       | 강남역         | 10       | 1        |
      | 신분당선 | red    | 강남역       | 양재역         | 10       | 1        |
      | 3호선  | orange | 교대역       | 남부터미널역      | 2        | 20       |
    And 지하철 구간을 등록 요청하고
      | lineName | upStation | downStation | distance | duration |
      | 3호선      | 남부터미널역    | 양재역         | 3        | 30       |

  Scenario: 지하철역 최단 거리 경로를 조회한다.
    When "교대역"에서 "양재역"까지의 "최소 거리" 기준으로 경로 조회를 요청하면
    Then "최소 거리" 기준 "교대역,남부터미널역,양재역" 경로를 응답
    And 총 거리 5와 소요 시간 50을 함께 응답함
    And 이용 요금 1250도 함께 응답함

  Scenario: 지하철역 최단 시간 경로를 조회한다.
    When "양재역"에서 "교대역"까지의 "최소 시간" 기준으로 경로 조회를 요청하면
    Then "최소 시간" 기준 "양재역,강남역,교대역" 경로를 응답
    And 총 거리 20와 소요 시간 2을 함께 응답함
    And 이용 요금 1450도 함께 응답함

public class PathStepDef implements En {

    @Autowired
    private AcceptanceContext context;

    public PathStepDef() {

        Given("지하철 구간을 등록 요청하고", (DataTable table) -> {
            List<Map<String, String>> maps = table.asMaps();
            maps.forEach(param -> {
                String lineName = param.get("lineName");
                Map<String, Object> params = new HashMap<>();
                params.put("upStationId",
                    context.getValueFromStore(param.get("upStation"), StationResponse.class).getId().toString());
                params.put("downStationId",
                    context.getValueFromStore(param.get("downStation"), StationResponse.class).getId().toString());
                params.put("distance", param.get("distance"));
                params.put("duration", param.get("duration"));
                LineResponse line = context.getValueFromStore(lineName, LineResponse.class);
                지하철_구간_등록_요청(line.getId(), params);
            });
        });

        When("{string}에서 {string}까지의 {string} 기준으로 경로 조회를 요청하면",
            (String sourceStationName, String targetStationName, String type) -> {
                Long sourceId = ((StationResponse) context.store.get(sourceStationName)).getId();
                Long targetId = ((StationResponse) context.store.get(targetStationName)).getId();
                PathSearchType searchType = getPathSearchType(type);
                context.response = 지하철_경로_조회_요청(sourceId, targetId, searchType);
            });
        Then("{string} 기준 {string} 경로를 응답", (String type, String path) -> {
            List<String> stationNames = Arrays.asList(path.split(","));
            SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(지하철역_경로_조회_응답에서_역_이름_목록_추출(context.response))
                    .containsExactlyElementsOf(stationNames);
            });
        });

        And("총 거리 {long}와 소요 시간 {long}을 함께 응답함", (Long distance, Long duration) -> {
            SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(지하철역_경로_조회_응답에서_경로_거리_추출(context.response))
                    .isEqualTo(distance);
                softAssertions.assertThat(지하철역_경로_조회_응답에서_경로_시간_추출(context.response))
                    .isEqualTo(duration);
            });
        });
        And("이용 요금 {int}도 함께 응답함", (Integer fare) -> {
            SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(지하철역_경로_조회_응답에서_지하철_요금_추출(context.response))
                    .isEqualTo(fare);
            });
        });
    }

		// ...

}

  • 해당 테스트 시나리오에서는 데이터 테이블을 이용하여 테스트 시나리오의 각 단계에 필요한 역, 노선, 구간 정보를 전달하여 경로와 요금을 조회하고 있다.
  • 이처럼 데이터 테이블은 시나리오에서 중복을 피하는 데 도움이 된다.

 

Scenario Outline: Running Scenarios with Multiple Data Sets


Scenario Outline은 여러 데이터 세트로 동일한 시나리오를 실행하려는 경우 강력한 도구다. 특히 데이터 기반 테스트에 특히 유용하며 시나리오 개요의 주요 특징은 다음과 같다.

 

Parameterized Scenarios

  • Scenario Outline은 서로 다른 데이터 집합으로 실행해야 하는 동일한 단계가 있을 때 사용된다.
  • 즉, 다양한 입력으로 동일한 기능을 테스트하려는 상황에서 주로 사용된다.

 

Placeholders

  • Scenario Outline에서는 시나리오에서 꺾쇠괄호(< >)를 사용하여 example table의 헤더 역할을 하는 placeholder들을 정의한다.
  • 이러한 placeholder들은 나중에 삽입될 데이터를 나타낸다.

 

Examples Section

  • Scenario Outline 다음에 example section이 포함된다.
  • 해당 섹션에서는 시나리오의 placeholder들을 대체하는 실제 데이터 세트를 제공한다.
  • 예제 섹션의 각 행은 고유한 테스트 사례를 나타낸다.

 

DRY Feature Files

  • Scenario Outline은 데이터를 단계별로 분리하여 피쳐 파일을 DRY로 유지하여 테스트의 유지 관리성을 높여준다.

 

Scenario Outline 예제

위에서 예를 들었던 지하철 경로 찾기 시나리오에서 만약 경로의 거리뿐만 아니라 노선별, 연령별 요금 정책이 추가되었다고 생각해 보자. 다양한 상황에서의 상태값 검증을 하고 싶다면 여러 개의 시나리오를 작성해야 할까?

Feature: 지하철역 경로 관련 기능

#  /**
#  * 교대역   ---  2호선, 10, 1  ----   강남역
#  * |                               |
#  * 3호선, 2, 20                 신분당선, 10, 1
#  * |                               |
#  * 남부터미널역 -- 3호선, 3, 30 ----   양재역
#  */
  Background: 지하철 역, 노선, 구간을 등록합니다.
    Given 지하철역들을 생성 요청하고
      | name   |
      | 교대역    |
      | 강남역    |
      | 양재역    |
      | 남부터미널역 |
    And 지하철 노선들을 생성 요청하고
      | name | color  | upStation | downStation | distance | duration | additionalFee |
      | 2호선  | green  | 교대역       | 강남역         | 10       | 1        | 0             |
      | 신분당선 | red    | 강남역       | 양재역         | 10       | 1        | 1000          |
      | 3호선  | orange | 교대역       | 남부터미널역      | 2        | 20       | 800           |
    And 지하철 구간을 등록 요청하고
      | lineName | upStation | downStation | distance | duration |
      | 3호선      | 남부터미널역    | 양재역         | 3        | 30       |

  Scenario Outline: 비로그인 사용자가 지하철역 지하철 경로를 조회한다.
    When "<fromStation>" 에서 "<toStation>" 까지의 "<searchType>" 기준으로 경로 조회를 요청하면
    Then "<path>" 경로를 응답
    And 총 거리 <totalDistance>와 소요 시간 <totalDuration>을 함께 응답함
    And 총 이용 요금 <totalFare>도 함께 응답함
    Examples:
      | fromStation | toStation | searchType | path           | totalDistance | totalDuration | totalFare |
      | 교대역         | 양재역       | 최소 거리      | 교대역,남부터미널역,양재역 | 5             | 50            | 2050      |
      | 교대역         | 양재역       | 최소 시간      | 교대역,강남역,양재역    | 20            | 2             | 2450      |

  Scenario Outline: 로그인 사용자가 지하철역 지하철 경로를 조회한다.
    When <age> 연령의 로그인된 사용자가 존재하고
    When "<fromStation>" 에서 "<toStation>" 까지의 "<searchType>" 기준으로 경로 조회를 요청하면
    Then "<path>" 경로를 응답
    And 총 거리 <totalDistance>와 소요 시간 <totalDuration>을 함께 응답함
    And 총 이용 요금 <totalFare>도 함께 응답함
    Examples:
      | age | fromStation | toStation | searchType | path           | totalDistance | totalDuration | totalFare |
      | 10  | 교대역         | 양재역       | 최소 거리      | 교대역,남부터미널역,양재역 | 5             | 50            | 1200      |
      | 15  | 교대역         | 양재역       | 최소 거리      | 교대역,남부터미널역,양재역 | 5             | 50            | 1710      |
      | 30  | 교대역         | 양재역       | 최소 거리      | 교대역,남부터미널역,양재역 | 5             | 50            | 2050      |
      | 10  | 교대역         | 양재역       | 최소 시간      | 교대역,강남역,양재역    | 20            | 2             | 1400      |
      | 15  | 교대역         | 양재역       | 최소 시간      | 교대역,강남역,양재역    | 20            | 2             | 2030      |
      | 30  | 교대역         | 양재역       | 최소 시간      | 교대역,강남역,양재역    | 20            | 2             | 2450      |

public class PathStepDef implements En {

    @Autowired
    private AcceptanceContext context;

    public PathStepDef() {
        Given("지하철 구간을 등록 요청하고", (DataTable table) -> {
            List<Map<String, String>> maps = table.asMaps();
            maps.forEach(param -> {
                String lineName = param.get("lineName");
                Map<String, Object> params = new HashMap<>();
                params.put("upStationId",
                    context.getValueFromStore(param.get("upStation"), StationResponse.class).getId().toString());
                params.put("downStationId",
                    context.getValueFromStore(param.get("downStation"), StationResponse.class).getId().toString());
                params.put("distance", param.get("distance"));
                params.put("duration", param.get("duration"));
                LineResponse line = context.getValueFromStore(lineName, LineResponse.class);
                지하철_구간_등록_요청(line.getId(), params);
            });
        });

        When("{string} 에서 {string} 까지의 {string} 기준으로 경로 조회를 요청하면",
            (String sourceStationName, String targetStationName, String type) -> {
                Object accessToken = context.store.get("accessToken");
                Long sourceId = ((StationResponse) context.store.get(sourceStationName)).getId();
                Long targetId = ((StationResponse) context.store.get(targetStationName)).getId();
                PathSearchType searchType = getPathSearchType(type);
                if (accessToken == null) {
                    context.response = 지하철_경로_조회_요청(sourceId, targetId, searchType);
                } else {
                    context.response = 지하철_경로_조회_요청(sourceId, targetId, searchType, accessToken.toString());
                }

            }
        );

        Then("{string} 경로를 응답", (String path) -> {
            List<String> stationNames = Arrays.asList(path.split(","));
            SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(지하철역_경로_조회_응답에서_역_이름_목록_추출(context.response))
                    .containsExactlyElementsOf(stationNames);
            });
        });

        And("총 거리 {long}와 소요 시간 {long}을 함께 응답함", (Long distance, Long duration) -> {
            SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(지하철역_경로_조회_응답에서_경로_거리_추출(context.response))
                    .isEqualTo(distance);
                softAssertions.assertThat(지하철역_경로_조회_응답에서_경로_시간_추출(context.response))
                    .isEqualTo(duration);
            });
        });

        And("총 이용 요금 {int}도 함께 응답함", (Integer totalFare) -> {
            SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(지하철역_경로_조회_응답에서_지하철_총_요금_추출(context.response))
                    .isEqualTo(totalFare);
            });
        });

    }

    // ....

}

  • 이처럼 같은 시나리오에 다양하 데이터 세트를 테스트하려는 경우(Date Drivent Testing) Scenario Outline을 사용하면 이상적이다.

 

Data Tables vs Scenario Outlines


위에서 본 것처럼 Data Table과 Scenario Outline 중 어떤 것을 선택할지는 테스트 요구 사항의 성격에 따라 달라진다. 어떤 것을 사용할지 결정하는 데 도움이 되는 몇 가지를 살펴보자.

 

Data Tables

  • 단계와 직접 관련된 특정 데이터를 전달해야 할 때 Data Table을 사용한다.
  • 동일한 시나리오 내에서 테스트하려는 긴밀하게 연결된 여러 데이터 세트가 있을 때 유용하다.
  • Data Table은 시나리오를 간결하고 집중적으로 유지하게 한다.

 

Scenario Outline

  • 서로 다른 데이터 집합으로 동일한 단계를 실행해야 하는 경우 시나리오 개요를 선택하자.
  • 동일한 작업을 다양한 입력으로 반복하는 경우 Scenario Outline을 선택하는 것이 가장 좋다.
  • Scenario Outline은 데이터를 단계별로 분리하여 feature 파일을 더 쉽게 유지 관리할 수 있게 해 준다.

 

효과적인 Scenario Design 하는 법


시나리오에 집중

  • 각 시나리오가 잘 정의된 하나의 동작을 다루도록 하자.
  • 이렇게 하면 문제를 식별하고 시나리오를 유지 관리하기가 더 쉬워진다.

 

의미 있는 이름 사용

  • 시나리오와 단계에 의미 있는 이름을 사용하자.
  • 이렇게 하면 가독성이 향상되고 비기술적인 이해관계자도 테스트를 이해하는 데 도움이 된다.

 

Data Table 및 Scenario Outline 결합

  • 경우에 따라 데이터 테이블과 시나리오 개요를 결합하면 가장 유연하게 사용할 수 있다.
  • 예를 들면, Scenario Outline 내에서 Data Table을 사용하여 특정 데이터 요구 사항과 일반 데이터 요구 사항을 모두 처리할 수 있다.

 

정기적인 검토 및 리팩터링

  • 시간이 지남에 따라 시나리오가 복잡해지거나 중복될 수 있다.
  • 기능 파일을 정기적으로 검토하고 리팩터링 하여 깔끔하고 효율적으로 유지하자.

 

참고 출처

728x90

'test' 카테고리의 다른 글

테스트 더블과 단위테스트  (2) 2024.11.08

+ Recent posts