Принципы юнит-тестирования
Қосымшада ыңғайлырақҚосымшаны жүктеуге арналған QRRuStore · Samsung Galaxy Store
Huawei AppGallery · Xiaomi GetApps

автордың кітабын онлайн тегін оқу  Принципы юнит-тестирования

 

Владимир Хориков
Принципы юнит-тестирования
2020

Научный редактор Е. Матвеев

Переводчик Е. Матвеев

Литературный редактор А. Руденко

Художник В. Мостипан

Корректоры М. Молчанова (Котова), Г. Шкатова


 

Владимир Хориков

Принципы юнит-тестирования. — СПб.: Питер, 2020.

 

ISBN 978-5-4461-1683-6

© ООО Издательство "Питер", 2020

 

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

 

Посвящаю моей жене Нине

Предисловие к русскому изданию

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

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

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

Предисловие к оригинальному изданию

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

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

Юнит-тестирование постепенно становится обязательным требованием для программных проектов, и эта книга даст вам все необходимое для построения хороших и простых в сопровождении тестов.

Благодарности

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

Хочу поблагодарить многих людей: Сэма Зейдела (Sam Zaydel), Алессандро Кампейса (Alessandro Campeis), Фрэнсис Буран (Frances Buran), Тиффани Тейлор (Tiffany Taylor) и особенно Марину Майклз (Marina Michaels), чье бесценное мнение помогло мне поддерживать качество материала на высшем уровне и попутно улучшило мои писательские навыки. Также спасибо остальным сотрудникам Manning, работавшим над книгой в процессе выпуска и оставшимся незамеченными.

Также хочу поблагодарить научных редакторов, которые не пожалели времени на чтение моей рукописи в различных фазах работы и предоставили полезнейшую обратную связь: Аарона Бартона (Aaron Barton), Алессандро Кампейса (Alessandro Campeis), Конора Редмонда (Conor Redmond), Дрор Хелпер (Dror Helper), Грега Райта (Greg Wright), Хемант Конеру (Hemant Koneru), Джереми Ланге (Jeremy Lange), Хорхе Эзекиля Бо (Jorge Ezequiel Bo), Джорта Роденбурга (Jort Rodenburg), Марка Ненадова (Mark Nenadov), Марко Умека (Marko Umek), Маркуса Мецкера (Markus Matzker), Шрихари Шридхарана (Srihari Sridharan), Стивена Джона Уорнетта (Stephen John Warnett), Суманта Тамбе (Sumant Tambe), Тима ван Дьюрзена (Tim van Deurzen) и Владимира Купцова (Vladimir Kuptsov).

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

О книге

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

Для кого написана эта книга

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

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

Структура книги

Одиннадцать глав этой книги разделены на четыре части. В части I изложены основы юнит-тестирования, а также напоминаются наиболее общие практики юнит-тестирования:

• Глава 1 показывает цели юнит-тестирования, в ней приводится краткий обзор того, как отличить хороший тест от плохого.

• В главе 2 анализируется определение юнит-тестирования и обсуждаются две основные школы в области юнит-тестирования.

• Глава 3 рассматривает некоторые базовые вопросы — такие как структура юнит-тестов, переиспользование тестовых данных и параметризация тестов.

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

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

• В главе 5 объясняется, для чего нужны моки (mocks), и анализируется их связь с хрупкостью тестов.

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

• Глава 7 показывает, как провести рефакторинг раздутых, чрезмерно усложненных тестов и получить тесты, сочетающие в себе максимальную эффективность с минимальными затратами на сопровождение.

В части III изучаются вопросы интеграционного тестирования:

• В главе 8 рассматривается интеграционное тестирование в целом, его достоинства и недостатки.

• В главе 9 обсуждаются моки (mocks) и как работать с ними так, чтобы максимально повысить эффективность ваших тестов.

• В главе 10 рассматривается работа с реляционными базами данных в тестах.

В главе 11 части IV представлены стандартные антипаттерны юнит-тестирования.

О коде

Примеры кода написаны на C#, но те аспекты, которые они демонстрируют, применимы к любому объектно-ориентированному языку (например, Java или C++). Я старался не пользоваться специфическими языковыми возможностями C# и сделать код примеров по возможности простым, чтобы вы легко разобрались в нем. Весь код примеров можно скачать по адресу www.manning.com/books/unit-testing.

Форум для обсуждения книги

Приобретая книгу, вы получаете бесплатный доступ к закрытому веб-форуму Manning, на котором можно публиковать комментарии по поводу книги, задавать технические вопросы и получать помощь от автора и других пользователей. Чтобы получить доступ к форуму, откройте страницу https://livebook.manning.com/#!/book/unit-testing/discussion. За информацией о форумах Manning и правилах поведения обращайтесь по адресу https://livebook.manning.com/#!/discussion.

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

Другие сетевые ресурсы

• Мой блог находится по адресу EnterpriseCraftsmanship.com.

• У меня также имеется онлайн-курс по юнит-тестированию (в данный момент находится в разработке), на который можно записаться по адресу UnitTestingCourse.com.

Об авторе

Владимир Хориков — разработчик, Microsoft MVP и автор на платформе Pluralsight. Профессионально занимается разработкой программного обеспечения более 15 лет, а также обучением команд тонкостям юнит-тестирования. За последние годы Владимир опубликовал несколько популярных серий в блогах, а также онлайн-курс на тему юнит-тестирования. Главное достоинство его стиля обучения, которое часто отмечают студенты, — сильная теоретическая подготовка, которая затем используется на практике.

Иллюстрация на обложке

Иллюстрация, помещенная на обложку второго издания книги и озаглавленная «Esthinienne», была позаимствована из изданного в 1788 г. каталога национальных костюмов Жака Грассе де Сен-Совера (1757–1810) «Costumes Civils Actuels de Tous les Peuples Connus». Каждая иллюстрация нарисована и раскрашена от руки. Иллюстрации из каталога Грассе де Сен-Совера напоминают о культурных различиях между городами и весями мира, имевших место почти двести лет назад. Люди, проживавшие в изолированных друг от друга регионах, говорили на разных языках и диалектах. По одежде человека можно было определить, в каком городе, поселке или поселении он проживает.

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

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

От издательства

Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com (издательство «Питер», компьютерная редакция).

Мы будем рады узнать ваше мнение!

На веб-сайте издательства www.piter.com вы найдете подробную информа­цию о наших книгах.

Часть I. Общая картина

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

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

Глава 1. Цель юнит-тестирования

В этой главе:

Состояние дел в юнит-тестировании.

Цель юнит-тестирования.

Последствия от написания плохих тестов.

Использование метрик тестового покрытия для оценки качества тестов.

Атрибуты успешных тестов.

 

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

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

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

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

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

1.1. Текущее состояние дел в юнит-тестировании

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

При разработке корпоративных приложений практически каждый проект включает какое-то количество юнит-тестов. Соотношение между рабочим и тестовым кодом обычно лежит в диапазоне от 1:1 до 1:3 (на каждую строку рабочего кода приходится от одной до трех строк тестового кода). Иногда это соотношение достигает существенно большего значения — вплоть до 1:10.

Но как и все новые технологии, юнит-тестирование продолжает развиваться. С вопроса «нужно ли писать юнит тесты?» обсуждение перешло в другую плоскость: как писать хорошие юнит-тесты? Именно в этой области кроются основные разногласия.

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

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

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

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

Что такое «корпоративное приложение»?

Корпоративным (enterprise) называется приложение, предназначенное для автоматизации внутренних процессов компании. Существует много разновидностей корпоративных приложений, но обычно оно обладает следующими характеристиками:

высокая сложность бизнес-логики;

большой срок жизни проекта;

умеренные объемы данных;

низкие или средние требования к быстродействию.

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

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

1.2. Цель юнит-тестирования

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

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

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

 

Рис. 1.1. Различия в динамике роста между проектами с тестами и без. Проект без тестов быстро стартует, но и быстро замедляется до состояния, в котором становится трудно двигаться вперед

Такое снижение скорости разработки называется программная энтропия (software entropy). Энтропия (мера беспорядка в системе) — математическая и научная концепция, также применимая к программным системам. (Если вас интересует математическая и научная сторона энтропии, обращайтесь к описанию второго закона термодинамики.)

Связь между юнит-тестированием и структурой кода

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

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

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

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

Определение

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

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

1.2.1. В чем разница между плохими и хорошими тестами?

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

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

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

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

• рефакторинг теста при рефакторинге нижележащего кода;

• выполнение теста при каждом изменении кода;

• отвлечение на ложные срабатывания теста;

• затраты на чтение теста при попытке понять, как работает нижележащий код.

 

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

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

Очень важно научиться отличать хорошие юнит-тесты от плохих. Эта тема рассматривается в главе 4.

