Давайте признаем, что развитие проектов в мире Django не всегда проходит гладко. Мы часто сталкиваемся с толстыми моделями и сериалайзерами, размытой бизнес-логикой и тестированием, которое больше напоминает головную боль, чем удовольствие. Меня зовут Павел Губарев, я backend-разработчик продукта 10D в компании Самолет. Последние пять лет я занимаюсь backend-ом и большую часть времени я использую именно Django. С ростом проекта увеличивается и его сложность, в случае с Django есть набор часто встречающихся проблем. В этой статье я расскажу о методах нашей команды, которые помогли нам справиться с этими проблемами и привести код к новому уровню надежности и эффективности.

Для меня список типичных проблем Django приложения выглядит примерно так:

  • Размытие бизнес логики
  • Толстые модели
  • Толстые сериалайзеры
  • Отсутствие структуры и четкой ответственности файлов utils.py, helpers.py и т.д.
  • Сложность тестирования — тяжело писать “чистые” unit-тесты
  • Сложность тестирования — использование monkey patching, сложность рефакторинга

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

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

Термины и понятия
Monkey patching — это подход в тестировании, когда мы модифицируем некоторый объект, способом который не предусмотрен программой или этим объектом. В программировании есть понятие как Mock-объект, но в зачастую когда мы говорим что мы что то мокаем, или используем мок, мы имеем ввиду именно monkey patching и динамическое изменение некоторого объекта. Monkey patching может выглядеть вот так:

some_instance = MyAwesomeModel()
some_instance.save = lambda *args, **kwargs: None

В данном случае мы создаем экземпляр какой то модели и изменяем его, заменяя метод save какой то функцией. Примерно тоже самое будет, если заменить метод save экземпляром класса Mock или MagicMock.

Но чаще всего мы встречаемся вот с такой конструкцией:

@mock.patch(“some.path”)
def test_some(*args, **kwargs):
    pass

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

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

if is_test_run():
    CACHES = {
        "default": {
            "BACKEND": "django.core.cache.backends.dummy.DummyCache",
        }
    }

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

Ну и в завершении про объект Mock() или MagicMock(). Это реализация Mock-объекта, заглушки/stub, dummy-объекта, можно использовать любой термин, они почти идентичны. На мой взгляд куда важнее что просто объект, который может заменить собой любой другой объект, вне зависимости от того применяем мы его используя monkey patching или нет.

Проблемы классических Django приложений


Проблема: размытие бизнес логики



Под размытием бизнес логики я понимаю ситуацию, когда один бизнес-процесс обрабатывается сразу несколькими файлами. Например в случае создание заказа, размытие может выглядеть примерно вот так:

  • serializers.py — валидация перед созданием, которая очень часто требует выполнения дополнительных запросов в базу;
  • views.py — подготавливаем входные парметры для какой то функции из никак не стандартизированных helpers.py / utils.py / services.py. Зачастую на этом этапе будет не только работа с query/body параметрами, но так же будут запросы в базу для получения каких то дополнительных данных;
  • helpers.py / utils.py / services.py — часто сюда выносят какую то часть бизнес логики. Проблема тут в отсутствии какого то единого формализованного процесса, сервисами могут быть функции или классы, они могут содержать только одно действие или сразу выполнять несколько операций;
  • models.py — часто на уровне модели добавляются какие то методы, которые должны добавлять выразительности коду или помочь избавится от дублирования;
  • views.py после выполнения функции сервиса, дополнительно отправляем уведомления или производим какие то еще действия до момента, когда будем возвращать запрос.

Такое размытие точно не является проблемой в маленьких проектах. В случае маленького персонального блога или например небольшого бэка для поддержки лендинга с динамическим контентом у вас просто не будет большого количества моделей и view, а вся логика будет умещаться в 20-30 строк. Выдумывать сложные подходы в таком случае — это как стрелять из пушки по воробьям.

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

Проблема: Толстые модели


Толстая модель — это модель содержащая бизнес-логику в методах. Иногда это просто вспомогательные свойства/методы которые добавляют выразительности коду, например вот так:

class Profile(models.Model):
    first_name = models.CharField(...)
    last_name = models.CharField(...)

    def get_full_name(self) -> str:
        return f”{self.first_name} {self.last_name}”

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

