Testing code

Тестирование — противоречивая тема. Люди крепко держатся за свои убеждения относительно подходов к тестированию. Разработка через тестирование — самый яркий пример. Нехватка чётких эмпирических данных провоцирует людей на громкие заявления. Я выступаю за экономический взгляд на тестирование. Далее, я утверждаю, что чрезмерная сосредоточенность на модульных тестах — не самый экономичный подход. Такую философию я буду называть бережливым тестированием.

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

Уверенность и тесты

В статье “Write tests. Not too many. Mostly integration” и сопутствующем видео Кента С. Доддса хорошо выражены идеи бережливого тестирования. Он приводит три измерения, по которым можно оценить тест:

  • Стоимость (дешёвый или дорогой)
  • Скорость (быстрый или медленный)
  • Уверенность (низкая или высокая, то есть «не работает щелчок мышью» или «не работает оформление заказа»)

А это — «кубок тестирования», показывающий, как Доддс предлагает распределить отведённые на тестирование ресурсы.

В отличие от «пирамиды тестирования» Фаулера, добавлено новое измерение — уверенность. Другое различие в том, что модульные тесты не занимают наибольшую площадь.

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

«Чем больше тесты похожи на то, как используется приложение, тем больше уверенности они могут вам дать».

Окупаемость инвестиций в тестирование

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

К тому же сквозное тестирование проверяет критические пути, по которым следуют ваши реальные пользователи. Модульные же тесты могут проверять исключительные случаи, которые на практике происходят очень редко или никогда. Возможно, отдельные части приложения работают, но приложение в целом — нет. Вышеприведённые аргументы можно найти в статье Мартина Сустрика “Unit Test Fetish”.

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

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


Отступление: терминология

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

В контексте React в статье Кента С. Доддса под интеграционными тестами понимается отказ от использования неглубокой отрисовки. Интеграционный тест покрывает много компонентов сразу. Такой тест легче написать, и он более стабилен, потому что не требует такого количества заглушек, и менее вероятно, что вам придётся тестировать особенности внедрения.

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


Покрытие кода

Другой момент: покрытию кода свойственна убывающая выгода. На практике большинство с этим согласно, так как во многих проектах устанавливается нижний порог покрытия в 80%. Даже есть исследования, которые подтверждают это, например “Exploding Software-Engineering Myths”. Далее приведены общие рассуждения.

Даже со 100-процентным покрытием кода вы доверяете зависимым объектам. Для них, по идее, покрытие кода может быть и нулевым.

Для многих продуктов считается приемлемым, когда типовые случаи работают, а необычные — нет). Если из-за низкого покрытия кода пропустить баг, актуальный в исключительных случаях и затрагивающий 0,1% ваших пользователей, вы, наверное, не умрёте. А вот если у вас увеличится время вывода на рынок из-за высоких требований к покрытию кода, это может быть смертельно. И ещё: «то, что вы запустили функцию или запустили строку, не значит, что она будет работать для разрешённого диапазона ввода».

Качество кода и модульные тесты

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

В статье “Unit Test Fetish” говорится, что модульные тесты — противоархитектурное приспособление. Именно благодаря архитектуре ПО поддаётся изменению. Модульные тесты делают внутреннюю структуру кода косной. Вот пример:

Представьте, что у вас три компонента, A, B и C. Вы написали исчерпывающий набор модульных тестов для их проверки. Позже вы решаете преобразовать архитектуру так, чтобы функциональность B распределилась между A и C. Теперь у вас два новых компонента с разными интерфейсами. Все ваши модульные тесты внезапно утратили смысл. Часть тестового кода, возможно, получится использовать, но набор тестов в целом придётся переписывать.

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

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

Статья “Test-induced Design Damage” Дэвида Хайнмейера Ханссона утверждает, что ради соответствия задачам модульного тестирования код ухудшают больше ни для чего не нужной косвенной адресацией. Вопрос в том, всегда ли предпочтительны лишняя косвенная адресация и расцепленный код. Разве они ничего не стоят? Пусть вы расцепили два компонента, которые всегда используются совместно. Стоило ли их расцеплять? Можно утверждать, что косвенная адресация всегда уместна, но невозможно, по крайней мере, отрицать, что становится сложнее ориентироваться внутри кодовой базы и во время прогона.

Вывод

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

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

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


Примечания

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

Нужно подчеркнуть: низкое покрытие кода не подразумевает, что багов будет меньше. Как сказал покойный Дейкстра (1969):

Тестирование обнаруживает присутствие, а не отсутствие багов.

Есть исследования, где обнаружилось, что разработка через тестирование (TDD) не улучшает показатели сцепленности и связности. TDD и модульные тесты — не одно и то же, но в контексте данной статьи это всё же интересно: “Does Test-Driven Development Really Improve Software Design Quality?” («Действительно ли разработка через тестирование повышает качество проектирования ПО?»). В другой статье, “Unit Testing Doesn’t Affect Codebases the Way You Would Think” («Модульное тестирование влияет на кодовые базы не так, как вы думаете»), анализируются кодовые базы и обнаруживается, что код с большим количеством модульных тестов имеет большую цикломатическую сложность для каждого метода, больше строк кода на метод и такую же глубину вложенности.

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

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

Перевод статьи Eugen Kiss: “Lean Testing or Why Unit Tests are Worse than You Think