Django 프레임워크에서 테스트 코드를 짜기 위한 환경을 구성해 보았다.
기존에 DB에 저장되어 있는 데이터를 바탕으로 테스트 코드를 작성하는 방법이다.
하지만 다음과 같은 사항 때문에 고민이 될 것이다.
그래서 다음과 같은 상황일 때 적합하다.
당장 테스트 코드가 필요하지만 인적 / 시간적 여유가 없을 때
기존의 테스트 코드를 작성하지 않아 이제 막 도입하려 할 때
- 핵심 로직을 빠르게 테스트해 볼 수 있다는 장점이 있다.
결국 임시방편이고, 시장과 타협한 셈이다.
물론 처음부터 테스트 코드를 작성해야 하는 과정을 다시 거쳐야 하긴 한다.
핵심 로직만 빠르게 테스트할 수 있는 코드를 만들어 놓고 운영하고 싶은 사람들을 위한 글이다.
Django에서 테스트 코드를 실행하는 방법을 알아보자.
다음 명령어로 test_xx.py처럼 파일명 앞에 'test_' 가 붙은 파일들을 찾아 테스트를 수행한다.
python manage.py test
우리는 기존에 저장되어 있는 데이터를 사용할 것이기 때문에 해당 작업이 필요 없다.
따라서 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
Django에서 제공하는 가장 기본적인 테스트 코드를 적는 방법을 알아보자.
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
TestCode는 메서드를 종료할 때 마다 , DB를 rollback 시킨다.
-> _enter_atomics(), _rollback_atomics() 부분
TestCode를 보면, TransactionTestCase를 다시 상속받고 있다.
그래서 Django에서 제공하는 TestCode의 Hierarchy를 가져왔다.
unittest.TestCase는 Python에서 자체적으로 제공해 주는 클래스이고, 장고에서 이 클래스를 확장시켜 TestCase를 제공해 주고 있는 것이다.
TransactionTestCase는 DB를 rollback 시키는 것이 아닌, Truncate 한다.
Truncate보다 rollback을 하는 속도가 빨라 TestCase를 이용한다.
Transaction을 테스트하는 Option에 ATOMIC_REQUESTS 라는 것이 있는데, 이것은 필요할 때 찾아보도록 하자.
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")
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(~)
그래서 간편하게 개발하기 위해 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/**'와 같이 쓰면 된다.