Django에서의 테스트

Django에서의 테스트 소개

모든 성숙한 프로그래밍 언어들처럼, Django도 내장된 유닛 테스트를 제공한다. 유닛 테스트는 각각의 소프트웨어 애플리케이션의 유닛들이 기대한대로 동작하는지 보장하기 위해 점검하는 소프트웨어 테스트 프로세스이다. 유닛 테스트는 다양한 단계로 수행할 수 있다. 개개의 함수가 올바른 값을 반환하는지, 혹은 잘못된 데이터를 어떻게 처리하는지 확인하는 것부터, 일련의 함수 묶음이 사용자의 입력을 기대한 결과로 만들어내는 것을 보장하는지 등의 단계로 구분할 수 있다.

유닛 테스트는 네 가지 근본적인 개념들에 근거하여 구성된다.

  1. Test Fixture: test fixture는 테스트를 수행하는데 필요한 구성을 말한다. 여기에는 데이터베이스, 견본 데이터셋, 서버 구성 등이 포함된다. test fixture에는 테스트가 종료된 뒤 다시 정리하는 작업이 포함되기도 한다.
  2. Test Case: test case는 테스트의 기본이 되는 구성 단위이다. test case는 주어진 입력값 모음이 예상되는 결과 모음으로 전환되는지 확인한다.
  3. Test Suite: test suite는 여러 개의 테스트 케이스를 말한다. 혹은 묶어서 실행되는 테스트 케이스를 말하기도 한다.
  4. Test Runner: test runner는 일련의 테스트 수행을 제어하고 그 결과를 사용자에게 보고하는 역할을 담당하는 소프트웨어 프로그램이다.

소프트웨어 테스트는 깊고 상세한 주제이기 때문에 여기서는 유닛 테스트의 기본에 대해서만 소개한다.

자동화된 테스트 소개

자동화된 테스트는 무엇인가?

테스트는 애플리케이션 개발의 평범한 한 부분이다. 이 책을 통해서도 Django의 특정 함수가 원하는 결과를 만드는지를 확인하는 과정을 거쳤을 것이며, 이 또한 테스트를 진행했다고 볼 수 있다. 자동화된 테스트와의 차이점은 개발자를 위해 시스템이 테스트를 수행해준다는 점이다. 개발자가 테스트를 한 번 만들어두면, 직접 테스트를 수행하지 않더라도, 앱에 변경이 있었을 때 최초에 의도했던대로 코드가 동작하는지 확인할 수 있다.

그래서 테스트는 왜 만드는가?

이 책에서 예시로 드는 간단한 Django 프로그램이라면 자동화된 테스트를 어떻게 만드는지는 몰라도 상관없다. 그러나 복합적인 프로젝트를 수행하기 위해서는 자동화된 테스트를 어떻게 만드는지 알아야 한다.

자동화된 테스트는 다음과 같은 장점이 있다.

  • 시간을 절약해준다. 거대한 애플리케이션에서 무수히 많은 컴포넌트 간의 복합적인 상호작용을 직접 테스트하는 것은 시간을 낭비하고 오류를 만들어내기 쉽다. 자동화된 테스트는 시간을 절약하고 프로그래밍에 집중하도록 만들어 준다.
  • 문제를 방지해준다. 테스트는 여러분이 작성한 코드의 내부를 강조해 줌으로써 어느 지점에서 잘못되었는지 볼 수 있게 만들어 준다.
  • 전문적으로 보이도록 만들어준다. 전문가들은 테스트를 작성한다. Django의 최초 개발자 중 한 명인 JacobKaplan-Moss는 “테스트가 없는 코드는 의도한대로 동작할 수 없다.(Code without tests is broken by design.)“고 말했다.
  • 팀웍을 향상시킨다. 테스트는 동료들이 여러분의 코드를 의도치않게 망가뜨리지 않도록 보장해준다.

기본적인 테스트 전략

테스트를 작성하는 방법은 매우 다양하다. 어떤 개발자들은 “테스트 주도 개발(test-driven development)”라고 부르는 원칙을 따른다. 그들은 코드를 작성하기 전에 테스트를 먼저 작성한다. 이것은 얼핏 직관적이지 않아 보인다. 하지만 실제로 사람들이 무언가를 하는 방식과 매우 유사하다. 사람들은 문제를 정의한 뒤에 코드를 작성해서 그 문제를 해결한다.