Но часто случается так, что в методе модели есть обращение к каким то связанным объектам. Посмотрим для примера на другую реализацию метода get_full_name, с обращением к ForeignKey полю user.

class Profile(models.Model):
    user = models.OneToOne(User, …)

    def get_full_name(self) -> str:
        return f”{self.user.first_name} {self.user.last_name}”

В данном случае обращение к self.user с большей вероятностью приведет к дополнительному запросу(посудите сами, мы реже используем only/defer, чем забываем добавить select_related). Таким образом, обращение к полю user, которое является отношением, может привести к проблеме N + 1, если программист не обратит внимание что для этого метода нужно также загрузить связанные данные.

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

Проблема: Толстые сериалайзеры


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

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

Пример 1


class CommentRegistryDetailSerializer(serializers.Serializer):

    @extend_schema_field(ProjectShortSerializer)
    def get_project(self, obj: Comment):
        return ProjectShortSerializer(obj.documentation.version.documentation_kit.project).data

Эта часть кода — отличный кандидат на рефакторинг, так как вероятно будет приводить к лишним и не оптимальным запросам. В нашем случае `obj.documentation.version.documentation_kit.project` это обращение к связанным через внешний ключ объектам. Проблема в том что часто найти такой кусок кода бывает сложно, если целенаправленно не проводить аудит того или иного метода API.

Пример 2


class CommentRegistryListSerializer(serializers.ModelSerializer):

    @extend_schema_field(ResponsibleProjectTeamShortSerializer(many=True))
    def get_responsible_engineers(self, obj: Comment):
        responsible_queryset = get_responsible_for_agreement_engineers(
            obj, include_responsible=True,
        )
        responsible_queryset = responsible_queryset.annotate(
            full_name=Concat("user__first_name", Value(" "), "user__last_name"),
        )
        return ResponsibleProjectTeamShortSerializer(responsible_queryset, many=True).data

Почти то же самое, но метод сильно сложнее. Это уже не просто обращение к полю, а дополнительный вызов кода за пределами сериалайзера + работа с базой внутри метода. Как итог, чтобы разобраться в том как формируется список ответственных инженеров, нужно не только посмотреть код сериалайзера, но также переключиться на функцию get_responsible_for_agreement_engineers. Конечно, в случае одного метода это терпимо, но помните я сказал что сознательно только упрощал код?

Проблема: utils.py/helpers.py без четкой зоны ответственности


Часто можно видеть, что внутри одного файлов utils.py/helpers.py есть как функции в одну-две строчки для конвертации данных или вычисления значения по формуле, так и сложные функции, которые отвечают сразу за несколько процессов.

Я думаю, что любой программист на Django без труда сможет описать за что отвечают models.py или например urls.py. Но как описать, за что отвечает utils.py если не смотреть на код проекта?

Часто в такие файлы попадает все что не подходит для привычных views.py/serializers.py/models.py. Как итог, такие файлы разрастаются и в них становится сложно ориентироваться + нарушается принцип единственной ответственности, что в свою очередь приводит к тому что становится сложно уследить за логикой и назначением тех или иных функций.

Проблема: тяжело писать “чистые” unit-тесты


Если чистые функции — это функции, которые не имеют побочных эффектов, то одним из свойств чистого unit-теста будет независимость от всех внешних факторов, будь то переменные окружения или третьи сервисы вроде базы данных.

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

Если мы хотим писать чистые unit тесты, то мы должны отказаться от запросов в базу, но тогда будет сложно тестировать бизнес логику, если она тесно сплетена с ORM или вызовами третьих сервисов: любые сторонние api, redis, создание celery-задач и тд.

Зачастую наш код работает не только с ORM, но также взаимодействует с redis, S3, различными API вендоров. Такие вызовы могут представлять проблему в тестах, так как любые дополнительные зависимости в тесах усложняют их поддержку, а также являются дополнительной точкой отказа.

Для решения этой проблемы часто используют моки, чтобы предотвратить вызовы любых внешних сервисов, кроме вызовов ORM. У такого подхода есть ряд существенных минусов:
  • любые изменения в путях требуют ручного поиска путей в моках;
  • любые новые запросы к сервисам требуют анализа существующих тестов и новых моков;
  • следствие первого пункта — нельзя после рефакторинга просто запустить тесты быть уверенным что все ок.

