Что происходит, когда вы выполняете manage.py test?

в 16:31, , рубрики: django, python, webdev, Блог компании OTUS. Онлайн-образование, Разработка веб-сайтов

Перевод статьи подготовлен специально для студентов курса «Python Web-Developer».


Вы запускаете тесты командой manage.py test, но знаете ли вы, что происходит под капотом при этом? Как работает исполнитель тестов (test runner) и как он расставляет точки, E и F на экране?

Когда вы узнаете, как работает Django, то откроете для себя множество вариантов использования, таких как изменение файлов cookie, установка глобальных заголовков и логирование запросов. Аналогично, поняв то, как работают тесты, вы сможете кастомизировать процессы, чтобы, например, загружать тесты в другом порядке, настраивать параметры тестирования без отдельного файла или блокировать исходящие HTTP-запросы.

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

Что происходит, когда вы выполняете manage.py test? - 1

Однако, прежде чем писать код, давайте проведем реконструкцию процесса тестирования.

Выходные данные тестов

Давайте разберемся в результатах выполнения тестов. За основу возьмем проект с пустым тестом:

from django.test import TestCase


class ExampleTests(TestCase):
    def test_one(self):
        pass

При выполнении тестов мы получаем знакомые выходные данные:

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

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

Чтобы понять, что происходит, попросим программу рассказать об этом подробнее, добавив флаг -v 3:

$ python manage.py test -v 3
Creating test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
Operations to perform:
  Synchronize unmigrated apps: core
  Apply all migrations: (none)
Synchronizing apps without migrations:
  Creating tables...
    Running deferred SQL...
Running migrations:
  No migrations to apply.
System check identified no issues (0 silenced).
test_one (example.core.tests.test_example.ExampleTests) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.004s

OK
Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...

Отлично, этого достаточно! Теперь давайте разбираться.

На первой строке мы видим сообщение «Creating test database…» - так Django отчитывается о создании тестовой базы данных. Если в вашем проекте несколько баз данных, вы увидите по одной строке для каждой.

В этом проекте я использую SQLite, поэтому Django автоматически ставит mode=memory в поле адреса базы данных. Так операции с базой данных станут быстрее примерно раз в 10. Другие базы данных, такие как PostgreSQL, не имеют подобных режимов, но для них есть другие методы запуска in-memory.

Вторая строка «Operations to perform» и несколько последующих строк – это выходные данные команды migrate в тестовых базах данных. То есть вывод тут получается идентичным с тем, который мы получаем при выполнении manage.py migrate на пустой базе данных. Сейчас я использую небольшой проект без миграций, но если бы они были, то на каждую миграцию в выводе приходилась бы одна строка.

Дальше идет строка с надписью «System check identified no issues». Он взялась из Django, который запускает ряд «проверок перед полетом», чтобы убедиться в правильной конфигурации вашего проекта. Вы можете запустить проверку отдельно с помощью команды manage.py check, и также она выполнится автоматически с запуском большинства команд управления. Однако в случае с тестами, она будет отложена до тех пор, пока тестовые базы данных не будут готовы, так как некоторые этапы проверки используют соединения баз данных.

Вы можете написать свои собственные проверки для обнаружения ошибок конфигурации. Поскольку они выполняются раньше, порой имеет смысл прописать их, а не писать отдельный тест. Я бы с удовольствием рассказал и об этом, но эта тема тянет на отдельную статью.

Следующие строки про наши тесты. По умолчанию test runner выводит по одному символу на тест, но с повышением verbosity в Django для каждого теста будет выводиться отдельная строка. Здесь у нас есть всего один тест «testone», и когда он закончил выполнение, test runner добавил к строке «ok».

Чтобы обозначить конец прогона, ставится разделитель «---». Если бы у нас были какие-то ошибки или сбои, они бы вывелись перед этими черточками. После этого идет краткое описание выполненных тестов и «OK», показывающее, что тесты прошли успешно.

Последняя строка сообщает нам, что тестовая база данных была удалена.

В итоге у нас получается следующая последовательность шагов:

  1. Создание тестовых баз данных.

  2. Миграция баз данных.

  3. Запуск проверок системы.

  4. Запуск тестов.

  5. Отчет о количестве тестов и успешном/неуспешном завершении.

  6. Удаление тестовых баз данных.

Давайте разберемся, какие компоненты внутри Django отвечают за эти шаги.

Django и unittest

Как вам, должно быть, уже известно, фреймворк для тестирования в Django расширяет фреймворк unittest из стандартной библиотеки Python. Каждый компонент, отвечающий за шаги, описанные выше, либо встроен в unittest, либо является одним из расширений Django. Мы можем отразить это на диаграмме:

Что происходит, когда вы выполняете manage.py test? - 2

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

Команда управления тестами «test»

Первое, на что нужно посмотреть, — это команда управления тестами, которую Django находит и выполняет при запуске manage.py test. Находится она в django.core.management.commands.test.

Что касается команд управления, то они довольно короткие – меньше 100 строк. Метод handle() отвечает за обработку в TestRunner. Если упрощать до трех основных строк:

def handle(self, *test_labels, **options):
    TestRunner = get_runner(settings, options['testrunner'])
    ...
    test_runner = TestRunner(**options)
    ...
    failures = test_runner.run_tests(test_labels)
    ...

Полный код.

Так что же представляет из себя класс TestRunner? Это компонент Django, который координирует процесс тестирования. Он настраиваемый, но класс по умолчанию, и единственный в самом Django – это django.test.runner.DiscoverRunner. Давайте рассмотрим его следующим.

Класс DiscoverRunner

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

Начинается он как-то так:

class DiscoverRunner:
    """A Django test runner that uses unittest2 test discovery."""

    test_suite = unittest.TestSuite
    parallel_test_suite = ParallelTestSuite
    test_runner = unittest.TextTestRunner
    test_loader = unittest.defaultTestLoader

(Документация, исходный код)

Эти атрибуты класса указывают на другие классы, которые выполняют различные шаги в процессе тестирования. Как видите, большинство из них – компоненты unittest.

Обратите внимание на то, что один из них называется test_runner, таким образом у нас получается два различных понятия, который называются «test runner» — это DiscoverRunner из Django и TextTestRunner из unittest. DiscoverRunner делает гораздо больше, чем TextTestRunner, и у него другой интерфейс. Возможно, в Django можно было бы обозвать DiscoverRunner по-другому, например, TestCoordinator, но сейчас об этом уже поздно думать. 

Основной поток в DiscoverRunner находится в методе runtests(). Если убрать кучу деталей, run_tests() будет выглядеть примерно так:

def run_tests(self, test_labels, extra_tests=None, **kwargs):
    self.setup_test_environment()
    suite = self.build_suite(test_labels, extra_tests)
    databases = self.get_databases(suite)
    old_config = self.setup_databases(aliases=databases)
    self.run_checks(databases)
    result = self.run_suite(suite)
    self.teardown_databases(old_config)
    self.teardown_test_environment()
    return self.suite_result(suite, result)

Шагов здесь совсем немного. Многие из методов соответствуют шагам из списка, который мы приводили выше:

  • setup_databases() создает тестовые базы данных. Но этот метод создает только те базы данных, которые необходимы для выбранных тестов, отфильтрованных с помощью get_databases(), поэтому если вы запускаете только SimpleTestCases без баз данных, то Django ничего создавать не будет. Внутри этого метода создаются базы данных и выполняется команда migrate.

  • run_checks() запускает проверки.

  • run_suite() запускает набор тестов, включая все выходные данные.

  • Функция teardown_databases() удаляет тестовые базы данных.

И еще парочка методов, о которых можно рассказать:

  • setup_test_environment() и teardown_test_environment() устанавливают или убирают некоторые настройки, такие как локальный сервер электронной почты.

  • suite_result() возвращает количество ошибок в ответ команде управления тестированием.

Все эти методы полезно рассмотреть, чтобы разобраться с настройками процесса тестирования. Но они все являются частью Django. Другие методы передаются компонентам в unittest - build_suite() и run_suite().

Давайте поговорим и о них.

buildsuite()

buildsuite() ищет тесты для запуска и перемещает их в объект «suite». Это длинный метод, но если его упростить, выглядеть он будет примерно так:

def build_suite(self, test_labels=None, extra_tests=None, **kwargs):
    suite = self.test_suite()
    test_labels = test_labels or ['.']

    for label in test_labels:
        tests = self.test_loader.loadTestsFromName(label)
        suite.addTests(tests)

    if self.parallel > 1:
        suite = self.parallel_test_suite(suite, self.parallel, self.failfast)

    return suite

В этом методе используются три из четырех классов, к которым, как мы видели, обращается DiscoverRunner:

  • test_suite - компонент unittest, который служит контейнером для запуска тестов.

  • parallel_test_suite - оболочка для набора тестов, которая используется с функцией параллельного тестирования в Django.

  • test_loader – компонент unittest, который умеет находить тестовые модули на диске и загружать их в набор.

runsuite()

Еще один метод DiscoverRunner, о котором надо поговорить – это run_suite(). Его мы упрощать не будем, и просто посмотрим, как он выглядит:

def run_suite(self, suite, **kwargs):
    kwargs = self.get_test_runner_kwargs()
    runner = self.test_runner(**kwargs)
    return runner.run(suite)

Его единственная задача – создавать test runner и говорить ему запустить собранный набор тестов. Это последний из компонентов unittest, на который ссылается атрибут класса. Он использует unittest.TextTestRunner - test runner по умолчанию для вывода результатов в виде текста, в отличие, например, от XML-файла для передачи результатов в вашу CI-систему. 

Закончим мы наше небольшое расследование, заглянув в класс TextTestRunner.

Класс TextTestRunner

Этот компонент unittest берет тест-кейс или набор и выполняет его. Начинается он вот так:

class TextTestRunner(object):
    """A test runner class that displays results in textual form.
    It prints out the names of tests as they are run, errors as they
    occur, and a summary of the results at the end of the test run.
    """
    resultclass = TextTestResult

    def __init__(self, ..., resultclass=None, ...):

(Исходный код)

По аналогии с DiscoverRunner, он использует атрибут класса для ссылки на другой класс. Класс TextTestResult по умолчанию отвечает за текстовый вывод. В отличие от ссылок класса DiscoverRunner, мы можем переопределить resultclass, передав альтернативу TextTestRunner._init_().

Теперь мы наконец-то можем кастомизировать процесс тестирования. Но сначала вернемся к нашему маленькому исследованию.

Карта

Теперь мы можем расширить карту и показать на ней классы, которые мы нашли:

Что происходит, когда вы выполняете manage.py test? - 3

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

Как кастомизировать

Django предлагает два способа кастомизации процесса выполнения тестов:

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

Супербыстрый Test Runner

В качестве базового примера давайте представим, что нам нужно пропускать тесты и каждый раз уведомлять об успешном прохождении теста. Сделать это мы можем создав подкласс DiscoverRunner с новым методом runtests(), который не будет вызывать метод super():

# example/test.py
from django.test.runner import DiscoverRunner


class SuperFastTestRunner(DiscoverRunner):
    def run_tests(self, *args, **kwargs):
        print("All tests passed! A+")
        failures = 0
        return failures

Затем воспользуемся им в файле с настройками следующим образом:

TEST_RUNNER = "example.test.SuperFastTestRunner"

А затем выполним manage.py test, и получим результат за рекордно короткое время!

$ python manage.py test
All tests passed! A+

Отлично, очень полезно!

А теперь давайте сделаем еще практичнее, и перейдем к выводу результатов теста в виде эмодзи!

Вывод в виде эмодзи

Мы уже выяснили, что компонент TextTestResult из unittest отвечает за вывод. Мы можем заменить его в DiscoverRunner, передав значение resultclass в TextTestRunner.

В Django уже есть опции для замены resultclass, например, опция --debug-sql option, которая выводит выполненные запросы для неудачных тестов.

 DiscoverRunner.run_suite() создает TextTestRunner с аргументами из метода DiscoverRunner.get_test_runner_kwargs():

<img alt="Изображение выглядит как текст

def get_test_runner_kwargs(self):
    return {
        'failfast': self.failfast,
        'resultclass': self.get_resultclass(),
        'verbosity': self.verbosity,
        'buffer': self.buffer,
    }

Он же в свою очередь вызывает get_resultclass(), который возвращает другой класс, если был использован один из двух параметров тестовой команды (--debug-sql или --pdb):

def get_resultclass(self):
    if self.debug_sql:
        return DebugSQLTextTestResult
    elif self.pdb:
        return PDBDebugResult

Если ни один из параметров не задан, метод неявно возвращает None, говоря TextTestResult использовать по умолчанию resultclass. Мы можем увидеть этот None в нашем собственном подклассе и заменить его подклассом TextTestResult:

class EmojiTestRunner(DiscoverRunner):
    def get_resultclass(self):
        klass = super().get_resultclass()
        if klass is None:
            return EmojiTestResult
        return klass

Наш класс EmojiTestResult расширяет TextTestResult и заменяет вывод точек по умолчанию на эмодзи. В конечном итоге получается довольно длинно, поскольку на каждый тип результата приходится отдельный метод:

class EmojiTestResult(unittest.TextTestResult):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # If the "dots" style was going to be used, show emoji instead
        self.emojis = self.dots
        self.dots = False

    def addSuccess(self, test):
        super().addSuccess(test)
        if self.emojis:
            self.stream.write('✅')
            self.stream.flush()

    def addError(self, test, err):
        super().addError(test, err)
        if self.emojis:
            self.stream.write('?')
            self.stream.flush()

    def addFailure(self, test, err):
        super().addFailure(test, err)
        if self.emojis:
            self.stream.write('❌')
            self.stream.flush()

    def addSkip(self, test, reason):
        super().addSkip(test, reason)
        if self.emojis:
            self.stream.write("⏭")
            self.stream.flush()

    def addExpectedFailure(self, test, err):
        super().addExpectedFailure(test, err)
        if self.emojis:
            self.stream.write("❎")
            self.stream.flush()

    def addUnexpectedSuccess(self, test):
        super().addUnexpectedSuccess(test)
        if self.emojis:
            self.stream.write("✳️")
            self.stream.flush()

    def printErrors(self):
        if self.emojis:
            self.stream.writeln()
        super().printErrors()

После указания TEST_RUNNER в EmojiTestRunner, мы можем запустить тесты и увидеть эмодзи:

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
?❎❌⏭✅✅✅✳️

...

----------------------------------------------------------------------
Ran 8 tests in 0.003s

FAILED (failures=1, errors=1, skipped=1, expected failures=1, unexpected successes=1)
Destroying test database for alias 'default'...

Урааа!

Вместо заключения

После нашего спелеологического исследования мы увидели, что архитектура unittest относительно проста. Мы можем заменить классы на подклассы, чтобы изменить любое поведение в процессе тестирования.

Так работает кастомизация, специфичная для конкретного проекта, но кастомизации других людей использовать непросто. Так происходит потому, что архитектура заточена под наследование, а не под композицию. Мы должны использовать множественное наследование через сеть классов, чтобы объединить кастомизации, да и будет ли это все работать, зависит от качества их реализаций. На самом деле именно из-за этого не существует экосистемы плагинов для unittest.

Я знаком лишь с двумя библиотеками, предоставляющими кастомные подклассы DiscoverRunner:

  • unittest-xml-reporting обеспечивает вывод в формате XML для вашей CI-системы.

  • django-slow-tests обеспечивает измерение времени выполнения тесты для поиска самых медленных тестов.

Сам я не пробовал, но их объединение может не сработать, поскольку обе они переопределяют процесс вывода.

С другой стороны, у pytest есть процветающая экосистема с более чем 700 плагинами. Так происходит из-за того, что в его архитектуре используется композиция с хуками, которые работают по аналогии с сигналами в Django. Плагины регистрируются только для тех хуков, которые им нужны, а pytest вызывает каждую зарегистрированную хук-функцию в соответствующей точке процесса тестирования. Многие из встроенных функций pytest даже реализованы в виде плагинов.

Если вам интересна более детальная настройка процесса тестирования, обратитесь к pytest.

Конец

Спасибо, что отправились со мной в это путешествие. Я надеюсь, что вы узнали что-то новое о том, как Django запускает ваши тесты, и научились кастомизировать их.

Читать ещё:

Автор: Дмитрий

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js