какой тип кода наиболее подходит для написания unit тестов
Unit тесты на практике
В последнее время появилось и продолжает появляться достаточно много публикаций на тему разработки через тестирование. Тема достаточно интересная и стоит того, чтобы посвятить её исследованию какую-то часть своего времени. В нашей команде мы используем модульное тестирование уже на протяжении года. В этой статье я хочу рассказать о том, что получилось и какой опыт в итоге мы приобрели.
Итак…
Какими должны быть модульные тесты?
Помимо того, что модульные тесты должны соответствовать функциональности программного продукта, основное требование — скорость работы. Если после запуска набора тестов разработчик может сделать перерыв (в моём случае на перекур), то подобные запуски будут происходить всё реже и реже (опять же, в моём случае, из-за опасения получить передозировку никотином). В результате может получиться так, что модульные тесты вообще не будут запускаться и, как следствие, потеряется смысл их написания. Программист должен иметь возможность запустить весь набор тестов в любой момент времени. И этот набор должен выполниться настолько быстро, насколько это возможно.
Какие требования необходимо соблюсти для того, чтобы обеспечить скорость выполнения модульных тестов?
Тесты должны быть небольшими
В идеальном случае — одно утверждение (assert) на тест. Чем меньше кусочек функциональности, покрываемый модульным тестом, тем быстрее тест будет выполняться.
Кстати, на тему оформления. Мне очень нравится подход, который формулируется как «arrange-act-assert».
Суть его заключается в том, чтобы в модульном тесте чётко определить предусловия (инициализация тестовых данных,
предварительные установки), действие (собственно то, что тестируется) и постусловия (что должно быть в
результате выполнения действия). Подобное оформление повышает читаемость теста и облегчает его
использование в качестве документации к тестируемой функциональности.
Если в разработке используется ReSharper от JetBrains, то очень удобно настроить template, с помощью которого будет создаваться заготовка для тестового случая. Например, template может выглядеть вот так:
И тогда тест, оформленный подобным образом, может выглядеть примерно так (все имена вымышленные, совпадения случайны):
Тесты должны быть изолированы от окружения (БД, сеть, файловая система)
Этот пункт, наверное, самый спорный из всех. Зачастую в литературе рассматриваются примеры использования TDD на таких задачах как разработка калькулятора или телефонного справочника. Понятно, что эти задачи оторваны от реальности и разработчик, возвращаясь в свой проект, не знает, как применить полученные знания и навыки в повседневной работе. Простейшие случаи, похожие на пресловутый калькулятор, покрываются модульными тестами, а остальная часть функциональности разрабатывается на прежних рельсах. В итоге пропадает понимание того, зачем тратить время на модульные тесты, если простой код можно и так отладить, а сложный всё равно тестами не покрывается.
На самом же деле нет ничего страшного, если в модульных тестах будет использоваться база данных или файловая система. Так, по крайней мере, можно быть уверенным в работоспособности той функциональности, которая по большому счёту и составляет ядро системы. Вопрос заключается только в том, как использовать внешнее окружение наиболее эффективным способом, соблюдая баланс между изолированностью тестов и скоростью их выполнения?
Случай 1. Слой доступа к данным (MS SQL Server)
Если в разработке проекта используется MS SQL сервер, то ответом на этот вопрос может быть использование установленного экземпляра MS SQL сервер (Express, Enterprise или Developer Edition) для разворачивания тестовой базы данных. Подобную базу данных можно создать с помощью стандартных механизмов, используемых в MS SQL Management Studio и поместить её в проект с модульными тестами. Общий подход к использованию такой базы данных заключается в разворачивании тестовой БД перед выполнением теста (например, в методе, отмеченном атрибутом SetUp в случае использования NUnit), наполнении БД тестовыми данными и проверками функциональности репозиториев или шлюзов на этих, заведомо известных, тестовых данных. Причём разворачиваться тестовая БД может как на жёстком диске, так и в памяти, используя приложения, создающие и управляющие RAM диском. Допустим, в проекте, над которым я работаю в данное время, используется приложение SoftPerfect RAM Disk. Использование RAM диска в модульных тестах позволяет снизить задержки, возникающие при операциях ввода/вывода, которые возникали бы при разворачивании тестовой БД на жёстком диске. Конечно, данный подход не идеален, так как требует внесения в окружение разработчика стороннего ПО. С другой стороны, если учесть, что среда для разработки разворачивается, как правило, один раз (ну, или достаточно редко), то это требование не кажется таким уж обременяющим. Да и выигрыш от использования такого подхода достаточно заманчив, ведь появляется возможность контролировать корректность работы одного из важнейших слоёв системы.
Кстати, если есть возможность использовать в модульных тестах LINQ2SQL и SMO для MS SQL Server, то можно воспользоваться следующим базовым классом для тестирования слоя доступа к данным:
После использования которого тесты на взаимодействие с БД будут выглядеть примерно так:
Вуаля! Мы получили возможность тестирования слоя доступа к БД.
Случай 2. ASP.NET MVC WebAPI
При тестировании WebAPI один из вопросов заключается в том, каким образом построить модульные тесты так, чтобы можно было протестировать вызов нужных методов нужных контроллеров с нужными аргументами при отправке запроса на определенный url. Если предположить, что ответственность контроллера заключается только в том, чтобы перенаправить вызов соответствующему классу или компоненту системы, то ответ на вопрос о тестировании контроллера сведется к тому, чтобы перед запуском тестов динамически построить некое окружение, в котором контроллеру можно было бы отправлять нужные HTTP запросы и, используя mock’и, проверить правильность настроенного роутинга. При этом совершенно не хочется использовать для разворачивания тестового окружения IIS. В идеале, тестовое окружение должно создаваться перед запуском каждого теста. Это поможет модульным тестам быть достаточно изолированными друг от друга. С IIS в этом плане было бы достаточно непросто.
Теперь можно использовать эти классы для тестирования выдуманного контроллера, который возвращает сериализованные даты и объекты некоего класса Platform.
Вот, собственно и все. Наши контроллеры покрыты модульными тестами.
Случай 3. Все остальное
А со всем остальным достаточно просто. Примеры модульных тестов на классы, которые содержат чистую логику, без взаимодействия с каким-либо внешним окружением, практически не отличаются от тех, которые предлагаются в популярной литературе типа «TDD by Example» Кента Бека. Поэтому каких-то особых хитростей здесь нет.
Добавлю, что помимо снижения количества ошибок в логике программы, от использования модульных тестов также можно получить следующие преимущества:
На сегодняшний день в проекте, над которым работает наша команда, около 1000 модульных тестов. Время сборки и запуска всех тестов на TeamCity составляет чуть больше 4 минут. Описанные в статье подходы позволяют нам тестировать практически все слои системы, контролируя изменение и развитие кода. Надеюсь, что наш опыт окажется для кого-нибудь полезным.
Unit-тесты, пытаемся писать правильно, чтобы потом не было мучительно больно
100 модульных тестов и приходится тратить продолжительное время на то чтобы заставить их работать вновь. Итак, приступим к «хорошим рекомендациям» при написании автоматических модульных тестов. Нет, я не буду капитаном очевидностью, в очередной раз описывая популярный стиль написания тестов под названием AAA (Arange-Act-Assert). Зато попытаюсь объяснить, чем отличается Mock от Stub-а и что далеко не все тестовые объекты — «моки».
Глобально модульные тесты можно условно поделить на две группы: тесты состояния (state based) и тесты взаимодействия (interaction tests).
Тесты состояния — тесты, проверяющие что вызываемый метод объекта отработал корректно, проверяя состояние тестируемого объекта после вызова метода.
Тесты взаимодействия — это тесты, в которых тестируемый объект производит манипуляции с другими объектами. Применяются, когда требуется удостовериться, что тестируемый объект корректно взаимодействует с другими объектами.
Стоит также заметить, что модульный (unit) тест может запросто превратиться в интеграционный тест, если при тестировании используется реальное окружение(внешние зависимости) — такие как база данных, файловая система и т.д.
Интеграционные тесты — это тесты, проверяющие работоспособность двух или более модулей системы, но в совокупности — то есть нескольких объектов как единого блока.
В тестах взаимодействия же тестируется конкретный, определенный объект и то, как именно он взаимодействует с внешними зависимостями.
Внешняя зависимость — это объект, с которым взаимодействует код и над которым нет прямого контроля. Для ликвидации внешних зависимостей в модульных тестах используются тестовые объекты, например такие как stubs (заглушки).
Стоит заметить что существует классический труд по модульным тестам за авторством Жерарда Месароша под названием «xUnit test patterns: refactoring test code«, в котором автор вводит аж 5 видов тестовых объектов, которые могут запросто запутать неподготовленного человека:
— dummy object, который обычно передается в тестируемый класс в качестве параметра, но не имеет поведения, с ним ничего не происходит, никакие методы не вызываются. Примером таких dummy-объектов являются new object(), null, «Ignored String» и т.д.
— test stub (заглушка), используется для получения данных из внешней зависимости, подменяя её. При этом игнорирует все данные, могущие поступать из тестируемого объекта в stub. Один из самых популярных видов тестовых объектов. Тестируемый объект использует чтение из конфигурационного файла? Передаем ему ConfigFileStub возвращающий тестовые строки конфигурации для избавления зависимости на файловую систему.
— test spy (тестовый шпион), используется для тестов взаимодействия, основной функцией является запись данных и вызовов, поступающих из тестируемого объекта для последующей проверки корректности вызова зависимого объекта. Позволяет проверить логику именно нашего тестируемого объекта, без проверок зависимых объектов.
— mock object (мок-объект), очень похож на тестовый шпион, однако не записывает последовательность вызовов с переданными параметрами для последующей проверки, а может сам выкидывать исключения при некорректно переданных данных. Т.е. именно мок-объект проверяет корректность поведения тестируемого объекта.
— fake object (фальшивый объект), используется в основном чтобы запускать (незапускаемые) тесты (быстрее) и ускорения их работы. Эдакая замена тяжеловесного внешнего зависимого объекта его легковесной реализацией. Основные примеры — эмулятор для конкретного приложения БД в памяти (fake database) или фальшивый вебсервис.
Roy Osherove в книге «The Art of Unit Testing» предлагает упростить данную классификацию и оставляет всего три типа тестовых объектов — Fakes, Stubs и Mocks. Причем Fake может быть как stub-ом, так и mock-ом, а тестовый шпион становится mock-ом. Хотя лично мне не очень нравится перемешивание тестового шпиона и мок-объекта.
Запутанно? Возможно. У данной статьи была задача показать, что написание правильных модульных тестов — достаточно сложная задача, и что не всё то mock, что создаётся в посредством mock-frameworks.
Надеюсь, я сумел подвести читателя к основной мысли — написание качественных модульных тестов дело достаточно непростое. Модульные тесты подчиняются тем же правилам, что и основное приложение, которое они тестируют. При написании модульных тестов следует тестировать модули настолько изолированно друг от друга, насколько это возможно, и различные разновидности применяемых тестовых объектов в этом, безусловно, помогают. Стиль написания модульных тестов должен быть таким же, как если бы вы писали само приложения — без копипастов, с продуманными классами, поведением, логикой.
Анатомия юнит тестирования
Юнит тесты — обязательная часть моих проектов. Это база, к которой добавляются другие виды тестов. В статье Тестирование и экономика проекта я рассказал почему тестирование выгодно для экономики проекта и показал, что юнит тестирование лидирует с экономической точки зрения. В комментариях было высказано мнение, что тестирование требует больших усилий, и даже юнит тестирование неприемлемо из-за этого. Одной из причин этого является неопытность команды в тестировании. Чтобы написать первую тысячу тестов команда тратит много времени, пробуя и анализируя различные подходы.
В этой статье я расскажу о лучших практиках, к которым я пришел за более чем 10 лет тестирования различных проектов. Эти практики позволят начать юнит тестирование без заметного снижения производительности программистов.
Я определяю юнит тестирования как тестирование одного продакш юнита в полностью контролируемом окружении.
Продакшн юнит — это обычно класс, но может быть также и функция, и файл.
Важно, чтобы юнит соответствовал принципал SOLID, в этом случае юнит тесты будут лаконичными. Юниты узкоспециализированны и очень хорошо выполняют одну конкретную задачу, для которой они созданы. В большинстве случаев юниты взаимодействуют друг с другом, делегируя выполнение специализированных задач.
Полностью контролируемое окружение — это окружение имитирующие среду, в которой юнит работает в программе, но полностью открытое для тестирования и настройки. Поведение окружения задается для конкретного тестового кейса через лаконичный API и любое поведение вне этого кейса для него не определено. В этом окружении не должно быть других продакшн юнитов, иначе возрастает сложность тестов и из юнит тестирования мы переходим к интеграционному тестированию.
О наследование
Постарайтесь не применять наследование. Вместо него используйте композицию зависимостей. Часто наследование применяют для реализации принципа DRY (don’t repeat yourself), вынося общий код в родителя, но тем самым нарушая принцип KISS (keep it simple stupid), увеличивая сложность юнитов.
AAA (Arrange, Act, Assert) паттерн
Если посмотреть на юнит тест, то для большинства можно четко выделить 3 части кода:
Arrange (настройка) — в этом блоке кода мы настраиваем тестовое окружение тестируемого юнита;
Act — выполнение или вызов тестируемого сценария;
Assert — проверка того, что тестируемый вызов ведет себя определенным образом.
Этот паттерн улучшает структуру кода и его читабельность, однако начинать писать тест нужно всегда с элемента Act.
Driven approach
Прежде чем продолжить рассмотрение структуры теста, я хотел бы рассказать немного о подходе, который я называю Driven Approach. К сожалению, я не могу перевести это лаконично на русский язык. Поэтому просто расскажу, что за этим стоит, и может быть, вы поможете с лаконичным переводом.
Суть в том, что код, который вы пишите, должен иметь причину своего существования. Важно, чтобы причина была существующей, а не предполагаемой, и эта причина должна иметь в конечном итоге связь с бизнес историей.
С чего мы начинаем разработку конкретного функционала? — с требований бизнеса, которые типично выглядят так: “Пользователь с любой ролью должен иметь возможность создать запись, таким образом, он выполнит такую то бизнес операцию”.
Используя driven approach первое что мы должны сделать —
Данный подход позволяет небольшими шагами реализовывать сложные бизнес истории, оставаясь все время сфокусированным только на нужном функционале, и избегать over engineering.
AAS (Act, Assert, Setup) паттерн
AAS — этот тот же AAA паттерн, но с измененным порядком частей, отсортированных с учетом Driven approach и переименованной Arrange частью в Setup, чтобы отличать их по названию.
Первое, что мы делаем, при создании теста — мы создаем Act. Обычно это создание экземпляра класса тестируемого юнита и вызов его функции. С одной стороны — это самый простой шаг, а с другой это то, что диктует нам бизнес история.
Второе — мы проверяем что Act действует ожидаемо. Мы пишем Assert часть, где выражаем требуемые последствия Act, в том числе с точки зрения бизнес истории.
И вот, когда у нас готовы первые 2 части, мы можем остановиться и подумать, как наш юнит будет выполнять требуемые действия. Да, да, именно сейчас мы можем первый раз задуматься, как реализовать его.
Смотрите, сам вид Act и его сигнатура продиктованы предыдущим шагом, тут нам нечего изобретать. Как предыдущий шаг хочет вызывать наш юнит, так он и будет его вызывать. Ожидаемые действия тоже продиктованы предыдущим шагом и самой бизнес историей.
Так что именно сейчас, когда мы будем писать последнюю часть теста, мы можем остановиться и продумать, как наш юнит будет работать и какое runtime окружение ему для этого нужно. И здесь мы переходим более подробно к “Контролируемому окружению” и дизайну юнита.
Принципы SOLID
Из принципа SOLID, с точки зрения юнит тестирования очень важны 2 принципа:
Single responsibility principle — позволяет снизить количество тест кейсов для юнита. В среднем на юнит должно приходиться от 1 до 9 тест кейсов. Это очень хороший индикатор качества юнита — если тест кейсов больше или хочется их сгруппировать, то вам точно нужно разделить его на два и больше независимых юнитов.
Dependency inversion principle — позволяет легко создавать и управлять сложнейшими окружениями для тестирования через IoC контейнеры. В соответствии с данным принципом, юнит должен зависеть от абстракций, что позволяет передавать ему любые реализации его зависимостей. В том числе, и не продакшен реализации, созданные специально для его тестирования. Эти реализации не имеют в себе никакой бизнес логики и созданы не только под конкретный тестируемый юнит, но и под конкретный сценарий его тестирования. Обычно они создаются с помощью одной из библиотек для mock объектов, такой как moq.
IoC контейнеры позволяют автоматически создавать экземпляр тестируемого юнита и экземпляры его зависимостей, сразу реализованные как mock объекты. Использование такого IoC контейнера очень важный шаг к снижению стоимости поддержания кода и его дружелюбности к автоматическому рефакторингу.
Качество кода
Кстати, несколько слов о качестве кода тестов и продакшн. Самым качественным кодом должен быть код тестов. Причина этому одна — это его размер. На 1 строку продакшн кода в среднем приходиться 2-3 строки тестового кода, то есть его в 2-3 раза больше чем продакшн кода. В этих условиях он должен хорошо читаться, быть структурированным, иметь хорошую типизацию и быть очень дружелюбным к инструментам автоматического рефакторинга. Это цели, которые достойны отдельных мероприятий и усилий.
Однотипность тестирования
Много приложения реализовано в распределенной и модульной архитектуре, где разные части написаны на различных языках, скажем, клиент-серверные приложения, где клиент написан под веб на typescript и сервер написанный на c#. Важной целью для таких проектов будет приведение тестов для любой части, независимо от языка к единому подходу. Это значит, что все тесты на проекте используют AAA или AAS подход. Все тесты используют mock библиотеки с похожим API. Все тесты используют IoC. И все тесты используют одинаковые метафоры. Это позволяет повысить переносимость удачных практик на разные части проекта, упростить адаптацию новых коллег (выучил раз и применяй везде).
Количество тестов для одного продакшн юнита
В среднем, на один продакшн юнит приходиться 1-9 тестов. Если тестов больше или у вас возникло желание сгруппировать тесты, то это — четкий сигнал проверить код продакшн юнита. Вполне возможно, что он нуждается в декомпозиции.
Анатомия юнит-теста
Эта статья является конспектом книги «Принципы юнит-тестирования». Материал статьи посвящен структуре юнит-теста.
В этой статье рассмотрим структуру типичного юнит-теста, которая обычно описывается паттерном AAA (arrange, act, assert — подготовка, действие и проверка). Затронем именование юнит-тестов. Автор книги описал распространенные советы по именованию и показал, почему он несогласен с ними и привел альтернативы.
Структура юнит-теста
Согласно паттерну AAA (или 3А) каждый тест разбивается на три части: arrange (подготовка), act (действие) и assert (проверка). Возьмем для примера класс Calculator с методом для вычисления суммы двух чисел:
Рис. 1 – Листинг теста для проверки поведения класса по схеме ААА
Паттерн AAA предоставляет простую единообразную структуру для всех тестов в проекте. Это единообразие дает большое преимущество: привыкнув к нему, вы сможете легко прочитать и понять любой тест. Структура теста выглядит так:
в секции подготовки тестируемая система (system under test, SUT) и ее зависимости приводятся в нужное состояние;
в секции действия вызываются методы SUT, передаются подготовленные зависимости и сохраняется выходное значение (если оно есть);
в секции проверки проверяется результат, который может быть представлен возвращаемым значением, итоговым состоянием тестируемой системы.
Как правило, написание теста начинается с секции подготовки (arrange), так как она предшествует двум другим. Но начинать писать тесты можно также и с секции проверки (assert). Если практиковать разработку через тестирование (TDD), то вы еще не знаете всего о поведении этой функциональности. Возможно, будет полезно сначала описать, чего вы ожидаете от поведения, а уже затем разбираться, как создать систему для удовлетворения этих ожиданий. В этом случае сначала мы думаем о цели: что разрабатываемая функциональность должна делать для нас. А затем начинаем решать задачу. Но следует еще раз подчеркнуть, что эта рекомендация применима только в том случае, когда практикуется TDD. Если вы пишете основной код до кода тестов, то к тому моменту, когда вы доберетесь до теста, вы уже знаете, чего ожидать от поведения, и лучше будет начать с секции подготовки.
Есть еще паттерн «Given-When-Then», похожем на AAA. Этот паттерн также рекомендует разбить тест на три части:
Given — соответствует секции подготовки (arrange);
When — соответствует секции действия (act);
Then — соответствует секции проверки (assert)
В отношении построения теста эти два паттерна ничем не отличаются. Единственное отличие заключается в том, что структура «Given-When-Then» более понятна для непрограммиста. Таким образом, она лучше подойдет для тестов, которые вы собираетесь показывать людям, не имеющим технической подготовки.
Избегайте множественных секций arrange, act и assert
Иногда встречаются тесты с несколькими секциями arrange (подготовка), act (действие) или assert (проверка).
Когда присутствует несколько секций действий, разделенных секциями проверки и, возможно, секциями подготовки, это означает, что тест проверяет несколько единиц поведения. Такой тест уже не является юнит-тестом — это интеграционный тест. Такой структуры тестов лучше избегать. Если вы видите тест, содержащий серию действий и проверок, отрефакторите его: выделите каждое действие в отдельный тест.
Интеграционным тестом называется тест, который не удовлетворяет хотя бы одному из следующих критериев: проверяет одну единицу поведения, делает это быстро и в изоляции от других тестов.
Например, тест, который обращается к совместной зависимости — скажем, базе данных, — не может выполняться в изоляции от других тестов. Изменение состояния базы данных одним тестом приведет к изменению результатов всех остальных тестов, зависящих от той же базы и выполняемых параллельно.
Иногда допустимо иметь несколько секций действий в интеграционных тестах. Интеграционные тесты могут быть медленными. Один из способов ускорить их заключается в том, чтобы сгруппировать несколько интеграционных тестов в один с несколькими секциями действий и проверок. Это особенно хорошо ложится на конечные автоматы (state machines), где состояния системы перетекают из одного в другое и когда одна секция действий одновременно служит секцией подготовки для следующей секции действий.
Однако для юнит-тестов или интеграционных тестов, которые работают достаточно быстро, такая оптимизация не нужна. Тесты, проверяющие несколько единиц поведения, лучше разбивать на несколько тестов.
Избегайте команд if в тестах
Наличие в тестах команды if также является антипаттерном. Тест — неважно, юнит- или интеграционный — должен представлять собой простую последовательность шагов без ветвлений.
Присутствие команды if означает, что тест проверяет слишком много всего. Следовательно, такой тест должен быть разбит на несколько тестов. Но в отличие от ситуации с множественными секциями AAA, здесь нет исключений для интеграционных тестов. Ветвление в тестах не дает ничего, кроме дополнительных затрат на сопровождение: команды if затрудняют чтение и понимание тестов.
Насколько большой должна быть каждая секция?
Насколько большой должна быть каждая секция? И как насчет завершающей (teardown) секции — той, что должна «прибирать» после каждого теста?
Секция подготовки обычно является самой большой из трех. Если она становится слишком большой, лучше выделить отдельные операции подготовки либо в приватные методы того же класса теста, либо в отдельный класс-фабрику. Есть несколько популярных паттернов, которые помогут организовать переиспользование кода в секциях подготовки: «Мать объектов» (Object Mother) и «Построитель тестовых данных» (Test Data Builder).
Секция действия обычно состоит всего из одной строки кода. Если действие состоит из двух и более строк, это может указывать на проблемы с API тестируемой системы. Этот пункт лучше продемонстрировать на примере, в котором клиент совершает покупку в интернет-магазине.
Обратите внимание: секция действия (act) в этом тесте состоит из вызова одного метода, что является признаком хорошо спроектированного API класса. Теперь сравним ее с версией, в которой секция действия состоит из двух строк. Это признак проблемы с API тестируемой системы: он требует, чтобы клиент помнил о необходимости второго вызова метода для завершения покупки, следовательно, тестируемая система недостаточно инкапсулирована.
В новой версии клиент сначала пытается приобрести пять единиц товара в магазине. Затем товар удаляется со склада. Удаление происходит только в том случае, если предшествующий вызов Purchase() завершился успехом.
Недостаток новой версии заключается в том, что она требует двух вызовов для выполнения одной операции. Следует заметить, что это не является проблемой самого теста. Тест проверяет ту же единицу поведения: процесс покупки. Проблема кроется в API класса Customer. Он не должен требовать от клиента дополнительного вызова. Если клиентский код вызывает первый метод, но не вызывает второй; в этом случае клиент получит товар, но количество товара на складе при этом не уменьшится.
Такое нарушение логической целостности называется нарушением инварианта (invariant violation). Защита кода от потенциальных нарушений инвариантов называется инкапсуляцией (encapsulation). Когда нарушение логической целостности проникает в базу данных, оно становится серьезной проблемой; теперь не удастся сбросить состояние приложения простым перезапуском. Придется разбираться с поврежденными данными в базе и, возможно, связываться с клиентами и решать проблему с каждым из них по отдельности.
Проблема решается поддержанием инкапсуляции кода. В предыдущих примерах удаление запрашиваемого товара со склада должно быть частью метода Purchase. Сustomer не должен полагаться на то, что клиентский код сделает это сам, вызвав метод store.RemoveInventory. Когда речь заходит о поддержании инвариантов в системе, вы должны устранить любую потенциальную возможность нарушить эти инварианты.
Эти рекомендации применимы к большинству кода, содержащего бизнес-логику. Иногда это правило можно нарушить в служебном или инфраструктурном коде. Тем не менее следует анализировать каждый такой случай на возможные нарушения инкапсуляции.
Сколько проверок должна содержать секция проверки?
Так как под «юнитом» в юнит-тестировании понимается единица поведения, а не единица кода, то одна единица поведения может приводить к нескольким результатам. Проверять все эти результаты в одном тесте вполне нормально.
Тем не менее, если секция проверки получается слишком большой: это может быть признаком того, что в коде недостает какой-то абстракции. Например, вместо того чтобы по отдельности проверять все свойства объекта, возвращенного тестируемой системой, возможно, будет лучше добавить методы проверки равенства (equality members) в класс такого объекта. После этого объект можно будет сравнивать с ожидаемым значением всего одной командой.
Нужна ли завершающая (teardown) фаза?
Иногда еще выделяют четвертую — завершающую (teardown) — секцию, которая следует после остальных. Например, в завершающей секции можно удалить любые файлы, созданные в ходе теста, закрыть подключение к базе данных и т. д. Завершение обычно представляется отдельным методом, который переиспользуется всеми тестами в классе. По этой причине автор книги не включил эту фазу в паттерн AAA.
Большинству юнит-тестов завершение не требуется. Юнит-тесты не взаимодействуют с внепроцессными зависимостями, а, следовательно, не оставляют за собой ничего, что нужно было бы удалять. Вопрос очистки тестовых данных относится к области интеграционного тестирования.
Переиспользование тестовых данных между тестами
Важно понимать, как и когда переиспользовать код между тестами. Переиспользование кода между секциями подготовки — хороший способ сокращения и упрощения ваших тестов.
Ранее упоминалось, что подготовка тестовых данных часто занимает много места. Есть смысл выделить эту подготовку в отдельные методы или классы, которые затем переиспользуются между тестами. Существуют два способа реализации такого переиспользования, но автор книги рекомендует использовать только один из них; первый способ приводит к повышению затрат на сопровождение теста.
Первый (неправильный) способ переиспользования тестовых данных — инициализация их в конструкторе теста. Такой подход позволяет значительно сократить объем кода в тестах — вы можете избавиться от большинства (или даже от всех) конфигураций в тестах. Однако у этого подхода есть два серьезных недостатка.
Он создает сильную связность (high coupling) между тестами. Изменение логики подготовки одного теста повлияет на все тесты в классе. Тем самым нарушается важное правило: изменение одного теста не должно влиять на другие тесты. Чтобы следовать этому правилу, необходимо избегать совместного состояния (shared state) в классах тестов.
Другой недостаток выделения кода подготовки в конструктор — ухудшение читаемости теста. С таким конструктором просмотр самого теста больше не дает полной картины. Чтобы понять, что делает тест, приходится смотреть в два места: сам тест и конструктор тест-класса.
Второй (правильный) способ — написать фабричные методы, как показано ниже.
Выделяя общий код инициализации в приватные фабричные методы, можно сократить код теста с сохранением полного контекста того, что происходит в этом тесте. Более того, приватные методы не связывают тесты друг с другом, при условии, что они достаточно гибкие (то есть позволите тестам указать, как должны создаваться тестовые данные).
Обратите внимание: в этом конкретном примере писать фабричные методы не обязательно, так как логика подготовки весьма проста. Этот код приводится исключительно в демонстрационных целях.
Именование юнит-тестов
Правильное именование помогает понять, что проверяет тест и как работает система. Как же выбрать имя для юнит-теста? Есть много рекомендаций на эту тему. Одна из самых распространенных (и, пожалуй, одна из наименее полезных по мнению автора книги) рекомендаций выглядит так:
Такая схема неоптимальная, так как она заставляет сосредоточиться на деталях имплементации вместо поведения системы. Простые фразы гораздо лучше подходят для этой задачи. Простыми фразами можно описать поведение системы в формулировках, которые будут понятны всем, в том числе заказчику или аналитику:
Можно возразить: неважно, что непрограммист не поймет названия этого имени. В конце концов, юнит-тесты пишутся программистами для программистов, а не для бизнеса или аналитиков.
Это верно, но только до определенной степени. Непонятные названия создают дополнительную когнитивную нагрузку для всех, независимо от того, программисты они или нет. На первый взгляд это не кажется серьезной проблемой, но эта когнитивная нагрузка дает о себе знать в долгосрочной перспективе. Все это медленно, но верно увеличивает затраты на сопровождение тестов.
Рекомендации по именованию юнит-тестов
Не следуйте жесткой структуре именования тестов. Высокоуровневое описание сложного поведения не удастся втиснуть в узкие рамки такой структуры. Сохраняйте свободу самовыражения.
Выбирайте имя теста так, словно вы описываете сценарий непрограммисту, знакомому с предметной областью задачи (например, бизнес-аналитику).
Разделяйте слова, например, символами подчеркивания. Это поможет улучшить читаемость, особенно длинных имен.
Также обратите внимание на то, что, хотя автор использует паттерн [ИмяКласса]Tests при выборе имен классов тестов, это не означает, что тесты ограничиваются проверкой только этого класса. Юнитом в юнит-тестировании является единица поведения, а не класс. Единица поведения может охватывать один или несколько классов. Рассматривайте класс в [ИмяКласса]Tests как точку входа — API, при помощи которого можно проверить единицу поведения.
Вывод
Все юнит-тесты должны строиться по схеме AAA: подготовка (Arrange), действие (Act), проверка (Assert). Если тест состоит из нескольких секций подготовки, действий или проверки, это указывает на то, что тест проверяет сразу несколько единиц поведения. Если этот тест — юнит-тест, разбейте его на несколько тестов: по одному для каждого действия.
Секция действия, содержащая более одной строки, — признак проблем с API тестируемой системы. Клиент должен не забывать выполнять эти действия совместно, чтобы не привести к нарушению логической целостности. Такие нарушения называются нарушениями инвариантов.
Переиспользование кода инициализации тестовых данных должно осуществляться с помощью фабричных методов (вместо конструктора тест-класса). Такой подход поддерживает изоляцию между тестами и улучшает читаемость.
Не используйте жесткую структуру именования тестов. Присваивайте имена тестам так, как если бы вы описывали сценарий непрограммисту, знакомому с предметной областью.