[DRF]Django REST Framework에서 PyTest로 테스트하기(1)

김동욱·2024년 3월 24일
post-thumbnail

Django REST Framework에서 PyTest로 테스트하기를 주제로 찾던 중, 이와 관련해서 꽤 괜찮은 글을 발견해서 이를 한국어로 옮겨보며 정리해보았다.(일부 의역이 존재하기 때문에 더 정확한 내용을 확인하고자 한다면 원글을 참고하길 바란다.)

내용이 길기 때문에 두 개의 글로 나눠서 작성했다. 이번 글은 테스트의 개요이고 다음 글은 테스트를 작성하는 방법이다.

PyTest를 사용하는 이유

  • 상용구 감소(unittest와 같은 Assert 문을 외울 필요가 없음)
  • 커스텀하게 만들 수 있는 에러 상세 정보
  • IDE 관계 없이 자동 탐색
  • 팀 간 표준화를 위한 표시들
  • 멋진 터미널 명령
  • DRY에 대한 매개변수화
  • 거대한 커뮤니티의 존재

테스트를 구조화하는 방법

tests 디렉토리를 만들고 내부에 장고 프로젝트의 각각의 앱 디렉토리를 만드는 방식은 확장 가능한 앱을 위해 좋은 방법이다.

...
├── tests
│   ├── __init__.py
│   ├── test_app1
│   │   ├── __init__.py
│   │   ├── conftest.py
│   │   ├── factories.py
│   │   ├── e2e_tests.py
│   │   ├── test_models.py <--
│   │   ├── test_signals.py <--
│   │   ├── test_serializers.py
│   │   ├── test_utils.py
│   │   ├── test_views.py
│   │   └── test_urls.py
│   │
│   └── ...
└── ...

PyTest 설정하기

PyTest 환경설정은 pytest.ini 파일의 [pytest] 블록 아래에 하거나, setup.cfg 파일의 [tool:pytest] 블록 아래에 할 수 있다.

:in pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = ...
markers = ...
python_files = ...
addopts = ...

:in setup.cfg
[tool:pytest]
DJANGO_SETTINGS_MODULE = ...
markers = ...
python_files = ...
addopts = ...

많은 예시에서 pytest.ini 파일을 만드는 것을 볼 수 있다. pytest.ini 파일은 pytest 설정에만 사용된다. 프로젝트가 성장함에 따라 새로운 플러그인을 사용하고자 하면 새로운 파일을 생성하여 관리를 해야하는 번거로움이 발생한다. 따라서 setup.cfg 파일을 사용하여 모든 플러그인 관리하는 것을 추천한다.

이 파일에서 주로 중요한 구성은 다음과 같다.

  • DJANGO_SETTINGS_MODULE : 작업 디렉토리에서 설정이 위치한 곳을 나타낸다.
  • python_files : pytest가 테스트 파일을 매치하기 위해 사용할 패턴을 나타낸다.
  • addopts : pytest를 실행할 때마다 명령어에 기본적으로 포함될 추가 옵션들을 지정한다.
  • markers : 테스트를 분류하기 위해 테스트에 사용자 정의 태그("unit", "integration", "e2e", "regression" 등)를 정의하는 곳이다.

테스트의 타입