Решение проблем


Что стало катализатором поиска другого подхода


В какой то момент стало понятно, что оставить все как есть не получится. С ростом продукта и увеличением сложности проекта писать новые тесты, покрывая все возможные случаи становится сложнее: код постоянно обрастал новой логикой, добавлялись новые зависимости, появлялись сервисы и компоненты которые нужно было экранировать в тестах. Ситуацию усугубляли старые тесты которые периодически ломались, нестабильные(flaky)flaky тесты значительно увеличивали время запуска тестов. Если в обычном случае запуск тестов на средней машине разработчика занимал 20-30 секунд, то с учетом перезапусков из-за flaky тестов общее время прогона локально могло занимать уже несколько минут.

Подводя итог, ключевыми проблемами стали:
  • Проблема изоляции тестов, некоторые тесты были завязаны на глобальные переменные или сервисы;
  • Неприлично высокое время выполнения тестов, которые многократно увеличивалось из-за периодических сбоев и перезапусков в тестах;
  • Постоянные перезапуски тестов не применимы в CI/CD.

К чему мы пришли


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



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

Такое разделение было выработано на основе CQRS(Command and Query Responsibility Segregation) паттерна. Простыми словами, CQRS паттерн, это разделения логики чтения и логики обновления данных. В нашем случае мы используем такой подход для выделения бизнес логики в отдельный слой, независимый от слоя работы с базой или другими сервисами. Фактически бизнес слой занимается оркестрацией вызовов функций-селекторов и функций-команд.

Для объединения слоев для работы с бизнес логикой, данными и инфраструктурой, мы решили использовать подход, схожий с Dependency Injection. Отказ от использования библиотек для инъекции зависимостей был обусловлен желанием максимально упростить код, так как большая часть наших зависимостей используется только один раз и является статичной. Вместо этого мы решили оформлять сервисные классы в виде dataclass с методом __call__. В таком случае сервис инициализируется один раз, получая в качестве входных параметров функции-зависимости. Метод __call__ позволяет превратить экземпляр сервиса в функтор, то есть в объект с которым можно работать как с функцией.

Использование dataclass с методом __call__ может показаться необычным, но такой подход имеет свои плюсы при тестировании, об этом будет рассказано подробней ниже. Хочу также отметить что использование dataclass не является ключевым в нашем подходе, важно только разделение на бизнес логику и код для работы с условной инфраструктурой и передача зависимостей при инициализации сервиса. Именно эти два принципа значительно упростили как тестирование, так и помогли четко разграничить запросы и логику.

Как это выглядит в коде


Типичная структура сервиса, который независим от API(например может быть вызван в celery-задаче):

some_service_name    — python пакет, который может быть импортирован
    __init__.py
    commands.py       — обновление данных
    selectors.py          — запросы на чтение
    services.py           — описание сервисного класса
    utils.py

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

api.awesome_feature.retrieve    — python пакет, который может быть импортирован
    __init__.py
    selectors.py          — запросы на чтение
    services.py           — описание сервисного класса
    views.py
    serializers.py
    utils.py

Типичная структура класса


Если посмотреть на сервисный класс, то обычно он выглядит примерно вот так:

@final
@dataclass(frozen=True, slots=True, kw_only=True)
class SomeAwesomeService:
    _load_some_items_from_database: Callable[[PK], Iterable[Item]]

    def __call__(self, item_id: PK) -> dict:
        item = self._load_some_items_from_database(item_id)
        
        return {
            “id”: item.id,
        }

awesome_service: Final = SomeAwesomeService(
    _load_some_items_from_database=load_some_items_from_database,
)

Разберем сервисный класс по частям

@final
@dataclass(frozen=True, slots=True, kw_only=True)

Мы используем @final так как против наследования сервисов. В 99% случаев возможно использовать композицию, в крайних случаях дублирование кода, так как в нашем случае дублирование будет меньшим злом, чем усложнение из-за наследования.

