Тестирование в .NET. Часть 1 — Вступление
Зачем вам тесты? Пишите сразу без багов!…
Сегодня поднимем больную тему (для меня), но сил больше терпеть никаких не осталось — прошу услышать мою боль и я, надеюсь, мы встанем на одну сторону баррикад. Тема сложная, при всей кажущейся простоте и выгоде — не очевидная. Ведь до сих пор, даже в наше передовое время, тестирование всё ещё не является нормой в разработке ПО. Для большого количества небольших проектов и малых компаний это весьма редкое, необязательное и не оправдано дорогое удовольствие. Для всех, кто ещё не познал боль легаси кода, по-настоящему больших перемен функционала и пивот-изменений в проекте — тесты остаются модным излишеством и почти всегда их опускают вне обязательных требований к разработке программного обеспечения. Это печально (с). Об этом говорит статистика. Про это шутят и жалуются. Для нескольких моих знакомых подобное отношение к продукту стало последней каплей в пользу смены места работы.
И даже если у вас в рабочем проекте и\или в личном пет-репозитории — всё в порядке с тестами и покрытием, это не означает, что проблемы нет и я всё придумал. Тесты — важная часть современной правильной разработки и чем больше и опытнее компания, тем больше и лучше там обстоят дела с покрытием и тестированием (полноценным, не только юнит, а ещё и автоматические, интеграционные и мануальные). Соответственно, чем меньше проект/офис/компания — тем ниже процент покрытия (в среднем по палате, это статистическое правило).
По этому поводу, обессилив объяснять одно и тоже своим заказчикам и новым коллегам-программистам которые выросли в лесу, сегодня я хочу открыть цикл записей, которые будут посвящены тестированию. Пришло время покрыть этот вопрос: от самого простого — до более сложного. Мы рассмотрим несколько фреймворков, подходов, поговорим про инфраструктуру организации тестов, нейминг и прочие важные моменты процесса тестирования.
Тестирование программного обеспечение — Виды
Наверное, нужно дать словарное определение. Итак, тестирование программного обеспечения — это один из важнейших и ключевых процессов в цикле разработки, на ряду с проектированием, отладкой, документирования и т.д. Основной целью данного процесса является выявление проблем и дефектов в разработке на момент самой разработки (и чем раньше — тем лучше), т.е. до того момента, как программным комплексом начнут пользоваться реальные пользователи и\или заказчик. Наша основная задача — найти неточности между ожидаемым поведением и реальным поведением программы; найти и «обезвредить».
Тестирование, как и всё в IT — подвластно дихотомии, т.е. тестам свойственно множественное разветвление на типы и виды. Различают мануальное (ручное) тестирование, автоматизированное, модульное, интеграционное, регрессионное, тесты производительности и другие виды. Теперь кратко о каждом виде более подробно:
Мануальное (ручное) тестирование — вид тестирования, при котором проверку качества и соответствия производит специально обученный специалист (тестировщик), ответственный за качество продукта или какой-то конкретной его области. Основная задача такого человека — эмулировать действия реального пользователя. В той степени, в какой программное обеспечение планируется использовать. Кроме того, конечно, роль тестировщика могут выполнять и обычные пользователи (уже постфактум столкнувшись с проблемой и заполнив необходиый отчёт). Как правило, ничего выделенного из инструментария в данном случае не требуется, кроме, наверное, каких-то узкоспециализированных ситуаций или систем менеджмента тест-кейсов, ставшими стандартом де-факто.
Автоматизированное тестирование — данный вид тестирования предполагает наличие средств для автоматизированного (следуя из названия 🙂 ) прохода по функциям\компонентам программы. Данный вид тестирования используется там, где можно автоматизировать работу мануального тестировщика, с целью экономии средств и времени. Используется либо общепринятые фреймворки (для каждого языка свои), либо же какие-то иные комплексы, которые позволяют «эмулировать» в самом прямом смысле поведение пользователя. К примеру, тот же Selenium, как стандарт индустрии по эмуляции веб-браузера и действия пользователя в нём.
Модульное unit тестирование — в отличии от предыдущих двух вариантов тестирования, данное, как правило, является частью работы программисты. Модульный тесты — это проверка работоспособности каких-то конкретных элементов программного комплекса — от какого-либо конкретного метода, класса или до целых сложных взаимодействий первых и вторых. Как правило, юнит тесты «лежат» где-то недалеко от исходных кодов тестируемого ПО. Юнит тесты незаменимы для частых изменений и проверок новой функциональности, когда программист может в максимально сжатые сроки (буквально — секунды) проверить правильность своего кода. Частота и глубина подобного тестирования — называется покрытием (coverage) и изменяется в процентном соотношении, соответственно, чем ближе к 100% — тем лучше.
Интеграционное тестирование — одна из последних стадий тестирования, при которых проверяются не столько сами компоненты (подразумевается, что их работоспособность проверена уже ДО этого момента), сколько связность между элементами системы. С появлением микросервисной архитектуры, интеграционное тестирование стало важным шагом во всей конечной проверке ПО. Смысл заключается в том, что каждая из частей может работать как нужно, но вместе могут быть выявлены какие-то проблемы (т.н. интеграционные), связанные с тем, что контракты общения, транспорт и всё, что лежит между данными элементами — не продумано или реализовано с ошибками. Как правило, интеграционное тестирование — это отдельный шаг в автоматизированном тестировании.
Тестирование производительности — хорошо работающая программа в условиях проверки в «тепличных условиях» тестировщика может совершенно по-другому работать в боевых условиях нагрузки. Для предотвращения этих проблем используют тесты производительности. Результатом которых будет ответ о возможной массштабируемости, надёжности, а также общая картина по используемым ресурсам (будь-то ЦПУ или IO: жесткого диска или сети). Разделяют: нагрузочное тестирование, когда мы проверяем способна ли справится система с необходимым уровнем нагрузки; стресс-тесты — когда мы выявляем реальные пределы работоспособности с максимальной нагрузкой\трафиком; тесты стабильности — когда мы держим программу на определённом уровне нагрузки с целью выявить время, при котором программное обеспечение способно стабильно работать и т.д. Для всех вышеописанных вариантов существует специализированные приложения, утилиты или сторонние внешние сервисы, которые позволяют проводить подобные проверки и предоставляют развёрнутые отчёты производительности.
Регрессионное тестирование — тестирование, которое необходимо проводить над УЖЕ протестированными раннее компонентами. Цель — выявить (или, наоборот, убедиться, что ничего не изменилось) проблемы, связанные с нововведениями, которые тем или иным образом затрагивают ранее протестированное. Как правило регресс производится мануально.
Это, конечно, не весь список. Существуют наверняка и другие, но куда более редкие виды тестирования. Базой и фундаментом всего процесса тестирования являются все вышеперечисленные варианты и вариации на тему.
Какой путь нам интересен?
Если коротко, любое тестирование — важно и, банально, чем его больше, тем мы уменьшаем вероятность нахождения ошибки в боевых условиях Production-среды. Понятно, что всё добавить слишком дорого и сложно, но в моей практике, когда я работал в медицине и мы разрабатывали программный комплекс для контроля дозировки препаратов в кардиологической клинике — писались тесты на тесты на тесты. Иначе — никак, цена ошибки невероятно велика. С учётом того, что процент смертности при поступлении в подобные клиники — сам по себе высок, любая ошибка могла быть фатальной для конечных «пользователей». Но этот случай — конкретный и особенный. Для современной компании хорошо если есть unit-тестирование, мануальное и интеграционное. Я бы сказал, что это оптимальное соотношение. Прям чудесно если ещё появляется и нагрузочное (если оно нужно вообще).
В рамках данного цикла статей я хочу поговорить больше про модульное unit-тестирование в плоскости .NET разработки. Нам повезло, .NET богат и щедр на предлагаемые фреймворки тестирования, инструменты и окружение. Мы всё можем делать внутри одного решения (Solution), что называется не отходя от кассы. Мы рассмотрим MSTest (как самый простой и лёгкий), xUnit, NUnit и Moq. Все эти фреймворки позволяют создавать отдельные проекты с тестами, запускать их внутри Visual Studio (или другой IDE) или же прям из консоли, проверять отдельные методы, какие-то части или же целиков всё. Также мы имеем возможность запускать тесты в режиме Debug, создавать «плейлисты» тестов и т.д.
TDD и почему здесь не будет об этом
Развитие технологий, усложнения процессов доставки кода, а также увеличения числа и сложности инструментария так или иначе приводит к тому, что у нас вычленяются сначала бест-практики (паттерны наиболее оптимального поведения), а потом из них формируются парадигмы или целые методологии. И TDD — яркий тому пример.
TDD — разработка через тестирование (test driven development), циклическая парадигма разработки, когда мы начинаем написание нового кода с тестов. Не code first, а test first. Сначала мы пишем то, как код должен работать, что возвращать и что получать и уже потом непосредственно сам код, который должен проходить наш написанный ранее тест.
Сегодня, даже средствами «чистой» студии, без ReSharper и прочих дополнений и плагинов, мы можем создавать целые классы при помощи сниппетов или горячих клавиш. Мы можем сначала написать строчку кода, которая вызывает несуществующий метод и уже потом в один клик создать такой метод с уже подставленными аргументами и возвратом. Это позволяет поменять нисходящую «линейность» создания кода и, как следствие, изменить направление. TDD — об этом. Мы сначала пишем тест, потом — код, отвечающий требования данного теста. Это по сути меняет направление разработки диаметрально на противоположное. Эта вся круговерть выглядит следующим образом (как всегда, авторское исполнение):
К этому нужно привыкнуть, это неизбежно увеличивает время разработки, но добавляет бесценные бенефиты в виде стремящегося покрытия к отметке в 100% и абсолютная адаптивность к последующим изменениям. Это уменьшит регрессию и ИТОГО увеличит скорость — это игра в долгую, игра на дистанции.
Почему здесь мы не рассматриваем TDD? Всё просто — т.к. практика TDD требует умение написать модульный тест, мы начнём именно с модульных тестов. Мы рассмотрим в первую очередь то, что позволяет покрывать уже существующий код и уже после того как рассмотрим все фреймворки тестирования в .NET — перейдём к TDD. И это, скорее всего, будет отдельная, завершающая статья.
Как выглядит хороший тест?
Стоит отметить, что не весь код поддаётся тестированию. Чем более связный и цельный тестируемый код, тем сложнее писать к нему тесты. Если код с «душком» и благоухает от антипаттернов, легаси и архитектурно-проектировочных огрехов — покрыть подобное может стать проблемой. С другой стороны, возможно, эта ситуация отлично подходит как ТОТ САМЫЙ момент для старта рефакторинга — разбить код на компоненты, модули, уменьшить методы, подправить всё, увеличить читаемость и сделать всё более доступным и удобным для тестирования.
Хороший тест отвечает на вопросы. Много разных вопросов и часто весьма глупых. Если у нас есть гипотетический метод валидации Email-адреса мы должны подумать, а что будет если:
- … мы отправим NULL на вход;
- … мы проверим разные адреса с разными регистром букв (большие и маленькие);
- … мы поставим пробелы перед или в конце вводимого адреса;
- … мы поставим не печатные символы в строку адреса;
- … мы добавим + (плюс, один из ньюансов и хитростей организации почты в Gmail);
- … и т.д. и т.п.
Кроме всего прочего, хороший тест — идемпотентный, т.е. мы должны гарантировать, что тело теста имеет жесткую, прогнозируемую и однозначную интерпретацию относительно кода. Мы, по-возможности, должны стараться делать тесты изолированными и независимыми от внешней среды (подробнее про Moq и о том, как «мокать» сложные объекты — мы обязательно рассмотрим позже). Лаконичность и скорость исполнения — также отличная черта теста. Чем меньшую часть кода мы будем тестировать и чем больше таких «маленьких» тестов — тем лучше.
Хорошие тесты — однообразные и одинаковые по стилю. Это не то, что пойдёт в прод, это только то, что есть на вашей локальной машине и то, что будет прогоняться на одном из шагов CI. Это инфраструктурный куски кода, которые в конечном итоге будут вырезаны и не доступны для Production-среды. Тесты — это весьма рутинный и примитивный процесс. Тесты — это больше про дисциплину, чем про творчество. И, да. Не бойтесь тысяч тестов. Бойтесь, когда на ваш солюшн в 50 проектов у вас два десятка тестов или нет их вообще 🙂
Ещё немного теории
Перед тем как мы непосредственно перейдём к рассмотрению MSTest (как я уже говорил, это самый лёгкий вариант и он отлично подойдёт нам для старта), необходимо рассказать о некоторых общепринятых бест-практиках и парадигмах, которые повсеместно распространены. Их, как и водится, огромное количество, но упомянуть стоит как минимум две: TDD живёт в связке с AAA (Arrang — Act — Assert) и BDD в связке с GWT (Given — When — Then).
AAA (Arrange — Act — Assert)
Одна из наиболее популярных парадигм организации тела теста. Вся суть данного подхода проста и очевидна, а дисциплина повсеместного применения в тестовом проекте позволит вам держать единую структуру всех тестов. Весь смысл заключается в просто разделении кода на три части: arrange, act и assert, соответственно.
Arrange (упорядочивание) — первый шаг теста, в котором мы создаём необходимые нам объекты, создаём переменные и настраиваем всё необходимое, что нам понадобится для дальнейшей работы.
Act (действие) — непосредственно работы над уже созданными в предыдущем шаге объектами. Мы проводим некоторую работу, вызываем другие методы, получаем результаты.
Assert (Проверка) — мы проверяем правильность (или, наоборот, ожидаемую «не правильность») результатов, сравниваем и выводим результат.
GWT — Given — When — Then
GWT это термин из вселенной BDD (одна из методологий разработки программного обеспечения, вышедшая из идей тестирования путём TDD) и представляет собой краткий способ описания поведения программного обеспечения. Не стоит путать AAA и GWT, т.к. они весьма похожи на первый взгляд. Сходство, действительно, кажется обманчивым:
Given — то, что нам ДАНО изначально. В каких условиях мы стартуем тот или иной модуль. Стартовых условий может быть несколько и они, как правило, комбинируются при помощи логического И — нанизываются все вместе.
When — событие, триггер; то, что случается или происходит.
Then — результат.
GWT, подчёркиваю, это не про тестирование, а про разработку больше. В тестировании GWT принято применять (как один из вариантов) в наименовании названии тестов. Чтобы все тесты были одинаково названы: что мы имеем, как тестируем, что получить должны в результате.
Почти всё
И вместо заключения, хочется отметить, что практически всё вышеописанное считается сегодня (на момент написания статьи) передовым, что называется «правильным» тестированием. То, что вы найдёте в подавляющем большинстве проектов, в которых в принципе есть тесты. Я надеюсь, что некоторое интро у меня получилось… и, ОК, давайте будем считать, что с этим вступлением — мы вступили 🙂 Дальше будет проще и легче. Дальше — практическое прикладное применение MSTest и написание нашего первого проекта с покрытием на данном фреймворке. Должно быть интересно 🙂