unit test что это такое
Юнит тесты. Первый шаг к качеству
Однажды меня попросили рассказать о юнит тестировании в javascript, но прежде чем рассказывать о тестировании в мире front-end, надо было сделать небольшой обзор юнит тестирования как такового. В результате чего на свет и появилась эта статья, в которой я попытался рассказать о самых важных моментах в юнит тестировании.
Несмотря на различные трактовки юнит тестирования, есть несколько вещей которые объединяют этот термин.
Но есть моменты, в определении юнит тестирования, которые до сих являются спорными. В частности, что рассматривается под юнитом (единицей тестирования)? Подход ООП рассматривает класс как юнит, процедурный (или функциональный) подход, рассматривает одну функцию как юнит. Некоторые разработчики берут несколько классов и считают это юнитом, или берут набор методов в качестве юнита. Но на самом деле это ситуационная вещь, команда сама решает, что должно быть единицей тестирования в их системе.
Преимущества юнит-тестирования очевидны:
Важное различия в юнит тестировании, это какой тип тестирования вы выберите: Solitary (одинокий) и Sociable (общительный) тест. Термины впервые ввел Jay Fields.
Sociable (общительный) тест — это тест который использует реальные методы (или классы), которые входят в тестируемую единицу. Например, вы тестируете метод «цена» из класса заказов. Методу «цена» необходимо вызвать методы из класса клиент и продукт. В данном виде тестов будут вызваны именно эти методы, и ошибка в этих методах приведет к ошибке теста. Методы из классов клиент и продукт называется партнеры (collaborators).
Solitary (одинокий) тест — это тест, который в качестве партнеров использует дубли (TestDouble). Тест-дубли — это общий термин для любого случая, в котором вы заменяете реальный объект, исключительно для целей тестирования.
Хорошую классификация дублей сделал Жерар Мезарос (Gerard Meszaros), более подробно об этом можно почитать здесь
Каждый из этих методов тестирования имеет свои достоинства и недостатки, и между сторонниками этих двух методов ведутся горячие споры. Сторонников Solitary (одинокий) тестов также условно называют Mock-исты (Mock — подделка), а сторонников Sociable (общительный) тестов условно называют Classicists (не смог найти аналогов в русском языке). Хочется отметить, что сторонники Sociable (общительного) тестирования, также используют дубль-тесты для доступа к внешним ресурсам, например, к БД. Отчасти, это делается по причине скорости доступа. Но использовать дубли для доступа к внешним ресурсам это не абсолютное правило, если доступ к ним стабилен и достаточно быстр, то можно обойтись и без дублей. В любом случае разработчик сам решает, когда ему лучше применить дубли.
Одно из достоинств техники тестирования Solitary (одинокий) в том, что разработчики фокусируются на поведении приложения, а не на состоянии. Недостаток в том, что подделки могут замаскировать ошибку, которая присутствует в методе-партнере. Поэтому, при использовании тестов-дублей необходимо выполнять интеграционное тестирование. Достоинство тестирования Sociable (общительный) в том, что это уже по сути начальное интеграционное тестирование, но недостаток в том, что если упадет один метод это приведет к падению всех тестов, связанных с этим методом, что затрудняет отладку.
Я не буду подробно останавливаться на достоинствах и недостатках того или иного подхода в тестировании, об этом можно почитать у Фаулера в статье Mocks Aren’t Stubs
Основные свойства unit-тестирования – это небольшой объем, сделанный самим программистом, и скорость – что означает, что они могут выполняется часто во время программирования.
Разработчики могут выполнять их после любого изменения в коде. Но не обязательно запускать всегда все тесты, достаточно выполнить только те тесты, которые взаимодействуют с кодом, над котором вы трудитесь в текущий момент.
В конце 1990-х годов Кент Бек разработал технику «разработка через тестирование» (Test-Driven Development, TDD), как часть экстремального программирования. Эта техника для построения ПО, которая управляет процессом разработки через написание тестов. В сущности, повторяет три простых правила:
Написание теста первым дает два преимущества:
1. Это способ получить само-тестируемый код
2. Думая сначала о тесте вы заставляете себя думать об интерфейсе самого кода. Эта фокусировка на интерфейсе и на том как вы используете класс помогает вам разделить интерфейс от реализации.
Самая большая ошибка при использовании данной методологии — это пренебрежение третьим шагом, рефакторинг. Это приводит к тому, что код будет “грязным” (но по крайней мере, будут тесты).
BDD (Behaviour Driven Development) или разработка на основе поведения, появилось в процессе эволюции unit-тестирования и разработана Дэном Нортом (Dan North) в 2006г. Как утверждает сам автор, методология должна помочь людям изучить TDD. Она появилось из agile практик и предназначена сделать их более доступными и эффективными для команд-новичков в Agile.
Со временем, BDD стало охватывать более широкую картину agile-анализа и автоматическое приемочное тестирование.
Это привело к тому, что сами тесты стали переименовывать в поведение (спецификации), что позволило сфокусироваться на том, что объекту нужно сделать. Таким образом, разработчики стали создавать для себя документацию и записывать названия тестов в виде предложений. Они обнаружили, что созданная документация, стала доступна бизнесу, разработчикам и тестерам.
Считается, что разработка на основе поведения одно из ответвлений Mock-стилей (или Solitary-тест), т.е. тесты преимущественно строятся с использованием дублей.
Позднее, появился стиль написания тестов Given-When-Then, или, как его стали называть, спецификация поведения системы. Стиль был разработан Дэном Нортом (Dan North), совместно с Крисом Маттисом (Chris Matts). Идея заключается в том, чтобы разбить написание тестового сценария на три раздела:
Описание: Пользователь продает акции.
Сценарий: Пользователь запрашивает продажу до закрытия торгов
Дано (Given): У меня есть 100 акций MSFT и 150 акций APPL и время до закрытия торгов.
Когда (When): Я прошу продать 20 акций MSFT
Тогда (Then): У меня должно остаться 80 акций MSFT и 150 акций APPL и заявка на продажу 20 акций должна быть выполнена.
Не взирая на то, что с момента появлений методологий TDD и BDD прошло довольно много времени, многие разработчики до сих пор спорят друг с другом о целесообразности их применения. Кто-то утверждает, что нет необходимости писать тесты перед кодом, другие заявляют, что написание тестов после кода бессмысленно. Но и та и другая стороны согласны в одном, что тесты нужно писать! Методология BDD с точки зрения программистов, как утверждает сам ее автор (BDD IS LIKE TDD IF…), не отличается от TDD. Там используются все те же правила, что и в TDD: тест, код, рефакторинг. Отличие заключается в том, что BDD охватывает более широкую публику. Спецификации становятся доступными не только программистам, но и людям, не разбирающимся в коде, но имеющим отношение к разработке ПО. Таким образом, в процесс создания тестов подключается вся команда: аналитики, тестеры, менеджеры.
Одно очевидное преимущество юнит-тестов в том, что они могут радикально уменьшить число ошибок, которые попадают в продукт. В основе этого лежит культура, в результате которой разработчики думают о написании кода и тестов вместе.
Но самое большое преимущество не в том, чтобы просто избегать ошибок в продукте, а в уверенности в том, что вы можете вносить изменения в систему. Старый код часто является ужасной картиной, где разработчики боятся его менять. Даже исправление одной ошибки может быть опасно, т.к. вы можете создать больше ошибок, чем исправите. В таких случаях, добавление новых возможностей происходит очень медленно, вы также боитесь сделать рефакторинг системы, увеличивая тем самым технический долг (TechnicalDebt) и попадаете в плохую спираль, где каждое изменение заставляет людей боятся еще большего изменения.
С тестами другая картина. Здесь люди уверены, что фиксация ошибок, может быть сделана безопасно, потому что, если вы допустили оплошность, то детектор ошибок сработает, и вы можете быстро восстановить и продолжить. С помощью этой системы безопасности, вы можете всегда поддерживать код в хорошей форме и уже не окажетесь в плохой спирали.
В качестве детектора ошибок (само-тестируемая система) выступает процесс выполнения серии автоматических тестов (не только юнит), и вы уверены, что тесты пройдут и ваш код не содержит существенных дефектов. Если кто-то в команде случайно сделает ошибку, сработает детектор. Выполняя тесты часто, несколько раз в день, вы можете обнаружить ошибки сразу после их появления, поэтому вы можете просто посмотреть последние изменения, что значительно облегчает поиск ошибок. Никакой программный эпизод не завершен без рабочего кода, и тестов, поддерживающих его работу.
Само тестируемая система – это часть Continuous Integration (непрерывная интеграция) и Continuous Delivery (непрерывная доставка), но это тема уже выходит за рамки данной статьи.
Одним из важных действий команды, которая практикует различные тесты – это реакция на ошибку в продукте. Обычная реакция команды, это сначала написать тест, чтобы обнажить ошибку, и только потом попытаться исправить ее. Часто написанием этого теста будет серия тестов, которая постепенно сужает область действия до тех пор, пока вы не добьетесь юнит-теста, который эмулирует ошибку. Эта техника гарантирует, что после исправления ошибки, она останется фиксированной. Позиция должна быть в том, что любая ошибка, это не просто провал в коде, это также провал в защите тестирования.
В качестве детектора ошибок или автоматических тестов, выступают не только unit-тесты, но также интеграционные тесты, и другие автоматические тесты. Но unit-тесты здесь играют основу, т.к. написать их просто и выполняются они очень быстро.
Высокоуровневые тесты — это вторая линия обороны. Если вы получили ошибку в высокоуровневом тестировании, то это не просто ошибка в коде, это отсутствующий или некорректный юнит тест!
Unit-тесты: что, как и когда тестировать?
Тестирование программного кода — кропотливый и сложный процесс. Львиную долю работы в нем совершают unit-тесты. Пока они не «загорятся зеленым», тестировать дальше смысла нет.
Как же писать unit-тесты правильно? Стоит ли гнаться за 100% покрытием? С какими сложностями приходится сталкиваться инженерам на практике? Своим опытом делятся Marc Philipp и Всеволод Брекелов.
Marc Philipp – один из основных разработчиков фреймворка JUnit 5 – инструмента для Java-тестировщиков. В данный момент работает в качестве инженера в немецкой компании LogMeIn над облачными SaaS-решениями.
Всеволод Брекелов — Senior QA Engineer в компании Grid Dynamics, более 5 лет занимается тестированием, имеет опыт построения автоматизации тестирования с нуля.
— В статьях про unit-тестирование в качестве примеров обычно приводят тестирование методов и классов калькулятора. Такие примеры могут показать сложность реальных задач? С чем приходится сталкиваться тестировщику полнофункциональных программ?
Marc Philipp: Действительно, на примерах с калькулятором невозможно показать сложность реальных задач. Они выбраны в статьях для того, чтобы читатели могли сосредоточиться на понимании подходов unit-тестирования без необходимости разбора сложного кода. Хотя эти примеры очень простые, они хорошо демонстрируют основную идею и принципы unit-тестирования. В реальной жизни тестируемый код должен быть изначально написан с учетом того, что по нему будет проводиться Unit-тестирование. Один из способов обеспечить это — писать тесты до написания кода или практически одновременно с ним. Когда у вас есть код, адаптированный к тестированию, написание unit-тестов не на много сложнее, чем для калькулятора.
Всеволод Брекелов: Думаю, что сложность реальных задач можно понять только на реальных задачах. Если серьезно, то есть и хорошие статьи, где весьма подробно рассматриваются нетривиальные примеры. Думаю, что они помогут приблизиться в реальности.
К примеру, по запросу «unit тестирование java» можно быстро найти статью на Хабре. Она опубликована довольно давно, но не потеряла своей актуальности.
Что касается особенностей работы, я бы выделил следующие группы тестировщиков (надеюсь никого не обидеть):
Хотел бы обратить внимание на процесс. Я считаю, что каждый тестировщик должен хорошо разбираться в построении процесса разработки, так как в моей практике ноги, баги и основная трата времени на имплементацию того, что не нужно, растут как раз оттуда.
— Каждый тест должен проверять одну вещь. Насколько полно на практике удается выполнить это условие? Как вы боретесь с зависимостями, какие фреймворки используете?
Marc Philipp: При написании unit-тестов обычно берется один образец входных данных из класса эквивалентности в тестируемой проблемной области. Конечно, вы должны сначала определить эти самые классы эквивалентности. В каждом тесте вы добавляете assertion только для тех свойств, которые релевантны вашему тесту. Не следует копипастить одни и те же assertions в каждый новый тест и прогонять их. Когда у вас есть зависимости, влияющие на работу юнита, подумайте об использовании стабов или моков, чтобы сохранить независимость теста.
Многие наши юнит-тесты для JUnit 5 используют моки, создаваемые mocking-фреймворком (Mockito в нашем случае). Как я уже говорил выше, они очень полезны для тестирования изолированного кода. Главная задача при этом — убедиться, что ваш мок ведет себя аналогично реальному коду. В противном случае тесты станут бессмысленными.
Всеволод Брекелов: Да, есть мнение: один юнит тест — один assertion. На практике такое я видел очень редко. Думаю, что это уже философия команды. Множественные assertions вполне себе имеют место.
Если мы проводим юнит тесты, а не компонентные, то все зависимости изолируем (моки, стабы — все в ваших руках). Тут нет каких-то сложностей на мой взгляд. А если и появляются, то StackOverflow точно поможет.
Так как я пишу на Java/JavaScript(Angular), то использую обычные популярные тулы:
на Java – Mockito/EasyMock. Для компонентных тестов написать свой responsive mock — тоже хорошая идея! Всем советую.
JavaScript – ngMock. Кстати, для компонентых тестов очень классная тема – AngularPlayground.
— Как найти компромисс между трудовыми и финансовыми затратами на тестирование и качеством итогового софта при реализации «горящих» проектов? Как обычно вы аргументируете важность полноценного тестирования в таких случаях?
Marc Philipp: По моему опыту, вы не можете спасти «горящий» проект, пропустив тесты. Написание unit-тестов является неотъемлемой частью разработки программного обеспечения. Без него у вас нет возможности узнать, действительно ли ваш код выполняет то, что, по вашему мнению, он должен делать. Вы не сможете ничего быстро починить, так как не поймете, где что сломалось. Как сказал UncleBob, «единственный способ быстро поехать — это хорошо идти».
Всеволод Брекелов: Думаю, тут нет однозначного ответа. Скорее, помогает опыт работы и тип проекта. Если вы делаете медицинский проект или строите ракету, то о важности тестирования не приходиться говорить. Если пилите стартап за неделю – то какие тесты?
Очень важно организовать процесс, чтобы избежать внезапных багов и неправильно реализованных требований. Что такое правильный процесс? Конечно, есть Agile Manifesto, на который многие смотрят при организации процесса, но все равно что-то не выходит. Можно взять и построить процесс ради процесса. А можно и наоборот, последовать за http://programming-motherfucker.com/.
Мне кажется, главное – иметь требования, детализация которых устраивает разработчиков и тестировщиков в команде. Это значит, что у них одинаковое понимание того, что будет на выходе.
— Какие приемы помогают сократить время и трудовые затраты на тестирование?
Marc Philipp: «Тестирование» — перегруженный термин. Это может означать что угодно: модульное тестирование, ручное тестирование, тестирование производительности… По моему опыту, ручное тестирование, то есть ручное выполнение плана пошагового прохождения тестовых примеров, действительно дорого и часто не так эффективно, как вы думаете. Более того, автоматизация этих скучных тестов имеет смысл только в определенной степени. Тем не менее, вы должны действительно следовать тестовой пирамиде, а не писать слишком много этих end-to-end/UI тестов. Большинство ваших тестов должны быть реальными unit-тестами: независимые, быстрые тесты, которые вы можете выполнять очень часто. Написание этих тестов относительно дешево, особенно если вы знаете свои инструменты. Они очень надежны, поэтому вы не будете тратить время на их актуализацию. UI и Integration тесты всегда будут более хрупкими из-за огромного количества задействованных компонентов.
Всеволод Брекелов: Есть хороший прием — писать меньше кода.
Главное – это понимание процесса и того, что вы хотите решить (или протестировать).
Всегда нужно адекватно оценивать бюджет и время. Что это значит? Если вы можете себе позволить вливать кучу денег в приближение к 100% coverage — why not? Хозяин – барин.
Если у вас нет денег на автотесты (которые, как известно, отбиваются в основном в долгоиграющих проектах), то толпа ручных тестировщиков – ваш вариант.
Если не впадать в крайности, то самая частая ошибка — это написание e2e тестов пачками до потери пульса до того, как написаны юнит тесты, компонентные тесты, интеграционные тесты на Backend, Frontend, DB, Performance и тд. Эта тенденция, вероятно, следует от модных BDD подходов (я их не очень люблю). К чему это все приводит?
Первая степень «опьянения» — у вас начинает реально работать автоматизация. Ручные тест кейсы вы заменяете на автоматические. Тестировщики начинают радоваться. Менеджеры начинают думать, что вот-вот сэкономят.
Вторая степень — тестов становится много, почему-то некоторые из них периодически падают. Тестировщики уже не очень рады. Нужно сидеть и разбираться в причинах. А баги все равно пролезают. И, вероятно, даже находятся на QA окружении путем ручного (может, даже monkey) тестирования.
Третья степень — все начинают ходить на конференции про Selenium (ничего не имею против этих конференций), узнавать как бороться с Flaky тестами, пробовать различные решения. Пускать тесты в параллель.
Четвертая степень — строить целые суперархитектуры по запуску 500 e2e тестов на 50 агентах, чтобы все летало быстро, аж за 10 минут (я тут утрирую, конечно). И все равно баги есть.
Пятая степень — я назову ее недостижимой. Приходит осознание того, что бОльшая часть e2e тестов не нужна. Нужны другие тесты, которых никто никогда не писал. Например, компонентные тесты на back-end или они же на UI. А может, не они, может, системные тесты? А может, и тесты на верстку? А может, Ваш,
Безусловно есть проекты, где все сделано «правильно». Но зачастую встречается проблема непонимания того, что нужно протестировать. Только правильное понимание может сохранить ваше время и финансы. И более того, улучшить качество продукта.
— Как влияет на инструменты и подходы тестировщиков развитие средств разработки и подходов к созданию кода? Что из новшеств облегчает
unit-тестирование (например, представление методов в виде лямбда-функций)?
Marc Philipp: Новые инструменты стараются облегчить жизнь разработчикам, предоставляя им большую гибкость. Однако, в конце концов, я считаю, что не имеет значения, представляете ли вы свои тесты как методы или в виде лямбда-функций. Понять, что тестировать и как тестировать, — это самая сложная часть.
Всеволод Брекелов: Развитие средств и подходов влияет позитивно, если ими пользуются. Не всегда есть возможность применить хайповые технологии или подходы на работе. Мы все-таки решаем бизнес-задачи. Но находить баланс всегда можно.
Что облегчает тестирование — странный вопрос. Думаю, что технологии не могут сильно облегчить жизнь. Так как, чтобы использовать что-то новое (технология, инструмент), его нужно изучить всей команде, принять какую-ту «полиси», code style. Это в перспективе может, конечно, облегчить жизнь, но на коротких дистанциях не очень полезно, так как трудозатратно, имхо.
Кстати, вариант перехода на Kotlin (если мы говорим про Java тесты) – может и неплохая идея. Я в своей практике пока не пробовал.
Касательно новшеств языка (лямбды и прочие полезности) — это все хорошо, конечно, но мне трудно сказать, насколько они облегчают жизнь, так как нужно это измерить. Я не измерял. Но только не записывайте меня в противники прогресса, я считаю, что практика по изучению/использованию чего-то нового должна присутствовать всегда. Это обычная continuos improvement история.
— Насколько вы покрываете unit-тестами ваши продакшн проекты? Стоит ли тратить время на 100% покрытие?
Marc Philipp: В зависимости от языка программирования и фреймворков, которые вы используете, в проекте может быть некоторый шаблонный код, который не содержит никакой логики. Но кроме таких кусков, на мой взгляд, вы должны написать unit-тесты для всего вашего кода. Таким образом, я бы посоветовал охват более 90%.
Всеволод Брекелов: В проектах, в которых мне приходилось работать, чаще всего разработчики стараются довести тесты до покрытия в 90%. Стоит ли тратить время – обычно решается менеджерами. Я не менеджер, но по мне юнит тесты – это очень хорошая практика, 100% покрытие хорошо иметь, когда есть на это ресурсы.
Главное, надо помнить, что 100% покрытие, к сожалению, не гарантирует, что у вас нет багов.
Из того, что кажется более полезным, чем гонка с 90% до 100% coverage, — это написание мутационных тестов. Ничего не скажу нового относительно статьи 2012 года. Но на практике не очень часто видел, чтобы применяли этот подход (да и сам я тоже, каюсь). Так может быть пора начинать?
— Как тестовые фреймворки помогают с unit-тестами? Какую часть работ они берут на себя? Чего не стоит ждать при использовании фреймфорков?
Marc Philipp: Хороший фреймворк позволяет очень быстро и легко писать простые unit-тесты и в то же время содержать мощные механизмы для проведения более сложных тестов. Например, он должен помочь вам подготовить тестовые данные и предоставить точки расширения, которые позволят вам повторно использовать одну и ту же логику во многих тестах. Но никакой фреймворк не решит за вас, что и как тестировать. Также он не может волшебным образом улучшить ваш проект, чтобы сделать его хорошо тестируемым.
— Какие элементы кода сложнее всего поддаются unit-тестированию? Как решается эта проблема у вас?
Всеволод Брекелов: Чем больше зависимостей — тем больше рутины, тем сложнее писать юнит тест. А в целом, не вижу каких-то особенных проблем, если честно. Хотя на тему unit тестов написано большое количество книг, из которых я ни одну не прочитал до конца. Может, поэтому я не обременен проблемами.
Например, сложно написать unit-тест, когда, скажем, конструктор объекта содержит в себе вермишели кода, но тогда можно советовать товарищам прочитать книжки,
например и ввести code review практику.
Что касается JavaScript кода, то там можно встретиться с различными сложностями и внезапностями (да, я очень люблю JavaScript), скорее связанными с используемым фреймворком, например, работа с digest’ом. Я использовал только AngularJS/Angular2/Angular4. Несмотря на старания команды Angular сделать удобно-тестируемый фреймворк, все равно периодически сталкиваешься с проблемами, которые безусловно имеют решения, мы ведь инженеры.
Огромный массив информации о всех аспектах тестирования ждет участников на ближайшем Гейзенбаге, где Mark Phillip прочтет доклад «JUnit 5 — The New Testing Framework for Java and Platform for the JVM».
О том, какие еще знаковые фигуры выступят на конференции и смогут ответить на самые актуальные вопросы в кулуарах, можно узнать на сайте.
Юнит-тесты. Быстрый старт – эффективный результат (с примерами на C++)
Вместо вступления
Всем привет! Сегодня хотелось бы поговорить о том, как просто и с удовольствием писать тестируемый код. Дело в том, что в нашей компании мы постоянно контролируем и очень ценим качество наших продуктов. Еще бы – ведь с ними ежедневно работают миллионы человек, и для нас просто недопустимо подвести наших пользователей. Только представьте, наступил срок сдачи отчетности, и вы тщательно и с удовольствием, используя заботливо разработанный нами пользовательский интерфейс СБИС, подготовили документы, еще раз перепроверили каждую циферку и вновь убедились, что встречи с вежливыми людьми из налоговой в ближайшее время не будет. И вот, легким нажатием мыши кликаете на заветную кнопку «Отправить» и тут БАХ! приложение вылетает, документы уничтожаются, жарким пламенем пылает монитор, и кажется, люди в погонах уже настойчиво стучат в двери, требуя сдачи отчетности. Вот как-то так все может и получиться:
Фух… Ну, согласен, с монитором, наверное, все-таки погорячился 😉 Но все же возникшая ситуация может оставить пользователя нашего продукта не в самом благостном состоянии духа.
Так вот, поскольку мы в Тензоре дорожим моральным состоянием наших клиентов, то для нас очень важно, чтобы разработанные нами продукты были всеобъемлюще протестированы — у нас в компании во многом это обеспечивают почти что 300 тестировщиков, контролирующих качество наших продуктов. Однако мы стараемся, чтобы качество контролировалось на всех этапах разработки. Поэтому в процессе разработки мы стараемся использовать автоматизированное юнит-тестирование, не говоря уже об интеграционных, нагрузочных и приемных тестах.
Однако на сегодняшний день из нашего опыта собеседований можно отметить, что не все владеют навыками создания тестируемого кода. Поэтому мы хотим рассказать «на пальцах» о принципах создания тестируемого кода, а также показать, как можно создавать юнит-тесты, которые легки в поддержке и модернизации.
Изложенный ниже материал во многом был представлен на конференции C++ Russia, так что вы можете его почитать, послушать и даже посмотреть.
Характеристики хороших юнит-тестов
Одной из первых задач, с которой приходится сталкиваться при написании любого автоматически выполняемого теста, является обработка внешних зависимостей. Под внешней зависимостью будем понимать сущности, с которыми взаимодействует тестируемый код, но над которыми у него нет полного контроля. К таким неподконтрольным внешним зависимостям можно отнести операции, требующие взаимодействия с жестким диском, базой данных, сетевым соединением, генератором случайных чисел и прочим.
Надо сказать, что автоматизированное тестирование можно производить на разных уровнях системы, но мы рассмотрим вопросы, связанные именно с юнит-тестами.
Для наиболее ясного понимания принципов, положенных в основу приведенных ниже примеров, код был упрощен (так, например, опущены квалификаторы const). Сами же примеры тестов реализованы с использованием библиотеки GoogleTest.
Одно из наиболее важных отличий интеграционного теста от юнит-теста в том, что юнит-тест имеет полный контроль над всеми внешними зависимостями. Это позволяет достичь того, что отдельно взятый юнит-тест обладает следующими свойствами:
Хороший юнит-тест выполняется быстро. Потому что если в проекте тестов много, и прогон каждого из них будет длительным, то прогон всех тестов займет уже значительное время. Это может привести к тому, что при изменениях кода прогон всех юнит-тестов будет производиться все реже, из-за этого время получения реакции системы на изменения увеличится, а значит увеличится и время обнаружения внесенной ошибки.
Говорят, что у некоторых с тестированием приложений все складывается гораздо проще, но нам, простым смертным, не обладающим такой скоростной вертушкой, приходится не так сладко. Так что будем разбираться дальше.
Юнит-тестирование. С чего все начинается
Написание любого юнит-теста начинается с выбора его имени. Один из рекомендуемых подходов к наименованию юнит-теста – формировать его имя из трех частей:
— имя тестируемой рабочей единицы
— сценарий теста
— ожидаемый результат
Таким образом, мы можем получить, например, такие имена: Sum_ByDefault_ReturnsZero, Sum_WhenCalled_CallsTheLogger. Они читаются как завершенное предложение, а это повышает простоту работы с тестами. Чтобы понять, что тестируется, достаточно, без вникания в логику работы кода, просто прочитать названия тестов.
Но в ряде случаев с логикой работы тестового кода все-таки нужно разбираться. Чтобы упростить эту работу, структуру юнит-теста можно формировать из трех частей:
— часть Arrange — здесь производится создание и инициализация требуемых для проведения теста объектов
— часть Act — собственно проведение тестируемого действия
— часть Assert — здесь производится сравнение полученного результата с эталонным
Для того чтобы повысить читабельность тестов рекомендуется эти части отделять друг от друга пустой строкой. Это сориентирует тех, кто читает ваш код, и поможет быстрее найти ту часть теста, которая их интересует больше всего.
При покрытии логики работы кода юнит-тестами каждый модуль тестируемого кода должен выполнять одно из следующих действий. Так, тестированию можно подвергать:
— возвращаемый результат
— изменение состояния системы
— взаимодействие между объектами
В первых двух случаях мы сталкиваемся с задачей разделения. Она заключается в том, чтобы не вводить в средства тестирования код, над которым мы не имеем полного контроля. В последнем случае приходится решать задачу распознавания. Она заключается в том, чтобы получить доступ к значениям, которые недоступны для тестируемого кода: например, когда нужен контроль получения логов удаленным web-сервером.
Чтобы писать тестируемый код, надо уметь реализовывать и применять по назначению поддельные объекты (fake objects).
Существует несколько подходов к классификации поддельных объектов, Мы рассмотрим одну из базовых, которая соответствует задачам, решаемым в процессе создания тестируемого кода.
Она выделяет два класса поддельных объектов: stub-объекты и mock-объекты. Они предназначены для решения разных задач: stub-объект – для решения задачи разделения, а mock-объект – для решения задачи распознавания. Наибольшая разница заключается в том, что при использовании stub-объекта assert (операция сравнения полученного результата с эталонным) производится между тестовым и тестируемым кодом, а использование mock-объекта предполагает его анализ, который и показывает пройден тест или нет.
Если логику работы можно протестировать на основе анализа возвращаемого значения или изменения состояния системы, то так и сделайте. Как показывает практика, юнит-тесты, которые используют mock-объекты сложнее создавать и поддерживать, чем тесты, использующие stub-объекты.
Рассмотрим приведенные принципы на примере работы с унаследованным (legacy) кодом. Пусть у нас есть класс EntryAnalyzer, представленный на рис. 1, и мы хотим покрыть юнит-тестами его публичный метод Analyze. Это связано с тем, что мы планируем изменять этот класс, или же хотим таким образом задокументировать его поведение.
Для покрытия кода тестами определим его внешние зависимости. В нашем случае этих зависимостей две: работа с базой данных и работа с сетевым соединением, которая проводится в классах WebService и DatabaseManager соответственно.
Рис.1. Код тестируемого класса, не пригодный для покрытия юнит-тестами
Таким образом, для класса EntryAnalyzer они и являются внешними зависимостями. Потенциально, между проверкой dbManager.IsValid и финальной инструкцией «return true» может присутствовать код, требующий тестирования. При написании тестов получить доступ к нему мы сможем только после избавления от существующих внешних зависимостей. Для упрощения дальнейшего изложения такой дополнительный код не приведен.
Теперь рассмотрим способы разрыва внешних зависимостей. Структура данных классов приведена на рис. 2.
Рис.2. Структура классов для работы с сетевым соединением и базой данных
Для написания тестируемого кода очень важно уметь разрабатывать, опираясь на контракты, а не на конкретные реализации. В нашем случае контрактом исходного класса является определение, валидно или нет имя ячейки (entry).
На языке С++ данный контракт может быть задокументирован в виде абстрактного класса, который содержит виртуальный метод IsValid, тело которого определять не требуется. Теперь можно создать два класса, реализующих этот контракт: первый будет взаимодействовать с базой данных и использоваться в «боевой» (production) версии нашей программы, а второй будет изолирован от неподконтрольных зависимостей и будет использоваться непосредственно для проведения тестирования. Описанная схема приведена на рис. 3.
Рис.3. Введение интерфейса для разрыва зависимости от взаимодействия с базой данных
Пример кода, позволяющий осуществить разрыв зависимости, в нашем случае от базы данных, представлен на рис. 4.
Рис.4. Пример классов, позволяющих осуществить разрыв зависимости от базы данных
В приведенном коде следует обратить внимание на спецификатор override у методов, реализующих функционал, заданный в интерфейсе. Это повышает надежность создаваемого кода, так как он явно указывает компилятору, что сигнатуры этих двух функций должны совпадать.
Также следует обратить внимание на объявление деструктора абстрактного класса виртуальным. Если это выглядит удивительно и неожиданно, то можно сгонять за книгой С. Майерса “Эффективное использование С++” и читать ее взахлеб, причем особое внимание уделить приведенному там правилу №7;).
Разрыв зависимости с использованием stub-объектов
Рассмотрим шаги, которые нужны для тестирования нашего класса EntryAnalyzer. Как было сказано выше, реализация тестов с использованием stub-объектов несколько проще, чем с использование mock-объектов. Поэтому сначала рассмотрим способы разрыва зависимости от базы данных.
Способ 1. Параметризация конструктора
Вначале избавимся от жестко заданного использования класса DatabaseManager. Для этого перейдем к работе с указателем, типа IDatabaseManager. Для сохранения работоспособности класса нам также нужно определить конструктор «по умолчанию», в котором мы укажем необходимость использования «боевой» реализации. Внесенные изменения и полученный видоизмененный класс представлены на рис. 5.
Рис.5. Класс после рефакторинга, который позволяет осуществить разрыв зависимости от базы данных
Для внедрения зависимости следует добавить еще один конструктор класса, но теперь уже с аргументом. Этот аргумент как раз и будет определять, какую реализацию интерфейса следует использовать. Конструктор, который будет использоваться для тестирования класса, представлен на рис. 6.
Рис.6. Конструктор, используемый для внедрения зависимости
Теперь наш класс выглядит следующим образом (зеленой рамкой обведен конструктор, используемый для тестирования класса):
Рис.7. Рефакторинг класса, позволяющий осуществить разрыв зависимости от базы данных
Теперь мы можем написать следующий тест, демонстрирующий результат обработки валидного имени ячейки (см. рис. 8):
Рис.8. Пример теста, не взаимодействующего с реальной базой данных
Изменение значения параметра конструктора fake-объекта влияет на результат выполнения функции IsValid. Кроме того, это позволяет повторно использовать fake-объект в тестах, требующих как утвердительные, так и отрицательные результаты обращения к базе данных.
Рассмотрим второй способ параметризации конструктора. В этом случае нам потребуется использование фабрик — объектов, которые являются ответственными за создание других объектов.
Вначале проделаем все те же шаги по замене жестко заданного использования класса DatabaseManager – перейдем к использованию указателя на объект, реализующий требуемый интерфейс. Но теперь в конструкторе «по умолчанию» возложим обязанности по созданию требуемых объектов на фабрику.
Получившаяся реализация приведена на рис. 9.
Рис. 9. Рефакторинг класса с целью использования фабрик для создания объекта, взаимодействующего с базой данных
С учетом введенного фабричного класса, сам тест теперь можно написать следующим образом:
Рис.10. Еще один пример теста, не взаимодействующего с реальной базой данных
Важное отличие данного подхода от ранее рассмотренного – использование одного и того же конструктора для создания объектов как для «боевого», так и для тестового кода. Всю заботу по созданию требуемых объектов берет на себя фабрика. Это позволяет разграничить зоны ответственности классов. Конечно, человеку, который будет разбираться с вашим кодом, потребуется некоторое время для понимания взаимоотношений этих классов. Однако в перспективе этот подход позволяет добиться более гибкого кода, приспособленного для долгосрочной поддержки.
Способ 2. «Выделить и переопределить»
Рассмотрим еще один поход к разрыву зависимости от базы данных — «Выделить и переопределить» (Extract and override). Возможно, его применение покажется более простым и таких вот эмоций не вызовет:
Его основная идея в том, чтобы локализовать зависимости «боевого» класса в одной или нескольких функциях, а затем переопределить их в классе-наследнике. Рассмотрим на практике этот подход.
Начнем с локализации зависимости. В нашем случае зависимость заключается в обращении к методу IsValid класса DatabaseManager. Мы можем выделить эту зависимость в отдельную функцию. Обратите внимание, что изменения следует вносить максимально осторожно. Причина – в отсутствии тестов, с помощью которых можно удостовериться, что эти изменения не сломают существующую логику работы. Для того чтобы вносимые нами изменения были наиболее безопасными, необходимо стараться максимально сохранять сигнатуры функций. Таким образом, вынесем код, содержащий внешнюю зависимость, в отдельный метод (см. рис. 11).
Рис.11. Вынесение кода, содержащего внешнюю зависимость в отдельный метод
Каким же образом можно провести тестирование нашего класса в этом случае? Все просто – объявим выделенную функцию виртуальной, отнаследуем от исходного класса новый класс, в котором и переопределим функцию базового класса, содержащего зависимость. Так мы получили класс, свободный от внешних зависимостей – и теперь его можно смело вводить в средства тестирования для покрытия тестами. На рис. 12 представлен один из способов реализации такого тестируемого класса.
Рис.12. Реализация метода «Выделить и переопределить» для разрыва зависимости
Сам тест теперь можно написать следующим образом:
Рис.13. И еще один пример теста, не взаимодействующего с реальной базой данных
Описанный подход является одним из самых простых в реализации, и его полезно иметь в арсенале своих навыков.
Разрыв зависимости с использованием mock-объектов
Теперь мы умеем разрывать зависимости от базы данных с использованием stub-объектов. Но у нас еще осталась необработанной зависимость от удаленного web-сервера. С помощью mock-объекта мы можем разорвать эту зависимость.
Что же надо для этого сделать? Здесь нам пригодится комбинация из уже рассмотренных методов. Вначале локализуем нашу зависимость в одной из функций, которую затем объявим виртуальной. Не забываем при этом сохранять сигнатуры функций! Теперь выделим интерфейс, определяющий контракт класса WebService и вместо явного использования класса будем использовать указатель unique_ptr требуемого типа. И создадим класс-наследник, в котором эта виртуальная функция будет переопределена. Полученный после рефакторинга класс представлен на рис. 14.
Рис.14. Класс после рефакторинга, подготовленный для разрыва зависимости от сетевого взаимодействия
Введем в класс-наследник указатель shared_ptr на объект, реализующий выделенный интерфейс. Все, что нам осталось — это использовать метод параметризации конструктора для внедрения зависимости. Теперь наш класс, который теперь можно протестировать, выглядит следующим образом:
Рис.15. Тестируемый класс, позволяющий осуществить разрыв зависимости от сетевого взаимодействия
И теперь мы можем написать следующий тест:
Рис.16. Пример теста, не взаимодействующего с сетевым соединением
Таким образом, внедрив зависимость с помощью параметризации конструктора, на основе анализа состояния mock-объекта мы можем узнать, какие сообщения будет получать удаленный web-сервис.
Рекомендации для создания тестов, легких для поддержки и модернизации
Рассмотрим же теперь подходы к построению юнит-тестов, которые легки в поддержке и модернизации. Возможно, во многом это опять же связано с недоверием к самому себе.
Первая рекомендация заключается в том, что один тест должен тестировать только один результат работы. В этом случае, если тест не проходит, то можно сразу однозначно сказать, какая часть логики работы «боевого» кода не прошла проверку. Если же в одном тесте содержится несколько assert, то без повторного прогона теста и последующего дополнительного анализа тяжело однозначно сказать, где именно была нарушена логика.
Вторая рекомендация в том, что тестированию следует подвергать только публичные методы класса. Это связано с тем, что публичные методы, по сути, определяют контракт класса — то есть тот функционал, который он обязуется выполнить. Однако конкретная реализация его выполнения остается на его усмотрение. Таким образом, в ходе развития проекта может быть изменен способ выполнения того или иного действия, что может потребовать изменения логики работы приватных методов класса. В итоге это может привести к непрохождению ряда тестов, написанных для приватных методов, хотя сам публичный контракт класса при этом не нарушен. Если тестирование приватного метода все-таки требуется, рекомендуется найти публичный метод у класса, который его использует и написать тест уже относительно него.
Однако порой тесты не проходят, и приходится разбираться, что же пошло не так. При этом довольно неприятная ситуация может возникнуть, если ошибка содержится в самом тесте. Как правило, в первую очередь причины непрохождения мы начинаем искать именно в логике работы тестируемого «боевого» кода, а не самого теста. В этом случае на поиск причины непрохождения может быть потрачена куча времени. Для того чтобы этого избежать, надо стремиться к тому, чтобы сам тестовый код был максимально простым – избегайте использования в тесте каких-либо операторов ветвления (switch, if, for, while и пр.). Если же необходимо протестировать ветвление в «боевом» коде, то лучше написать два отдельных теста для каждой из веток. Таким образом, типовой юнит-тест можно представить как последовательность вызовов методов с дальнейшим assert.
Рассмотрим теперь следующую ситуацию: есть класс, для которого написано большое количество тестов, например, 100. Внутри каждого из них требуется создание тестируемого объекта, конструктору которого требуется один аргумент. Однако с ходом развития проекта, ситуация изменилась — и теперь одного аргумента недостаточно, и нужно два. Изменение количества параметров конструктора приведет к тому, что все 100 тестов не будут успешно компилироваться, и для того чтобы привести их в порядок придется внести изменения во все 100 мест.
Чтобы избежать такой ситуации, давайте следовать хорошо известному нам всем правилу: «Избегать дублирования кода». Этого можно добиться за счет использования в тестах фабричных методов для создания тестируемых объектов. В этом случае при изменении сигнатуры конструктора тестируемого объекта достаточно будет внести соответствующую правку только в одном месте тестового проекта.
Это может значительно сократить время, затрачиваемое на поддержку существующих тестов в работоспособном состоянии. А это может оказаться особенно важным в ситуации, когда в очередной раз нас будут поджимать все сроки со всех сторон.
Стало интересно? Можно погрузиться глубже.
Для дальнейшего и более подробного погружения в тему юнит-тестирования советую книгу Roy Osherove «The art of unit testing». Кроме того, довольно часто также возникает ситуация, когда требуется внести изменения в уже существующий код, который не покрыт тестами. Один из наиболее безопасных подходов заключается в том, чтобы вначале создать своеобразную «сетку безопасности» — покрыть его тестами, а затем уже внести требуемые изменения. Такой подход очень хорошо описан в книге М. Физерса «Эффективная работа с унаследованным кодом». Так что освоение описанных авторами подходов может принести нам, как разработчикам, в арсенал очень важные и полезные навыки.
Спасибо за уделенное время! Рад, если что-то из выше изложенного окажется полезным и своевременным. С удовольствием постараюсь ответить в комментариях на вопросы, если такие возникнут.