Всем привет! Сегодня хочу рассказать об особенностях автоматического тестирования крупного проекта на Django, в котором тесты написаны в разных стилях, а модели данных имеют множество пересечений. Поговорим о том, как привести всё к единому знаменателю без риска возникновения непредсказуемого поведения тестов, и разберём конфигурацию pytest, параметры его запуска, работу с фабриками моделей и стили тестирования в двух парадигмах (Django и чистый pytest).

Чтобы не раскрывать код коммерческого проекта, для эксперимента я создал учебный Django‑проект с аналогичными особенностями. В нём оставлено только всё необходимое для нашей задачи — проверки поведения автотестов при различных настройках.

Основные проблемы, с которыми я столкнулся при работе с проектом:

  1. Различия в поведении тестов при подготовке данных в разных парадигмах — Django и чистом pytest

  2. Использование захардкоженных ID-сущностей в константах проекта

  3. Ручные миграции данных с созданием записей в таблицах

  4. Особенности и нюансы написания фабрик моделей

  5. Непредсказуемое поведение тестов при их параллельном запуске

  6. Поиск оптимальных настроек 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

Разберёмся подробнее, что здесь происходит:

  1. Первый тест создаёт запись в таблице A через фабрику (ID = 1), а затем добавляет вторую запись через View (ID = 2). Тест проходит успешно.

  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)

В этой парадигме подготовка тестовых данных проходит в три этапа:

  1. На уровне класса (setUpTestData): выполняется один раз перед всеми тестами класса и создаёт первую запись в таблице (ID = 1)

  2. На уровне каждого теста (setUp): выполняется перед каждым тест-методом и добавляет ещё по одной записи (в сумме две записи на два теста)

  3. Внутри тест-методов: здесь можно было бы снова вызвать фабрику, но для наглядности мы просто вызываем наше 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 и т.д.), где синхронизировать автоинкременты становится невозможно. В остальном грамотная настройка тестовой среды и параметров запуска тестов — главный залог стабильности и высокой скорости прогонки тестов.

Комментарии (2)


  1. bobrovsashka
    27.08.2026 08:13

    Если тесты приходится так подстраивать под особенности ID, воркеров и конкретных флагов запуска, то проблема, кажется, всё-таки глубже в архитектуре тестов. А --dist loadfile больше похож на обход проблемы, чем на её решение.


    1. xanhex Автор
      27.08.2026 08:13

      Именно так, изначально выбрана неправильная архитектура, которая потом снежным комом привела к куче проблем. Основной посыл как раз в том, чтобы планировать все грамотно изначально, ну или рефакторить (что просто ад в таких огромных проекта)