Devlog

[Django Restframework] 두개 이상의 데이터베이스를 가지고 테스트하기 본문

Backend/DJango

[Django Restframework] 두개 이상의 데이터베이스를 가지고 테스트하기

recoma 2024. 4. 19. 09:15

개요

어느 프레임워크들이 그렇듯, DJango기반의 서비스를 구현함에 있어 여기에 포함되는 데이터베이스는 DJango-ORM으로 관리되는 것이 통상적입니다. 그래야 추후 DB내용이 바뀔 때 ORM 자체 라이브러리 내에서 쉽게 해결을 할 수 있으니까요. 이렇게 ORM 하에 관리되는 Model들을 Managed한 Model이라고 합니다. 말 그대로 관리되고 있는 테이블아라고도 하죠.

그렇지만 가끔식 서비스 외의 데이터베이스에 직접 접근을 해야 할 때가 있습니다. 관리자 페이자가 그 대표적인 예가 되겠군요. 물론 DJango 자체 내에 어드민 페이지 기능을 제공하지만, 이걸 사용하지 않고 따로 DJango로 A부터 Z까지 구축하게 된다면. 메인 데이터베이스는 관리자 페이지의 시점으로부터 외부 데이터베이스가 됩니다.

서비스 내 데이터베이스와는 달리 외부 데이터베이스의 성질들(대표적으로 테이블의 컬럼을 관리하는 것들)을 절대 건드려서는 안됩니다. 이렇게 DJango 내에서 절대 건드려서는 안되는 Model들을 Unmanged Model이라고 합니다.

 

Managed, Unmanaged의 개념

Managed와 Unmanaged는 단순히 용어 같은 것이 아닌 실제로 django model에 지정할 수 있는 개념 입니다. "class Meta"에 "managed=True"를 적거나 아무것도 적지 않으면 해당 Model은 Managed하게 되고, 반대로 "managed=False"로 세팅을 하면 Unmanaged한 Model이 됩니다

class ExampleModel(models.Model):
	... 이하 생략 ...
    class Meta:
    	__tablename__ = "example_model"
        managed = False

 

위의 코드 처럼 managed = False로 세팅되어있는 모듈들은 migration 대상에서 제외됩니다. 즉, 테이블 성질의 변경으로부터 보호가 되기 때문에, 외부 데이터베이스의 테이블들이 DJango의 Migration작업으로 인해 훼손되지 않게 돼죠.

여기까진 다 좋은데 문제가 하나 생깁니다. Unmanaged한 Model들은 유닛테스트를 하는 도중에 발목을 잡게 됩니다.

 

왜 발목을 잡는가?

DJango에서는 유닛테스트를 시작하기 전에 개발자가 지정한 데이터베이스 안에. 테이블들을 마이그레이션을 합니다. SQLite로 세팅되었다면 SQLite 파일을 생성을 한 다음 마이그레이션을, MySQL이나 다른 외부 데이터베이스를 연동했다면, 접두사가 "test_"인 스키마를 생성한 다음, 그 안에 마이그레이션을 합니다.

그런데 아까 설명했듯이, Unmanaged한 Model들은 Migration대상에 포함되지 않습니다. 그렇기 때문에 DJango는 Unmanaged한 Model모델들을 가져오지 못해 문제가 발생하게 됩니다.

 

😅 해결법 1(무식한 방법): django.settings를 활용해 테스트 할 때만 managed=True로 변경하기

1. settings.py에 TEST를 진행중인지에 대한 여부를 나타내는 환경변수를 추가합니다.

UNDER_TEST = (len(sys.argv) > 1 and sys.argv[1] == 'test')

 

2. managed=False상태인 모든 모델의 메타 클래스의 managed를 전부다 아래와 같이 변경합니다.

class Meta:
    db_table = "event_banner_image"
    managed = getattr(settings, "UNDER_TEST", False)

 

3. 대상 DB모델을 호출하는 테스트 스크립트를 작성해서 정상적으로 테스트가 돌아가는지 확인한다.

 

이 방법은 단순하지만 클린코드가 아닌 더티코드로 가는 지름길 입니다. 각 Model.Meta 클래스 마다 저렇게 똑같이 작성해야 하기 때문이지요. 코드 가시성이 떨어지고 유지보수에 문제가 생깁니다. 이보다 더 나은 방법이 존재합니다.


⭐️ Test Runner 활용하기

