[Перевод] Антипаттерны тестирования ПО


Есть несколько статей об антипаттернах разработки ПО. Но большинство из них говорят о деталях на уровне кода и фокусируются на конкретной технологии или языке программирования.

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


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

b6d7a83ed09e1a8046529bebfe02836e.png


Если не видели пирамиду тестирования, настоятельно рекомендую ознакомиться с ней. Вот некоторые хорошие статьи для начала:
Пирамида тестирования сама заслуживает отдельного обсуждения, особенно по количеству тестов, необходимых для каждой категории. Здесь я просто ссылаюсь на неё, чтобы определить две низшие категории тестов. Обратите внимание, что в этой статье не упоминаются тесты пользовательского интерфейса (верхняя часть пирамиды) — в основном, для краткости и потому что у них собственные специфические антипаттерны.

Поэтому определим две основные категории тестов: модульные (юнит-тесты) и интеграционные.

Тесты Цель Требует Скорость Сложность Нужна настройка
Юнит-тесты класс/метод исходный код очень быстро низкая нет
Интеграционные тесты компонент/сервис часть работающей системы медленно средняя да


Юнит-тесты более широко известны как по названию, так и по своему значению. Эти тесты сопровождают исходный код и обладают прямым доступ к нему. Обычно они выполняются с помощью фреймворка xUnit или аналогичной библиотеки. Юнит-тесты работают непосредственно на исходнике и имеют полное представление обо всём. Тестируется один класс/метод/функция (или наименьшая рабочая единицей для этой конкретной функциональности), а всё остальное имитируется/заменяется.

Интеграционные тесты (также именуемые сервисными тестами или даже компонентными тестами) фокусируются на целом компоненте. Это может быть набор классов/методов/функций, модуль, подсистема или даже само приложение. Они проверяют компонент путём передачи ему входных данных и изучения выдачи. Обычно требуется какое-то предварительное развёртывание или настройка. Внешние системы можно полностью имитировать или заменить (например, используя СУБД в памяти вместо реальной), а реальные внешние зависимости используются по ситуации. В сравнении с юнит-тестами требуются более специализированные инструменты либо для подготовки тестовой среды, либо для взаимодействия с ней.

Вторая категория страдает от размытого определения. Именно здесь больше всего споров о названиях. «Область» интеграционных тестов также весьма противоречива, особенно по характеру доступа к приложению (тестирование в чёрном или белом ящике; разрешены mock-объекты или нет).

Основное практическое правило таково: если тест…

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


… то это интеграционный, а не модульный тест.

Разобравшись с терминами, можно погрузиться в список антипаттернов. Их порядок примерно соответствует их распространённости. Самые частые проблемы перечислены в начале.


  1. Модульные тесты без интеграционных
  2. Интеграционные тесты без модульных
  3. Неправильный тип тестов
  4. Тестирование не той функциональности
  5. Тестирование внутренней реализации
  6. Чрезмерное внимание покрытию тестами
  7. Ненадёжные или медленные тесты
  8. Запуск тестов вручную
  9. Недостаточное внимание коду теста
  10. Отказ писать тесты для новых багов из продакшна
  11. Отношение к TDD как к религии
  12. Написание тестов без предварительного чтения документации
  13. Плохое отношение к тестированию по незнанию


Эта классическая проблема для малых и средних компаний. Для приложения создаются только юнит-тесты (основание пирамиды) — и больше ничего. Обычно отсутствие интеграционных тестов вызвано одной из следующих проблем:

  1. У компании нет разработчиков-сеньоров. Есть только джуниоры, только что окончившие колледж. Они встречали лишь модульные тесты.
  2. В какой-то момент интеграционные тесты существовали, но от них отказались, потому что они вызывали больше проблем, чем приносили пользы. Юнит-тесты гораздо проще в обслуживании, поэтому оставили только их.
  3. Рабочая среда приложения слишком «сложна» для настройки. Характеристики «испытаны» в продакшне.


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

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

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

Но почему вообще интеграционные тесты так важны?

Дело в том, что некоторые типы проблем могут обнаружить только интеграционные тесты. Канонический пример — всё, что связано с операциями СУБД. Транзакции, триггеры и любые хранимые процедуры БД можно проверить только с помощью интеграционных тестов, которые их затрагивают. Любые подключения к другим модулям, разработанным вами или внешними командами, требуют интеграционных тестов (они же контрактные тесты). Любые тесты для проверки производительности, являются интеграционными тестами по определению. Вот краткий обзор того, почему нам нужны интеграционные тесты:

Тип проблемы Определяется юнит-тестами Определяется интеграционными тестами
Основная бизнес-логика да да
Проблемы интеграции компонентов нет да
Транзакции нет да
Триггеры/процедуры БД нет да
Неправильные контракты с другими модулями/API нет да
Неправильные контракты с другими системами нет да
Производительность/таймауты нет да
Взаимные/самоустраняемые блокировки возможно да
Перекрёстные проблемы безопасности нет да


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

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


