Django Test Case Customizing

Manx·2022년 11월 23일
0

TDD

목록 보기
1/1

Django 프레임워크에서 테스트 코드를 짜기 위한 환경을 구성해 보았다.
기존에 DB에 저장되어 있는 데이터를 바탕으로 테스트 코드를 작성하는 방법이다.

하지만 다음과 같은 사항 때문에 고민이 될 것이다.

  • 테스트 코드는 독립적이어야 한다.
  • 의존할 수 없는 것에 의존하면 안된다. ( DB의 데이터는 계속 변할 수 있으므로)

그래서 다음과 같은 상황일 때 적합하다.

당장 테스트 코드가 필요하지만 인적 / 시간적 여유가 없을 때
기존의 테스트 코드를 작성하지 않아 이제 막 도입하려 할 때

  • 핵심 로직을 빠르게 테스트해 볼 수 있다는 장점이 있다.

결국 임시방편이고, 시장과 타협한 셈이다.
물론 처음부터 테스트 코드를 작성해야 하는 과정을 다시 거쳐야 하긴 한다.

핵심 로직만 빠르게 테스트할 수 있는 코드를 만들어 놓고 운영하고 싶은 사람들을 위한 글이다.


1. TestRunner 설정

Django에서 테스트 코드를 실행하는 방법을 알아보자.

다음 명령어로 test_xx.py처럼 파일명 앞에 'test_' 가 붙은 파일들을 찾아 테스트를 수행한다.

python manage.py test
  • 매번 테스트 코드를 실행 할 때 마다 DB Schema를 하나 생성해 사용하고 다시 삭제하는 작업을 거치게 된다.

우리는 기존에 저장되어 있는 데이터를 사용할 것이기 때문에 해당 작업이 필요 없다.

따라서 setting.py에 TestRunner 설정을 변경하자.

TEST_RUNNER = 'xxx.test_runner.TestRunner'

그 후 TestRunner Class를 만들어 아무 동작을 하지 않게 Override 해준다.

# tesr_runner.py

from django.test.runner import DiscoverRunner

class TestRunner(DiscoverRunner):

    def setup_databases(self, **kwargs):
        pass

    def teardown_databases(self, old_config, **kwargs):
        pass


2. Basic TestCase

Django에서 제공하는 가장 기본적인 테스트 코드를 적는 방법을 알아보자.

기본적인 TestCase

app을 만들면 자동적으로 tests.py 폴더가 생기게 된다.

from django.test import TestCase

# Create your tests here.

TestCase라는 class를 기본적으로 제공해 주며, 이 클래스를 상속받아 테스트를 진행하면 된다.

TestCase의 구현부이다.

class TestCase(TransactionTestCase):
	@classmethod
    def _enter_atomics(cls):
        """Open atomic blocks for multiple databases."""
        atomics = {}
        for db_name in cls._databases_names():
            atomic = transaction.atomic(using=db_name)
            atomic._from_testcase = True
            atomic.__enter__()
            atomics[db_name] = atomic
        return atomics

    @classmethod
    def _rollback_atomics(cls, atomics):
        """Rollback atomic blocks opened by the previous method."""
        for db_name in reversed(cls._databases_names()):
            transaction.set_rollback(True, using=db_name)
            atomics[db_name].__exit__(None, None, None)

    @classmethod
    def _databases_support_transactions(cls):
        return connections_support_transactions(cls.databases)

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        if not cls._databases_support_transactions():
            return
        cls.cls_atomics = cls._enter_atomics()

        if cls.fixtures:
            for db_name in cls._databases_names(include_mirrors=False):
                try:
                    call_command(
                        "loaddata",
                        *cls.fixtures,
                        **{"verbosity": 0, "database": db_name},
                    )
                except Exception:
                    cls._rollback_atomics(cls.cls_atomics)
                    raise
        pre_attrs = cls.__dict__.copy()
        try:
            cls.setUpTestData()
        except Exception:
            cls._rollback_atomics(cls.cls_atomics)
            raise
        for name, value in cls.__dict__.items():
            if value is not pre_attrs.get(name):
                setattr(cls, name, TestData(name, value))

    @classmethod
    def tearDownClass(cls):
        if cls._databases_support_transactions():
            cls._rollback_atomics(cls.cls_atomics)
            for conn in connections.all(initialized_only=True):
                conn.close()
        super().tearDownClass()

    @classmethod
    def setUpTestData(cls):
        """Load initial data for the TestCase."""
        pass
  • setUp() : TestCode의 메서드들이 실행되기 전마다 실행된다 -> 테스트 중 변할 수 있는 데이터들 등록
  • setUpClass() : TestCase Class에 필요한 변수들 정의
  • setUpTestData() : TestCode가 실행되기 전 한 번만 실행된다.
    -> 전체적으로 필요한 데이터 등록 ( 주의! RollBack 되지 않는다. )
  • tearDownClass() : 모든 TestCode의 메서드 종료 후, 데이터들을 제거할 때 사용한다. ( Rollback되지 않은 데이터를 여기서 제거해야 한다.)

TestCode는 메서드를 종료할 때 마다 , DB를 rollback 시킨다.
-> _enter_atomics(), _rollback_atomics() 부분



3. TestCode Hierarchy

TestCode를 보면, TransactionTestCase를 다시 상속받고 있다.
그래서 Django에서 제공하는 TestCode의 Hierarchy를 가져왔다.

unittest.TestCase는 Python에서 자체적으로 제공해 주는 클래스이고, 장고에서 이 클래스를 확장시켜 TestCase를 제공해 주고 있는 것이다.
TransactionTestCase는 DB를 rollback 시키는 것이 아닌, Truncate 한다.
Truncate보다 rollback을 하는 속도가 빨라 TestCase를 이용한다.
Transaction을 테스트하는 Option에 ATOMIC_REQUESTS 라는 것이 있는데, 이것은 필요할 때 찾아보도록 하자.



