Что такое модульное тестирование
Модульное тестирование: что, зачем и почему
Если вы когда-нибудь собирали мебель из «Икеи», вам наверняка знакомо это неприятное чувство, когда в самом конце ящик вдруг не встает на рельсы, а дверца шкафа оказывается перевернутой. Вы внимательно просматриваете инструкцию и с раздражением замечаете, что где-то на первых шагах пропустили важную деталь. Теперь нужно все аккуратно разбирать и начинать заново.
В разработке ПО такие ситуации случаются сплошь и рядом — какой-то модуль отказывается работать из-за сбоя какой-то небольшой функции. Только, в отличие от икеевской мебели, найти и исправить такую ошибку в простыне кода ок азывается гораздо сложнее и дороже. Чтобы не столкнуться с этими проблемами, разработчики и QA-инженеры используют модульное тестирование, проверяя работоспособность каждого программного элемента сразу после его написания. В нашей статье мы расскажем о базовых принципах и преимуществах юнит-тестирования. Подробнее — на нашем курсе «Тестировщик ПО».
Что такое модульное тестирование
Этот процесс еще называют блочным или юнит-тестированием. Последний вариант лучше всего отражает суть метода, поскольку юнит — это и есть простейший элемент приложения. Как правило, он работает с одним-двумя источниками информации и выдает один поток данных. Например, форма авторизации, которая принимает учетные данные, сверяет их с базой и выдает один результат (пустить пользователя или отказать в доступе).
Разработчик проводит юнит-тестирование с помощью метода «прозрачного ящика» (white box) — он видит весь необходимый код, поэтому он быстро может написать и провести нужные ему тесты. Программист сам определяет, какие данные нужно «скормить» системе и какие результаты нужно получить на выходе.
В большинстве случаев модульное тестирование можно и нужно автоматизировать, хотя при желании можно использовать и ручной метод. Если разработчик предпочитает автоматизировать процесс, он встраивает тест прямо в код и убирает его перед сдачей своего участка работы. Тестируемый юнит также можно изолировать, выделив ему собственную среду разработки. Это помогает ненужные связи между разным модулями системы, от которых можно сразу избавиться.
Чтобы добиться полной автоматизации, программисты применяют специализированные фреймворки, которые позволяют встраивать критерии работоспособности в условия теста и вести журналы ошибок. Этот способ максимально защищает процесс от человеческого фактора.
Почему вам необходимо тестировать юниты
Эффективность модульного тестирования для процесса разработки переоценить очень сложно — если вы уверены, что каждый отдельный процесс работает, как нужно, вы не только страхуетесь от множества проблем, но и упрощаете жизнь всем участникам команды, а также всем, кто будет разбираться в коде после вас. Пропустите юнит-тестирование — и все последующие баги придется отлавливать вслепую.
Попытки сэкономить время на юнит-тестировании всегда оборачиваются серьезными затратами на следующих этапах разработки. В традиционной модели именно с этих тестов начинаются испытания системы — только работоспособные юниты можно отдавать на интеграционное тестирование, затем на системное и на приемное. Каждый из этих этапов предполагает, что все элементы приложения работают должным образом. Так что если какая-то форма вдруг выдаст ошибку, это затормозит всю работу над продуктом.
Помимо прочего, результаты юнит-тестирования сильно помогают с подготовкой проектной документации. А разработчики получают возможность использовать готовый код в других модулях системы — ведь они уверены, что в нем нет ошибок.
Итак, главные преимущества юнит-тестирования:
Стоит отметить, что есть проблемы, с которыми модульное тестирование вам не поможет. В продукте все равно неизбежно будут ошибки — даже в самых простых системах есть процессы, которые могут вызывать конфликты. Поскольку при блочных испытаниях разработчики сосредотачиваются на базовых модулях будущего продукта, сбои интеграции и ошибки более высокого уровня нужно устранять с помощью других видов тестирования.
Unit-тестирование в сложных приложениях
Ни один разработчик в здравом уме и трезвой памяти при разработке сложных приложений (> 100K LOC, например) не станет отрицать необходимость использования тестирования вообще и модульного тестирования (unit tests) в частности. Это так же верно, как и то, что каждый разработчик постарается исключить бессмысленную работу из творческого процесса создания приложения. Где же та грань, которая отделяет необходимость от бессмысленности, если мы говорим о модульном тестировании в контексте сложных приложений? Пару своих соображений по этому поводу я изложил под катом.
Назначение
Модульное тестирование, или юнит-тестирование (англ. unit testing) — процесс в программировании, позволяющий проверить на корректность отдельные модули исходного кода программы.
Идея состоит в том, чтобы писать тесты для каждой нетривиальной функции или метода. Это позволяет достаточно быстро проверить, не привело ли очередное изменение кода к регрессии, то есть к появлению ошибок в уже оттестированных местах программы, а также облегчает обнаружение и устранение таких ошибок.
Все как бы понятно. Есть 5 строчек кода:
Есть юнит-тест для него (уже 10 строк, но это нормально для юнит-теста, когда количество строк в тесте превышает количество строк тестируемого кода):
Тест позволяет проверить логику работы кода и обнаружить ошибку в случае, если что-то или кто-то эту логику нарушил. Так как модульные тесты проверяют код отдельно от всего приложения, то они является очень простыми и очень быстрыми, и способны за очень короткое время оценить «здоровье» значительной части кода в разработке.
Модульные тесты сами по себе не гарантируют правильность функционирования всего приложения, но являются первым, базовым этапом в списке тестов:
(картинка взята из интернетов исключительно за свою треугольную форму и послойное перечисление некоторых типов тестирования; цифры процентов и прочие детали — несущественны в контексте изложенного;)
Тривиальная тривиальность
«… писать тесты для каждой нетривиальной функции или метода.«
С кодом, который имплементирует логику согласно заданной спецификации, все понятно. А что делать с кодом, где этой самой логики нет? Например, с акцессорами в DTO-like классах?
Сетевой разум относит такие случаи к тривиальным, несмотря на ненулевую вероятность поиметь в коде ошибку типа:
Вероятность подобной ошибки сильно повышается при массовом применении в коде прогрессивной техники «Find&Replace», а желание применить прогрессивную технику возрастает с ростом проекта и более полным погружением в детали предметной области.
Компромиссным вариантом между бессмысленностью и необходимостью может быть обращение к акцессорам при подготовке данных для тестирования других, менее тривиальных классов (таких, как сервисы), в которых используются DTO-like объекты, или проверять через assert’ы результат по возвращении:
Хотя в данном случае нарушается принцип изоляции тестируемого кода от остального кода приложения. Ну, на то он и компромисс, чтобы из двух очень хороших вариантов выбрать третий, не очень плохой.
Нетривиальная тривиальность
Все объектно-ориентированные разработчики рано или поздно натыкались на аббревиатуру SOLID (кто не натыкался — самое время), в которой первая буква «S» соответствует SRP — «класс должен иметь только одну обязанность«. Методичное и последовательное применение этого принципа приводит с одной стороны к упрощению кода отдельного класса, а с другой — к росту количества классов и связей между ними. Для преодоления проблемы роста с успехом используется модульный подход, многоуровневая архитектура и инверсия управления. В чистом остатке имеем сплошной профит в виде «упрощения кода отдельного класса«, вплоть до вот таких реализаций отдельных методов:
Тестирование подобного кода опять балансирует на грани между бессмысленностью и необходимостью — по сути тест сводится к созданию заглушек/моков и проверке, что будут вызваны соответствующие методы в соответствующем порядке. Точно такого же эффекта можно добиться гораздо быстрее, если делать контрольную копию файла с исходным кодом и сообщать при тестировании обо всех отклонениях текущего кода от контрольной копии.
Коллега Dimitar Ginev рекомендует разделять в подобных случаях код по двум категориям классов (orchestrator и decision makers) и покрывать тестами код только второй категории.
Code coverage
Замечательной метрикой для оценки качества кода явлется % покрытия кода тестами. Этот процент можно рассчитать как для отдельного файла с исходным кодом, так и для всей кодовой базы проекта (например, покрытие модульными тестами Magento 2.1.1). Покрытие кода дает возможность визуально оценить проблемные области в разрабатываем исходном коде и должно стремиться к 100% покрытию значимого кода. Причем, чем сложнее разрабатываемое приложение, тем больше значимого кода в нем, и большее значение начинает иметь стопроцентность покрытия. Модульные тесты являются очень хорошими кандидатами для использования их результатов при расчетах этой метрики опять таки в силу своей независимости (друг от друга и от остального, нетестируемого в данный момент кода) и скорости выполнения.
Покрытие всего кода в проекте можно довести до 100% двумя путями:
Первый способ подразумевает, что тесты должны будут создаваться также и для каждой тривиальной функции или метода, что увеличивает бессмысленность. Второй способо чреват пропуском покрытия тестами нетривиального кода, что отрицательно сказывается на необходимости.
Так где же баланс?
Так как сообщество сходится во мнении, что нет нужды в тестировании тривиального функционала, то вполне очевидно, что чем проще код или гениальнее разработчики, тем меньше поводов создавать тесты вообще и модульные тесты в частности. И наоборот, чем сложнее код или посредственнее разработчики, тем поводов больше. Т.е., если вы в одиночку разрабатываете проект на 100К строк кода, то вы вполне можете обойтись без тестов вообще, но как только к проекту подключается еще один разработчик (не такой гениальный, как вы), то необходимость создания тестов резко возрастает. А если этот разработчик еще и junior, то тесты становятся жизненно важны, т.к. даже ваша гениальность может спасовать перед тем энтузиазмом, с которым junior вносит ошибки в ваш любимый код.
Если на начальном этапе разработки вполне можно исключить из модульного тестирования тривиальный код (акцессоры и оркестраторы), то чем больше становится проект и чем больше народа работает над проектом, тем меньше в нем остается тривиального кода. В предельном случае, когда код общедоступен (т.е., pull request может прийти от какого-нибудь скучающего охранника автостоянки, решившего в эту ночь побыть программистом), модульными тестами должна быть покрыта каждая строка кода, даже самая тривиальная.
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».
О том, какие еще знаковые фигуры выступят на конференции и смогут ответить на самые актуальные вопросы в кулуарах, можно узнать на сайте.