• django
  • testing
  • python

Django TestCase 커스터마이징: 멀티 테넌트 환경에서 기존 DB로 테스트하기

스키마를 분리한 멀티 테넌트 Django 프로젝트에서 TestRunner와 TestCase를 커스터마이징해 기존 DB 데이터로 테스트하는 환경을 만든 과정.

Django에서 테스트 코드를 작성하기 위한 환경을 구성했다. 이미 DB에 저장되어 있는 데이터를 바탕으로 테스트를 돌리는 방식이다.

물론 이 방식은 테스트의 기본 원칙과 어긋난다.

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

그래서 다음과 같은 상황에 한해 적합하다.

  • 당장 테스트 코드가 필요하지만 인적·시간적 여유가 없을 때
  • 테스트 코드가 없던 프로젝트에 이제 막 테스트를 도입하려 할 때

핵심 로직을 빠르게 테스트해 볼 수 있다는 것이 장점이다. 결국 임시방편이고 현실과 타협한 셈이라, 나중에는 테스트 데이터를 직접 구성하는 제대로 된 테스트를 다시 작성해야 한다.

1. TestRunner 설정

Django는 다음 명령으로 test_로 시작하는 파일을 찾아 테스트를 수행한다.

python manage.py test

이때 테스트를 실행할 때마다 테스트용 DB를 새로 만들어 사용하고, 끝나면 삭제한다. 기존에 저장된 데이터를 쓸 것이므로 이 작업은 필요 없다. settings.py에서 TestRunner를 바꿔 준다.

# settings.py
TEST_RUNNER = 'xxx.test_runner.TestRunner'

그리고 DB 생성과 삭제가 아무 동작도 하지 않도록 오버라이드한 TestRunner를 만든다.

# test_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. Django의 기본 TestCase

app을 만들면 tests.py 파일이 자동으로 생기고, Django가 제공하는 TestCase를 상속받아 테스트를 작성하게 되어 있다.

from django.test import TestCase

# Create your tests here.

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(): 테스트 메서드가 실행되기 전마다 실행된다. 테스트 중 변할 수 있는 데이터를 등록한다.
  • setUpClass(): 테스트 클래스 단위로 한 번 실행된다. 클래스에 필요한 변수를 정의한다.
  • setUpTestData(): 테스트 메서드들이 실행되기 전에 한 번만 실행된다. 전체적으로 필요한 데이터를 등록한다. 여기서 만든 데이터는 메서드가 끝날 때마다 롤백되지 않고 클래스 단위로 유지된다는 점에 주의한다.
  • tearDownClass(): 모든 테스트 메서드가 끝난 뒤 실행된다. 클래스 단위로 남아 있는 데이터를 여기서 정리한다.

TestCase는 테스트 메서드가 끝날 때마다 DB를 롤백한다. _enter_atomics()와 _rollback_atomics()가 그 역할을 한다.

3. TestCase 계층 구조

TestCase는 TransactionTestCase를 상속받고 있다. Django가 제공하는 테스트 클래스의 계층은 다음과 같다.

Django 테스트 클래스 계층 구조: unittest.TestCase에서 Django TestCase까지

  • unittest.TestCase는 파이썬이 자체적으로 제공하는 클래스이고, Django가 이를 확장해 TestCase를 제공한다.
  • TransactionTestCase는 DB를 롤백하지 않고 truncate한다.
  • truncate보다 롤백이 빠르기 때문에 보통 TestCase를 사용한다.

4. TenantTestCase

TestCase를 상속받아 테스트를 돌렸더니 테이블을 찾을 수 없다는 에러가 났다.

현재 프로젝트는 DB 스키마를 테넌트별로 분리해 사용하고 있다. 테스트가 기본 테넌트인 public 스키마로 접속하고, 실제 테이블이 있는 test 스키마에는 접근하지 않아서 생긴 오류였다.

테넌트를 지정할 방법을 찾다가 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라는 테넌트를 새로 만들어 저장한다. 그런데 내 DB에는 이미 test라는 스키마가 있고, 스키마 이름은 모델에서 unique로 선언되어 있어 그대로는 쓸 수 없었다.

그래서 테넌트를 생성하는 부분만 지우고 돌려 보았는데, test 스키마가 통째로 날아갔다. tearDownClass()가 DROP SCHEMA IF EXISTS test CASCADE 쿼리를 직접 날려 스키마를 지우는 부분을 놓친 것이다.

MhTestCase

환경을 다시 세팅한 뒤, TenantTestCase를 상속받은 MhTestCase를 만들었다. (Mh는 내 이름의 이니셜이다.)

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()를 오버라이드해 테넌트를 save()하는 부분을 지웠다. 기존 스키마를 가리키는 테넌트 객체만 만들어 연결한다.
  • tearDownClass()를 오버라이드해 테넌트를 삭제하고 스키마를 DROP하는 부분을 제거했다.
  • 테스트마다 client를 만들어야 하는데 request에 들어갈 내용은 공통이므로, setUp()에서 TenantClient를 만들어 둔다.

MhTestCase를 상속받은 클래스는 super().setUp()만 호출하면 client를 바로 사용할 수 있다.

class LoginTest(MhTestCase):

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

        # data setting

최종 계층 구조는 다음과 같다.

TenantTestCase를 상속한 MhTestCase까지 포함한 최종 테스트 클래스 계층 구조

5. TestDecorator

한 가지 문제가 더 남아 있었다. setUp()에서 데이터를 세팅할 때마다 테넌트를 지정해야 하고, assert를 위해 데이터를 꺼낼 때도 매번 with tenant_context(tenant):를 적어야 한다.

with tenant_context(tenant):
    user = User.objects.create(...)

이를 줄이기 위해 데코레이터를 만들었다.

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

setUp()과 test_*() 메서드에 TestDecorator를 붙이면 with tenant_context()를 쓰지 않아도 된다.

# TestDecorator 적용 전
class LoginTest(MhTestCase):

    def setUp(self) -> None:
        with tenant_context(self.tenant):
            user = User.objects.create_user(...)
            # ...


# TestDecorator 적용 후
class LoginTest(MhTestCase):

    @TestDecorator
    def setUp(self) -> None:
        user = User.objects.create_user(...)

다 만들고 나서 보니 pytest라는 선택지도 있었다. 나중에 적용을 고려해 볼 만하다.

번외: coverage로 커버리지 측정

coverage 라이브러리를 사용하면 코드 커버리지를 측정할 수 있다. pip install coverage로 설치하며, 결과를 HTML로 볼 수 있어 편리하다.

.coveragerc 파일에 설정을 적어 두고 쓴다. omit은 측정에서 제외할 파일로, 예를 들어 migration 파일을 빼려면 다음과 같이 적는다.

[run]
source = .
omit = */migrations/*
coverage run manage.py test          # 전체 테스트 수행
coverage run manage.py test myapp    # myapp만 테스트 수행
coverage report                      # 커버리지 확인
coverage html                        # HTML 리포트 생성 (커버되지 않은 부분 확인)