QA 단계에서는 다양한 테스트가 존재한다. 하지만 개발자가 알아야 할 테스트 유형은 다음 네 가지이다.

  • 단위 테스트(Unit Tests): 코드의 특정 부분을 다른 코드 단위와의 상호 작용에서 격리하여 테스트한다(대부분 외부 코드를 Mock 객체로 대체하여 테스트). 이것은 내부 코드일 수도 있고, 데이터베이스 호출이나 외부 API 호출일 수도 있다.

  • 통합 테스트(Integration Tests): 코드의 특정 부분을 다른 단위와의 상호 작용에서 격리하지 않고 테스트한다. 이 테스트는 여러 컴포넌트가 서로 올바르게 작동하는지 확인하기 위해 실행된다. 예를 들어, 사용자 인터페이스와 데이터베이스 사이의 데이터 흐름을 테스트하여 모든 연결이 정상적으로 작동하는지 확인할 수 있다.

  • 엔드 투 엔드 테스트(e2e Tests): 테스트 대상인 Django 앱의 시작부터 끝까지의 흐름을 테스트하는 통합 테스트다. 이러한 테스트는 사용자의 전체 작업 흐름을 시뮬레이션하여 앱이 사용자의 입장에서 예상대로 작동하는지 확인한다. 예를 들어, 사용자가 로그인하여 특정 데이터를 입력하고 결과를 보는 전체 과정을 테스트할 수 있다.

  • 회귀 테스트(Regression Tests): 단위 테스트든 통합 테스트든, 버그 수정 직후 해당 버그를 커버하기 위해 생성된 테스트다. 이 테스트는 과거에 발견된 문제가 미래에 다시 발생하지 않도록 예방한다. 예를 들어, 고객 데이터 처리 중 발견된 오류를 수정한 후, 해당 오류가 재발하지 않도록 특정 시나리오를 테스트하여 그 결과를 확인한다.

테스트를 진행하는 이유

테스트를 통해 앱을 보호하기 위한 단계별 접근법을 추천하기 전에, 왜 테스트를 하는지 이해하는 것이 좋다. 주로 두 가지 이유가 있다.

  • 코딩하는 동안 우리를 안내하기 위해 : 개발을 진행하는 동안, 이 테스트들이 우리 코드를 안내해 줄 것이다. 우리가 구축하고 있는 코드의 목표는 이 테스트들을 통과하는 것이다.
  • 우리가 무언가를 망가뜨리지 않았는지 확인하기 위해 : 코드의 모든 조각은 스파게티 접시 위의 스파게티 면처럼 서로 얽혀 있다. 면 한 조각을 뽑았다가 다시 넣을 때 무언가를 망가뜨릴 확률은 매우 높다.

적절한 테스트 흐름

개발자 테스트를 위한 두 가지 주요 코드 테스트 흐름이 있다.

  • TDD(Test Driven Development): 코드를 통해 확인하기 전에 테스트를 수행한다.
  • 개발 후 테스트 : 코드를 만든 후 바로 테스트한다.

작성하고자 하는 코드에 대해 잘 알지 못하는 한, 거의 항상 후자의 테스트를 사용하는 것이 좋다. 즉, 코드를 작성하고 코드가 완료되었다고 간주하면 예상대로 작동하는지 테스트를 진행한다. 하지만 두 가지 방법을 혼합하는 것을 추천한다.

  1. 엔드 투 엔드 테스트를 만들기: 이것은 최종 테스트 스위트의 TDD(Test-Driven Development) 구성 요소가 되어야 한다. 이 테스트들은 무엇보다 먼저 각 엔드포인트가 반환해야 할 것을 사전에 구성해 놓는 데 도움을 주며, 앱의 나머지 코드가 어떻게 진행될지에 대한 초기 비전을 갖는 데 도움을 준다.
  2. 단위 테스트 생성하기: 코드를 작성하고 Django 앱의 각 부분에 대해 격리된 테스트를 생성한다. 이는 개발자가 개발 과정을 통해 도움을 받는 데에만 유용한 것이 아니라, 문제가 발생하는 위치를 정확히 찾는 데에도 매우 효과적이다.

엔드 투 엔드 테스트는 개발 과정 중 통합 테스트로 자주 사용되어서는 안 된다. 특히 큰 코드베이스를 다룰 때, 이러한 테스트는 시간이 많이 소요되기 때문에, 지속적 통합(CI/CD) 파이프라인의 일부로 설정하는 것이 권장되지 않는다. 엔드 투 엔드 테스트는 모든 단위 테스트가 통과한 후에 코드 단위 간의 결합이 제대로 이루어졌는지 확인하기 위한 최종 검증 수단으로 사용되어야 한다. 이는 상태 확인 용도로 활용되어 코드의 전체적인 연동이 잘 이루어졌는지 확인하는 데에 목적이 있다.(PyTest 문서에서 이 전략에 대해 언급)