Это противоположность предыдущему антипаттерну. Она чаще встречается в крупных компаниях и больших корпоративных проектах. Почти всегда такая ситуация связана с разработчиками, которые считают, что юнит-тесты не имеют реальной ценности, а отловить регрессии способны лишь интеграционные тесты. Многие опытные разработчики считают модульные тесты пустой тратой времени. Обычно если их порасспрашивать, то обнаружится, что когда-то в прошлом менеджеры потребовали увеличить покрытие кода тестами (см. антипаттерн 6) и заставили их писать тривиальные юнит-тесты.

Действительно, теоретически в проекте могут быть только интеграционные тесты. Но на практике такое тестирование очень дорого обойдётся (по времени как разработки, так и сборки). В таблице из предыдущего раздела мы видели, что интеграционные тесты тоже могут находить ошибки бизнес-логики и поэтому способны «заменять» модульные тесты. Но жизнеспособна ли такая стратегия в долгосрочной перспективе?

Интеграционные тесты сложны


Рассмотрим пример. Предположим, что у нас сервис с четырьмя такими методами/классами/функциями.

602de3040a2fc48bb331d4e658ba540e.png


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

Разработчик Мэри действует по учебнику и хочет написать юнит-тесты для этого сервиса (потому что она понимает важность юнит-тестов). Сколько тестов нужно написать, чтобы полностью охватить все возможные сценарии?

Очевидно, можно написать 2+5+3+2 = 12 изолированных юнит-тестов, которые полностью охватывают бизнес-логику этих модулей. Помните, что это количество только для одного сервиса, а у приложения, над которым работает Мэри, несколько сервисов.

Разработчик Джо «Ворчун» не верит в ценность юнит-тестов. Он считает, что это пустая трата времени, и решает писать только интеграционные тесты для данного модуля. Сколько? Он начинает смотреть на все возможные маршруты через все части сервиса.

bbb4c9d1b5fe12b0bc838b54209e1608.png

Опять же, должно быть очевидно, что возможно 2×5*3×2 = 60 маршрутов кода. Значит ли это, что Джо на самом деле напишет 60 интеграционных тестов? Конечно нет! Он будет хитрить. Сначала попробует выбрать подмножество интеграционных тестов, которые кажутся «репрезентативными». Это «репрезентативное» подмножество обеспечит достаточное покрытие с минимальным количеством усилий.

В теории всё просто, но на практике быстро возникнет проблема. В реальности эти 60 сценариев создаются не одинаково. Некоторые из них — пограничные случаи. Например, через модуль C проходит три маршрута кода. Один из них — очень частный случай. Его можно воссоздать только если C получит специфические входные данные из компонента B, который сам по себе является пограничным случаем и может быть получен только специальными входными данными из компонента A. Значит, этот конкретный сценарий может потребовать очень сложной настройки для выбора входных данных, которые вызовут специальное условие на компоненте C.

С другой стороны, Мэри просто воссоздаст пограничный случай с помощью простого модульного теста без дополнительной сложности.

13a3390e06302b257fdce580943044db.png


Значит, Мэри будет писать только юнит-тесты? В конце концов, это приведет ее к антипаттерну 1. Чтобы избежать этого, она напишет и модульные, и интеграционные тесты. Сохранит все модульные тесты для реальной бизнес-логики, а затем напишет один-два интеграционных теста для проверки, что остальная часть системы работает должным образом (т. е. части, которые помогают этим модулям выполнять свою работу).

Интеграционные тесты должны сосредоточиться на остальных компонентах. Саму бизнес-логику могут обрабатывать юнит-тесты. Интеграционные тесты Мэри сфокусируются на сериализации/десериализации, коммуникациях с очередью и БД системы.

437408d6106e7decd4c4d542e1358947.png


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

Интеграционные тесты медленные


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

Чтобы получить представление о разнице по времени, предположим следующие цифры.

  • Каждый юнит-тест занимает 60 мс (в среднем).
  • Каждый интеграционный тест занимает 800 мс (в среднем).
  • В приложении 40 сервисов, как показано в предыдущем разделе.
  • Мэри пишет 10 модульных тестов и 2 интеграционных теста для каждого сервиса.
  • Джо пишет 12 интеграционных тестов для каждого сервиса.


Теперь посчитаем. Обратите внимание, что Джо якобы нашёл идеальное подмножество интеграционных тестов, которые дают то же покрытие кода, что у Мэри (в реальности будет не так).

Время выполнения Имея только интеграционные тесты (Джо) Имея юнит-тесты и интеграционные тесты (Мэри)
Только юнит-тесты N/A 24 секунды
Только интеграционные тесты 6,4 минуты 64 секунды
Все тесты 6,4 минуты 1,4 минуты


