Речь пойдет о тестировании. О культуре, которая меняет качество продукта.
В этом материале обсуждается:
Как превратить автотесты в живую документацию(как техническую, так и аналитическую).
Как сделать так, чтобы они рассказывали о предметной области.
И в конце концов, проверяли, что все это работает.
Введение. Как появился данный материал
Я решил поделиться с коллегами некоторыми фишками, которыми пользуюсь при разработке тестов в проекте.
Первый черновик статьи мне не понравился. Никак не мог найти причину почему делюсь этим. И в один вечер, наконец, понял... Весь мой путь в программировании — это путь к разработке через тестирование. Я совершал множество ошибок в этом направлении, и еще множество совершу. Но сегодня речь не про ошибки. Сегодня, я хочу поделиться причиной: почему вообще считаю это важным.
Зачем вообще нужны автотесты?
Современный цикл разработки качественного ПО выглядит примерно следующим образом:
Анализ, сбор требований.
Договор о требованиях с владельцами продукта или заказчиками.
Реализация.
Заказчик и разработчик подтвердили соответствие продукта требованиям.
Переход к разработке следующего функционала.
К сложным проектам предъявляется множество требований. Неизбежны ситуации, когда ошибку удалось отследить лишь через время. И цена такой ошибки — стоимость нескольких дней работы; нескольких специалистов.
Чтобы минимизировать такие риски, мы используем три ключевых метода:
Моделирование предметной области. Позволяет создать единую модель бизнес-процессов, которая служит источником истины для всех участников проекта.
Документация. Даёт аналитикам и пользователям чёткое понимание, как система работает в настоящий момент.
Автотесты. Проверяют, что новые доработки и изменения не нарушили существующий функционал.
Однако само по себе использование этих методов не гарантирует успеха. На практике разработка часто сталкивается с классическими препятствиями, которые снижают эффективность методов:
Коммуникационный барьер. Аналитики и разработчики часто говорят на разных языках.
Устаревание документации. Отстающие фрагменты документации могут долго оставаться незамеченными, дезориентируя команду.
Устаревание автотестов. Новые разработчики удаляют прежние тесты, только для того, чтобы проект не выдавал ошибок при сборке.
Предлагаю разобрать методологию, которая была призвана бороться с данными препятствиями — BDD.
Что такое BDD
BDD (Behavior-Driven Development) — это методология разработки ПО, которая фокусируется на взаимодействии между разработчиками, тестировщиками и бизнес-аналитиками. Основная цель BDD — улучшить понимание требований к системе и обеспечить более качественное взаимодействие между всеми участниками процесса разработки.
Как описать поведения системы
При разработке по методологии BDD используется язык Gherkin, который строится на нескольких ключевых конструкциях: «Feature» — для описания функционала, «Scenario» — для определения сценария, а также шаги «Given», «When» и «Then» — для описания предусловий, действий и ожидаемых результатов в рамках этого сценария.
BDD в действии
Предположим, необходимо разработать «функционал разделения прав доступа по доменам».
Сложность задачи заключается в том, что должны быть учтены не только прямые права пользователя, но и права доменных ролей, к которым он привязан.
Тогда схема связей получается:

Псевдокод реализации функции:
def get_user_permissions(user_id: UUID4, domain_id: UUID4):
return [
*формирование объекта*
for permission in
(
Permission
.select()
.distinct()
.join(RolePermission, *условия*)
.join(UserRole, *условия*)
.join(RoleDomain, *условия*)
.join(UserDomain, *условия*)
.where(*условия*)
)
]
Предлагаю мысленно представить, как расписываете условия.
Цепочка джоинов на мой взгляд — один из не многочисленных случаев, когда чтение кода проще, если все собрано в одном месте. И как бы я не пытался отрефакторить эту функцию, ничего толкового не выходило.
Теперь мы хотим проверить, что все работает. Тесты:
def test_set_permissions_before_domain_association(user: User, domain: Domain):
"""Тест установки прав до присоединения пользователя к домену."""
# Первая установка прав
_set_domain_permissions(user.id, domain.id, DomainPermissions())
_assert_permissions_equal(user.id, domain.id, DomainPermissions())
# Вторая установка прав (должна перезаписать предыдущие)
read_permissions = DomainPermissions(object={ObjectAction.READ: True})
_set_domain_permissions(user.id, domain.id, read_permissions)
_assert_permissions_equal(user.id, domain.id, DomainPermissions())
def test_permissions_after_domain_association(user: User, domain: Domain):
"""Тест прав после присоединения пользователя к домену."""
# Устанавливаем права и добавляем пользователя в домен
read_permissions = DomainPermissions(object={ObjectAction.READ: True})
_set_domain_permissions(user.id, domain.id, read_permissions)
_add_user_to_domain(user.id, domain.id)
expected_permissions = DomainPermissions(
object=_create_action_map({ObjectAction.READ: True})
)
_assert_permissions_equal(user.id, domain.id, expected_permissions)
def test_role_permissions_combined_with_domain(user: User, domain: Domain, role: Role):
"""Тест комбинирования прав роли и домена."""
# Настраиваем пользователя и роль
_add_user_to_domain(user.id, domain.id)
_add_role_to_user(user.id, role.id)
# Устанавливаем права домена и роли
domain_permissions = DomainPermissions(object={ObjectAction.READ: True})
role_permissions = DomainPermissions(user={UserAction.CREATE: True})
_set_domain_permissions(user.id, domain.id, domain_permissions)
_set_role_permissions(role.id, role_permissions)
expected_permissions = DomainPermissions(
object=_create_action_map({ObjectAction.READ: True}),
user=_create_action_map({UserAction.CREATE: True})
)
_assert_permissions_equal(user.id, domain.id, expected_permissions)
# Вспомогательные функции для скрытия деталей реализации
def _set_domain_permissions(user_id: int, domain_id: int, permissions: DomainPermissions):
"""Установить права домена для пользователя."""
permission_service.set_domain_permissions_to_user(
user_id=user_id,
domain_id=domain_id,
permissions_object=permissions,
)
def _set_role_permissions(role_id: int, permissions: DomainPermissions):
"""Установить права для роли."""
permission_service.set_permissions_to_role(
role_id=role_id,
permissions_object=permissions,
)
def _add_user_to_domain(user_id: int, domain_id: int):
"""Добавить пользователя в домен."""
domain_service.add_domain_to_user(user_id=user_id, domain_id=domain_id)
def _add_role_to_user(user_id: int, role_id: int):
"""Добавить роль пользователю."""
role_service.add_role_to_user(user_id=user_id, role_id=role_id)
def _assert_permissions_equal(user_id: int, domain_id: int, expected_permissions: DomainPermissions):
"""Проверить, что права пользователя соответствуют ожидаемым."""
actual_permissions = get_user_domain_permissions(
user_id=user_id,
domain_id=domain_id,
)
assert actual_permissions.model_dump() == expected_permissions.model_dump()
def _create_action_map(enabled_actions: dict) -> dict:
"""Создать карту действий с указанными включенными правами."""
action_type = next(iter(enabled_actions.keys())).__class__
return {
action: action in enabled_actions
for action in dict.fromkeys(action_type, False)
}
Кто-нибудь вообще понял, что тут происходит?
Если вас смутило отсутствие этапа прорабатки требований к функции и тестам, примите мои поздравления, вы внимательны. Я намеренно это допустил, чтобы продемонстрировать случай на практике, когда задача кажется понятной и простой. И реализация складывается на лету.
Запуск. Результат теста: Успешно.
Затем, запускаем BDD тест:
Feature: Управление правами доступа пользователей в доменах
# Как система управления правами доступа,
# я должна гарантировать соблюдение границ пользователей
# внутри и между доменами. И тд.
# Описание может быть развернутое, чтобы от лица фичи
# лучше понимать ее зоны ответственности.
@roles @domains @permissions
Scenario: Пользователь с ролью на создание тестов в другом домене
Given я авторизован как пользователь "bddtest_user_ab"
And у пользователя "bddtest_user_ab" назначена роль "bdd_role_a"
When создаю тест "bddtest_test_b" в требовании "bddtest_req_b" с подключением "bddtest_conn_b"
Then должен получить ошибку доступа "Нет права на создание сущности в домене."
Запуск. Результат теста: Ошибка.
Причина ошибки в отсутствии изоляции между доменами. В блоке where или в одном из join я забыл добавить проверку на принадлежность одного из объектов к домену.
Что показывает пример
В примере иллюстрируется два варианта написания тестов:
Тестирование технических требований (Интеграционные тесты). Фокусируются на корректности работы отдельных связей между модулями и проверяют выполнение определённых технических требований.
Тестирование сценариев (Сквозные тесты). Имитируют действия реального пользователя, проверяя, что вся система в целом работает корректно для достижения конкретной бизнес-цели.
И это не просто разные уровни тестирования. Это разные подходы. Нам нужны оба. Первый проверяет, что внутри все работает согласно нашим требованиям(например: при неожиданном использовании API). Второй проверяет, как сценарии в общем, так и конкретные бизнес-требования описанные в сценариях.
Сценарии, написанные простым языком, позволяют эффективнее находить контрпримеры, крайние случаи и избегать пробелов в логике.
Но юнит и интеграционные тесты остаются нужны.
Связь модели и тестов
Чтобы меньше держать в голове и выполнять мыслительные операции с большей точностью, необходимо возвести стены абстракций, за которыми будут прятаться мелкие детали. Идея в общем-то приходит из ООП. Но есть несколько нюансов, которые стоит проговорить.
Модель, как основной источник истины
Cуществует методология DDD, предполагающая вовлечение как можно большего числа разработчиков* продукта, за счет обмена информацией о предметной области с помощью общих моделей. Благодаря чему всем участникам процесса видно направление движения. Разработчики предупреждены о возможном появлении новых объектов системы. А объекты, создающие ненужные ответвления, замечаются раньше.
* Под разработчиками, я понимаю всех участников, которые вносят свой вклад: аналитики, специалисты по качеству, программисты.
Задача в том, чтобы разрабатывать общую модель, которая будет держаться в голове у всех участников разработки.
Наблюдение №1
Чтобы избежать дублей и упростить поиск по системе — очень важно давать объектам понятные наименования.
Не стоит чураться англицизмов или не существующих слов. Слова из разговорной речи подойдут лучше, чем что-то заумное.
Наблюдение №2
Начните глоссарий с самых простых, частоиспользуемых общепринятых терминов.
Команда сэкономит время на митингах, если у большинства простых терминов будет единозначная трактовка. Например, слово: "модуль" или "представление". Сколько ассоциаций оно у вас вызвало? И у каждого члена команды будут свои.
Эволюция
Вы наверное замечали, что на старте проекта сложно придумать архитектурную модель. Ну или вернее сказать, у нее мало уникальных черт, за что ее можно считать настоящей моделью. Но постепенно, проект обрастают и внутренней терминологией, и уникальными чертами моделей. Каждый такой эволюционный шаг очень важен, так как создает ветвь развития проекта. Несколько упущенных шагов не в ту сторону, создают совершенно иной от задуманного проект.

