백엔드 Back-end/테스트 Test

pytest-BDD(Behavior-driven development) REST API 테스트 픽스처fixture 예

Tap to restart 2023. 12. 30. 12:00

BDD(Behavior-driven development)란?

BDD는 한국어로 번역하면 행위 주도 개발로, 소프트웨어 개발 방법론 중 하나다. 소프트웨어 기능을 사용자의 행위 중심으로 설명한다. 예를 들면 아래와 같다.

 

Title: Returns and exchanges go to inventory.

As a store owner,
I want to add items back to inventory when they are returned or exchanged,
so that I can sell them again.

Scenario 1: Items returned for refund should be added to inventory.
Given that a customer previously bought a black sweater from me
and I have three black sweaters in inventory,
when they return the black sweater for a refund,
then I should have four black sweaters in inventory.
출처: 위키백과

 

한국어로 비슷한 예를 만든다면

나는 일반 사용자입니다.
나는 로그인을 합니다.
나는 상품 목록을 요청합니다. 
상품 목록을 받습니다.

 

이런 식이다. 이 시나리오 그대로 테스트 코드도 작성한다. 

어떤 기능이 어떻게 작동하는지 개발자 뿐만 아니라, PO, QA 등 다른 사람들도 쉽게 파악할 수 있게 되는 것이 장점이다.

 

BDD 테스트 구현의 어려움 

실제 위와 같은 식으로 테스트를 구현하려면 보통 테스트 코드 작성하는 시간보다 시간이 더 걸린다. 예를 들면 "상품 목록을 받습니다."란 행위에 대응하는 테스트 함수를 만들 듯 새로운 API를 만들 때마다 테스트 함수를 만들게 되기 때문이다.

 

백엔드 REST API 테스트 픽스처 예

QA나 개발자가 아닌 사람들한테는 일부 불필요한 정보이고 이해하려면 약간의 이해가 필요하지만 중복을 최소화할 수 있게 테스트 픽스처를 만들어봤다. 

코드 전체는 github.com/taptorestart/forms 저장소에서 확인할 수 있다.

 


DATA_TYPES = {
    "bool": bool,
    "int": int,
    "str": str,
    "float": float,
    "list": list,
    "tuple": tuple,
    "dict": dict,
    "NoneType": NoneType,
}


@given(parsers.parse("I am a/an {user_type} user."), target_fixture="user")
def i_am_a_user_type_user(user_type):
    if user_type == "anonymous":
        return None
    if user_type == "staff":
        return UserFactory(is_staff=True, is_superuser=False)
    if user_type == "superuser":
        return UserFactory(is_staff=True, is_superuser=True)
    return UserFactory(is_staff=False, is_superuser=False)


@given(parsers.parse("I am logging in."), target_fixture="client")
def i_am_logging_in(user):
    client: APIClient = APIClient()
    if user:
        client.force_authenticate(user=user)
    return client


@when(parsers.parse("I am making a request to the server using the {method} and {path}."), target_fixture="response")
def i_am_making_a_request_to_the_server_using_the_method_and_path(client, method, path):
    response = None
    if method in ["GET", "get"]:
        response = client.get(path=path)
    if method in ["POST", "post"]:
        response = client.post(path=path)
    if method in ["PUT", "put"]:
        response = client.put(path=path)
    if method in ["PATCH", "patch"]:
        response = client.patch(path=path)
    if method in ["DELETE", "delete"]:
        response = client.delete(path=path)
    return response


@given(
    parsers.parse("The data to be sent is as follows.\n{payload}"),
    target_fixture="data",
)
def the_data_to_be_sent_is_as_follows(payload):
    return json.loads(payload)


@when(
    parsers.parse("I am making a request to the server with data using the {method} and {path}."),
    target_fixture="response",
)
def i_am_making_a_request_to_the_server_with_data_using_the_method_and_path(client, method, path, data):
    response = None
    if method in ["GET", "get"]:
        response = client.get(path=path, data=data)
    if method in ["POST", "post"]:
        response = client.post(path=path, data=data)
    if method in ["PUT", "put"]:
        response = client.put(path=path, data=data)
    if method in ["PATCH", "patch"]:
        response = client.patch(path=path, data=data)
    if method in ["DELETE", "delete"]:
        response = client.delete(path=path, data=data)
    return response