위의 방법과 마찬가지로 managed = True로 바꿔야 한다는 방향은 일치하지만, 위의 방법에 비해 기술부채가 일어나지 않고 또 클린코드하게 할 수 있는 해결방안 입니다.

DiscoverRunner를 상속받아서 테스트가 맨 처음에 시작했을 때 managed를 True로 바꾸고, 끝나면 다시. 원래대로 돌려놓게 함수를 오버라이딩 함으로써 Model 클래스에 위와 같이 똑같은 코드 작성 필요없이 깔끔하게 해결할 수 있습니다.

TestRunner

A test runner is a class defining a  run_tests() method. Django ships with a DiscoverRunner class that defines the default Django testing behavior. This class defines the run_tests() entry point, plus a selection of other methods that are used by run_tests() to set up, execute and tear down the test suite.

 

TestRunner는 유닛테스트의 작동 방식을 정의하며. 각 TestCase 마다 작동 방식을 정의하는 함수들(setUpClass, setUp 등…)과는 다르게 전체적인 작동 방식을 정의합니다. 예를 들어, 모든 테스트 케이스에서 공통적으로 사용할 수 있는 환경변수를 정의할 수도 있고, 데이터베이스 모델 정보도 변경할 수 있으며, 마이그레이션 없이 테스트를 진행하게 수정할 수 있습니다. 즉, TestCase에서는 할 수 없는 정밀한 작업 프로세스를 여기서 구현할 수 있습니다.

보통 django.test.runner.DiscoverRunner 를 상속받아서 사용합니다

 

오버라이딩 할 수 있는 함수들

setup_test_environment()

  • 막 테스트가 시작될 때 작동하는 함수, 모델들을 managed=True로 변경하는 로직을 여기서 구현합니다.

teardown_test_environment()

  • 모든 테스트 케이스가 끝나면 작동되는 함수, managed정보가 변경된 모델들을 원상복귀할 때 사용됩니다..

setup_databases()

  • 테스트용 데이터베이스를 생성하고, 마이그레이션을 하는 등, 테스트에 사용되는 데이터베이스들을 세팅하는 데 사용합니다..
  • 테스트용 데이터베이스를 생성하는 것을 원하지 않는다면 함수내용을 비우는 방향으로 오버라이딩을 하면 됩니다

teardown_databases()

  • 테스트용 데이터베이스를 거두는 등, DB 환경을 원상복귀 시키는데 사용됩니다.

 

run_tests()

그동안 DJango 프로젝트 안의 모든 테스트 파일들이 돌아갈 수 있었던 이유는 매 테스트를 실행할 때마다 run_tests()함수가 작동되기 때문입니다. run_tests()의 로직은 다음과 같습니다.

 

  1. 테스트가 시작되기 전 전처리 수행 setup_test_environment()
  2. 테스트 케이스 수집 build_suite()
  3. 테스트용 데이터베이스 수집 및 세팅 get_databases(), setup_databases()
  4. 테스트 수행 run_suite()
    • test실패 (assert)시 exception 호출
  5. 테스트 종료 후 프로세스 수행 teardown_databases() teardown_test_environment()
    • 테스트실패 여부 상관업싱 해당 로직은 수행된다.

run_tests() 코드 전문

더보기
    def run_tests(self, test_labels, extra_tests=None, **kwargs):
        """
        Run the unit tests for all the test labels in the provided list.

        Test labels should be dotted Python paths to test modules, test
        classes, or test methods.

        Return the number of tests that failed.
        """
        if extra_tests is not None:
            warnings.warn(
                "The extra_tests argument is deprecated.",
                RemovedInDjango50Warning,
                stacklevel=2,
            )
        self.setup_test_environment()
        suite = self.build_suite(test_labels, extra_tests)
        databases = self.get_databases(suite)
        suite.serialized_aliases = set(
            alias for alias, serialize in databases.items() if serialize
        )
        with self.time_keeper.timed("Total database setup"):
            old_config = self.setup_databases(
                aliases=databases,
                serialized_aliases=suite.serialized_aliases,
            )
        run_failed = False
        try:
            self.run_checks(databases)
            result = self.run_suite(suite)
        except Exception:
            run_failed = True
            raise
        finally:
            try:
                with self.time_keeper.timed("Total database teardown"):
                    self.teardown_databases(old_config)
                self.teardown_test_environment()
            except Exception:
                # Silence teardown exceptions if an exception was raised during
                # runs to avoid shadowing it.
                if not run_failed:
                    raise
        self.time_keeper.print_results()
        return self.suite_result(suite, result)

 