개발자의 일상적인 작업 흐름에서는 주로 단위 테스트를 활용해야 한다. 단위 테스트는 비교적 실행 시간이 짧고, 특정 함수나 메소드의 정확성만을 평가하기 때문에 개발 과정 중 자주 사용하기에 적합하다. 단위 테스트는 PyTest 마커나 테스트 함수의 이름을 통해 식별할 수 있으며, 이를 사용해 한 번의 명령으로 모든 관련 테스트를 실행할 수 있다. 이러한 방식은 개발 과정을 효율적으로 만들고, 코드 변경에 따른 신속한 피드백을 제공한다.

파이프라인과 개발자의 작업 흐름에서 엔드 투 엔드 테스트를 피하고 대신 단위 테스트를 사용하는 또 다른 이유는 플래키 테스트, 즉 스위트에서 실행할 때 실패하지만 혼자 실행할 때는 완벽하게 동작하는 테스트를 피하기 위해서이다. (플래키 테스트의 해결 방법에 대한 글)

단위 테스트에서 무엇을 테스트해야 하나?

이 접근 방식을 따를 경우 모델을 테스트할 수 없다는 문제가 생길 수 있다. 실제로 이는 제가 의도한 바이다. 비록 이 글이 당신의 Django 앱 내 다른 부분에서 비즈니스 로직을 어떻게 관리할지에 대해 설명하는 것은 아니지만, 모델은 데이터 표현용으로만 사용되어야 한다. 모델에 필요한 로직은 시그널이나 모델 매니저에 저장되어야 한다.

주요 테스트에서 데이터베이스 접근을 피함으로써, 모델을 올바르게 구축했는지 확인하는 작업은 엔드 투 엔드 테스트에 맡긴다.

다른 프로그래밍 언어나 프레임워크에서는 이것이 까다로운 질문일 수 있지만, Django에서는 테스트 가능한 단위가 무엇인지 알기 쉽다. 우리는 로직을 6개 부분으로 분류할 수 있기 때문에, 단위 테스트는 다음과 같은 것일 수 있다.

  • Model (model methods/model managers)
  • Signal
  • Serializer
  • Helper Object a.k.a "utils" (functions, classes, method, etc.)
  • View/Viewset
  • URL Configuration

공통 테스팅 유틸리티

Markers

테스트 예제를 소개하기 전에, PyTest가 제공하는 몇 가지 유용한 마커를 소개해보겠다. 마커는 테스트 함수를 감싸는 @pytest.mark.<marker> 형식의 데코레이터이다.

  • @pytest.mark.parametrize() : 이 마커는 동일한 테스트를 다른 값으로 여러 번 실행하도록 한다. 이는 for 반복문처럼 작동한다.
  • @pytest.mark.django_db : 테스트에 데이터베이스 접근 권한을 주지 않으면 기본적으로 데이터베이스에 접근할 수 없다. 이 마커는 pytest-django 플러그인에서 제공되며, 물론 통합 테스트에서만 의미가 있다.

Mocking

단위 테스트를 수행할 때, 외부 API, 데이터베이스 및 내부 코드에 대한 접근을 흉내내고자 할 것이다. 이때 다음과 같은 라이브러리가 도움이 된다.

  • pytest-mock : unittest.mock 객체를 제공하고 unittest.mock의 컨텍스트 매니저 대신 침투하지 않는 패치 함수를 제공한다.
  • requests-mock : rf 픽스처를 통해 요청 팩토리를 제공하고 요청 객체를 모킹할 수 있는 기능을 제공한다.
  • django-mock-queries : 비영구 객체 인스턴스로 채울 수 있는 쿼리셋 객체를 모킹할 수 있는 클래스를 제공한다.

PyTest Commands

