Меня зовут Сергей, и я инженер автоматизации тестирования.
Компания, в которой я сейчас работаю, занимается разработкой программного обеспечения, краеугольным камнем которого являются различные алгоритмы: расчёта значений, построения графов связей, проверки состояний и т.п. В связи с этим, нам очень важно уделять особое внимание unit-тестированию.
Один из моих коллег-автоматизаторов упомянул, что к нему обращаются разработчики с вопросом: "А как написать unit-тест?". Не конкретный тест, а "в принципе". Это послужило для меня поводом подготовить эту статью, и адресована она молодым программистам. Они смогут ознакомиться с рекомендациями, которым стоит следовать при разработке unit-тестов. Но также может быть любопытна и QA-инженерам - ведь полезно получить представление об аспектах тестирования, выполняемого разработчиками.
Материал подготовлен на основании книги "The Art of Unit Testing, Second Edition" за авторством Роя Ошерова (ISBN: 9781617290893). Настойчиво рекомендую к прочтению, так как там тема раскрыта более полно, и с практическими примерами.
Что такое unit-тест?
Для начала, мне стоит дать определение, которого я придерживаюсь, когда говорю о unit-тесте.
Unit-тест - это автоматизированный код, который вызывает исполнение тестируемого модуля, и проверяет один из результатов его работы.
Этот код надежный, читаемый, поддерживаемый. Вместе с тем, что очень важно, этот код не имеет внешних зависимостей, и имеет полный контроль над объектом тестирования. Именно это отличает unit-тесты от интеграционных.
Если рассмотреть свойства unit-теста, то можно прийти к следующему набору качеств, которые такой тест должен иметь:
Исполняется автоматически и часто. Для того чтобы это качество было, тесты должны быть интегрированы в процессы CI. Код приложения часто изменяется, и необходимо контролировать его качество как минимум с этой же частотой. Минимум тесты должны исполняться в момент сборки. Лучше расширить список поводов для запуска тестов, и включить в него: ежедневные (ночные) запуски; запуск перед поставкой; запуск перед выгрузкой кода в репозиторий (пушем).
Легкий во внедрении. Подобный тест должно быть легко (быстро) разработать и добавить к тестовому набору. Если вы видите, что тест разрабатывать долго, то это признак того, что или вы пишете не unit-тест, или вы неправильно выбрали "размеры" объекта тестирования. Да, тестируемые модули могут быть разными по размеру. Это могут быть и отдельные методы, и несколько классов.
Актуален (релевантен) в любое время. Это значит, что тест не теряет актуальность до тех пор, пока объект тестирования актуален (не подвергся изменениям или не удален). И не должно быть никаких других условий релевантности.
Легко исполняемый. Каждый участник команды разработки должен иметь возможность запустить тест. Как локально, так и на CI-сервере. Это позволит быть уверенным любому, что он не сломал чужой код.
Быстрый. Unit-тесты исполняются если не за доли секунды, то за секунды. Это является гарантией того, что они будут исполняться часто. Никому не хочется долго ждать завершения тестов. И, зачастую, продолжительные тесты просто не выгодно запускать, если у нас есть ограничения (к примеру, небольшое количество сборщиков на CI; или нам срочно нужно сделать поставку кода).
Консистентный. Всегда должен быть один и тот же результат при каждом исполнении теста. Это одно из главных условий стабильности тестов.
Имеет полный контроль над объектом тестирования (модулем). Это значит, что тест исключает и подменяет "общение" модуля с любыми внешними источниками: БД, файловая система, системное время, различные генераторы и т.д. В противном случае, тест не может быть консистентным.
Прост в анализе. Для облегчения анализа причин неуспешной проверки, как минимум, тест должен иметь понятную архитектуру, и сообщать какой результат актуальный, а какой ожидаемый.
Чтобы добиться получения и сохранения описанных качеств, необходимо следовать некоторым правилам.
Базовые правила разработки unit-тестов
Начну с самого очевидного – разрабатывайте unit-тесты в специализированном фреймворке. Независимо от языка программирования, который вы используете, возможно найти подходящий xUnit фреймворк, который предоставит возможность:
Разрабатывать структурированные тесты. Как минимум, у вас будут атрибуты, которые будут помечать методы как "тесты".
Использовать готовые методы для разных типов проверок. Использование таких методов увеличит читаемость кода и упростит анализ исполненных тестов.
Формировать тестовые наборы. Вы сможете логически объединять тесты в группы, основываясь на каком-нибудь признаке. Например, относительно функционала.
Видеть успешность проверок в момент их исполнения.
Проводить анализ исполненных тестов: сколько тестов исполнилось, сколько не исполнялось, какие результаты проверок, причины провалов, и т.д.
Разрабатывать параметризованные тесты. А значит драматично уменьшить количество необходимых строк кода при сохранении уровня тестового покрытия.
К сожалению, использование подобных фреймворков не даст гарантии того, что ваши тесты читаемы, поддерживаемы, и имеют достаточное покрытие. Но об этом далее.
После того как выбран фреймворк, следует продумать, и придерживаться единых и понятных правил структурирования и наименования тестов.
Для начала решите - использовать отдельный проект для unit-тестов, или ваш проект позволяет добавить их в существующую архитектуру. Оцените: должны ли ваши тесты быть частью поставки, будет ли участникам команды удобно разрабатывать тесты в едином проекте, и т.д.
Для каждого объекта тестирования используйте отдельные классы с тестами. Напоминаю, что в качестве объекта тестирования может выступать как отдельная функция, так и несколько классов.
Давайте вашим тестам понятные и ёмкие названия. Считается хорошей практикой использовать следующий шаблон для наименования: ОбъектТестирования_ПроверяемыйСценарий_ОжидаемыйРезультат. На пример: UserLogon_UnknownLogin_Code401Returns. И не опасайтесь того, что сигнатуры тестов непривычно длинные, не соответствуют стандартам остального кода. Это просто особенность, которая позволяет увеличить читаемость, понятность теста.
И вот, фреймоворк выбран, структура подготовлена. Можно начать писать тесты. Но что же проверять? Следует соблюдать следующий порядок, при выборе результата взаимодействия с тестируемым объектом: возвращаемое значение, переход в состояние, взаимодействие с другими объектами. Есть некий объект тестирования и мы должны в первую очередь проверить значения, которые он возвращает. Далее проверяем изменения состояния объекта тестирования. И в последнюю очередь проверятся взаимодействие с другими объектами (в этом помогут фикции, о них позже).
А как тесты должны выглядеть? В первую очередь – одинаково! Придерживайтесь единой структуры кода в тестах. Чтобы облегчить читаемость кода, стоит стараться разрабатывать как можно более похожие, и понятные тесты. В общем виде, тест должен иметь следующую структуру:
Объявление, создание, настройка объекта тестирования. Фактически, тут вы определяете то, что, и в каких условия будет тестироваться.
Взаимодействие с объектом тестирования. Происходит вызов метода с нужными параметрами.
Проверка результата. Результат должен проверяться при помощи нужных методов, которые вам предоставляет выбранный фреймворк тестирования.
Проектируйте ваши тесты без использования "Before" (или "SetUp") и "After" (или "TearDown") методов. Упомянутые методы необходимы, соответственно, для приведения объекта тестирования к нужному состоянию, и для возвращения состояния к первоначальному. Если после исполнения тестового метода вам нужно "откатывать" состояние, значит вы разрабатываете интеграционный тест. Вам ведь не это было нужно?
По мере разработки тестов, начнете замечать, что вы волей-неволей разрабатываете тесты, в основном, с позитивными (правильное выполнения функции) или негативными проверками (исключительные ситуации). Постарайтесь держать фокус на их разумном соотношении. Чтобы иметь максимальную уверенность в том, что код работает, и будет работать как ожидается, вам нужно разработать такой набор тестов, который покроет все возможные пути. С идеями для тестов вам могут помочь QA-инженеры. Они имеют специальные знания и навыки для этого.
А теперь об одной из важнейших сторон unit-тестирования. Использование симуляций. Симуляция позволит не только обеспечить консистентность, быстроту исполнения, но и выполнять проверки исключительных ситуаций, которые весьма трудно произвести с реальными внешними зависимостями. Если кратко - позволит обеспечить полный контроль над объектом тестирования.
Симулируйте взаимодействие объекта тестирования с внешними зависимостями. В качестве внешних зависимостей могут выступать разные объекты: файловая система, потоки, программные интерфейсы, время и т.д. Для симуляции используйте фикции (mock) или заглушки (stub). Конечно, для того чтобы была возможность с легкостью подменять внешние зависимости симуляцией, ваш код должен иметь достаточный уровень абстрактности (но не увлекайтесь, иначе он станет излишне запутанным), и соответствовать принципу открытости-закрытости. Про то, как сделать свой код более тестопригодным, можно узнать из книг "Working Effectively with Legacy Code" за авторством Майкла Физерса (ISBN 978-5-8459-1530-6) и "Clean Code" Роберта Мартина (ISBN 9780132350884).
Инкапсулируйте код исходя из того, что его модули будут объектами тестирования. Предусмотрите возможность изменять область видимости при необходимости. Желательно не использовать отражения (reflection) для доступа к полям и методам. Зачастую лучше отказаться от проверки приватных методов вовсе, так как необходимо вносить изменения в объект тестирования, и тест не сможет считаться «чистым». Нам это не нужно. Полезнее найти публичный метод, который использует нужный приватный метод, и сконцентрироваться на его проверке. Это даст больше гарантий того, что всё отрабатывает как ожидается.
Соблюдение приведенных правил позволит вам приблизиться к главным свойствам unit-тестов: надежности, поддерживаемости и читаемости. Чтобы уже разработанные тесты не потеряли со временем эти свойства, необходимо поддерживать их актуальность. Всегда проводите анализ существующего тестового набора, когда: выявляются дефекты пользователем; выявляются дефекты в самом тесте; вносятся изменения в объект тестирования; выявляются конфликтующие/дублирующие тесты.
Бонус: базовые техники тест-дизайна
Собственно говоря, а чем разработчику могут помочь QA-инженеры? Как минимум со сценариями тест-кейсов. Ниже перечислены основные техники, которые помогают при наименьшем количестве тестов, добиться хорошего тестового покрытия.
Классы эквивалентности. Это хорошее решение для случаев, когда вы имеете дело с большим количеством вариантов данных для ввода.
Граничные значения. Тут данные тоже группируются по эквивалентным классам, но фокусируются проверки на значения, которые находятся на «границах» классов.
Таблица состояний. Этот метод эффективен при создании наборов тестов для систем со множеством вариаций состояний. Он предназначен для тестирования последовательности событий с конечным числом входных параметров.
Попарное тестирование. Суть метода – сопоставление данных. Комбинаторика приходит нам на помощь, когда нужно охватить тестами максимум функционала, и при этом потратить минимальное время.
Эти и многие другие техники хорошо известны QA-инженерам. Не стесняйтесь обращаться к ним.
Вместо заключения
Хочу обратиться ко всем разработчикам (если кто-нибудь из них добрался до этих строк). Код, который не покрыт тестами – это legacy код. Никому не хочется с ним работать. Поэтому, пожалуйста, пишите unit-тесты. А QA-инженеры, я надеюсь, окажут вам в этом посильную помощь.