Основной (рабочий) и тестовый код

Люди часто думают, что основной (рабочий) код (production code) и тестовый код (test code) — не одно и то же. Предполагается, что тесты, в отличие от основного кода, не несут затрат на сопровождение. Вследствие этого люди часто полагают, что чем больше тестов, тем лучше. Тем не менее это не так. Код — обязательство, а не актив (liability, not an asset). Чем больше кода вы пишете, тем больше вы оставляете возможностей для появления потенциальных ошибок и тем выше будут затраты на сопровождение проекта. Лучше всего писать проекты, используя минимальное количество кода.

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

1.3. Использование метрик покрытия для оценки качества тестов

В этом разделе речь пойдет о двух самых популярных метриках покрытия — code coverage и branch coverage: о том, как их вычислять, как они используются и какие проблемы с ними связаны. Я покажу, почему программистам не стоит ставить цель достичь какого-то конкретного процента тестового покрытия и почему тестовое покрытие само по себе не может служить критерием качества тестов.

Определение

Метрика покрытия (coverage metric) показывает, какая доля исходного кода была выполнена хотя бы одним тестом — от 0 до 100 %.

Существуют различные типы метрик покрытия, которые используются для оценки качества тестов. Часто считается, что чем выше процент покрытия, тем лучше.

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

Если покрытие слишком мало — допустим, всего 10 % — это хороший признак того, что тестов слишком мало. Однако обратное неверно: даже 100 %-ное покрытие еще не гарантирует хорошего качества тестов. Тесты, обеспечивающие высокое покрытие, тем не менее могут быть плохого качества.

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

1.3.1. Метрика покрытия code coverage

Первая и наиболее часто используемая метрика покрытия — code coverage, также известная как test coverage (рис. 1.3). Эта метрика равна отношению количества строк кода, выполняемых по крайней мере одним тестом, к общему количеству строк в основном коде проекта.

 

Рис. 1.3. Code coverage вычисляется как отношение количества строк кода, выполняемых тестами, к общему количеству строк в основном коде проекта

Пример поможет вам лучше понять, как вычисляется эта метрика. В листинге 1.1 показан метод IsStringLong и тест, который покрывает его код. Метод определяет, является ли строка, переданная во входном параметре, длинной (в данном случае «длинной» считается любая строка, длина которой превышает 5 символов). Тест выполняет метод со строкой "abc" и проверяет, является ли эта строка длинной.

 

Покрытие в этом примере вычисляется легко. Общее количество строк в методе равно 5 (фигурные скобки тоже считаются). Количество строк, выполняемых в тесте, равно 4 — тест проходит все строки кода, кроме команды returntrue;. Таким образом, покрытие равно 4/5 = 0,8 = 80 %.

Что будет, если отрефакторить этот метод и убрать избыточную команду if?

public static bool IsStringLong(string input)

{

    return input.Length > 5;

}

 

public void Test()

{

    bool result = IsStringLong("abc");

    Assert.Equal(false, result);

}

Изменился ли процент покрытия? Да, изменился. Так как тест теперь выполняет все три строки кода (команда return и две фигурные скобки), покрытие кода увеличилось до 100 %.

Но улучшилось ли качество тестов с таким рефакторингом? Конечно же, нет. Я просто переставил код внутри метода. Тест по-прежнему проверяет то же количество ветвлений в коде.

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

1.3.2. Branch coverage

Другая метрика покрытия называется branch coverage (покрытием ветвей). Branch coverage показывает более точные результаты, чем code coverage. Вместо того чтобы использовать количество строк кода, эта метрика ориентируется на управляющие структуры — такие как команды if и switch. Она показывает, какое количество таких управляющих структур обходится по крайней мере одним тестом в проекте (рис. 1.4).

 

Рис. 1.4. Branch coverage вычисляется как отношение количества ветвей кода, выполненных хотя бы одним тестом, к общему количеству ветвей в коде

Чтобы вычислить метрику branch coverage, необходимо подсчитать все возможные ветви (branches) в коде и посмотреть, сколько из них выполняются тестами. Вернемся к предыдущему примеру:

public static bool IsStringLong(string input)

{

    return input.Length > 5;

}

 

public void Test()

{

    bool result = IsStringLong("abc");

    Assert.Equal(false, result);

}