Разница в общем времени работы огромна. Ожидание одной минуты после каждого изменения кода значительно отличается от ожидания целых шести минут. И предположение в 800 мс для каждого интеграционного теста чрезвычайно консервативно. Я видел наборы интеграционных тестов, где один тест занимает несколько минут.

Подводя итог, попытка использовать только интеграционные тесты для покрытия бизнес-логики — огромная потеря времени. Даже если автоматизировать тесты с помощью CI, цикл обратной связи (от коммита до получения результата теста) всё равно очень долгий.

Интеграционные тесты сложнее отладить, чем модульные тесты


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

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

Ваш коллега (или вы) присылает новый коммит, который инициирует запуск интеграционных тестов со следующим результатом:

05caaca5a8b0a49df7d2ac518debca0a.png


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

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

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

400fc300b1da49041a2248faf37796b4.png


Теперь не прошли два теста:

  • «Клиент покупает товар» не проходит как раньше (интеграционный тест).
  • «Тест специальной скидки» тоже не проходит (юнит-тест).


Теперь очень легко понять, где искать проблему. Можно перейти непосредственно к исходному коду функциональности скидки, найти ошибку и исправить её — и в 99% случаев интеграционный тест тоже будет отлажен.

Сбой юнит-теста до или вместе с интеграционным — гораздо более безболезненный процесс, когда нужно найти ошибку.

Краткий вывод, зачем нужны юнит-тесты


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

  1. Юнит-тесты легче поддерживать.
  2. Юнит-тесты легко воспроизводят пограничные случаи и редкие ситуации.
  3. Юнит-тесты выполняются гораздо быстрее интеграционных тестов.
  4. Сбойные юнит-тесты легче исправить, чем интеграционные.


Если у вас есть только интеграционные тесты, то вы впустую тратите и время разработки, и деньги компании. Нужны как модульные, так и интеграционные тесты одновременно. Они не взаимоисключающие. В сети есть несколько статей, которые пропагандируют использование только одного типа тестов. Все эти статьи распространяют дезинформацию. Печально, но это так.
Теперь мы поняли, почему нужны оба вида тестов (модульные и интеграционные). Нужно определиться, сколько тестов нужно в каждой категории.

Здесь нет твёрдого и чёткого правила. Всё зависит от вашего приложения. Важно понять, что придётся потратить некоторое время, чтобы понять, какой тип тестов более ценный для вашего приложения. Тестовая пирамида — лишь предположение о количестве тестов. Она предполагает, что вы пишете коммерческое веб-приложение, но это не всегда так. Рассмотрим несколько примеров:

Пример: утилита командной строки Linux


Ваше приложение — утилита командной строки. Он берёт файлы одного формата (например, CSV), выполняет некоторые преобразования и экспортирует в другой формат (например, JSON). Приложение автономное, не общается ни с какими другими системами и не использует сеть. Преобразования представляют собой сложные математические процессы, которые имеют решающее значение для правильной работы приложения (они всегда должны выполняться правильно, независимо от скорости работы).

Что нужно для такого примера:

  • Множество юнит-тестов для математических вычислений.
  • Некоторые интеграционные тесты для чтения CSV и записи JSON.
  • Никаких тестов GUI, потому что графический интерфейс отсутствует.


Вот как выглядит «пирамида» тестирования для такого проекта:

b809f9da545bbc8189f1466f6cf92f72.png


Тут доминируют юнит-тесты, а получившаяся форма не является пирамидой.

Пример: управление платежами


Вы добавляете новое приложение, которое внедрится в большую коллекцию существующих корпоративных систем. Приложение представляет собой платёжный шлюз, который обрабатывает платёжную информацию для внешней системы. Это новое приложение должно вести журнал всех транзакций во внешней БД, оно должно общаться с внешними платёжными провайдерами (Paypal, Stripe, WorldPay и др.), а также отправлять платёжные данные в ещё одну систему, которая выписывает счета.

Что нужно для такого примера:

  • Почти никаких юнит-тестов, потому что нет бизнес-логики.
  • Много интеграционных тестов для внешних коммуникаций, хранилища БД, системы выставления счетов.
  • Никаких тестов GUI, потому что графический интерфейс отсутствует.


Вот как выглядит «пирамида» тестирования для такого проекта:

214a3c775fc47a82731b806731d7e558.png


Интеграционные тесты тут доминируют, а получившаяся форма опять не является пирамидой.

Пример: конструктор сайтов


Вы работаете над совершенно новым стартапом, который разработал революционный способ создания сайтов: единственный в своем роде конструктор веб-приложений в браузере.

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

Что нужно для этого надуманного примера:

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


Вот как выглядит «пирамида» тестирования для такого проекта:

44365589580fb8428873e47100cab4f0.png