Как я писал выше, @dataclass не является принципиальной частью подхода, но упрощает инициализацию — не нужно постоянно писать присвоение атрибутов в __init__. Также мы передаем параметры в @dataclass декоратор:

  • frozen — для предотвращения обновления объекта сервиса. В хотим явно требовать создания нового экземпляра класса, если для тестов или в случае изменения функциональности, требуется изменить зависимости.
  • slots — для уменьшения потребления памяти, так как мы точно не будем использовать динамическое добавление атрибутов
  • kw_only — используется для повышения читаемости в момент инициализации сервиса.

_load_some_items_from_database: Callable[[PK], Iterable[Item]]

Мы стараемся использовать аннотацию типов там где это возможно и имеет смысл. Можно заметить необычный алиас PK, который позволяет подсветить что в качестве параметра функции принимает идентификатор объекта(Как дополнение, помогает победить ошибку PyCharm который говорит что поле django-модели содержит type[int]).

def __call__(self, item_id: PK) -> dict:
        item = self._load_some_items_from_database(item_id)
        
        return {
            “id”: item.id,
        }

Метод __call__ — это логика сервиса, внутри сервиса допустимы только обращения к зависимостям сервиса и примитивам. Например uuid.uuid4 будет вызван напрямую, в то время как cache.get будет передан как зависимость сервиса.

awesome_service: Final = SomeAwesomeService(
    _load_some_items_from_database=load_some_items_from_database,
)

После объявления класса-сервиса мы сразу производим инициализацию, передавая функции слоя данных как зависимости. Следствием передачи зависимостей в качестве параметров стало то, что мы смогли начать писать чистые unit тесты для сервисов.

В тестах мы создаем экземпляр сервиса, передавая ему в качестве зависимостей тестовые функции или Mock объекты. Это позволяет нам возвращать предподготовленые данные из селекторов, проверять что функции-команды были вызваны и что сервис корректно отработал если функция-зависимость выбросила исключение. Мы называем такой подход Fake Injection.

Плюсы такого подхода


  • Сервис класс можно протестировать отдельно, в каждом тесте создавая новый экземпляр класса с тестовыми зависимостями
  • Легко писать unit-тесты, так как зависимости, которые взаимодействуют с базой можно подменить на имитирующую функцию
  • Такой подход не требует следить за путями при подмене, так как подмены не происходит, а происходит создание экземпляра класса с другим набором зависимостей
  • Унифицируется подход к написанию бизнес-логики
  • Можно строить сложные системы используя композицию и используя одни сервисы как зависимости для других

Примеры рефакторинга сервисов


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

Ниже код сервиса который отвечает за выпуск рабочей документации в работу. Если немного упростить, то весь процесс это обновление состояния для объекта версии, создание записей в базе для отслеживания времени принятия подрядчиками и отправка уведомлений. В реальности процесс несколько сложнее, но на упрощенном примере легче показать разницу. Вот код нашей функции до:

def approve_new_version(documentation_kit: DocumentationVersion, responsible: User):
    logger.info(f”approve new version by chief engineer: {responsible.username}”)
    
    version = DocumentationVersion.objects.get(documentation_kit=documentation_kit, approved=False)
    
    version.approved = True
    version.approved_at = timezone.now()
    version.approved_by = responsible
    version.save()

    contractors = ProjectContractor.objects.get_responsible_contractors_for_kit(documentation_kit)
    StatusTracker.objects.create_trackers_for_contractors(version, contractors)

    send_version_approved_email_notification.delay(version.id)


В исходном коде не так много логики, она сокращена специально для наглядности, но мы можем заметить что:
  • Функция не имеет единственной зоны ответственности, так как она одновременно отвечает и за выпуск версии и за то как будут созданы трекеры для подрядчиков;
  • В тестах для этой функции потребуется использовать monkey patching, как минимум для предотвращения обращения к celery;
  • Мы не сможем протестировать логику обновления полей отдельно от запросов к базе.

А вот версия с использованием сервисных классов:

@final
@dataclass(frozen=True, slots=True, kw_only=True)
class ApproveVersionService:
    """Содержит логику по выпуску новой версии в производство работ в ручном режиме."""

    _get_latest_version_for_kit: Callable[[DocumentationKit], DocumentationVersion]
    _perform_save: Callable[[DocumentationVersion], None]
    _create_trackers_for_version: Callable[[DocumentationVersion], None]
    _send_version_approved_email_notification: Callable[[PK], None]

    def __call__(self, documentation_kit: DocumentationKit, responsible: User):
        logger.info(f”approve new version by chief engineer: {responsible.username}”)
        
        version = self._get_latest_version_for_kit(documentation_kit)
    
        version.approved = True
        version.approved_at = timezone.now()
        version.approved_by = responsible
        self._perform_save(version)

        self._create_trackers_for_version(version)

        self._send_version_approved_email_notification(version.id)

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

Тест для сервиса выше может выглядеть примерно вот так:

def test_approve_version_by_chief_engineer():
    documentation_kit = DocumentationKitFactory.build()
    version = DocumentationVersionFactory.build()
    responsible = UserFactory.build()
    service = ApproveVersionService(
        _get_latest_version_for_kit: lambda: *: version,
        _perform_save: Mock(),
        _create_trackers_for_version: Mock(),
        _send_version_approved_email_notification: Mock(),
    )

    service(documentation_kit, responsible)

    assert version.approved_by == responsible
    assert version.approved

    service._perform_save.assert_called_once()
    service._create_trackers_for_version.assert_called_once()
    service._send_version_approved_email_notification.assert_called_once()

Важное замечание: передача Mock() в качестве зависимостей никак не отменяет наше желание как можно меньше использовать моки, просто в данном случае мы не применяем monkey patching, а просто используем экземпляр класса Mock для упрощения дальнейших проверок. Этот подход больше похож на использование stubs, то есть на использование тестовых заглушек, так как в примере выше мы можем заменить Mock(), например на lambda функцию которая будет выбрасывать AssertionError в момент вызова.

Примеры рефакторинга тестов


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

В одном из наших бизнес процессов можно загрузить файл авторского надзора к актуальной версии рабочей документации. Для нас важно чтобы в момент загрузки в комплекте существовала версия РД и при этом последняя версия не была в стадии согласования. Ниже код этого теста:

@pytest.mark.parametrize("version", [None, DocumentationVersion(approved=False, version=4)])
def test_upload_architectural_supervision_file_without_any_versions(version):
    """Проверяем что в случае если комплект пустой или содержит новую версию,
    то к нему нельзя загрузить лист авторского надзора."""
    documentation_kit = KitCatalogFactory.build()

    attach_architectural_supervision_file = AttachArchitecturalSupervisionFileService(
        _get_latest_version_for_kit=lambda *args, **kwargs: version,
        _get_version_files_map=Mock(),
        _get_file_storage_for_kit=Mock(),
        _save_to_database=Mock(),
    )

    with pytest.raises(ServiceException):
        attach_architectural_supervision_file(
            documentation_kit=documentation_kit,
            uploaded_file=TemporaryDocumentationFile(documentation_kit=documentation_kit, file_name="docs.pdf"),
            uploaded_by=UserFactory.build(),
        )

    # Проверяем что все команды после проверки версии не были вызваны
    attach_architectural_supervision_file._get_file_storage_for_kit.assert_not_called()
    attach_architectural_supervision_file._save_to_database.assert_not_called()
    attach_architectural_supervision_file._get_version_files_map.assert_not_called()

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

В начале идет подготовка тестовых данных:

@pytest.mark.parametrize("version", [None, DocumentationVersion(approved=False, version=4)])
def test_upload_architectural_supervision_file_without_any_versions(version):
    documentation_kit = KitCatalogFactory.build()

Мы используем метод build в случае фабрики и создаем экземпляры моделей без сохранения объектов в базу.

attach_architectural_supervision_file = AttachArchitecturalSupervisionFileService(
        _get_latest_version_for_kit=lambda *args, **kwargs: version,
        _get_version_files_map=Mock(),
        _get_file_storage_for_kit=Mock(),
        _save_to_database=Mock(),
    )

Мы создаем экземпляр сервиса используя подход Fake Injection — в качестве зависимостей мы передаем lambda-функцию которая вернет версию которая была передана в качестве параметра теста, а для остальных зависимостей экземпляр класса Mock, что бы можно было в одну строчку проверить что метод не был вызван.