@given(
    parsers.parse("I will save the following data using {module}'s {factory_class_name}.\n{data}"),
)
def i_will_save_the_following_data_using_modules_factory_class_name(module: str, factory_class_name: str, data):
    module = importlib.import_module(module)
    factory_class = getattr(module, factory_class_name)
    factory_class(**json.loads(data))
    return None


@then(parsers.parse("The response status code is {status_code:d}."))
def the_response_status_code_is_status_code(response, status_code):
    assert response.status_code == status_code


@then(parsers.parse("The number of result in the response JSON is {number:d}."))
def the_number_of_result_in_the_response_json_is_number(response, number):
    assert len(response.json()) == number


@then(parsers.parse("The {field} data in the response JSON is the same as {value}."))
def the_field_data_in_the_response_json_is_the_same_as_value(response, field, value):
    assert str(response.json()[field]) == value


@then(parsers.parse("The {field} data in the response JSON is of type {data_type} and the same as {value}."))
def the_field_data_in_the_response_json_is_of_type_data_type_and_is_the_same_as_value(
    response, field, data_type, value
):
    data = response.json()[field]
    assert isinstance(data, DATA_TYPES.get(data_type, str)) is True
    assert str(data) == value


@then(
    parsers.parse(
        "The {field} data in the {index:d}st/nd/rd/th entry of the response JSON list is the same as {value}."
    )
)
def the_field_data_in_the_indexstndrdth_entry_of_the_response_json_list_is_the_same_as_value(
    response, field, index, value
):
    assert str(response.json()[int(index) - 1][field]) == value


@then(
    parsers.parse(
        "The {field} data in the {index:d}st/nd/rd/th entry of the response JSON list is of type {data_type} and the same as {value}."
    )
)
def the_field_data_in_the_indexstndrdth_entry_of_the_response_json_results_list_is_of_type_data_type_and_the_same_as_value(
    response, field, index, value
):
    assert str(response.json()[int(index) - 1][field]) == value


@then(parsers.parse("The existence of data with an ID of {pk:d} in the {model} model from {module} is {existance}."))
def the_existence_of_data_with_an_id_of_pk_in_the_model_model_from_module_is_existance(pk, model, module, existance):
    module = importlib.import_module(module)
    model_class = getattr(module, model)
    obj = model_class.objects.filter(pk=pk).last()
    assert str(bool(obj)) == existance


@then(
    parsers.parse(
        "The {field} data of the {model} model from {module} with an ID of {pk:d} is of type {data_type} and the same as {value}."
    )
)
def the_field_data_of_the_model_model_from_module_with_an_id_of_pk_is_of_type_data_type_and_the_same_as_value(
    field, model, module, pk, data_type, value
):
    module = importlib.import_module(module)
    model_class = getattr(module, model)
    obj = model_class.objects.filter(pk=pk).last()
    attr = getattr(obj, field)
    assert bool(obj) is True
    assert isinstance(attr, DATA_TYPES.get(data_type, str)) is True
    assert str(attr) == value

 

픽스처를 사용한 테스트 예

테스트 예는 아래와 같다.

@django_db
Feature: Form Update Test
  Background:
    Given I will save the following data using backend.tests.apis.factories's FormFactory.
          """
          {
            "id": 1,
            "slug": "test",
            "title": "test",
            "start_date": "2023-12-01",
            "end_date": "2023-12-31"
          }
          """
    And The data to be sent is as follows.
        """
        {
          "title": "test1"
        }
        """

  Scenario Outline: Form Partial Update Permission Test
    Given I am a/an <user_type> user.
    And I am logging in.
    When I am making a request to the server with data using the PATCH and /v1/forms/test/.
    Then The response status code is <status_code>.
  Examples:
    | user_type | status_code |
    | anonymous | 403 |
    | general | 403 |
    | staff | 200 |


  Scenario: Form Partial Update Test
    Given I am a/an staff user.
    And I am logging in.
    When I am making a request to the server with data using the PATCH and /v1/forms/test/.
    Then The response status code is 200.
    And The id data in the response JSON is the same as 1.
    And The title data in the response JSON is the same as test1.
    And The existence of data with an ID of 1 in the Form model from apps.forms.models is True.
    And The title data of the Form model from apps.forms.models with an ID of 1 is of type str and the same as test1.

 

참고할 문서

github.com/taptorestart/forms

Behavior-driven development, wikipedia

pytest-bdd documentation