4. TenantTestCase

TestCase를 상속받아 테스트 코드를 돌려 보았지만, 테이블을 찾을 수 없는 에러가 나왔다.
현재 DB schema를 분리해서 사용하고 있기 때문에, Default Tenant인 public으로 설정되어 실제로 테이블들이 생성 되어 있는 test schema에 접근하지 않아 테이블을 찾을 수 없는 오류였다.

그래서 Tenant를 별도로 설정할 수 있는 방법을 찾았고,
TenantTestCase 라는 녀석이 있었다.

ALLOWED_TEST_DOMAIN = '.test.com'

class TenantTestCase(TestCase):
	@classmethod
    def setUpClass(cls):
        cls.sync_shared()
        cls.add_allowed_test_domain()
        tenant_domain = 'tenant.test.com'
        cls.tenant = get_tenant_model()(domain_url=tenant_domain, schema_name='test')
        cls.tenant.save(verbosity=0)  # todo: is there any way to get the verbosity from the test command here?

        connection.set_tenant(cls.tenant)

    @classmethod
    def tearDownClass(cls):
        connection.set_schema_to_public()
        cls.tenant.delete()

        cls.remove_allowed_test_domain()
        cursor = connection.cursor()
        cursor.execute('DROP SCHEMA IF EXISTS test CASCADE')

setUpClass()에서 'test' Tenant를 만들고 있다.
나는 이미 DB에 test라는 이름의 Schema가 있고, 그게 Django Model에서 유니크로 선언되어 있기 때문이었다.

그래서 TenantTestCase에서 Tenant를 생성하는 부분을 지우고 돌려 보았는데, 이게 무슨 일이람 내 test Schema가 통째로 날아갔다..

tearDown 부분에서 test Tenant가 존재할 때, 강제로 쿼리를 날려 없애는 부분에 당해버렸다..


그렇게 다시 환경 세팅을 한 뒤, TenantTestCase를 상속받은 MhTestCase를 만들게 되었다.
MhTestCase인 이유는 내 이름의 이니셜이기 때문이다.

class MhTestCase(TenantTestCase):

    @classmethod
    def setUpClass(cls):
        cls.sync_shared()
        cls.add_allowed_test_domain()
        cls.tenant = get_tenant_model()(domain_url=TENANT_DOMAIN, schema_name=SCHEMA_NAME)

        connection.set_tenant(cls.tenant)

    @classmethod
    def tearDownClass(cls):
        connection.set_schema_to_public()

        cls.remove_allowed_test_domain()

    def setUp(self) -> None:
        super().setUp()

        self.client = TenantClient(self.tenant, HTTP_USER_AGENT="Mozilla/5.0")
  • setUpClass를 오버라이딩하여 tenant를 save()하는 부분을 지웠다.
  • tearDownClass를 오버라이딩 하여 Delete하는 부분도 제거하였다.

MhTestCase를 상속받아 client를 계속 만들어야 하는데, request를 만들 시 그 안에 들어갈 내용은 공통이기 때문에 클래스 변수로 만들었다.

MhTestCase를 상속받아 만든 클래스들은 다음 과정을 통해 client를 손쉽게 주입받을 수 있다.

class LoginTest(MhTestCase):

	def setUp(self) -> None:
    super().setUp()
    
    # data setting

다음은 최종 Hirachy이다.


그러나 한 가지 문제점이 더 남아 있었다.

setUp() 메서드에서 데이터를 세팅할 때, tenant를 지정해 줘야 하는데 지정해 줄 때 마다
다음과 같은 과정을 거치고, assert를 하기 위해 데이터를 꺼낼 때에도 'with tenant_context(tenant): '를 적어야 하는 노릇이다.

with tenant_context(tenant):
	user = User.objects.creat(~)

5. TestDecorator

그래서 간편하게 개발하기 위해 TestDecorator를 만들었다.

def TestDecorator(test_function):
    tenant = get_tenant_model()(domain_url=TENANT_DOMAIN, schema_name=SCHEMA_NAME)

    @wraps(test_function)
    def wrapper(*args, **kwargs):
        with tenant_context(tenant):
            test_function(*args, **kwargs)

    return wrapper

이제 TestDecorator를 setup(), test_~() 메서드를 호출할 때마다 붙여주면 with tenant_context()를 쓰지 않아도 된다.

변환 예시이다.


class LoginTest(MhTestCase):
	
    # TestDecorator 전
	def setUp(self) -> None:
	    with tenant_context(self.tenant):
        	user = User.objects.create_user(~)
            # ...
    
    # TesDecorator 후
    
    @TestDecorator
    def setUp(self) -> None:
    	user = User.objects.create_user(~)
    
    


다 만들고 나서 보니 pytest라는 것이 있는데, 나중에 적용시킬지 고려해봐야겠다.

이상 TestCode Setting 과정이었다.


번외

coverage library를 사용하면 code coverage를 측정할 수 있다.

pip install로 간단하게 설치할 수 있으며, 결과를 html로 볼 수도 있어 편리하다.

.coveragerc 파일에 다음 내용들을 적으면 편리하게 사용할 수 있다.
[run]
source = .
omit = ~

omit -> 어떤 파일들을 제외할지이다. migration 파일들을 제외하고 싶다면, '*/migrations/**'와 같이 쓰면 된다.

  • coverage run manage.py test : 전체 테스트 수행
  • coverage run manage.py test myapp : myapp만 테스트 수행
  • coverage report : Code Coverage 확인
  • coverage html : index.html 파일 생성, html파일로 어느 부분이 커버되지 않았는지 등 판단.
profile
백엔드 개발자

0개의 댓글