Как стать автором
Обновить

Аспекты хороших юнит-тестов

Время на прочтение11 мин
Количество просмотров8.8K

Эта статья является конспектом книги «Принципы юнит-тестирования».

Давайте для начала перечислим свойства хороших юнит-тестов.

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

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

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

Однако распознавание и написание эффективного теста – два разных навыка. И для приобретения второго навыка необходимо сначала освоить первый. Далее в этой статье будет показано, как распознать эффективный тест. Также будет рассмотрена пирамида тестирования и тестирование по принципу «черного ящика» / «белого ящика».

Четыре аспекта хороших юнит-тестов

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

Эти четыре атрибута фундаментальны. Они могут использоваться для анализа любых автоматизированных тестов, будь то юнит-, интеграционные или сквозные (end-to-end) тесты.

Начнем с первого атрибута хорошего юнит-теста: защиты от багов. Баг (или регрессия) — это программная ошибка. Как правило, такие ошибки возникают после внесения изменений в код.

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

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

  • объем кода, выполняемого тестом;

  • сложность этого кода;

  • важность этого кода с точки зрения бизнес-логики.

Как правило, чем больше кода тест выполняет, тем выше вероятность выявить в нем баг. Само собой, тест также должен иметь актуальный набор проверок (assertions).

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

Как следствие, тестирование тривиального кода обычно не имеет смысла. Этот код слишком простой и не содержит сколько-нибудь значительного объема бизнес-логики.

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

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

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

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

Частые ложные срабатывания могут привести к следующим ситуациям:

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

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

Что приводит к ложному срабатыванию?

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

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

Рис. 1 – Тест слева связан с наблюдаемым поведением SUT, а не с деталями реализации. Такой тест более устойчив к рефакторингу, чем тест справа
Рис. 1 – Тест слева связан с наблюдаемым поведением SUT, а не с деталями реализации. Такой тест более устойчив к рефакторингу, чем тест справа

Связь между первыми двумя атрибутами

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

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

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

Рис. 2 - Отношение между защитой от багов и устойчивостью к рефакторингу
Рис. 2 - Отношение между защитой от багов и устойчивостью к рефакторингу

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

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

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

  • насколько хорошо тест выявляет присутствие ошибок (отсутствие ложноотрицательных срабатываний, сфера защиты от багов);

  • насколько хорошо тест выявляет отсутствие ошибок (отсутствие ложных срабатываний, сфера устойчивости к рефакторингу).

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

Рис. 3 – Формула точности теста
Рис. 3 – Формула точности теста

Третий и четвертый аспекты: быстрая обратная связь и простота поддержки

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

Простота поддержки оценивает затраты на сопровождение кода. Метрика состоит из двух компонентов:

  • Насколько сложно тест понять. Этот компонент связан с размером теста. Чем меньше кода в тесте, тем проще он читается и проще изменяется при необходимости.

  • Насколько сложно тест запустить. Если тест работает с внепроцессными зависимостями, вам придется тратить время на то, чтобы поддерживать эти зависимости в рабочем состоянии.

В поисках идеального теста

Произведение этих четырех атрибутов определяет эффективность теста. И в данном случае автор книги использует термин «произведение» в математическом смысле: если один из атрибутов равен нулю, то ценность всего теста тоже обращается в нуль.

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

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

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

Первый пример — сквозные (end-to-end) тесты. Сквозные тесты рассматривают систему с точки зрения конечного пользователя. Они обычно проходят через все компоненты системы, включая пользовательский интерфейс, базу данных и внешние приложения.

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

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

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

Рис. 4 - Тривиальный тест, покрывающий простой фрагмент кода
Рис. 4 - Тривиальный тест, покрывающий простой фрагмент кода

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

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

Рис. 5 – Места, которые занимают тесты по отношению друг к другу
Рис. 5 – Места, которые занимают тесты по отношению друг к другу

Четвертый атрибут — простота поддержки — не так сильно связан с первыми тремя, за исключением сквозных (end-to-end) тестов. Сквозные тесты обычно имеют больший размер из-за необходимости подготовки всех зависимостей, к которыми могут обращаться такие тесты. Они также требуют дополнительных усилий для поддержания этих зависимостей в работоспособном состоянии. Таким образом, сквозные тесты требуют больших затрат на сопровождение.

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

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

Рис. 6 – Компромиссы между атрибутами хорошего теста
Рис. 6 – Компромиссы между атрибутами хорошего теста

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

Компромисс между первыми тремя атрибутами хорошего юнит-теста напоминает теорему CAP. Эта теорема утверждает, что распределенное хранилище данных не может предоставить более двух из трех гарантий одновременно: согласованность (consistency) данных, доступность (availability), устойчивость к разделению (partition tolerance).

Сходство является двойным:

1. В CAP вы тоже можете выбрать максимум два атрибута из трех;

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

Пирамида тестирования

Концепция пирамиды тестирования предписывает определенное соотношение разных типов тестов в проекте: юнит-тесты, интеграционные тесты, сквозные тесты.

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

Рис. 7 - Пирамида тестирования предписывает определенное соотношение юнит-,
интеграционных и сквозных тестов
Рис. 7 - Пирамида тестирования предписывает определенное соотношение юнит-, интеграционных и сквозных тестов
Рис. 8 - Разные типы тестов в пирамиде принимают разные решения относительно быстрой обратной связи и защиты от багов
Рис. 8 - Разные типы тестов в пирамиде принимают разные решения относительно быстрой обратной связи и защиты от багов

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

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

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

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

Выбор между тестированием по принципу «черного ящика» и «белого ящика»

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

Тестирование по принципу «белого ящика» работает по противоположному принципу. Этот метод тестирования проверяет внутренние механизмы приложения. Тесты строятся на основе исходного кода, а не на основе требований или спецификаций.

Рис. 9 - Достоинства и недостатки тестирования по принципу «черного ящика» и «белого ящика»
Рис. 9 - Достоинства и недостатки тестирования по принципу «черного ящика» и «белого ящика»

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

Ссылки на все части

Теги:
Хабы:
+4
Комментарии0

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн