Всем привет! Сегодня хочу рассказать об особенностях автоматического тестирования крупного проекта на Django, в котором тесты написаны в разных стилях, а модели данных имеют множество пересечений. Поговорим о том, как привести всё к единому знаменателю без риска возникновения непредсказуемого поведения тестов, и разберём конфигурацию pytest, параметры его запуска, работу с фабриками моделей и стили тестирования в двух парадигмах (Django и чистый pytest).
Чтобы не раскрывать код коммерческого проекта, для эксперимента я создал учебный Django‑проект с аналогичными особенностями. В нём оставлено только всё необходимое для нашей задачи — проверки поведения автотестов при различных настройках.
Основные проблемы, с которыми я столкнулся при работе с проектом:
Различия в поведении тестов при подготовке данных в разных парадигмах —
Djangoи чистомpytestИспользование захардкоженных ID-сущностей в константах проекта
Ручные миграции данных с созданием записей в таблицах
Особенности и нюансы написания фабрик моделей
Непредсказуемое поведение тестов при их параллельном запуске
Поиск оптимальных настроек
pytest
Итак, поднимаем PostgreSQL в Docker и приступаем к разбору! Важно отметить, что тестовые данные мы будем хранить в памяти (tmpfs). Это значительно ускоряет прогон тестов, но никак не влияет на воспроизводимость описанных выше проблем.
Вот как выглядит единственная модель проекта, которую мы будем тестировать:
from django.db import models class A(models.Model): name = models.CharField(max_length=100, unique=True)
Обратите внимание на уникальность поля name — это пригодится нам позже.
Вьюха на простое создание записи:
from rest_framework import generics from sandbox.models import A from sandbox.serializers import ASerializer class SandboxCreateAView(generics.CreateAPIView): queryset = A.objects.all() serializer_class = ASerializer
Напишем фабрику модели для создания записей внутри самих тестов:
import factory from factory.django import DjangoModelFactory from sandbox.models import A class AFactory(DjangoModelFactory): name = factory.Sequence(lambda n: f'name_{n}') class Meta: model = A
Sequence позволяет всегда генерировать уникальные имена, чтобы наш Constrain успешно проходил.
Настало время перейти к написанию самих тестов. Наш первый сценарий — создание записей в таблице A и подготовка тестовых данных через фабрику.
import json import pytest from django.conf import settings from django.urls import reverse from faker import Faker from rest_framework import status from tests.factories import AFactory fake = Faker() @pytest.mark.django_db def test_pytest_create_a(client): AFactory() response = client.post( reverse('sandbox_create_a'), data=json.dumps({'name': fake.word()}), content_type='application/json', ) assert response.status_code == status.HTTP_201_CREATED assert response.json()['id'] == 2 @pytest.mark.django_db def test_pytest_create_a_again(client): response = client.post( reverse('sandbox_create_a'), data=json.dumps({'name': fake.word()}), content_type='application/json', ) assert response.status_code == status.HTTP_201_CREATED assert response.json()['id'] == 1 # Упадет assert 3 == 1
Разберёмся подробнее, что здесь происходит:
Первый тест создаёт запись в таблице
Aчерез фабрику (ID = 1), а затем добавляет вторую запись через View (ID = 2). Тест проходит успешно.Второй тест создаёт запись только через View.. Он падает на проверке:
assert 3 == 1.
Что это значит? Тестовые данные не изолированы? На самом деле, изолированы, но лишь частично. Чтобы увидеть полную картину, расширим тесты.
@pytest.mark.django_db def test_pytest_create_a(client): AFactory() response = client.post( reverse('sandbox_create_a'), data=json.dumps({'name': fake.word()}), content_type='application/json', ) assert response.status_code == status.HTTP_201_CREATED assert response.json()['id'] == 2 assert A.objects.count() == 2 # Добавили проверку количества записей в базе @pytest.mark.django_db def test_pytest_create_a_again(client): response = client.post( reverse('sandbox_create_a'), data=json.dumps({'name': fake.word()}), content_type='application/json', ) assert response.status_code == status.HTTP_201_CREATED assert response.json()['id'] == 3 assert A.objects.count() == 1 # Добавили проверку количества записей в базе
Теперь оба теста проходят, и количество записей соответствует ожиданиям. Однако возникает другая проблема: счётчик автоинкремента ID не сбрасывается.
Это одна из главных проблем нашего проекта — жёсткая завязка на конкретные ID. Причём при разных параметрах запуска и подходах к тестированию баг может то появляться, то исчезать. Отловить его крайне сложно, особенно когда в моделях много связей и активно используется SubFactory , который тоже «расходует» значения автоинкремента.
# Модель class B(models.Model): a = models.ForeignKey(A, on_delete=models.CASCADE) name = models.CharField(max_length=100, unique=True) # Фабрика class BFactory(DjangoModelFactory): name = factory.Sequence(lambda n: f'name_{n}') a = factory.SubFactory(A) class Meta: model = B
Отдельная головная боль — Data‑миграции. Они создают записи в базе данных непосредственно перед запуском тестов, а код ссылается на них по конкретным ID из констант проекта.
Как это законтрить? Если завязки на константы нет, то самой простой способ — передавать в pytest флаг ‑no‑migrations. В этом случае тесты разворачиваются на чистой схеме. Если же такая завязка есть, то правильный подход — отказаться от использования ID и перейти к уникальным бизнес‑ключам (name, slug, code, uuid и др.), которые контролируются разработчиком.
Давайте перепишем наши тесты и константы. Ниже пример Data‑миграции с контролем записей через Settings и честным откатом (с аккуратным сбросом счётчика ID):
from django.db import migrations from django.conf import settings def create_initial_a_record(apps, schema_editor): A = apps.get_model('sandbox', 'A') A.objects.create(name=settings.INITIAL_NAME) def remove_initial_a_record(apps, schema_editor): A = apps.get_model('sandbox', 'A') A.objects.filter(name=settings.INITIAL_NAME).delete() with schema_editor.connection.cursor() as cursor: cursor.execute("SELECT setval(pg_get_serial_sequence('sandbox_a', 'id'), COALESCE(max(id), 1)) FROM sandbox_a;") class Migration(migrations.Migration): dependencies = [ ('sandbox', '0002_b'), ] operations = [ migrations.RunPython( create_initial_a_record, reverse_code=remove_initial_a_record, ), ]
Использовать такие константы в тестах гораздо удобнее. В этот раз прогон прошёл безупречно!
@pytest.mark.django_db def test_pytest_create_a_initial(client): response = client.post( reverse('sandbox_create_a'), data=json.dumps({'name': settings.INITIAL_NAME}), content_type='application/json', ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json()['name'] == ['a with this name already exists.'] @pytest.mark.django_db def test_pytest_create_a_slug(client): AFactory() name = fake.word() response = client.post( reverse('sandbox_create_a'), data=json.dumps({'name': name}), content_type='application/json', ) assert response.status_code == status.HTTP_201_CREATED assert response.json()['name'] == name assert A.objects.count() == 3 @pytest.mark.django_db() def test_pytest_create_a_again_slug(client): name = fake.word() response = client.post( reverse('sandbox_create_a'), data=json.dumps({'name': name}), content_type='application/json', ) assert response.status_code == status.HTTP_201_CREATED assert A.objects.count() == 2 assert response.json()['name'] == name
Теперь напишем тесты с проверкой на ID в парадигме Django и посмотрим, как поведёт себя система.
import json from django.test import TestCase from django.urls import reverse from faker import Faker from rest_framework import status from tests.factories import AFactory fake = Faker() class UserParallelTestCase(TestCase): @classmethod def setUpTestData(cls): cls.a1 = AFactory() def setUp(self): self.a2 = AFactory() def test_django_testcase_create_a(self): response = self.client.post( reverse('sandbox_create_a'), data=json.dumps({'name': fake.word()}), content_type='application/json', ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.json()['id'], 4) def test_django_testcase_create_a_again(self): response = self.client.post( reverse('sandbox_create_a'), data=json.dumps({'name': fake.word()}), content_type='application/json', ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.json()['id'], 6)
В этой парадигме подготовка тестовых данных проходит в три этапа:
На уровне класса (
setUpTestData): выполняется один раз перед всеми тестами класса и создаёт первую запись в таблице (ID = 1)На уровне каждого теста (
setUp): выполняется перед каждым тест-методом и добавляет ещё по одной записи (в сумме две записи на два теста)Внутри тест-методов: здесь можно было бы снова вызвать фабрику, но для наглядности мы просто вызываем наше View, которое само создаёт записи
При обычном одиночном запуске pytest оба теста успешно проходят, а все ID совпадают (счётчик не сбрасывался).
В CI‑конвейере для ускорения сборки у нас задействован плагин pytest‑xdist. Если натравить его на наш синтетический проект прямо сейчас, тесты отработают даже медленнее (3,26 с против 40 мс) — длительность Spawn и подготовки воркеров значительно превышает время самого прогона.
Но главное сейчас в другом: если запустить тесты параллельно с флагом ‑n auto, то второй тест упадёт.
========================= 1 failed, 1 passed in 3.28s ========================== test_django_test_case.py::UserParallelTestCase::test_django_testcase_create_a_again [gw0] [ 50%] PASSED test_django_test_case.py::UserParallelTestCase::test_django_testcase_create_a [gw1] [100%] FAILED test_django_test_case.py::UserParallelTestCase::test_django_testcase_create_a_again tests/sandbox/test_django_test_case.py:30 (UserParallelTestCase.test_django_testcase_create_a_again) test_django_test_case.py:39: in test_django_testcase_create_a_again self.assertEqual(response.json()['id'], 6) E AssertionError: 4 != 6
Причина в том, что каждый тест отработал в отдельном воркере со своей изолированной базой данных. Из‑за этого при создании записи каждый из них получил ID = 4 (пройдя цепочку из Data‑миграции, setUpTestData и setUp). В некоторых сценариях это удобно, но точно не с django.test.TestCase, от которого мы ожидаем единого и предсказуемого состояния базы данных.
Решить проблему помогает флаг ‑dist loadfile в pytest‑xdist. Он гарантирует, что весь файл с тестами будет выполняться в рамках одного воркера, а значит, данные останутся изолированными внутри этого файла. В итоге мы получаем успешный прогон за 3,81 с.
Если запустить наши первые тесты с проверкой ID на чистом pytest без --dist loadfile , мы увидим знакомую картину: данные изолированы вместе со счётчиками ID.
========================= 1 failed, 4 passed in 2.39s ========================== test_pytest.py::test_pytest_create_a_slug test_pytest.py::test_pytest_create_a_again_slug test_pytest.py::test_pytest_create_a_again test_pytest.py::test_pytest_create_a [gw2] [ 20%] PASSED test_pytest.py::test_pytest_create_a_initial [gw3] [ 40%] PASSED test_pytest.py::test_pytest_create_a_slug [gw4] [ 60%] PASSED test_pytest.py::test_pytest_create_a_again_slug [gw0] [ 80%] PASSED test_pytest.py::test_pytest_create_a [gw1] [100%] FAILED test_pytest.py::test_pytest_create_a_again tests/sandbox/test_pytest.py:29 (test_pytest_create_a_again) test_pytest.py:40: in test_pytest_create_a_again assert response.json()['id'] == 4 E assert 2 == 4
Таким образом, мы обнаружили опасный подводный камень, который может проявиться в больших проектах, где тесты написаны в разных парадигмах: конфигурация запуска напрямую влияет на поведение тестов.
Знакомая многим ситуация: разработчик локально прогоняет свои свеженаписанные тесты — всё зелёное. Но стоит запустить их в общем пуле с остальными, как они начинаю падать. И снова виновата магия автоинкремента! Убедимся на наших примерах.
Изолированный запуск чистых pytest‑тестов в один поток: PASSED.
pytest tests.sandbox.test_pytest ============================= test session starts ============================== collecting ... collected 5 items test_pytest.py::test_pytest_create_a test_pytest.py::test_pytest_create_a_again test_pytest.py::test_pytest_create_a_initial test_pytest.py::test_pytest_create_a_slug test_pytest.py::test_pytest_create_a_again_slug ============================== 5 passed in 0.40s ===============================
Изолированный запуск Django-тестов в один поток: PASSED.
tests.sandbox.test_django_test_case ============================= test session starts ============================== collecting ... collected 2 items test_django_test_case.py::UserParallelTestCase::test_django_testcase_create_a test_django_test_case.py::UserParallelTestCase::test_django_testcase_create_a_again ============================== 2 passed in 0.45s ===============================
Совместный прогон всех тестов в общем пуле: FAILED.
============================= test session starts ============================== collecting ... collected 7 items tests/sandbox/test_django_test_case.py::UserParallelTestCase::test_django_testcase_create_a tests/sandbox/test_django_test_case.py::UserParallelTestCase::test_django_testcase_create_a_again tests/sandbox/test_pytest.py::test_pytest_create_a tests/sandbox/test_pytest.py::test_pytest_create_a_again tests/sandbox/test_pytest.py::test_pytest_create_a_initial tests/sandbox/test_pytest.py::test_pytest_create_a_slug tests/sandbox/test_pytest.py::test_pytest_create_a_again_slug ========================= 2 failed, 5 passed in 0.41s ========================== tests/sandbox/test_pytest.py:14 (test_pytest_create_a) 8 != 3 Expected :3 Actual :8 E assert 8 == 3 FAILED [ 57%] tests/sandbox/test_pytest.py:29 (test_pytest_create_a_again) 9 != 4 Expected :4 Actual :9 E assert 9 == 4
Ещё одна распространённая ловушка — флаг ‑reuse‑db в pytest‑django. Он может накапливать нежелательное состояние между запусками, что приведёт к хаосу в тестах. С ним стоит быть осторожнее, а для гарантированной чистки всегда можно один раз запустить тесты с флагом ‑create‑db.
Из полезного: в pytest.ini (в секции addopts) можно прописать следующие флаги:
--max-worker-restart 5: определяет, сколько раз pytest может перезапустить «упавший» воркер, прежде чем полностью остановить тестирование и признать сборку аварийной-n auto: автоматический подбор оптимального количества воркеров под текущий процессор, избегая перегрузки (мои тесты показали, что лучше не указывать число вручную, но выбор остаётся за вами--dist loadfile: группирует воркеров по файлам, ставится по необходимости, если вы точно знаете, какое поведение хотите (по умолчанию используется опцияload, когда каждый тест запускается в своём воркере--tb=short: сокращённый вывод ошибок, который помогает экономить место на экране-p no:warnings: отключает лишние предупреждения, если они забивают журнал-x: останавливает тестирование при первом же падении, что очень удобно в CI/CD--doctest-modules: этот флаг лучше не использовать, если в проекте нет доктестов, чтобы экономить время на лишнем парсинге файлов-p no:doctest: имеет смысл вообще отключить инициализацию этого модуля, если доктесты в проекте не используются
Перед тем как подвести итоги, давайте ради интереса замерим реальную эффективность параллельного запуска тестов. Раньше наша View-функция была очень лёгкой, и выгода от распараллеливания не бросалась в глаза. Немного модифицируем её для наглядности:
class SandboxCreateAView(generics.CreateAPIView): queryset = A.objects.all() serializer_class = ASerializer def post(self, request, *args, **kwargs): time.sleep(2) return super().post(request, *args, **kwargs)
Запуск pytest в один поток: 10,59 с.
Запуск pytest -n auto --dist loadfile: 8,55 с (два файла = два воркера).
Запуск pytest -n auto: 4,45 с (по воркеру на каждый тест).
Выводы
Наш эксперимент на реальном проекте показал, с какими проблемами может столкнуться разработчик, если тестовая база организована не лучшим образом: ID в константах, смешанный стиль написания тестов, неосмотрительные параметры запуска и изолированный запуск отдельных файлов.
Решением большинства этих проблем станет отказ от завязки на ID в пользу уникальных бизнес‑ключей в полях модели. Это особенно удобно при развёртывании проекта в разных средах (QA, Stage, Prod и т.д.), где синхронизировать автоинкременты становится невозможно. В остальном грамотная настройка тестовой среды и параметров запуска тестов — главный залог стабильности и высокой скорости прогонки тестов.
bobrovsashka
Если тесты приходится так подстраивать под особенности ID, воркеров и конкретных флагов запуска, то проблема, кажется, всё-таки глубже в архитектуре тестов. А
--dist loadfileбольше похож на обход проблемы, чем на её решение.xanhex Автор
Именно так, изначально выбрана неправильная архитектура, которая потом снежным комом привела к куче проблем. Основной посыл как раз в том, чтобы планировать все грамотно изначально, ну или рефакторить (что просто ад в таких огромных проекта)