테스트 주도 개발은 파이썬 테스트 케이스의 문제를 쉽게 만들어준다. 종종 테스트를 처음 만들어보는 사람들은 코드를 먼저 작성하고 뒤늦게 테스트가 필요하다는 걸 깨닫는다. 테스트를 먼저 만들었다면 더 좋았겠지만, 여전히 테스트를 시작하기에 늦지는 않았다.

테스트 작성하기

최초의 테스트 케이스를 작성하기 위해 Book 모델에 버그를 심어보자. Book 모델에 책이 최근에 출판되었는지를 알려주는 커스텀 함수를 추가한다고 해보자. Book 모델은 다음과 같이 작성할 수 있을 것이다.

import datetime
from django.utils import timezone
from django.db import models

# ... #

class Book(models.Model):
    title = models.CharField(max_length=100)
    authors = models.ManyToManyField(Author)
    publisher = models.ForeignKey(Publisher)
    publication_date = models.DateField()

    def recent_publication(self):
        return self.publication_date >= timezone.now().date() - datetime.timedelta(weeks=8)
  
    # ... #

먼저 파이썬의 datetimedjango.utilstimezone 모듈을 추가했다. 이 모듈들은 날짜의 계산을 위해 필요하다. 그리고 recent_publication이라는 함수를 Book 모델에 추가했다. 이 함수는 출판일이 8주 이내인지를 알려준다. 그리고 쉘에서 새로운 함수를 테스트해보자.

python manage.py shell
>>> from books.models import Book
>>> import datetime
>>> from django.utils import timezone
>>> book = Book.objects.get(id=1)
>>> book.title
'Mastering Django: Core'
>>> book.publication_date
datetime.date(2016, 5, 1)
>>>book.publication_date >= timezone.now().date() - datetime.timedelta(weeks=8)
True

지금까지는 괜찮다. Book 모델을 불러와서 책을 하나 검색해왔다. 오늘은 2016년 6월 11일이다. 그리고 책은 5월 1일에 출판했다고 데이터베이스에 기록해두었다. 이 날은 8주 이내이니 함수는 올바르게 True를 반환했다. 명백하게 출판일을 어떻게 수정하더라도 문제가 없을 것이다.

그렇다면 이제 출판일을 9월 1일, 미래로 입력해보자.

>>> book.publication_date
datetime.date(2016, 9, 1)
>>>book.publication_date >= timezone.now().date() - datetime.timedelta(weeks=8)
True

안타깝게도 미래의 시간을 입력했을 때도 8주 내에 출판된 것이라고 결과를 반환한다. 그럼 이제 잘못된 로직을 드러낼 수 있는 테스트 케이스를 만들어보자.

테스트 생성하기

Django의 startapp 커맨드를 활용해서 books 앱을 만들었을 때, tests.py 라는 파일이 앱 디렉토리에 함께 생성되었을 것이다. 이 위치가 books 앱을 위한 테스트 케이스를 위한 위치이다.

import datetime
from django.utils import timezone
from django.test import TestCase
from .models import Book

class BookMethodTests(TestCase):

    def test_recent_pub(self):
        """
        recent_publication() should return False for future publication dates.
        """
        futuredate = timezone.now().date() + datetime.timedelta(days=5)
        future_pub = Book(publication_date=futuredate)
        self.assertEqual(future_pub.recent_publication(), False)

이 코드는 매우 간단하며 Django 쉘에서 했던 것과 거의 비슷하다. 차이점은 클래스 내에 포함되어 있으며 미래의 날짜에 대해 recent_publication() 함수를 테스트하는 assertion 을 추가했다는 점이다.

테스트 실행하기

테스트를 작성했으니 실행해 볼 차례이다. 다행히도 이 과정은 매우 간단하다. 터미널로 가서 다음과 같이 입력하자.

python manage.py test books

잠시뒤면 Django가 다음과 같은 내용을 출력할 것이다.

Creating test database for alias 'default'...
F
======================================================================
FAIL: test_recent_pub (books.tests.BookMethodTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\Users\admin\ ... mysite\books\tests.py", line 25, in test_recent_pub
    self.assertEqual(future_pub.recent_publication(), False)
AssertionError: True != False
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (failures=1)
Destroying test database for alias 'default'...

여기서 벌어진 일은 다음과 같다.

  • python manage.py test books는 books 앱의 테스트를 찾는다.
  • 만약 django.test.TestCase의 하위 클래스를 찾는다면,
  • 테스트를 위한 특별한 데이터베이스를 생성한다.
  • 그리고 test라는 이름으로 시작하는 함수들을 찾는다.
  • test_recent_pub 함수 내에서 Book 인스턴스를 생성하고 publication_date 필드에 5일 미래의 날짜를 입력한다.
  • 마지막으로 assertEqual() 함수를 사용해서 recent_publication() 함수가 False를 반환할 것이라고 가정하고 있을 때 True를 반환하는지 알아본다. 그리고 어떤 테스트가 실패했는지와 어떤 라인에서 문제가 있었는지 알려준다. *nix 시스템이나 맥을 사용한다면 파일 경로는 다를 것이다.

여기까지가 Django에서의 기본적인 테스트 방법이다. 앞서 언급한 것과 같이 테스트는 깊고 상세한 주제들을 담고 있으며, 개발자로서의 경력에도 매우 중요하다. 하나의 장에서 테스트의 모든 면을 다루기는 어려우며, Django 공식 문서에서 언급된 참고 문서들을 살펴보도록 하자.

테스트 도구

Django는 테스트를 작성할 때 도움이 될만한 도구를 제공한다.

테스트 클라이언트

테스트 클라이언트는 웹 브라우저인척 하는 파이썬 클래스이다. view 클래스들을 테스트하고 Django로 만든 애플리케이션과 코드로 상호작용할 수 있도록 만들어준다. 테스트 클라이언트로 할 수 있는 것들은 다음과 같다.

  • URL로 GETPOST 요청을 모의로 보내볼 수 있으며, 저수준의 HTTP(결과의 헤더나 상태 코드 등)부터 페이지의 내용까지 모든 응답에 대한 내용을 살펴볼 수 있다.
  • redirect되는 경로를 볼 수 있으며 각 단계별로 URL이나 상태코드를 볼 수 있다.
  • Django 템플릿 엔진에 의해 렌더링되는 요청을 템플릿 컨텍스트와 함께 테스트할 수 있다. 주의할 점은 테스트 클라이언트는 셀레니움(Selenium)이나 다른 브라우저 상의 프레임워크를 대체하기 위해 만들어진 것은 아니라는 점이다.
    • Django의 테스트 클라이언트는 올바른 템플릿을 렌더링하는지, 올바른 컨텍스트 데이터를 템플릿으로 전달하는지 확인하는 용도로 사용해야 한다.
    • 렌더링된 HTML과 자바스크립트의 기능성 등 웹 페이지의 동작을 확인하기 위해서는 셀레니움과 같은 in-browser 프레임워크를 사용해야 한다. Django는 이러한 프레임워크들을 위한 기능들을 제공한다. LiveServerTestCase 절에서 조금 더 자세한 내용을 다룬다. 포괄적인 테스트를 하기 위해서는 두 가지 타입의 테스트를 함께 사용해야 한다.

제공되는 테스트 케이스 클래스

일반적인 파이썬 유닛 테스트 클래스는 unittest.TestCase를 확장한 클래스이다. Django는 이 기본 클래스에서 몇 가지 확장 기능을 제공한다.

SimpleTestCase

unittest.TestCase의 확장이다.

  • 파이썬의 기계적인 경고 상태를 저장하고 불러올 수 있다.
  • 유용한 assertion들을 제공한다.
  • 정확히 원하는 예외가 발생하는지 점검할 수 있다.
  • form field와 에러 처리를 테스트할 수 있다.
  • 주어진 HTML 조각이 완벽한지 혹은 부족한지 테스트가 가능하다.
  • 템플릿이 주어진 응답 내용들을 담고 있는지 검사할 수 있다.
  • 두 개의 HTML 조각이 일치하는지 혹은 포함 관계인지 테스트할 수 있다.
  • 두 XML 조각이 일치하는지 테스트할 수 있다.
  • 두 JSON 조각이 동일한지 테스트할 수 있다.
  • 지정된 설정으로 테스트를 실행하는 기능이 있다.

TransactionTestCase

Django의 TestCase 클래스는 데이터베이스의 트랜잭션 기능을 사용해서 각 테스트의 시작 상태로 빠르게 재설정할 수 있다. 그 결과 특정 데이터베이스의 동작은 Django의 TestCase 내에서는 테스트되지 않는 경우가 있다.

이러한 경우, TransactionTestCase를 사용해야 한다. TransactionTestCaseTestCase는 데이터베이스가 알려진 상태로 재설정되는 방식과 테스트 코드에서 커밋 및 롤백의 영향을 테스트하는 기능을 제외하고는 동일하다.

  • TransactionTestCase는 테스트가 수행된 이후 모든 테이블을 비움으로써 데이터베이스를 재설정한다. 또한 commitrollback을 사용해서 데이터베이스로의 호출에 대한 효과를 관찰할 수 있다.
  • 반면 TestCase는 테스트 종료 후 테이블을 비우지 않는다. 대신 테스트코드를 트랜젝션을 사용해서 감싸는 방식으로 테스트 종료 후 롤백 처리를 한다. 이 방식은 테스트 후 데이터베이스를 초기 상태로 돌려주는 것을 보장한다.

TransactionTestCaseSimpleTestCase를 상속받은 클래스이다.

TestCase

이 클래스는 웹사이트를 테스트하는데 유용한 기능들이 추가되어 있다. 일반적인 unittest.TestCase를 Django의 TestCase로 변경하는 것은 간단하다. 단지 unittest.TestCase 대신 django.test.TestCase를 상속하도록 변경하기만 하면 된다. 모든 표준 파이썬 유닛 테스트의 기능을 사용할 수 있으며 몇몇 유용한 기능들이 들어가 있다.

  • fixture를 자동으로 불러온다.
  • 테스트를 두 개의 atomic 블럭으로 감쌀 수 있다. 하나는 전체 클래스를 감싸고, 하나는 각 테스트를 감쌀 수 있다.
  • TestClient 인스턴스를 생성한다.
  • Django에 특화된 리다이렉션이나 form 에러 등에 대한 assertion을 제공한다. TestCaseTransactionTestCase를 상속받았다.

LiveServerTestCase

LiveServerTestCase는 기본적으로 TransactionTestCase와 동일하며 하나의 추가 기능을 제공한다. setup 시 백그라운드에서 Django 서버를 실행하며 teardown 시 종료한다. 이 방식은 셀레니움과 같은 테스트 클라이언트를 Django의 더미 클라이언트 대신 사용할 수 있도록 한다. 이로 인해 실제 유저의 동작을 브라우저를 통해 테스트해볼 수 있다.

테스트 케이스의 특징

기본적인 테스트 클라이언트

*TestCase 인스턴스의 각 테스트 케이스는 Django 테스트 클라이언트에 접근한다. 이 클라이언트는 self.client와 같이 접근할 수 있다. 이 클라이언트는 매 테스트마다 재생성됨으로써 쿠키와 같은 상태값이 하나의 테스트에서 만들어지고 다른 테스트에서 사용되지 않는 것을 보장해준다. 따라서 다음과 같이 Client를 새롭게 생성하는 대신,

import unittest
from django.test import Client

class SimpleTest(unittest.TestCase):

    def test_details(self):
        client = Client()
        response = client.get('/customer/details/')
        self.assertEqual(response.status_code, 200)

    def test_index(self):
        client = Client()
        response = client.get('/customer/index/')
        self.assertEqual(response.status_code, 200)

self.client를 사용하면 된다.

from django.test import TestCase

class SimpleTest(TestCase):

    def test_details(self):
        response = self.client.get('/customer/details/')
        self.assertEqual(response.status_code, 200)

    def test_index(self):
        response = self.client.get('/customer/index/')
        self.assertEqual(response.status_code, 200)

Fixture 불러오기

데이터베이스와 함께 구성된 웹사이트를 테스트하는 경우 데이터베이스에 아무 데이터가 없는 경우는 거의 없다. 테스트 데이터를 데이터베이스에 입력하기 쉽도록 만들기 위해 Django의 커스텀 TransactionTestCase 클래스는 fixture를 불러오는 방식을 제공한다. fixture는 Django가 어떻게 데이터베이스에 입력할지 알 수 있는 데이터의 모음이다. 예를 들어, 사용자의 계정을 갖고 있는 사이트라면, 테스트를 진행하는 동안 가짜 사용자 계정 fixture를 데이터베이스에 입력할 수 있다.

fixture를 만드는 가장 쉬운 방법은 manage.py dumpdata 커맨드를 사용하는 것이다. 이 것은 데이터베이스에 이미 어떤 데이터가 있다는 것을 가정한다. 한 번 fixture를 생성하고 INSTALLED_APPS에 등록된 앱 중의 fixtures 디렉토리에 넣어두면, django.test.TestCase 서브클래스의 유닛 테스트에서 fixtures 속성을 사용할 수 있다.

from django.test import TestCase
from myapp.models import Animal

class AnimalTestCase(TestCase):

    fixtures = ['mammals.json', 'birds']

    def setUp(self):
        # Test definitions as before.
        call_setup_methods()

    def testFluffyAnimals(self):
        # A test that uses the fixtures.
        call_some_test_code()

이 코드에서는 다음과 같은 일들이 벌어진다.

  • 각 테스트 케이스가 시작할 때, setUp()이 실행되기 전, Django는 데이터베이스를 비운다. 그리고 migrate가 호출된 직후의 상태인 데이터베이스를 반환한다.
  • 그리고 지정된 모든 fixture가 설치된다. 이 예제에서 Django는 mammals라는 이름의 JSON fixture와 birds라는 이름의 fixture를 설치할 것이다. fixture를 정의하고 설치하는 것에 대한 자세한 내용은 loaddata 공식문서를 참고하면 된다. 이렇게 비우고 불러오는 절차는 테스트 케이스의 각 테스트마다 반복된다. 따라서 다른 테스트의 결과나 테스트의 실행 순서에 영향을 받지 않는다. 기본적으로 fixture는 default 데이터베이스에 적재된다. 만약 다중 데이터베이스를 사용하고 있다면, multi_db=True를 설정해야하며, 이렇게 설정한 경우 fixture는 모든 데이터베이스에 적재될 것이다.

설정을 재정의하기

테스트에서만 설정값을 임시로 변경하기 위해서는 다음 함수를 사용해야 한다. django.conf.settings를 직접 수정하면 Django는 최초 값을 불러올 수 없다.

settings()

테스트가 목적인 경우, 일시적으로 설정을 변경하고 테스트 코드를 실행한 뒤에는 다시 원래의 값으로 복구하는 것이 유용하다. 이러한 상황에서 Django는 settings()라고 불리는 파이썬의 표준 컨텍스트 매니저를 제공한다.

from django.test import TestCase

class LoginTestCase(TestCase):

    def test_login(self):
        # First check for the default behavior
        response = self.client.get('/noname/')
        self.assertRedirects(response, '/accounts/login/?next=/noname/')
        # Then override the LOGIN_URL setting
        with self.settings(LOGIN_URL='/other/login/'):
            response = self.client.get('/noname/')
            self.assertRedirects(response, '/other/login/?next=/noname/')

이 예제의 with 블록 내에서는 LOGIN_URL을 재정의하며, 블록을 빠져나오면 다시 이전 상태로 값이 돌아간다.

modify_settings()

값들의 리스트와 같은 것들은 재정의하기 쉽지 않은 설정이다. 실제로 값을 추가하거나 빼는 것이면 충분하다. modify_settings() 컨텍스트 매니저는 이런 방식을 쉽게 만들어준다.

from django.test import TestCase

class MiddlewareTestCase(TestCase):

    def test_cache_middleware(self):
        with self.modify_settings(MIDDLEWARE_CLASSES={
            'append': 'django.middleware.cache.FetchFromCacheMiddleware',
            'prepend': 'django.middleware.cache.UpdateCacheMiddleware',
            'remove': [
               'django.contrib.sessions.middleware.SessionMiddleware',
               'django.contrib.auth.middleware.AuthenticationMiddleware',
               'django.contrib.messages.middleware.MessageMiddleware',
            ],
        }):
            response = self.client.get('/')
            # ...

각 상황에서 값들의 리스트나 문자열을 할당할 수 있다. 값이 이미 리스트 안에 존재할 때, appendprepend는 아무 영향이 없으며, 값이 설정되지 않은 경우 remove도 영향을 끼치지 않는다.

override_settings()

테스트 함수에 대해 설정을 재정의 하고 싶다면, Django가 제공하는 override_settings() 데코레이터(PEP 318)를 사용하면 된다.

from django.test import TestCase, override_settings

class LoginTestCase(TestCase):
    @override_settings(LOGIN_URL='/other/login/')
    def test_login(self):
        response = self.client.get('/noname/')
        self.assertRedirects(response, '/other/login/?next=/noname/')

이 데코레이터는 TestCase 클래스에 적용할 수도 있다.

from django.test import TestCase, override_settings

@override_settings(LOGIN_URL='/other/login/')
class LoginTestCase(TestCase):
    def test_login(self):
        response = self.client.get('/noname/')
        self.assertRedirects(response, '/other/login/?next=/noname/')
modify_settings()

마찬가지로, Django는 modify_settings 데코레이터도 제공한다.

from django.test import TestCase, modify_settings

class MiddlewareTestCase(TestCase):

    @modify_settings(MIDDLEWARE_CLASSES={
        'append': 'django.middleware.cache.FetchFromCacheMiddleware',
        'prepend': 'django.middleware.cache.UpdateCacheMiddleware',
    })
    def test_cache_middleware(self):
        response = self.client.get('/')
        # ...

이 데코레이터 또한 테스트 케이스 클래스에 적용할 수 있다.

from django.test import TestCase, modify_settings

@modify_settings(MIDDLEWARE_CLASSES={
    'append': 'django.middleware.cache.FetchFromCacheMiddleware',
    'prepend': 'django.middleware.cache.UpdateCacheMiddleware',
})
class MiddlewareTestCase(TestCase):
    def test_cache_middleware(self):
        response = self.client.get('/')
        # ...

설정을 재정의 할 때, 설정을 변경하더라도 앱의 코드가 상태를 유지하는 캐시와 같은 기능을 사용하는 경우를 처리하도록 만들어야 한다. Django는 django.test.signals.setting_changed 신호를 제공하며, 설정이 변경되었을 때 상태를 복원하거나 정리할 수 있는 콜백을 등록할 수 있다.

Assertions

파이썬의 일반적인 unittest.TestCase 클래스는 assetTrue()assertEqual()과 같은 assertion 함수를 구현해두었다. Django의 커스텀 TestCase 클래스는 웹 애플리케이션을 테스트하는데 유용한 커스텀 assertion 함수들을 제공한다.

  • assertRaisesMessage
  • assertFieldOutput
  • assertFormError
  • assertFormsetError
  • assertContains
  • assertNotContains
  • assertTemplateUsed
  • assertTemplateNotUsed
  • assertRedirects
  • assertHTMLEqual
  • assertHTMLNotEqual
  • assertXMLEqual
  • assertXMLNotEqual
  • assertInHTML
  • assertJSONEqual
  • assertJSONNotEqual
  • assertQuerysetEqual
  • assertNumQueries

이메일 서비스

만약 Django의 이메일 기능을 이용해서 이메일을 발송하는 Django의 뷰가 있다면, 그 뷰를 사용하는 테스트 가 실행될 때마다 이메일이 발송되는 것을 원하지 않을 수 있다. 이러한 이유로 Django의 테스트 러너는 자동으로 Django에서 발송되는 모든 이메일을 임시 보관함으로 전송한다. 이로 인해 실제로 메시지를 발송하지 않고도 각 메시지들의 내용을 발송하는 이메일 전송을 테스트할 수 있다. 테스트 러너는 일반적인 이메일 백엔드를 테스트 백엔드로 치환함으로써 이 작업을 수행한다. (만약 별도 메일 서버를 사용하는 등 Django 외부의 이메일 발송기를 사용한다면 영향을 끼치지 못한다.)

테스트가 수행되는 동안, 발송된 이메일은 django.core.mail.outbox에 저장된다. 이것은 발송된 EmailMessage 인스턴스의 단순한 리스트이다. outbox 속성은 locmem 이메일 백엔드를 사용할 때만 생성되는 특별한 속성이다. 이는 보통 django.core.mail 모듈의 구성으로 존재하는 것은 아니며, 직접 불러올 수는 없다. 다음 코드는 이 속성에 올바르게 접근하여 django.core.mail.outbox의 길이와 내용을 검사하는 방법을 보여준다.

from django.core import mail
from django.test import TestCase

class EmailTest(TestCase):

    def test_send_email(self):
        # 메시지 발송하기.
        mail.send_mail('여기에 제목을 작성해주세요.', '이것은 메시지입니다.',
            'from@example.com', ['to@example.com'],
            fail_silently=False)
        # 하나의 메시지가 발송된 것을 테스트하기.
        self.assertEqual(len(mail.outbox), 1)
        # 첫 번째 메시지의 제목이 올바른지 검증하기.
        self.assertEqual(mail.outbox[0].subject, '여기에 제목을 작성해주세요.')

앞서 언급한 것처럼, 테스트용 임시 보관함은 Django의 *TestCase 내의 각 테스트의 시작 시점에 비워진다. 임시 보관함을 직접 비우기 위해서는 비어있는 리스트를 mail.outbox에 할당하면 된다.

from django.core import mail

# 테스트용 임시 보관함을 비우기.
mail.outbox = []

운영 커맨드

운영 커맨드는 call_command() 함수로 테스트할 수 있다. 커맨드의 출력은 StringIO 인스턴스로 돌릴 수 있다.

from django.core.management import call_command
from django.test import TestCase
from django.utils.six import StringIO

class ClosepollTest(TestCase):
    def test_command_output(self):
        out = StringIO()
        call_command('closepoll', stdout=out)
        self.assertIn('Expected output', out.getvalue())

테스트 건너뛰기

unittest 라이브러리는 @skipIf@skipUnless 데코레이터를 제공함으로써 특정 상황에서 실패할 것이 예상되는 테스트를 건너뛸 수 있도록 만들어준다. 예를 들어, 테스트가 수행되기 위해 상황에 따라 특정 라이브러리가 필요한 경우, @skipIf 데코레이터를 사용할 수 있다. 그러면 테스트 러너는 테스트를 실패처리하거나 빠뜨리는 대신 그 테스트를 실행하지 않고 왜 수행되지 않았는지 보고해준다.

테스트 데이터베이스

모델 테스트와 같이 데이터베이스가 필요한 테스트는 프로덕션 데이터베이스를 사용하지 않고 분리되어 비어있는 데이터베이스를 생성하고 이를 사용한다. 테스트가 통과하거나 실패하는 것과 상관없이, 모든 테스트가 수행되면 데이터베이스는 삭제된다. 만약 테스트 데이터베이스가 삭제되지 않도록 하려면 --keepdb를 테스트 커맨드에 붙여주면 된다.

만약 데이터베이스가 존재하지 않는다면 생성한다. 어떤 마이그레이션이든지 최신 상태를 유지하기 위해 적용된다. 기본적으로 DATABASES에 정의된 데이터베이스의 NAME 설정값의 앞에 test_를 붙여서 테스트 데이터베이스로 활용한다. SQLite 데이터베이스를 사용하는 경우, 테스트는 기본적으로 in-memory 데이터베이스를 사용한다.

만약 다른 데이터베이스 이름을 사용하고 싶다면, DATABASES의 데이터베이스 설정에 TEST 딕셔너리의 NAME을 지정하면 된다. PostgreSQL에서 USER는 내장 postgres 데이터베이스에 대한 읽기 권한이 있어야 한다. 별도의 데이터베이스를 사용하는 대신, 테스트 러너는 설정 파일의 ENGINE, USER, HOST 등과 같은 설정을 모두 사용한다. 테스트 데이터베이스는 USER에 지정된 사용자로 생성하며, 따라서 그 사용자 계정은 시스템에 새로운 데이터베이스를 생성할 수 있는 권한이 있어야 한다.

다른 테스트 프레임워크 사용하기

unittest는 파이썬의 유일한 테스트 프레임워크는 아니다. Django가 명시적으로 다른 프레임워크를 제공하지는 않지만, 일반적인 Django 테스트와 유사한 다른 프레임워크로 구축할 수 있는 방법을 제공한다.

./manage.py test를 실행할 때, Django는 TEST_RUNNER 설정을 보고 무엇을 할지 결정한다. 기본적으로 TEST_RUNNERdjango.test.runner.DiscoverRunner를 가리킨다. 이 클래스는 기본 Django 테스트 동작을 정의하며, 다음과 같은 사항을 포함한다.

  1. 전역적인 사전 테스트 절차를 수행한다.
  2. 현재 디렉토리 아래의 test*.py 패턴에 일치하는 모든 파일에서 테스트를 찾는다.
  3. 테스트 데이터베이스를 생성한다.
  4. 모델을 설치하고 테스트 데이터베이스에 입력하기 위한 데이터를 초기화하기 위해 마이그레이트를 수행한다.
  5. 찾아낸 테스트를 수행한다.
  6. 테스트 데이터베이스를 삭제한다.
  7. 전역적인 사후 테스트 절차를 수행한다.

만약 직접 만든 테스트 러너 클래스를 정의하고 TEST_RUNNER 설정에서 이 클래스를 가리키도록 하면, ./manage.py test를 수행했을 때 Django는 그 테스트 러너를 실행할 것이다.

이와 같이, 파이썬 코드에서 실행될 수 있는 다른 테스트 프레임워크를 사용하거나, 테스트 요구사항을 만족시킬 수 있는 Django 테스트 실행 프로세스를 수정할 수 있다.

테스트에 대한 매우 개인적인 이야기

역설적으로 들리겠지만, 매우 빠르게 새로운 기능들을 출시하고 더 빠르게 기존의 기능을 뒤엎어버리는 스타트업의 개발자라면 테스트를 작성하고 테스트가 자동으로 돌아갈 수 있도록 시스템을 구축해두는 것이 생산성을 극대화할 수 있다. 그렇다고 이 책에서 강조하는 TDD를 완벽하게 지키라고 권장하는 것은 아니다. 논란의 여지는 있겠지만, 완벽한 TDD를 하게 되면 시스템의 안정성은 매우 올라가면서도 필연적으로 개발 속도는 현저히 떨어질 수 밖에 없다.

TDD의 문제는 변화에 유연하지 않다는 것이다. 스타트업에서는 정말 잘 고민해서 만들어둔 애플리케이션 구조의 근본을 흔드는 사업의 방향 전환이 매우 잦다. 이 때 너무 세세하게 만들어둔 테스트 케이스들은 수정하는데 시간이 너무 많이 들어가게 된다. 어느 정도의 오류가 발생할 확률을 인정하며 동시에 어느 정도의 안정성을 추구하는 방법을 고민하는 것은 상당히 중요하다. 안정적이고 깔끔한 구조의 코드를 작성하는 것이 싫은 개발자가 어디 있겠느냐만은, 사업의 속도를 제때 따라가지 못해 발생하는 문제들은 성공하는 스타트업과 실패하는 스타트업을 가르는 중요한 요소 중 하나라고 본다. 다만, 오류를 인정하는 범위는 사업의 특성에 따라 달라질 수 있을 것이다. 안정성 또한 제품의 승패를 가르는 중요한 요소 중 하나이기 때문이다.

초기에 피플펀드의 시스템을 개발할 때 선택했던 전략은 몇 가지 원칙하에 테스트를 작성하는 것이었다. 첫 째는 매우 중요한 부분에는 반드시 테스트를 작성한다는 것이었고, 두 번째로는 테스트를 작성하는 단위를 특정 함수 혹은 특정 API가 아닌 어느 정도 묶여 있는 도메인 단위로 작성한다는 것이었다.

중요도는 어떤 기능에 오류가 있을 때 사용자가 이 서비스에 대한 믿음을 얼마나 져버릴 것인가에 대한 고민으로 판단했다. 시간은 한정되어 있으니 모든 테스트를 만들 수도 없었고, 한 가지를 선택하기로 했는데 그것은 과 관련된 기능들이었다. 예를 들면, 투자를 신청했는데 중복으로 신청되지 않도록, 정산을 했을 때 원 단위의 오차도 발생하지 않도록 하는 것과 같은 부분들이었다.

도메인 단위라는 것은 예를 들면 ‘정산‘과 같은 식이었다. 처음에 만들었던 테스트 케이스는 ‘test_2개월거치후3개월만기일시상환_상환완료해보기‘와 같은 이름의 테스트 케이스들이었다. 작은 단위의 테스트 케이스는 하나도 없이 거대한 테스트 케이스 몇몇 개를 만드려니 매우 고달픈 시간이었지만, 이 몇 개의 테스트 케이스를 만들어둔 덕분에 사업팀에서 정말 다양한 상품을 내놓는 것을 두려워하지 않을 수 있었다. 짧은 시간 내에 정산 시스템을 수정해가며 배포해 나갔지만 문제는 없었다.(솔직히 말하면 약간의 문제는 있었으나 눈에 띌 정도는 아니었다.) 버그들을 사전에 발견할 수 있었기 때문이었다.

이러한 방법이 정답이냐고 묻는다면 정답이라고 말하기는 어렵겠지만, 새롭게 시작하는 스타트업이라면 이런 고민과 실행과정을 겪었던 피플펀드를 통해 조금 더 나은 접근법을 만들어내는데 도움이 될 것이라 믿는다.