Новое тестирование фичей в Django 3.2

Автор оригинала: Adam Johnson
  • Перевод

Пару недель назад Django 3.2 выпустил свой первый альфа-релиз, а финальный релиз выйдет в апреле. Он содержит микс новых возможностей, о которых вы можете прочитать в примечаниях к релизу. Эта статья посвящена изменениям в тестировании, некоторые из которых можно получить на более ранних версиях Django с пакетами backport.

1.  Изоляция setUpTestData() 

В примечании к релизу говорится:

«Объекты, назначенные для классификации атрибутов в TestCase.setUpTestData() теперь выделяются для каждого тестового метода».

setUpTestData() — очень полезный прием для быстрого выполнения тестов, и это изменение делает его использование намного проще.

Прием TestCase.setUp() нередко используется из юнит-теста для создания экземпляров моделей, которые используются в каждом тесте:

from django.test import TestCase

from example.core.models import Book

class ExampleTests(TestCase):
    def setUp(self):
        self.book = Book.objects.create(title="Meditations")

Тест-раннер вызывает setUp() перед каждым тестом. Это дает простую изоляцию тестов, так как берутся свежие данные для каждого теста. Недостатком такого подхода является то, что setUp() запускается много раз, что при большом объеме данных или тестов может происходить довольно медленно.

setUpTestData() позволяет создавать данные на уровне класса — один раз на TestCase. Его использование очень похоже на setUp(), только это метод класса:

from django.test import TestCase

from example.core.models import Book

class ExampleTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.book = Book.objects.create(title="Meditations")

В промежутках между тестами Django продолжает откатывать (rollback) любые изменения в базе данных, поэтому они остаются там изолированными. К сожалению, до этого изменения в Django 3.2 rollback не происходили в памяти, поэтому любые изменения в экземплярах моделей сохранялись. Это означает, что тесты не были полностью изолированы.

Возьмем, к примеру, эти тесты:

from django.test import TestCase
from example.core.models import Book

class SetUpTestDataTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.book = Book.objects.create(title="Meditations")

    def test_that_changes_title(self):
        self.book.title = "Antifragile"

    def test_that_reads_title_from_db(self):
        db_title = Book.objects.get().title
        assert db_title == "Meditations"

    def test_that_reads_in_memory_title(self):
        assert self.book.title == "Meditations"

Если мы запустим их на Django 3.1, то финальный тест провалится:

$ ./manage.py test example.core.tests.test_setuptestdata
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.F.
======================================================================
FAIL: test_that_reads_in_memory_title (example.core.tests.test_setuptestdata.SetUpTestDataTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../example/core/tests/test_setuptestdata.py", line 19, in test_that_reads_in_memory_title
    assert self.book.title == "Meditations"
AssertionError

----------------------------------------------------------------------
Ran 3 tests in 0.002s

FAILED (failures=1)
Destroying test database for alias 'default'...

Это связано с тем, что in-memory изменение из test_that_changes_title() сохраняется между тестами. Это происходит в Django 3.2 за счет копирования объектов доступа в каждом тесте, поэтому в каждом тесте используется отдельная изолированная копия экземпляра модели in-memory. Теперь тесты проходят:

$ ./manage.py test example.core.tests.test_setuptestdata
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.002s

OK
Destroying test database for alias 'default'...

Спасибо Simon Charette за изначальное создание этой функциональности в проекте django-testdata, и до его объединения в систему Django. На старых версиях Django вы можете использовать django тест-данные для той же изоляции, добавив  декоратор @wrap_testdata в ваши методы setUpTestData(). Он очень удобен, и я добавлял его в каждый проект, над которым работал.

(В более старых версиях Django также документируется обходной путь, включающий повторный запрос базы данных в каждом тесте, но это медленнее).

2. Использование faulthandler по умолчанию

В примечании к релизу говорится:

DiscoverRunner сейчас использует faulthandler по умолчанию.

Это небольшое улучшение, которое может помочь вам дебажить сбои низкого уровня. Модуль Python faulthandler предоставляет способ сброса «экстренной» трассировки в ответ на проблемы, которые приводят к сбою в работе интерпретатора Python. Тогда тестовый runner Django использует faulthandler. Подобное решение было скопировано из pytest, который делает то же самое.

Проблемы, которые ловит faulthandler, обычно возникают в библиотеках на языке C, например, в драйверах баз данных. Например, OS сигнал SIGSEGV указывает на ошибку сегментации, что означает попытку чтения памяти, не принадлежащей текущему процессу. Мы можем эмулировать это на Python, напрямую посылая сигнал самим себе:

import os
import signal

from django.test import SimpleTestCase


class FaulthandlerTests(SimpleTestCase):
    def test_segv(self):
        # Directly trigger the segmentation fault
        # signal, which normally occurs due to
        # unsafe memory access in C
        os.kill(os.getpid(), signal.SIGSEGV)

Если мы делаем тест в Django 3.1, мы видим это:

$ ./manage.py test example.core.tests.test_faulthandler
System check identified no issues (0 silenced).
[1]    31127 segmentation fault  ./manage.py test

Это нам не очень помогает, так как нет никакой зацепки на то, что вызвало ошибку сегментации.

Вместо этого мы видим трассировку на Django 3.2:

$ ./manage.py test example.core.tests.test_faulthandler
System check identified no issues (0 silenced).
Fatal Python error: Segmentation fault

Current thread 0x000000010ed1bdc0 (most recent call first):
  File "/.../example/core/tests/test_faulthandler.py", line 12 in test_segv
  File "/.../python3.9/unittest/case.py", line 550 in _callTestMethod
  ...
  File "/.../django/test/runner.py", line 668 in run_suite
  ...
  File "/..././manage.py", line 17 in main
  File "/..././manage.py", line 21 in <module>
[1]    31509 segmentation fault  ./manage.py test

 ( Сокращенно )

Faulthandler не может генерировать точно такую же трассировку, как стандартное исключение в Python, но он дает нам много информации для дебаггинга сбоя.

3. Timing (тайминг)

В примечании к релизу говорится:

DiscoverRunner теперь может трекать тайминг, включая настройку базы данных и общее время работы.

Команда manage.py test включает опцию --timing, которая активирует несколько строк вывода в конце пробного тест-запуска для подведения итогов по настройке базы данных и времени:

$ ./manage.py test --timing
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.002s

OK
Destroying test database for alias 'default'...
Total database setup took 0.019s
  Creating 'default' took 0.019s
Total database teardown took 0.000s
Total run took 0.028s

Благодарим Ахмада А. Хуссейна за участие в этом мероприятии в рамках Google Summer of Code 2020.

Если вы используете pytest, опция --durations N работает схоже. 

Из-за системы фикстуры pytest время настройки базы данных будет отображаться как время «настройки» (setup time) только для одного теста, что сделает этот тест более медленным, чем он есть на самом деле.

4. Обратный вызов (callbacks) теста transaction.on_commit() 

В примечании к релизу говорится:

Новый метод TestCase.captureOnCommitCallbacks() собирает функции обратного вызова (callbacks functions), переданные в transaction.on_commit(). Это позволяет вам тестировать эти callbacks, не используя при этом более медленный TransactionTestCase.

Это вклад, который я ранее сделал и о котором ранее рассказывал.

Итак, представьте, что вы используете опцию  ATOMIC_REQUESTS от Django, чтобы перевести каждый вид в транзакцию (а я думаю, что так и должно быть!). Затем вам нужно использовать функцию transaction.on_commit() для выполнения любых действий, которые зависят от того, насколько длительно данные хранятся в базе данных. Например, в этом простом представлении для формы контактов:

from django.db import transaction
from django.views.decorators.http import require_http_methods

from example.core.models import ContactAttempt


@require_http_methods(("POST",))
def contact(request):
    message = request.POST.get('message', '')
    attempt = ContactAttempt.objects.create(message=message)

    @transaction.on_commit
    def send_email():
        send_contact_form_email(attempt)

    return redirect('/contact/success/')

Мы не посылаем сообщения по электронной почте, если только транзакция не сохраняет изменения, так как в противном случае в базе данных не будет ContactAttempt.

Это правильный способ написания подобного вида, но ранее было сложно протестировать callback (обратный вызов), переданный функции on_commit(). Django не будет запускать callback без сохранения транзакции, а его TestCase избегает сохранения, и вместо этого откатывает (rollback) транзакцию, так что это повторяется при каждом тесте.

Одним из решений является использование TransactionTestCase, которое позволяет сохранять транзакции кода. Но это намного медленнее, чем TestCase , так как он очищает все таблицы между тестами.

(Я ранее говорил об увеличении скорости в три раза благодаря конвертации тестов из TransactionTestCase в TestCase.)

Решением в Django 3.2 является новая функция captureOnCommitCallbacks(), которую мы используем в качестве контекстного менеджера. Она захватывает любые callbacks и позволяет вам добавлять утверждения или проверять их эффект. Мы можем использовать это, чтобы проверить наше мнение таким образом:

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

from example.core.models import ContactAttempt


class ContactTests(TestCase):
    def test_post(self):
        with self.captureOnCommitCallbacks(execute=True) as callbacks:
            response = self.client.post(
                "/contact/",
                {"message": "I like your site"},
            )

        assert response.status_code == 302
        assert response["location"] == "/contact/success/"
        assert ContactAttempt.objects.get().message == "I like your site"
        assert len(callbacks) == 1
        assert len(mail.outbox) == 1
        assert mail.outbox[0].subject == "Contact Form"
        assert mail.outbox[0].body == "I like your site"

Итак, мы используем captureOnCommitCallbacks() по запросу тестового клиента на просмотр, передавая execute флаг, чтобы указать, что «фальшивое сохранение (коммит)» должно запускать все callbacks. Затем мы проверяем HTTP-ответ и состояние базы данных, прежде чем проверить электронную почту, отправленную обратным вызовом (callback). Наш тест затем покрывает все на просмотре, оставаясь быстрым и классным!

Чтобы использовать captureOnCommitCallbacks() в ранних версиях Django, установите django-capture-on-commit-callbacks.

5. Улучшенный assertQuerysetEqual() 

В примечании к релизу говорится:

TransactionTestCase.assertQuerysetEqual() в данный момент поддерживает прямое сравнение с другой выборкой элементов запроса в Django

Если вы используете assertQuerysetEqual() в ваших тестах, это изменение точно улучшит вашу жизнь!

В дополнение к Django 3.2, assertQuerysetEqual() требует от вас сравнения с QuerySet после трансформации. Далее происходит переход по умолчанию к repr(). Таким образом, тесты, использующие его, обычно проходят список предварительно вычисленных repr() strings для вышеупомянутого сравнения:

from django.test import TestCase

from example.core.models import Book


class AssertQuerySetEqualTests(TestCase):
    def test_comparison(self):
        Book.objects.create(title="Meditations")
        Book.objects.create(title="Antifragile")

        self.assertQuerysetEqual(
            Book.objects.order_by("title"),
            ["<Book: Antifragile>", "<Book: Meditations>"],
        )

Проверка требует немного больше размышлений о том, какие экземпляры моделей ожидаются. А также требует отработки тестов в случае изменения метода repr() модели.

Из Django 3.2 можно передать QuerySet или список объектов для сравнения, что позволяет упростить тест:

from django.test import TestCase

from example.core.models import Book


class AssertQuerySetEqualTests(TestCase):
    def test_comparison(self):
        book1 = Book.objects.create(title="Meditations")
        book2 = Book.objects.create(title="Antifragile")

        self.assertQuerysetEqual(
            Book.objects.order_by("title"),
            [book2, book1],
        )

Спасибо Питеру Инглсби и Хасану Рамезани за то, что они внесли эти изменения. Они помогли улучшить тестовый набор Django.

Финал

Наслаждайтесь этими изменениями, когда Django 3.2 выйдет или сделайте это раньше через пакет backport.


Перевод статьи подготовлен в преддверии старта курса «Web-разработчик на Python».

Также приглашаем всех желающих посмотреть открытый вебинар на тему «Использование сторонних библиотек в django». На занятии мы рассмотрим общие принципы установки и использования сторонних библиотек вместе с django; а также научимся пользоваться несколькими популярными библиотеками: django-debug-toolbar, django-cms, django-cleanup и т.д.

OTUS
Цифровые навыки от ведущих экспертов

Комментарии 1

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое