[Django]pytest를 이용한 TDD- 환경 구축

차민철·2022년 5월 11일
0

TDD?

Test Driven Development의 약자로 [테스트 주도 개발]이라는 뜻이다.

Clean Code (클린코드) 책에서 제안하는 5가지 TDD의 규칙 FIRST
F : Fast
I : Independent
R : Repeatable
S : Self-Validating
T : Timely

  • Fast: 테스트는 빨라야 한다.
  • Independent: 각 테스트는 서로 의존 하면 안된다.
  • Repeatable: 테스트는 어떤 환경에서도 반복 가능해야 한다. 실제 환경, QA 환경, 버스를 타고 집으로 가는 길에 사용하는 (네트워크에 연결되지 않은) 노트북 환경에서도 실행할 수 있어야 한다.
  • Self-Validating: 테스트는 bool값으로 결과를 내야 한다. 성공 아니면 실패이다. 통과 여부를 알고나서 콘솔을 읽고 있으면 안된다.
  • Timely: 테스트는 적시에 작성해야한다. 즉, 단위 테스트는 테스트 하려는 실제 코드를 구현하기 직전에 구현한다.

Django에서 pytest를 도입해보며...

django에서 TDD로 처음에 Django에서 제공하는 test를 사용했고 확장성을 위해서 pyest를 새로 도입해보기로 하였습니다.
그러나 너무나 적은 레퍼런스에 의해 힘들었던 경험을 생각하며 다른 분들은 저와 같은 고민을 하지 않도록 하기 위해 기록해 봅니다.

pytest

pytest 구조로 들어가기 전에...

제가 작성한 구조가 표준 구조는 아니고 이번에 도입해보며 작성해본 구조이기에 다양한 구조가 가능함을 알려드립니다.

pytest 기본 세팅

설정을 위해 만드는 파일로
1. pytest.ini
2. conftest.py
두종류가 있다.
이 두 종료의 파일을 사용해서 설정을 진행해주면된다.
pytest.ini파일은 프로젝트 전반에 걸친 설정을 넣어주면 된다.
이 후 conftest.py에는 해당 디렉토리 이하에 있는 것들에 대한 설정으로 특정 폴더에 대해서 설정이 필요할 때 적용해주면된다.
다음은 제가 작성해본 설정 예시다.

1. pytest.ini

[pytest]
DJANGO_SETTINGS_MODULE = project.settings.test
; python_files = tests.py test_*.py *_tests.py
python_files = tests/test_*.py
FAIL_INVALID_TEMPLATE_VARS = True
markers = mandatory: mark tests that should be executed
django_debug_mode = True
addopts = -vv -x --create-db --no-migrations
  • DJANGO_SETTINGS_MODULE
    test로 사용할 데이터베이스와 같은 것들을 환경번수를 사용해서 설정해 주는부분이다.
    필자의 경우 project.settings.test라는 파일을 생성하여 실제 production에서 사용하는 환경설정에서 db에 관한 변수만 local db로 덮어 씌우는 형식으로 생성해 주었다.
from .local import *
import os

DATABASES = {
    "default": {
        "ENGINE": "mysql_cymysql",
        "NAME": os.getenv("TEST_DATABASE_NAME"),
        "USER": os.getenv("TEST_DATABASE_USER"),
        "PASSWORD": os.getenv("TEST_DATABASE_PASSWORD"),
        "HOST": os.getenv("TEST_DATABASE_HOST"),
        "PORT": os.getenv("TEST_DATABASE_PORT"),
        "OPTIONS": {"charset": "utf8mb4"},
    },
}
  • python_files
    어떠한 함수를 pytest로 testing을 할 것인지를 정해주는 부분이다.

  • markers: marker를 지정해서 이 후
    @pytest.mark.marker_name를 test앞에 붙여 사용할 수 있다.

  • addopts로 추가 세부 세팅을 해줄 수 있다.
    -vv: 어떠한 폴더에 있으며 어떠한 test를 진행하는 중이며 통과했는지를 일일이 출력해주는 설정이다.
    -x: 오류가 난개 하나라도 있으면 바로 exit을 하는 명령어이다.
    --create-db: test database의 생성을 강제한다. --reuse-db와 같이 사용하면 항상 db는 다시 생성된다.
    --no-migrations: 매번 테스트를 실행할 때 migration을 하지 않기 위해 사용한다.

2. conftest.py

간단하게 말하면 conftest.py는 사실 같은 디렉토리 이하에 있는 test_.py file들에서 사용하는 pytest fixture 들의 집합니다.
fixture에 대한 자세한 설명은 다음 페이지에서 진행해보도록 하겠다.

이번 프로젝트를 진행하며 크게 두 종류의 conftest.py를 사용했다.

[1] 첫번째 종류: root 폴더에 위치한 conftest

전체 프로젝트 관련 test를 하기 전에 해야할 작업들을 진행해주도록 만들었다.
test.py를 진행하기 위한 default유저를 2명 생성해주었다. me는 이 후에 로그인 되어있는 client를 위해 사용하였으며 test_user는 다른 유저에 대해서 테스트가 필요할 때를 위하여 한명 추가해주었다.