Метод IsStringLong содержит две ветви: одна для ситуации, в которой длина строкового аргумента превышает пять символов, и другая для строк, длина которых менее или равна 5 символам. Тест покрывает только одну из этих ветвей, поэтому метрика покрытия составляет 1/2 = 0,5 = 50 %. При этом неважно, какое представление будет выбрано для тестируемого кода — будете ли вы использовать команду if, как прежде, или выберете более короткую запись. Метрика branch coverage принимает во внимание только количество ветвей; она не учитывает, сколько строк кода понадобилось для реализации этих ветвей.

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

 

Рис. 1.5. Метод IsStringLong представлен в виде графа возможных путей выполнения кода. Тест покрывает только один из двух путей, обеспечивая таким образом 50%-ное покрытие

1.3.3. Проблемы с метриками покрытия

Хотя метрика branch coverage дает результаты лучше, чем метрика code coverage, вы все равно не сможете положиться на эту метрику для определения качества тестов по двум причинам:

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

• Ни одна метрика покрытия не может учитывать ветвления кода во внешних библиотеках.

Рассмотрим каждую из этих причин подробнее.

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

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

В листинге 1.2 приведена другая версия метода IsStringLong, которая записывает последний результат в свойство WasLastStringLong.

 

Теперь метод IsStringLong имеет два результата: явный, закодированный возвращаемым значением, и неявный, которым является новое значение свойства. И хотя второй, косвенный результат не проверяется, метрики покрытия демонстрируют те же результаты: 100 % для code coverage, 50 % для branch coverage. Как видите, метрики покрытия не гарантируют, что код реально тестируется — только то, что он выполнялся в какой-то момент.

Крайним выражением этой ситуации с частично тестируемыми результатами является тестирование без проверок (assertion-free testing): когда вы пишете тесты, которые вообще не содержат никаких проверочных команд. В листинге 1.3 приведен пример тестирования без проверок.

 

В этом тесте обе метрики — как code coverage, так и branch coverage — достигают 100 %. В то же время этот тест совершенно бесполезен, поскольку он ничего не проверяет.

Но допустим, вы тщательно проверяете каждый результат тестируемого кода. Создаст ли это (в сочетании с использованием branch coverage вместо code coverage) надежный механизм, который может использоваться для определения качества тестов? К сожалению, нет.

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

public static int Parse(string input)

{

    return int.Parse(input);

}

 

public void Test()

{

    int result = Parse("5");

    Assert.Equal(5, result);

}

Метрика branch coverage показывает 100 %, и при этом тест проверяет все составляющие результата метода. Такая составляющая здесь всего одна — возвращаемое значение. В то же время такой тест совершенно не является исчерпывающим: он не учитывает ветвления, через которые может проходить метод .NET Framework int.Parse. В то же время даже такой простой метод может содержать большое количество ветвлений, как видно из рис. 1.6.

 

Рис. 1.6. Скрытые пути во внешних библиотеках. Метрики покрытия не видят, сколько существует таких путей и сколько из них будет отработано вашими тестами

История с полей

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

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

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

Как нетрудно догадаться, ничего хорошего из этого не вышло. Разработчики стали искать пути обойти эту систему. Многие пришли к интересному наблюдению: если обернуть все тесты в блоки try/catch и не включать в них проверки (assertions), то такие тесты всегда проходят успешно. Люди стали добавлять такие тесты ради достижения 100 % покрытия.

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

Со временем требования были снижены до 90 %, затем до 80 %, а через какое-то время были полностью сняты (и к лучшему!).

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

• Null;

• пустая строка;

• строка, не представляющая целое число;

• слишком большая строка.

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

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

1.3.4. Процент покрытия как цель

Надеюсь, я убедил вас, что метрики покрытия не могут стать основой для определения качества тестов в вашем проекте. Также не стоит ставить себе цель достичь какого-то конкретного процента покрытия, будь то 100 %, 90 % или даже «скромные» 70 %. Лучше всего рассматривать метрику покрытия как индикатор, а не как самоцель.

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

СОВЕТ

Полезно иметь высокое покрытие в наиболее важных частях системы. Плохо превращать такое высокое покрытие в требование.

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

Еще раз: метрики покрытия — хороший негативный признак, но плохой позитивный. Низкие показатели покрытия — допустим, ниже 60 % — являются верным признаком проблем с тестами. Они означают, что в проекте присутствует большой объем непротестированного кода. Однако высокое покрытие не означает, что проблем с тестами нет. Таким образом, метрики покрытия — только первый шаг на пути к определению качества тестов.

1.4. Какими должны быть успешные тесты?

