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);
            });
        });

    }

    // ....

}

  • 이처럼 같은 시나리오에 다양한 데이터 세트를 테스트하려는 경우 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
728x90

테스트 더블이란?


제라드 메스자로스(Gerard Meszaros)가 사람들이 테스트를 위해 시스템의 일부를 떼어낼 때 사용하는 Stub, Mock, Fake, Dummy 등 다양한 이름들로 불리고 있던 것을 해결하기 위해 만든 용어가 바로 테스트 더블이다.

즉, 테스트 더블이란 테스트 목적으로 프로덕션 객체를 대체하는 모든 경우를 통칭하는 용어이다.

테스트 더블이란 용어는 영화 촬영 시 위험한 역할을 대신하는 스턴트 더블에서 비롯되었다고 한다.

 

테스트 더블 종류


일반적으로 크게 5가지의 종류로 Dummy, Fake, Stub, Spy, Mock으로 분류된다.

Dummy

  • 가장 기본적인 테스트 더블이다.
  • 객체가 전달되지만 사용되지 않는 객체다.
    • 보통 매개변수 목록을 채우는 데만 사용된다.
    • 동작하지 않아도 테스트에는 영향을 미치지 않은 테스트 객체.
  • 정말 말 그대로 모조 객체. 객체인 척만 한다.
    • 테스트 핵심 로직과는 상관없는 객체이다.

 

Fake

  • 복잡한 로직이나 객체 내부에서 필요로 하는 다른 외부 객체들의 동작을 단순화하여 구현한 객체이다.
  • 실제 동작의 구현을 가지고 있지만, 실제 프로덕션에는 적합하지 않는 객체를 의미한다
예시로는 DAO 혹은 repository의 inmemory 구현이 있다.

 

Stub

  • 스텁은 미리 정의된 데이터를 보관하고 테스트 중에 호출에 응답하는 객체이다.
    • 테스트를 위해 프로그래밍된 내용에 대해서만 준비된 결과를 제공한다.
  • 쉽게 말해 인터페이스 또는 기본 클래스가 최소한으로 구현된 상태다.

 

Spy

  • 스파이는 일부 기록 기능이 있는 테스트 스텁이다.
    • Stub의 역할을 하면서 호출된 내용에 대한 정보를 기록한다.
    • ex. 이메일 서비스를 목킹할 때 전송된 메시지 수를 기록할 때 사용할 수 있다.
  • 실제 객체처럼 동작시킬 수도 있고, 필요한 부분에 대해서는 Stub으로 만들어서 동작을 지정할 수 있다.
    • 실제 객체로도 사용할 수 있고, Stub 객체로도 활용할 수 있으며 필요한 경우 특정 메서드가 제대로 호출되었는지 여부를 확인할 수 있다.
  • 상태를 가질 수 있어 대표적인 상태 검증 테스트 더블이다.
Mockito 프레임워크의 verify() 메서드가 같은 역할을 한다.

 

 

Mock

  • 호출에 대한 기대를 명세하고 내용에 따라 동작하도록 프로그래밍된 객체를 의미한다.
  • 프로덕션 코드를 호출하고 싶지 않거나 의도한 코드가 실행되었는지 쉽게 확인할 방법이 없을 때 사용한다,
  • 따라서 다른 테스트 더블과는 다르게 행위 검증 사용을 추구한다.
Mockito 프레임워크가 대표적인 Mock 프레임워크라 볼 수 있다.

 

 

단위테스트


공통적인 속성

마틴 파울러가 정리한 '유닛테스트'에 대한 다양한 의견 중 공통점은 다음과 같다.

  • there is a notion that unit tests are low-level, focusing on a small part of the software system.
    • 단위 테스트는 소프트웨어 시스템의 작은 부분들에 집중하는 로우 레벨이다.
  • unit tests are usually written these days by the programmers themselves using their regular tools
    • 일반적인 도구를 사용해서 스스로 단위 테스트를 작성한다
  • Thirdly unit tests are expected to be significantly faster than other kinds of tests.
    • 단위 테스트는 다른 테스트 종류들의 테스트에 비해 확실하게 빠르기를 기대한다.

 

단위의 기준 

 

그렇다면 가장 헷갈리는 부분은 바로 단위이다. 무엇이 단위가 되어야 할까? 단위의 범위는 어디까지일까?

 

마틴 파울러는 단위는 상황적이라고 언급한다. 무엇이 단위가 되는 것인지는 팀이나 개인이 (그때그때) 정하는 것이라고 한다.

또한, 이런 것을 정의하는 것은 전혀 중요하지 않는다고 한다. 클래스를 하나의 단위로 취급할수도 있고 클래스 메서드들의 부분 집합을 하나의 단위로 삼을 수도 있다.

 

Sociable & Solitary


따라서 단위 테스트를 말할 때 더 중요한 차이는 단위가 단독(Solitary)으로 진행할지 혹은 협동적(Sociable)으로 진행할지 정의하는 데 있다고 한다. 

 

Mock 객체를 이용하여 고립되어 단독으로 실행하는 것을 지향하는 'Mockist`파와 Mock객체를 사용하지 않고 실제 의존하고 있는 객체를 이용하여 협동적인 테스트를 지향하는 'Classist'파로 나뉜다고 한다.

즉 SUT의 협력 객체를 실제 객체로 사용하는지 Mock 객체로 사용하는지에 따라 구분되어지는 것이다.

 

 

Classist vs Mockist


 

Classist

  • 협력 객체를 실제 객체를 사용한다.
    • 실제 객체를 사용하기 때문에 협력 객체의 상세 구현에 대해서 알 필요가 없다.
  • 행위가 끝난 후 직접적으로 상태를 검증을 한다.
    • 테스트의 안정성은 높아질 수 있다.
  • 비결정적인 외부 요인은 테스트 더블 사용이 가능하다.
  • 테스트와 테스트간의 격리

 

Mockist

  • 협력 객체를 Mock 객체를 사용한다.
    • 하지만 테스트가 협력 객체의 상세 구현을 알아야 한다.
  • 객체 내부의 상태가 아닌 행위를 검증한다.
    • 비교적 테스트의 안정성은 낮아질 수 있다.
  • 테스트가 상세 구현에 의존하는 경향이 생긴다.
  • SUT와 협력 객체간의 격리

 

Inside-Out vs Outside-In


이렇게 격리의 단위에 따라 테스트의 구현 방법이 달라지므로 자연스럽게 TDD의 방향도 달라지게 된다. 

주로 Classist는 Inside out 방법을 사용하고 Mockist는 outside in 방법을 사용한다.

Inside-Out

일반적으로 도메인부터 시작하여 개발을 진행한다. 도메인에 필요한 것은 무엇인지 생각하여 도메인 개체가 필요한 작업을 수행한다.

이후 작업이 완료되면 그 위에 사용자와 맞닿는 영역을 구현한다. 따라서

  • 리팩토링 단계에서 디자인이 도출된다.
  • TDD에서 빠른 피드백이 가능하다.
  • 오버 엔지니어링을 피하기 쉽다.
  • 객체 간의 협력이 이상하거나 public api가 잘못 설계될 수 있다.

 

Outside-In

일반적으로 시스템 외부에 대한 첫 테스트를 작성한다. 요구사항을 만족하기 위해 협력 객체들에 대해 생각한다.

이후 작업이 완료되면 시스템 내부적으로 들어가며 구현한다. 따라서

  • Test Red 단계에서 디자인이 도출된다.
  • 오버 엔지니어링으로 이어질 수 있다.
  • 협력 객체의 public api가 자연스럽게 도출된다.
  • 객체들 간의 구현보다는 행위에 집중할 수 있기 때문에 객체지향적으로 바라보기 쉽다.

 

그래서?


둘 중의 한 가지 방식을 선택하는 개념의 문제가 아니다. 켄트백의 TDD에서는 방향성을 가질 필요가 있다면 아는 것에서 모르는 것으로(known-to-unknown)의 방향이 유용할 수 있다고 말한다.

 

개인적으로는 보통 요구사항을 단위로 전달받아 주로 개발이 진행되므로 인수테스트를 작성하여 전반적인 요구사항의 전반적인 이해를 먼저 진행한 뒤에 도메인부터 구현하는 방법도 유용할 수 있을 것이다.

 

참고

728x90

'test' 카테고리의 다른 글

[Cucumber] Scenario Outline vs Data Table  (1) 2024.11.27

+ Recent posts