with pytest.raises(ServiceException):
        attach_architectural_supervision_file(
            documentation_kit=documentation_kit,
            uploaded_file=TemporaryDocumentationFile(
                documentation_kit=documentation_kit, file_name="docs.pdf",
            ),
            uploaded_by=UserFactory.build(),
        )

    # Проверяем что все команды после проверки версии не были вызваны
    attach_architectural_supervision_file._get_file_storage_for_kit.assert_not_called()
    attach_architectural_supervision_file._save_to_database.assert_not_called()
    attach_architectural_supervision_file._get_version_files_map.assert_not_called()

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

Личный опыт, что поменялось в моем проекте


Когда мы начали внедрять новый подход в проекте, я думал что покрытие unit-тестами быстро дойдет до 70-90%, в реальности все получилось несколько интересней.
До внедрения подхода статистика покрытия по тестам выглядела примерно вот так:
  • total code coverage: 85%
  • integration tests coverage: 85%
  • Unit tests: единичные тесты, почти не влияющие на покрытие

После старта внедрения нового подхода, для новых фич я старался писать как минимум один интеграционный тест для успешного сценария и один интеграционный для случайного сценария с ошибкой, чтобы проверить, что методы api отрабатывают правильно в любой ситуации. Но теперь все возможные сценарии ветвления в логике, то есть разные случаи для валидации или разные входные параметры которые приводят к разной обработке, я стал покрывать unit тестами с использованием Fake Injection.

Результатом стали слудующие метрики:
  • total code coverage: 85%
  • integration tests coverage: 82%
  • Unit tests coverage: 44%

  • Общее время выполнения тестов: 39 секунд
  • Время выполнения только integration тестов 34 секунды
  • Время выполнения unit тестов: 4.91 секунды, при этом большую часть занимает инициализация
  • Время выполнения самого медленного unit теста: 0.09 секунды
  • Всего тестов в проекте: 303
  • integration тестов: 176
  • unit тестов: 127 — столько тестов было добавлено

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

Общие покрытие unit-тестами оказалось в районе 44-45% от общей кодой базы. В моем случае unit тестами покрыты все сервисы которые вызываются внутри методов апи которые изменяют данные. Я считаю это хорошим показателем, так как при расчете общего объема кода учитываются также классы для админки, view-классы и другие модули которые не относятся к тому что мы называем сервисный слой. Принимая это во внимание, я могу с уверенностью запускать локально только unit-тесты, которые проверят большую часть кода, а значит сокращается время на разработку, так как теперь мне не нужно ждать выполнения интеграционных тестов чтобы убедиться что у меня в коде нет например ошибки с типизацией.

Что касается времени выполнения, то новые unit тесты почти не увеличивают общее время выполнения. Из общего времени выполнения тестов, большую часть занимает инициализация Django и связанных библиотек. При этом самый медленный тест выполняется за 0.09 секунд.

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

Немного про минусы


У предложенного подхода ряд минусов, которые стоит принять во внимание. Большая когнитивная сложность для простых случаев — часто создание отдельных функций для получения данных и отдельных классов для вызова этих функций будет избыточным. Сложно заставить себя распилить view, если вся логика view укладывается в определение queryset на уровне клаcса. Как следствие, такой подход не является заменой для generic view/viewset в случае простых CRUD операций.

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

Во многих случаях проверка входных данных будет производится с использованием RelatedField полей сериалайзера или например get_object_or_404. Это может быть расценено как нарушение подхода.

Заключение


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

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


  1. von_cidevant
    25.04.2024 16:03
    +1

    Маленькая ошибочка:

    views.py — подготавливаем входные парметры для какой то функции из...

    views.py после выполнения функции сервиса, дополнительно отправляем уведомления или...

    Views.py два раза, помойму должен быть контроллер


    1. danilovmy
      25.04.2024 16:03
      +3

      Тоже несколько раз перечитал, но похоже, что так и запланировано. Смотри получили запрос во view. Вызвали serializer, в котором почистили данные. Передали чистые данные в model, создали подключение в Utils, и с помощью helpers, вероятно, отправили в базу, и, если все успешно, вернулись опять во view, в котором отправляем email или message перед возвратом response.

      Суть в том, что вся схема изначально, как и изобретение функтора во второй половине статьи говорит о том, что понимание Django у разработчиков описываемого кода не очень, собственно, как и Тестов.

      И за такой базар могу ответить: переведи они тесты на DjangoTestCase, Django.SimpleTestCase - нытья про долгий старт быть не должно, как про суммарную скорость выполнения тестов. Хотя это им особо не важно, это не 43 минуты ожидания в четыре потока: на последнем проекте было, бесило страшно.

      Назвать Unit.тестом тестирование нескольких sideeffects сразу язык не поворачивается, и беспокоит мысль: что ж за ка-ка-фония у них творится в реальной функции/методе?

      По использованию django в статье все выглядит, что пользуются в основном drf, мучаются, но продолжают грызть юзать, опять же, без особого понимания: Там кое-где фильтры прям в коде проскакивали, аж больно смотреть.

      Хотя чего я брюзжу. Если самолет у автора не падает, то значит он все правильно сделал. И любой критикан, как я, идет посрамленный, потому как у меня самолета нету. Даже игрушечного. :)


      1. pgubarev Автор
        25.04.2024 16:03
        +2

        Спасибо, интересный комментарий)

        И за такой базар могу ответить: переведи они тесты на DjangoTestCase, Django.SimpleTestCase - нытья про долгий старт быть не должно, как про суммарную скорость выполнения тестов.

        Ну с одной стороны так, с другой нет. Если брать только скорость, то да, вполне вероятно отказ от pytest пошел бы на пользу, а еще можно было бы использовать setUpClass вместе с setUp было бы вообще быстро. Только в реальности это приводит к тому что появляются тесты которые не проходят с первого раза, это и была одна из проблем. Аргументом обычно тут служит что то в роде: "пишите хорошо, не пишите плохо, хороший разрабочик должен уметь" и мне этот аргумент нравится, но он хорошо работает в маленьких проектах. Когда распространить на всю компанию, где есть и очень опытные разработчики и новички, где есть как внутренние сотрудники, так и внешние подрядчики, становится очень сложно поддерживать любые решения которые требуют большей экспертизы.

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

        По использованию django в статье все выглядит, что пользуются в основном drf, мучаются, но продолжают грызть юзать, опять же, без особого понимания: Там кое-где фильтры прям в коде проскакивали, аж больно смотреть.

        И вот тоже интересный момент. С одной стороны да, клево когда встречаешь код, где все проверки доступа в permisssion_classes, а вся фильтрация в filter_backends или filterset_class, но для меня всегда была главной сложностью подгонка задачи под эти либы и подходы. Я не спроста написал именно подгонка задачи, потому что сложность появляется не там где мы просто получаем список, а когда к этому добавляются какие то не стандартные фильтры, или проверки. Чем больше задача отходит от простых CRUD операций тем сложней использовать любые generic подходы, проще фильтрацию в filter_queryset засунуть. С другой стороны это все отлично работает на простых CRUD операциях, там мы от этого не уходим и стараемся делать по канонам.


        1. danilovmy
          25.04.2024 16:03
          +2

          Привет, спасибо за спасибо. Более чем согласен, что стандарты и контекст организации на проект влияют сильно больше, чем условности использования выбранного Framework. И менять стандарты это точно не вариант, принимаю полностью.

          Я просто уже много лет докладываюсь на конференциях о том, что стоит прекратить писать на Python в Django-проектах, и стоит начать писать на Django и будет всем счастье. Все потому, что в 99% случаев в Django-проектах я вижу в способы написать код по-своему, хотя:

          1. В Django это уже есть. Достаточно сделать один import.

          2. Благодаря community это протестировано.

          3. Часто (не всегда) это в Django написано лучше или буквально так же. Последнее прям боль какая-то. DRY весь промок от слез.

          Все, что мне остается, это сушить DRY и писать комментарии в стиле: RTFM! В смысле, посмотрите в Django, там уже это есть начиная с версии 0.9 :'(


    1. pgubarev Автор
      25.04.2024 16:03
      +1

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


  1. Suor
    25.04.2024 16:03
    +1

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


    1. pgubarev Автор
      25.04.2024 16:03
      +1

      На самом деле не только для упрощения моков, просто в статье на это упор, как на проблему актуальную для всех. Другие плюшки это:

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

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