사용 방법

1. DiscoverRunner를 상속받는 모듈을 작성합니다.

  • setup_test_environment
    • 테스트가 본격적으로 시작하기 전에 딱 한번 실행되는 함수로 TestCase가 처음 실행될 때 작동하는 setUpClass나 setUpTestData 와는 결이 다르다
  • teardown_test_environment
    • 모든 테스트 케이스가 다 끝나는 순간에 작동되는 함수
from typing import Set

from django.test.runner import DiscoverRunner
from django.apps import apps


class UnManagedModelTestRunner(DiscoverRunner):
    un_managed_models: Set[str]

    def __init__(self, *args, **kwargs):
        self.un_managed_models = set()
        super().__init__(*args, **kwargs)

    def setup_test_environment(self, **kwargs):
        for model in apps.get_models():
            model_name = model._meta.model_name
            if not model._meta.managed:
                self.un_managed_models.add(model_name)
                model._meta.managed = True

        super(UnManagedModelTestRunner, self).setup_test_environment()

    def teardown_test_environment(self, **kwargs):
        for model in apps.get_models():
            model_name = model._meta.model_name
            if model_name in self.un_managed_models:
                model._meta.managed = False

        super(UnManagedModelTestRunner, self).teardown_test_environment()

 

2.settings.py에 TEST_RUNNER 변수를 추가합니다

# settings.py

TEST_RUNNER = "api.tests.runner.UnManagedModelTestRunner"

 

3. 유닛테스트를 실행해 제대로 작동되는 지 확인합니다.

 

유의 사항

Auth User Model 관련

유저를 담당하는 테이블을 AUTH_USER_MODEL로 설정을 했다면 아래와 같은 에러가 발생할 수 있습니다

django.db.utils.IntegrityError: (1215, 'Cannot add foreign key constraint')

 

유닛테스트를 진행하기 전에 DJango에서는 테스트 전용 데이터베이스들을 생성하고 그 데이터베이스 위에 개발자가 작성한 모델들을 토대로 Migration을 진행하게 됩니다. 이때 생성되는 모델들 중에 유저가 정의한 모델들 말고 DJango 내장의 모델들도 같이 Migration을 하게 되는데, 이 중 django_admin_log라는 테이블이 생성되면서 개발자가 정의한 유저 모델과 relation 작업을 하게 됩니다. 그러나 유저 모델이 없는 데이터베이스에서는 django_admin_log와 relation을 진행할 수 없기 때문에 위의 에러가 발생하게 됩니다.

 

해결 방법은 간단합니다. INSTALLED_APPS에 'django_contrib_admin'을 주석처리 또는 제거하면 됩니다.

 

보통 직접 정의한 모델을 마이그레이션을 하기 위해 INSTALLED APPS에 해당 모델과 관련된 앱 이름을 작성하게 됩니다. python manage.py 을 수행할 때, 내가 직접 정의한 모델 말고도 장고 내장 테이블도 같이 마이그레이션 되는데, 이는 직접 하드코딩 된 것이 아니라 장고 내장 테이블을 포함하고 있는 모듈이 INSTALLED_APPS에 선언되어 있기 때문에 저절로 같이 마이그레이션 된 것입니다.

 

django_admin_log도 마찬가지로 django.contrib.admin 안에 포함되어 있기 때문에 마이그레이션이 된 것입니다. 따라서 반대로 django.contrib.admin을 주석처리 및 제외를 하게 되면 django_admin_log를 생성하지 않게 될 것이고 마찬가지로 외래키 문제도 더이상 발생하지 않게 됩니다.

 

단, DJango 앱에 어드민 페이지가 반드시 필요하다고 한다면, 단순히 제거하는 것이 아니라 test할대만 제거하는 방향으로 전략을 짜야 합니다.

INSTALLED_APPS = [
    # 'django.contrib.admin'
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

 

References

 

Django Database Testing Unmanaged Tables with Migrations

The answer is here: Models inside tests – Django 1.7 issue. The problem is that you have some tables that are not managed by Django. (They get called “legacy”, but in my case, the…

technote.fyi

 

Applications | Django documentation

The web framework for perfectionists with deadlines.

docs.djangoproject.com

 

반응형