Влияние на цикл разработки
Зачем производить рефакторинг?
Модель меняется, отрываются и удаляются старые связи, что-то пересматривается. Проект обрастает внутренней терминологией, что делает его лучше. И терминология, используемая на технической стороне, начинает отставать от модели. Нужно, проводить актуализацию. Это становится частью процесса, а не праздностью разработчика.
Если актуализацию не производить, программистам становится сложно. Они теряют вовлеченность в процесс. Их перестает интересовать проект. Вероятность, что они создадут ту самую, не нужную ветвь повышается. Если делаешь то, что нужно и получается хорошо, то энергия прибавляется. Если не получается или пришлось сделать шаг назад, то энергия была потрачена.
Так увеличивается время разработки.
Но как производить рефакторинг? Если у вас нет автотестов, вероятнее всего: долго и дорого.
Написание автотестов
Далее опишу три главные на мой взгляд причины написания автотестов.
Быстрая проверка разрабатываемой функции
Отсутствие автоматизированных тестов на стороне фронтенда вынуждает разработчиков вручную проверять сценарии через пользовательский интерфейс. Этот процесс отнимает значительное время и подвержен человеческой ошибке, особенно при монотонной повторной проверке. Уставший или перегруженный специалист может пропустить самопроверку и отдать задачу в тестирование, полагаясь лишь на свою интуицию. В результате количество дефектов, обнаруживаемых на этапе тестирования, возрастает, что приводит к увеличению времени на доработку и снижению общей скорости разработки.
Со стороны бэкенда ситуация может казаться лучше благодаря использованию инструментов вроде Postman. Однако и здесь сохраняется ключевая проблема: такие инструменты предоставляют лишь сырые данные, но не дают однозначного вердикта о корректности работы системы. Ответственность за анализ и выводы по-прежнему лежит на человеке, что оставляет пространство для ошибки.
Опора для рефакторинга
Часто можно услышать, что при рефакторинге тесты всё равно переписываются, поэтому их ценность снижается. Да, тесты для изменённой логики придётся обновить. Но как быть с остальными частями системы, которые не должны были пострадать? На практике даже малое изменение может неожиданно сломать отдаленный компонент.
Несмотря на тестирование всех уязвимых мест, дефект обнаружился со временем в непредсказуемом участке приложения.
Ловить себя на мысли «кто же мог это предвидеть?» — верный признак того, что в кодовой базе не хватает автоматических проверок.
Моделирование через написание тестов
Для меня одно из ключевых преимуществ разработки через тестирование, это раннее выявление проблем архитектуры.
Количество деталей, которое человек может учитывать при проектировании, ограничено. Это приводит к возникновению тех же ошибок как при рефакторинге и расширении бизнес-логики.
Со мной часто случалось так: при проектировании функционала я был уверен, что предусмотрел все детали и их идеальное взаимодействие. Но спустя несколько дней разработки я сталкиваюсь с тем, что не хватает каких-то объектов или связей. В результате всё проектирование приходится начинать заново, потому что внедрение необходимых элементов делает первоначальную архитектуру неудобной.
При проектировании через тесты необязательно сразу писать детальную реализацию. Достаточно создать тесты, которые определяют интерфейсы и контракты будущих объектов: их зоны ответственности, ожидаемые аргументы и свойства. Такой подход уже на раннем этапе помогает выявить пробелы в проектировании и недостающие детали.
Могут ли тесты помочь в ревью кода?
Думаю, могут. Однако есть важные нюансы. Чтобы тесты стали реальным подспорьем, ревьюеры должны:
Понимать, как вы пишете тесты.
Знать, как их правильно читать.
И, что главное, — уделять им должное внимание.
Есть несколько видов тестов, которые я пишу:
Spec-тесты (Тесты-спецификации). Они работают как живая документация, показывая другим разработчикам, как следует работать с объектами. Особенно важны при разработке core компонентов, используемых в разных частях системы. При ревью такой тест — это наглядный пример, который сразу отвечает на вопрос: «Будет ли этим функционалом удобно пользоваться?»
Тесты бизнес-логики. Они проверяют основные сценарии, кейсы использования и крайние случаи. К сожалению, их часто бывает сложно читать из-за большого объема setup'а, поэтому ревьюеры нередко пропускают их.
Нагрузочные тесты. Их чтение редко бывает необходимым во время стандартного код-ревью.
Писать тесты должно быть удобно
Проще пройти сценарий через интерфейс приложения, чем написать автотест. Знакомая ситуация?
С высокой вероятностью, вы не будете создавать автотесты, пока не разберетесь как упростить их написание и запуск.
Нестандартный подход к e2e
Я и мои коллеги сталкивались со сложностями поддержки Gherkin. Что приводило к отказу от этого языка. Оба моих примера, это e2e тестирование проекта через API браузера. Постараюсь объяснить почему это проблема.
У меня был опыт разработки нескольких веб-скрейпинг(автоматизированное извлечение данных с веб страниц) проектов, посредством тех же headless браузеров. Процесс скрапинга похож на написание e2e: он производит имитацию действий пользователя и поиск элементов на странице по CSS-селекторам или XPath. Любое, даже самое незначительное изменение в верстке или логике фронтенда основного проекта приводит к поломке скраперов.
Вышеизлошенные нюансы требуют от специалистов по качеству не просто знание специальных языков для сбора данных, но и иметь представление об устройстве самого проекта, чтобы в дальнейшем вносить меньше изменений. Вести отдельный проект по тестированию поверх основного.
В моих примерах, один-два клафицированных специалиста не в состоянии поддерживать такую систему.
Альтернативы
Для себя я сделал однозначный вывод: e2e-тестирование через скрапинг фронтенда не оправдано для большинства малых и средних проектов.
Исходя из посыла: «Писать тесты должно быть удобно», могу ввести несколько предложений:
Смесь e2e с интеграционными тестами. Разработчики предоставляют аналитикам и специалистам по качеству доступ к файлам, в которых описываются сценарии на языке Gherkin. Проводят ревью сценариев, дополняют. Так и расширяется модель со стороны разработки. Затем разработчики самостоятельно пишут реализации интеграционных тестов на описанные сценарии.
Тестирование контрактов между клиентами и API. Проблема несогласованности частей приложения входит в мои топ-3 проблем. К тому же, поиск причин такого рода дефектов обычно занимает больше времени, потому что код работает казалось бы так, как было задумано.
Разработка e2e тестирования через скриншоты и снимки из Figma. Такого опыта у меня нет. Очень хочу когда-нибудь прийти и к этому методу.
Пример: как в Python внедряется BDD
В Python есть более 3х популярных библиотек для BDD тестов. Мой выбор: pytest_bdd.
Одна из самых сильных сторон pytest_bdd — это возможность постепенного внедрения. Нет необходимости переписывать старые тесты.
Ранее написанные pytest тесты продолжают выполняться, для них ничего не меняется.
Пример связывания сценария Gherkin с тестовой функцией:
from pytest_bdd import scenario
def test_create_purchase():
# Обычные тесты продолжают работать как прежде.
purchase = create_purchase(product_id=product.id)
assert purchase.id is not None
# Декоратор @scenario выполняет связывание:
# 1. Ищет файл 'create_purchase.feature'
# 2. Внутри него находит сценарий с именем "Покупка со скидкой"
# 3. Связывает шаги этого сценария с реализующими их функциями (используя фикстуры pytest-bdd)
@scenario('create_purchase.feature', 'Покупка со скидкой')
def test_create_purchase_with_discount_applied():
# Тело функции пустое, так как вся логика теста
# описана в шагах сценария и их реализациях.
# Само название этой функции не используется для связывания.
pass
Организация фича-файлов:
Стандартная практика: Явное указание пути к фича-файлу предотвращает недопонимание при повторяющихся названиях сценариев.
Автоматизация: Можно настроить автоматическое создание всех тестовых функций сценариев.
Вывод отчетов
pytest_bdd предоставляет следующие возмозможности для вывода:
Текстовый вывод в терминал.
JSON вывода, для экспорта в системы отчетности и мониторинга.
Заключение
Надеюсь, я кого-нибудь натолкнул на хорошие идеи.