Большая часть этой главы прошла за обсуждением неправильных способов оценки качества тестов: использования метрик покрытия. Какой способ будет правильным? Как оценить качество ваших тестов? Единственный надежный способ — оценка каждого теста по отдельности. Конечно, вам не нужно оценивать их все сразу; это может быть слишком серьезным мероприятием, требующим значительных вложений. Оценку можно проводить постепенно. Суть в том, что автоматизированного способа проверки качества тестов не существует. Вам придется руководствоваться субъективной оценкой.

Давайте рассмотрим более широкую картину того, что обеспечивает успешность тестов в целом. (Вопрос о том, как отличить хорошие тесты от плохих, будет по­дробно рассмотрен в главе 4.)

Успешный набор тестов обладает следующими свойствами:

• он интегрирован в цикл разработки;

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

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

1.4.1. Интеграция в цикл разработки

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

1.4.2. Проверка только самых важных частей кода

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

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

Все остальные части можно разделить на три категории:

• инфраструктурный код;

• внешние сервисы и зависимости — например, базы данных и сторонние системы;

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

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

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

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

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

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

Саму задачу написания эффективных тестов можно разделить на две подзадачи:

• умение распознать эффективный тест (и по аналогии — тест с низкой эффективностью);

• умение написать эффективный тест.

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

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

1.5. Что вы узнаете из книги

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

После подготовки фундамента (глава 4) книга анализирует существующие методы и практики юнит-тестирования (главы 4–6 и часть главы 7). При этом неважно, знакомы вы с этими методами и практиками или нет. Если знакомы, книга поможет вам взглянуть на них по-новому. Скорее всего, вы уже владеете этими практиками на интуитивном уровне. Книга поможет вам осознать, почему методы и приемы, которыми вы пользовались все это время, настолько эффективны.

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

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

• проводить рефакторинг тестов вместе с основным кодом приложения;

• применять разные стили юнит-тестирования;

• писать интеграционные тесты для проверки поведения системы в целом;

• выявлять антипаттерны в юнит-тестах и избегать их.

Кроме юнит-тестов в книге рассматривается вся тема автоматизации тестирования, так что вы также узнаете об интеграционных и end-to-end тестах.

Я использую C# и .NET в своих примерах, но для чтения книги не нужно быть профессионалом в C#. Все обсуждаемые концепции не привязаны к конкретному языку и могут применяться в любом другом объектно-ориентированном языке (например, Java или C++).

Итоги

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

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

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

• Не все тесты одинаково полезны. С каждым тестом связаны плюсы и минусы, которые необходимо тщательно оценивать. Включайте в проект только наиболее эффективные тесты и избавляйтесь от всех остальных. И код приложения, и код тестов — обязательство, а не актив (liabilities, not assets).

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

• Аналогичным образом метрики покрытия служат хорошим негативным, но плохим позитивным признаком. Низкий процент покрытия — хороший признак проблем с тестами, но высокий процент покрытия еще не означает высокого качества тестов.

• Branch coverage предоставляет более качественную информацию о полноте тестов, чем code coverage, но по нему все равно нельзя судить о том, достаточно хороши ваши тесты или нет. Ни одна из метрик покрытия не учитывает наличия проверок (assertions) и ветвей выполнения в сторонних библиотеках, используемых в вашем проекте.

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

• Успешные тесты обладают следующими свойствами:

• интегрирован в цикл разработки;

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

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

• Чтобы добиться цели юнит-тестирования (то есть обеспечить стабильный рост проекта), необходимо:

• научиться отличать хорошие тесты от плохих;

• научиться рефакторить тесты для повышения их качества.

1 См. Domain-Driven Design: Tackling Complexity in the Heart of Software, Эрик Эванс (Eric Evans), Addison-Wesley, 2003.  (На русском языке:  Эванс, Эрик. Предметно-ориентированное проектирование (DDD): структуризация сложных программных систем. : Пер. с англ. — М.: ООО «И.Д. Вильямс», 2011. — 448 с. — Примеч. ред.)

См. Domain-Driven Design: Tackling Complexity in the Heart of Software, Эрик Эванс (Eric Evans), Addison-Wesley, 2003.  (На русском языке:  Эванс, Эрик. Предметно-ориентированное проектирование (DDD): структуризация сложных программных систем. : Пер. с англ. — М.: ООО «И.Д. Вильямс», 2011. — 448 с. — Примеч. ред.)

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