drf에서 제공하는 APIClient를 사용하여 두개의 client를 pytest fixture를 사용하여 만들어 주었다.

  • client() : me가 로그인 된 client
  • no_auth_client() : 아무도 로그인 되어있지 않은 client

pytest에서 제공하는 client도 있지만 이는 사용하지 않았다. 이유는 이후에 자세히 설명하도록 하겠다.
이 후 client와 no_auth_client를 사용하여 모든 test 함수들에서 로그인 상태에서, 비로그인 상태에서 해야하는 테스트들을 손쉽게 진행해볼 수 있었다.

다음은 작성해본 첫번째 종류의 conftest.py의 예시이다.

import pytest
from django.test import RequestFactory
from accounts.tests.samples import create_user
from rest_framework.test import APIClient


@pytest.fixture(autouse=True)
def enable_db_access_for_all_tests(db):
    pass


@pytest.fixture(scope="package")
def base_setup(django_db_blocker):
    with django_db_blocker.unblock():
        me_payload = {
            "email": "me@test.com",
            "username": "me",
            "password": "testpassword",
        }
        test_user_payload = {
            "email": "test_user@test.com",
            "username": "test_user",
            "password": "testpassword",
        }
        me = create_user(**me_payload)
        test_user = create_user(**test_user_payload)
        client = APIClient()
        client.force_authenticate(user=me)

        return {
            "client": client,
            "me": me,
            "test_user": test_user,
        }


@pytest.fixture(scope="package")
def client(base_setup):
    return base_setup["client"]


@pytest.fixture(scope="package")
def no_auth_client():
    no_auth_client = APIClient()
    return no_auth_client

주의 할 점!!!!
mysql 데이터베이스를 사용할 때 미리 데이터를 세팅해주기 위해서 django_db_blocker fixture를 사용해주어야한다.
또한 with django_db_blocker.unblock()를 통해 django_db를 unblock해준 후에만 데이터를 삽입해줄 수 있다.

[2] 두번째 종류: 각각 폴더 안에 위치한 conftest

이는 각 폴더 내에 추가해주는 conftest로 특정 그룹의 test를 수행하기전에 모듈별로 미리 세팅해주는 파일이다.
conftest를 추가한 전체 폴더의 구조는 다음과 같다.

tests
L init.py
L conftest.py
L samples.py
L test_social_relations.py
...
L urls.py

conftest.py외 다른 파일들에 대한 설명은 다음 페이지에서 진행하도록 하겠다.

이 곳은 현재 module안에서만 사용할 세팅들을 해주는 곳이다.

다음은 작성해본 두번째 종류의 conftest.py의 예시이다.
첫번째 종류에서는 두명의 유저만 생성했다면 이곳에서는 첫번째 contest.py에서 만든 me를 불러온 후 차단 로직을 테스트 하기위해 차단된 유저, 관련없는 유저를 추가로 생성해주었음을 확인할 수 있다.

import pytest
from accounts.enums import RelationshipType
from accounts.tests.samples import create_user, sample_social_relation

from test_detail.tests.samples import (
    sample_test_detail,
    sample_test_detail_comment,
)
from test_detail.tests.urls import get_test_detail_comments_url


@pytest.fixture(scope="module")
def social_relation_setup(base_setup, django_db_blocker):
    with django_db_blocker.unblock():
        me = base_setup["me"]
        payload3 = {
            "email": "no_relationship_user@test.com",
            "username": "no_relationship_user",
            "password": "testpassword",
        }
        payload4 = {
            "email": "blocked_user@test.com",
            "username": "blocked_user",
            "password": "testpassword",
        }
        no_relationship_user = create_user(**payload3)
        blocked_user = create_user(**payload4)
        blocked_user_relation = sample_social_relation(
            me, blocked_user, RelationshipType.BLOCK.value
        )
        return {
            "me": base_setup["me"],
            "test_user": base_setup["test_user"],
            "no_relationship_user": no_relationship_user,
            "blocked_user": blocked_user,
            "blocked_user_relation": blocked_user_relation,
        }


@pytest.fixture(scope="function")
def test_detail_setup(social_relation_setup):
    test_detail = sample_test_detail()
    test_detail_comment_by_not_blocked = sample_test_detail_comment(
        test_detail,
        social_relation_setup["no_relationship_user"],
        content="차단 안한 사람의 댓글",
    )
    test_detail_comment_by_blocked = sample_test_detail_comment(
        test_detail,
        social_relation_setup["blocked_user"],
        content="차단한 사람의 댓글",
    )
    test_detail_comment_url = get_test_detail_comments_url(test_detail.key)
    return {
        "me": social_relation_setup["me"],
        "test_user": social_relation_setup["test_user"],
        "test_detail_comment_url": test_detail_comment_url,
        "test_detail_comment_by_not_blocked": test_detail_comment_by_not_blocked,
        "test_detail_comment_by_blocked": test_detail_comment_by_blocked,
    }

0개의 댓글