Здесь доминируют тесты UI, а получившаяся форма опять не является пирамидой.

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

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


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

Теоретически, конечной целью является покрыть 100% кода. На практике эту цель трудно достичь и она не гарантирует отсутствие багов.

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

Но не всем разработчикам так повезло. В большинстве случаев вы наследуете существующее приложение с минимальным количеством тестов (или вообще без них!). Если вы работает в большой и устоявшейся компании, то работа с legacy-кодом — скорее правило, чем исключение.

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

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

Покрытие кода подробно анализируется в отдельном антипаттерне. В этом же разделе поговорим о «важности» кода и как она связана с тестами.

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

0f543df93fff02f3ad1db75d392ec7d2.png


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

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

  1. Клиенты не могут расплатиться по товарам из корзины, что остановило все продажи.
  2. Клиенты получают неправильные рекомендации при просмотре продуктов.


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

Если обобщить, то в любом среднем/большом приложении рано или поздно у разработчика возникает иное представление кода — ментальная модель.

caae242b0182a0e36ab614260019cb38.png


Здесь показаны три слоя кода, но в зависимости от размера приложения их может быть больше. Это:

  1. Критический код — код с частыми сбоями, куда вносится большинство новых функций и который важен на пользователей.
  2. Основной код с периодическими сбоями, небольшим количеством новых функций и средним влиянием на пользователей.
  3. Другой код с редкими сбоями, малым количеством новых функций и минимальным влиянием на пользователей.


Такую ментальную модель следует держать в голове всякий раз при написании нового теста ПО. Задайтесь вопросом, относится ли функциональность, для которой вы пишете тесты, к критическому или основному коду. Если да, то пишите тест. Если нет, то может есть смысл потратить время на что-нибудь другое (например, на другой баг).

Концепция степени важности кода также хорошо помогает ответить на вечный вопрос: какое покрытие кода достаточно для приложения? Для ответа нужно или самому знать уровни важности кода приложения, или спросить у того, кто знает. Когда вы обладаете этой информацией, то ответ очевиден: попробуйте написать тесты, покрывающие 100% критического кода. Если вы уже сделали это, то попробуйте написать тесты, покрывающие 100% основного кода. Однако не рекомендуется пытаться покрыть тестами 100% общего кода.

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

В общем, пишите модульные и интеграционные тесты для кода, который:

  • часто ломается
  • часто изменяется
  • критичен для бизнеса


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

Неверно! Ещё нужно убедиться, что тесты на самом деле правильно структурированы. Наличие неправильно написанных тестов наносит двойной ущерб:

  • Сначала они тратят драгоценное время разработчика при написании.
  • Затем они тратят ещё больше времени, когда приходится их переделывать (при добавлении новой функции).


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

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

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

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

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

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

Допустим, объект Customer в приложении интернет-магазина выглядит следующим образом:

fc65e99ce172fc46d53e1cb3606624d1.png


Тип Customer принимает только два значения, где 0 означает «гость», а 1 означает «зарегистрированный пользователь». Разработчики смотрят на объект и пишут десять юнит-тестов для проверки «гостей» и десять для «зарегистрированных пользователей». И когда я говорю «для проверки», то имею в виду, что тесты проверяют это конкретное поле в этом конкретном объекте.

Проходит время, и менеджеры принимают решение, что для филиалов необходим новый тип пользователя со значением 2. Разработчики добавляют ещё десять тестов для филиалов. Наконец, добавлен ещё один тип пользователя под названием «premium customer» — и разработчики добавляют ещё десять тестов.

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

e9522b74b6a0fb787113ea9320714a4e.png

Если вы опытный разработчик, то можете представить дальнейшее развитие событий. Приходят новые требования:

  1. Для зарегистрированных пользователей нужно сохранять ещё электронную почту.
  2. Для пользователей в филиалах нужно сохранять ещё название компании.
  3. Премиум-пользователям теперь начисляются бонусные баллы.


Объект клиента изменяется следующим образом:

bf8cc4ba08d7215347db90eed31c95af.png

Теперь у нас четыре объекта, связанные с внешними ключами, а все 40 тестов сразу ломаются, потому что проверяемое ими поле больше не существует.

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

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

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

5dabc4973e6aa23927cfad59a5d4e56d.png

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

При изменении внутренней реализации объекта код верификации тестов остаётся прежним. Может измениться только код настройки для каждого теста, который должен централизованно храниться в одной вспомогательной функции createSampleCustomer() или в чём-то подобном (подробнее см. антипаттерн 9).

Конечно, теоретически сами верифицированные объекты могут измениться. На практике же нереально одновременное изменение loginAsGuest(), register(), showAffiliateSales() и getPremiumDiscount(). В реалистичном сценарии потребуется рефакторинг десяти тестов вместо сорока.

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


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

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

© Habrahabr.ru