작업 디렉토리에서 모든 테스트를 실행하기 위해 우리가 사용하는 명령어는 pytest이지만, PyTest는 다른 것들 중에서도 우리가 원하는 테스트만 쉽게 선택하고 실행 범위를 좁힐 수 있도록 도와주는 명령어들을 제공한다. 주요 명령어들은 다음과 같다.

  • -k <expression> : 지정된 표현식과 일치하는 테스트 폴더 내의 파일, 클래스 또는 함수 이름을 가진 테스트들만 실행한다.
  • -m <marker> : 입력된 마커를 가진 모든 테스트를 실행한다.
  • -m "not <marker>" : 입력된 마커를 가지지 않는 모든 테스트를 실행한다.
  • -x : 테스트가 실패하면 테스트 실행을 중지하여, 테스트 스위트의 실행을 기다리는 대신 디버깅으로 돌아갈 수 있게 해준다.
  • --lf : 마지막에 실패한 테스트부터 테스트 스위트를 실행하기 시작한다. 디버깅 시 이미 통과하는 것으로 알려진 테스트를 계속 실행하는 것을 피하기에 적합하다.
  • -vv : 실패한 assertion에 대한 더 자세한 버전을 보여준다.
  • --cov : 테스트에 의해 커버된 테스트의 %를 보여준다(pytest-cov 플러그인에 의존).
  • --reruns <num_of_reruns> : 테스트 스위트에서 실행할 때는 실패하지만 혼자 실행할 때는 통과하는 플래키 테스트, 즉 불안정한 테스트를 다루는 데 사용된다.

Addopts

Addopts는 PyTest 명령을 실행할 때마다 실행하기를 원하는 PyTest 옵션에 대한 명령으로, 설정 시 매번 입력할 필요가 없다.

다음과 같은 방법으로 애드옵트를 구성할 수 있다.

DJANGO_SETTINGS_MODULE = ...
markers = ...
python_files = ...
addopts = -vv -x --lf --cov

특정 테스트 지정 실행

원하는 특정 테스트만 빠르게 실행하고 싶을 때, -k 명령어를 사용하여 pytest를 실행하고 원하는 테스트의 이름을 입력할 수 있다.

대신 공통적인 특성을 가진 일련의 테스트를 실행하고 싶다면, -k 명령어를 사용하여 모든 test_*.py 파일, 모든 Test* 클래스 또는 입력된 표현식을 포함하는 모든 test_* 함수를 선택할 수 있다.

테스트를 그룹화하는 좋은 방법은 pytest.ini 또는 setup.cfg 파일에 사용자 정의 마커를 설정하고, 이 마커들을 팀 전체와 공유하는 것이다. 우리가 테스트를 필터링하기 위해 사용하고 싶은 모든 종류의 패턴에 대해 마커를 만들 수 있다.

어떤 마커를 만들 것인지는 개발자의 기준에 달려 있지만, 제가 제안하는 것은 -k 명령어를 사용하여 표현식 매칭으로 그룹화하기 어려운 테스트만을 그룹화하는 데 Marker를 사용하는 것이다. 일반적으로 유용한 Marker의 예로는 단위 테스트만을 선택하는 unit 마커가 있다.

pytest.ini에서 Marker를 다음과 같이 지정할 수 있다.

[tool:pytest]
markers =
    # Define our new marker
    unit: tests that are isolated from the db, external api calls and other mockable internal code.

따라서 단위 테스트의 패턴을 따르는 테스트를 mark하려면 다음과 같이 mark할 수 있다.

import pytest

@pytest.mark.unit
def test_something(self):
    pass

각 파일에 하나 이상의 단위 테스트가 있을 것이기 때문에(제 조언을 따르면 테스트 파일의 대부분은 실제로 단위 테스트 파일이 될 것이다), 가져온 직후 파일 상단에 pytestmark 변수를 선언함으로써 모든 파일에 대한 전역 마커를 설정하여 각 기능에 대한 마커를 만드는 지루함을 피할 수 있다. 여기에는 단일 pytest marker 또는 마커 목록이 포함된다.

# (imports)

# Only one global marker (most commonly used)
pytestmark = pytest.mark.unit
# Several global markers
pytestmark = [pytest.mark.unit, pytest.mark.other_criteria]

# (tests)

만약 모든 테스트에 대해 전역 마커를 설정하고자 한다면, pytest는 포함된 디렉토리 내의 모든 PyTest 테스트 객체를 나타내는 'items'라는 픽스처를 생성한다. 예를 들어, 'all' 마커를 생성하여 conftest.py와 같은 수준 또는 그 아래에 있는 모든 테스트 파일에 이 마커를 사용하여 다음과 같이 표시할 수 있다:

# in conftest.py
def pytest_collection_modifyitems(items):
    for item in items:
        item.add_marker('all')

Factories

팩토리는 미리 채워진 모델 인스턴스이다. 수동으로 모델 인스턴스를 만드는 대신, 팩토리가 우리를 대신해 작업을 수행한다. 팩토리를 생성하기 위한 주요 모듈로는 factory_boymodel_bakery가 있다. 생산성을 위해 거의 항상 model_bakery를 사용하는 것이 좋다. 이 모듈은 Django 모델을 주어진 경우, 유효한 데이터로 채워진 모델 인스턴스를 생성한다. model_bakery의 문제점은 무작위적인 의미없는 데이터를 생성한다는 것이다. 그래서 만약 우리가 임의의 문자가 아니라 의미 있는 필드를 생성하는 팩토리를 원한다면, 예를 들어 'name' 필드가 있다면 이름처럼 보이는 이름을 생성하기 위해 factory_boyfaker를 함께 사용해야 한다.

팩토리를 생성할 때는 통합 테스트를 수행하며 이를 생성하고 저장하고자 할 것이지만, 단위 테스트를 만들 때는 데이터베이스에 접근하고 싶지 않기 때문에 저장하고 싶지 않을 것이다. 데이터베이스에 저장되는 인스턴스를 "persistent 인스턴스"라고 하며, 데이터베이스 호출의 응답을 모방하는 데 사용할 "non persistent 인스턴스"와 대조된다. 다음은 model_bakery를 사용한 두 경우의 예이다.

from model_bakery import baker

from apps.my_app.models import MyModel

# create and save to the database
baker.make(MyModel) # --> One instance
baker.make(MyModel, _quantity=3) # --> Batch of 3 instances

# create and don't save
baker.prepare(MyModel) # --> One instance
baker.prepare(MyModel, _quantity=3) # --> Batch of 3 instances

만약 우리가 무작위 데이터 이외의 것을 보여주고 싶다면, model_bakery의 기본 행동을 덮어쓸 수 있다(링크). 또는 다음과 같은 방식으로 factory_boyfaker를 사용하여 팩토리를 작성할 수 있다.

# factories.py
import factory

class MyModelFactory(factory.DjangoModelFactory):
    class Meta:
        model = MyModel
    field1 = factory.faker.Faker('relevant_generator')
    ...

# test_something.py

# Save to db
MyModelFactory() # --> One instance
MyModelFactory.create_batch(3) # --> Batch of 3 instances

# Do not save to db
MyModelFactory.build() # --> One instance
MyModelFactory.build_batch() # --> Batch of 3 instances

Faker는 많은 다른 topic에 대해 관련 무작위 데이터를 생성할 수 있는 생성기를 갖추고 있으며, 각 topic은 faker 제공자(provider)에 의해 표현된다. Factory boy의 기본 Faker 버전은 모든 제공자를 포함하고 있어서, 여기에 접속하여 모든 제공자를 확인하고 원하는 제너레이터의 이름을 문자열로 전달하여 필드를 생성할 수 있다(예: faker.Faker('name')).

팩토리는 conftest.py에 저장할 수 있다. 이 파일은 같은 디렉토리 수준 및 그 아래의 모든 테스트에 대한 설정을 할 수 있는 곳이기도 하다(예를 들어, 여기서는 픽스처를 정의한다). 또는, 앱에 많은 다른 팩토리가 있는 경우, 각 앱별 factories.py 파일에 직접 저장할 수 있다.

....
├── tests
│   ├── __init__.py
│   ├── test_app1
│   │   ├── __init__.py
│   │   ├── conftest.py <--
│   │   ├── factories.py <--
│   │   ├── e2e_tests.py
│   │   ├── test_models.py
│   │   ├── test_signals.py
│   │   ├── test_serializers.py
│   │   ├── test_utils.py
│   │   ├── test_views.py
│   │   └── test_urls.py
│   │
│   └── ...
└── ...
profile
안녕하세요! 질문과 피드백은 언제든지 환영입니다:)

0개의 댓글