Сообщений 8    Оценка 446 [+2/-1]         Оценить  
Система Orphus

Модульное тестирование: 2+2 = 4?

Автор: Андрей Каща
GlobalLogic

Источник: RSDN Magazine #3-2008
Опубликовано: 28.12.2008
Исправлено: 10.12.2016
Версия текста: 1.0
Зачем эта статья?
Структура
Unit Testing
Путь Тестивуса
Если пишешь код — пиши тесты
Не застряньте в догме юнит тестирования
Примите карму юнит-тестов
Думай о коде и тестах как о едином
Тест важнее, чем юнит
Тестируй, пока свежо
Тесты без запуска — пустая трата времени
Несовершенный тест сегодня лучше совершенного теста когда-нибудь
Ужасный тест лучше, чем никакой
Иногда тест оправдывает средство
Только дураки обходятся без инструментов
Хороший тест падает
Путь Тестивуса
Почему нужно тестировать?
Что тестировать, а что нет?
Сколько должно быть тестов?
Как писать хорошие тесты?
Затрагивает ли написание тестов архитектурные вопросы?
Как быть, если мы сопровождаем старый код?
Case Studies
Case Study #1: Логика сосредоточена в хранимых процедурах.
Case Study #2: Наша система постоянно с кем-то сотрудничает!
Case Study #3: Невозможно сделать этот модуль изолированным!
Case Study #4: Код сильно привязан к окружению (диалоги, потоки, процессы)
Case Study #5: Ваш случай или Вместо Заключения.
Литература

Зачем эта статья?

У нас, в харьковском GlobalLogic’е, появилась традиция: проводим мы неформальные сходки, обсуждаем темы околоайтишные... На одной из таких сходок, посвященной Agile/не Agile процессам, встал вопрос Unit Test’ов. Оказывается, модульное тестирование выглядит очень просто в примерах из книг, но когда мы начинаем применять его на проектах, которые достались нам по наследству, на проектах с нетривиальной многоуровневой архитектурой, тут же сталкиваемся с проблемами.

Посмотрели мы на это дело и решили создать статью, посвященную наиболее распространенным проблемам, с которыми сталкиваются наши разработчики.

Структура

Есть выражение: "Дайте человеку рыбу, и вы накормите его на один день. Научите его ловить рыбу, и он никогда не будет голоден". Именно исходя из этого соображения статья разделена на две части:

1. Unit Testing – здесь рассказывается, почему стоит ловить рыбу (использовать модульное тестирование), и приводятся ссылки на конкретные способы ловли (использования модульного тестирования).

2. Case Studies. Вы уже слышали обо всех "почему", но не верите теории? Сложный, древний проект абсолютно не поддается тестированию? Вероятно, вам будет интересен этот документ. Здесь мы рассмотрим следующие вопросы:

Эта часть больше ориентирована на "матерых" программистов, поскольку здесь затрагивается широкий круг вопросов из областей проектирования дизайна и архитектуры. Знание стандартных паттернов крайне желательно.

Надеюсь, вам понравится. Дайте знать о своих впечатлениях :). Приятного чтения!

Unit Testing

Путь Тестивуса

Прежде чем погрузиться в пучину проблем Unit Test’ов, давайте вооружимся философией Тестивуса (терпение, вскоре мы поймем, кто такой Тестивус). Эта философия отвечает на большинство вопросов и является отличным подспорьем в стране, где живут драконы.

ПРИМЕЧАНИЕ

Так раньше на картах помечались неизведанные места.

Введение переводчика

В мае 2006 года плохо подготовленная экспедиция в Гималаях сбилась с пути. После двух недель скитаний, страдая от голода, жажды и озонируя воздух, как может его озонировать группа затерявшихся на две недели неопытных путешественников, наши страдальцы оказались у входа в древнюю пещеру.

В пещере они увидели лабиринт боксов (cubicles, кабинок). В каждом боксе был деревянный стол, эргономически правильное бамбуковое кресло, календарь Дилберта и странное компьютеро-подобное механическое устройство. В одном углу офиса они нашли запасы темной жидкости (позже определенной как ранний пример газированного напитка с высоким содержанием кофеина) и стол для пинг-понга. Путешественники поняли, что пещера была древним софтверным стартапом. Самым древним. Древнее даже чем Netscape...

Среди прочих замечательных вещей они нашли нечто потрясающее: записку, оставленную одним из программистов. Гид экспедиции, будучи не очень хорошим гидом, знал, как читать древний язык, и перевел записку:

Мы выкатили релиз раньше срока, как обычно. Все тесты проходят, так что в оставшуюся неделю мы решили отдохнуть. Собираемся в морское путешествие. Поскольку это занятие по тим-билдингу, мы надеемся, что получим возмещение расходов.

Путешественники смотрели друг на друга в полном недоумении. Они не только открыли самый древний софтверный стартап в истории, но еще и команду разработчиков, которая оказывается релизилась впереди расписания на постоянной основе!

Каков был секрет древних программистов? И что с ними случилось? Путешественники обрыскали каждый бокс в поисках зацепок, и нашли две заношенные брошюры. Одна из них называлась "Научись путешествовать по морю за 30 минут", что объясняло судьбу программистов. Вы держите в руках перевод второй брошюры "Путь Тестивуса". Кто написал ее? Что такое Тестивус? Одному Гуглу известно наверняка...

Объясняет ли содержимое этого текста успехи древних программистов? Нельзя сказать точно, но мы верим, что их потрясающая доблесть обязана комбинации философии Тестивуса и приему в больших количествах темной жидкости с кофеином, найденной в пещере. Прочитайте брошюру и сделайте свои выводы.

Alberto Savoia, CTO/Cofounder of Agitar Software
Апрель 2007, Mountain View, Calif.

Если пишешь код — пиши тесты

Ученик спросил мастера-программиста:

"Когда я могу перестать писать тесты?"

Мастер ответил:

"Когда ты перестаешь писать код"

Ученик спросил:

"Когда я перестаю писать код?"

Мастер ответил:

"Когда ты становишься менеджером"

Ученик задрожал и спросил:

"Когда я становлюсь менеджером?"

Мастер ответил:

"Когда ты перестаешь писать тесты"

Ученик побежал писать тесты.

Остались только следы.

Если код заслуживает быть написанным, он заслуживает иметь тесты.

Не застряньте в догме юнит тестирования

Догма говорит:

"Делай это.
Делай только это.
Делай это только так.
И делай это потому, что я тебе говорю"

Догма не гибкая.

Тестированию нужна гибкость.
Догма убивает творчество.

Тестированию нужно творчество.

Примите карму юнит-тестов

Карма говорит:

"Делай хорошие вещи, и хорошие вещи произойдут с тобой.
Делай их так, как ты знаешь.
Делай их так, как тебе нравится".

Карма гибка.

Тестированию нужна гибкость.

Карма процветает на творчестве.

Тестированию нужно творчество.

Думай о коде и тестах как о едином

Когда пишешь код, думай о тесте.

Когда пишешь тест, думай о коде.

Когда ты думаешь о коде и тесте как о едином,

тестирование просто, а код красив.

Тест важнее, чем юнит

Ученик спросил великого мастера программирования Летящего Пера:

"Что превращает тест в юнит-тест?"

Великий мастер программирования ответил:

"Если он обращается к базе, значит, он не юнит-тест.
Если он обращается к сети, значит, он не юнит-тест.
Если он обращается к файловой системе, значит, он не юнит-тест.
Если он не может выполняться одновременно с другими тестами, значит, он не юнит тест.
Если ты должен делать что-то с окружением, чтобы выполнить тест, значит, он не юнит тест".

Другой мастер-программист присоединился и начал возражать.
"Извините, что я спросил", — сказал ученик.
Позже ночью он получил записку от величайшего мастера-программиста. Записка гласила:

"Ответ великого мастера Летящего Пера прекрасный ориентир.
Следуй ему, и в большинстве случаев не пожалеешь.
Но не стоит застревать на догме.
Пиши тест, который должен быть написан".

Ученик спал хорошо.
Мастера все еще продолжали спорить глубокой ночью.

Тестируй, пока свежо

Твой код — как глина.
Пока она свежая — она мягкая и податливая.
Со временем она становится твердой и хрупкой.
Если ты пишешь тесты, пока код свежий и легко изменяемый —
тестирование будет проще, а код вместе с тестами будет прочнее.

Тесты без запуска — пустая трата времени

Запускай тесты часто.
Не позволяй им застаиваться.
Радуйся, когда они проходят.
Радуйся, когда они не проходят.

Несовершенный тест сегодня лучше совершенного теста когда-нибудь

Лучшее — враг хорошего.
Не жди лучшего, чтобы сделать лучше.
Не жди лучшего, чтобы сделать хорошо.
Напиши тест, какой сможешь, сегодня.

Ужасный тест лучше, чем никакой

Когда код ужасен — тесты могут быть ужасны.
Ты не любишь писать ужасные тесты,
Но ужасный код нуждается в тестах больше всего.
Не разрешай ужасному коду отговорить тебя писать тесты,
Но позволь ужасному коду отговорить тебя писать ужасный код в будущем

Иногда тест оправдывает средство

Ученик спросил двух мастеров-программистов:

"Я не могу написать этот код без создания моков и нарушения инкапсуляции.
Что мне делать?"

Один мастер программист ответил:

"Моки — это плохо, и ты никогда не должен нарушать инкапсуляцию.
Перепиши код, чтобы можно было тестировать правильно".

Другой мастер ответил:

"Моки — это хорошо, и тестирование важнее инкапсуляции".

Обескураженный ученик ушел за пивом. В местной пивной он встретил
Величайшего мастера-программиста, посасывающего пивко с куриными крылышками.

"Величайший мастер!" — сказал ученик, — "Я думал, что вы не пьете.
И разве вы не вегетарианец?"

Величайший мастер улыбнулся и ответил:

"Иногда жажда лучше утоляется пивом, а голод — куриными крылышками".

Ученик больше не был обескуражен.

Только дураки обходятся без инструментов

Фермер без плуга — плохой фермер.
Счетовод без абака — плохой счетовод.
Некоторые задачи лучше выполняются голыми руками.
А некоторые — инструментами.
Нет ничего благородного делать что-то руками,
что может быть лучше сделано с инструментом.
Неразумно использовать голову там, где она не нужна.

Хороший тест падает

Ученик подошел к мастеру-программисту и сказал:

"Все мои тесты всегда проходят. Не заслуживаю ли я повышения?"

Мастер отвесил леща ученику и ответил:

"Если все твои тесты всегда проходят, тебе стоит лучше писать тесты"

С красной шеей пошел ученик жаловаться в HR.
Но это уже другая история...

Путь Тестивуса

Если пишешь код — пиши тесты.
Не застряньте в догме юнит тестирования.
Примите карму юнит тестов.
Думай о коде и тестах как о едином.
Тест важнее, чем юнит.
Тестируй, пока свежо.
Тесты без запуска — пустая трата времени.
Несовершенный тест сегодня лучше совершенного теста когда-нибудь.
Ужасный тест лучше, чем никакой.
Иногда тест оправдывает средство.
Только дураки обходятся без инструментов.
Хороший тест падает.

Оригинал статьи можно найти по адресу [1].

Не стоит воспринимать этот рассказ как догму. Думайте о нем, как об "еще одной точке зрения".

Почему нужно тестировать?

Ведь большая часть программного обеспечения разрабатывается без использования автоматического тестирования. Значит автоматические тесты – это необязательная составная часть разработки ПО Кент Бек [2] дает два ответа на этот вопрос: один для краткосрочной перспективы, другой – для долгосрочной.

В долгосрочной перспективе тесты позволяют программе жить дольше (если, конечно, они работают и должным образом поддерживаются в рабочем состоянии). Если у вас есть тесты, вы можете вносить в программу более значительные изменения в течение более длительного времени. Если у вас нет тестов, вы теряете такую возможность, так как результаты любого изменения перестают быть предсказуемым, и оно может обернуться катастрофой. Если вы продолжаете писать тесты, со временем ваша уверенность в системе увеличивается.

Если тестирование выглядит необходимым лишь в долгосрочной перспективе, об этой необходимости можно легко позабыть. В этом случае многие будут заниматься тестированием потому, что они обязаны это делать, или потому, что кто-то тщательно контролирует правильность их работы. Как только внимание надсмотрщика ослабевает, или если сроки сдачи работы стремительно приближаются, разработка новых тестов прекращается, уже имеющиеся тесты перестают запускаться, и в результате вся система разваливается на части. Таким образом, если мы хотим работать сообразно с человеческой природой и при этом обеспечить тестирование, необходима насущная и эгоистическая причина для тестирования.

Такая насущная причина существует. Программирование с использованием тестов – это более приятный процесс, чем программирование без тестов. Вы кодируете значительно увереннее. У вас никогда не возникает страхов наподобие: "В это место системы надо бы внести изменения, но вдруг я что-нибудь сломаю?" Вы просто меняете код, щелкаете по кнопке, запускаются все тесты. Если при этом на экране загорается зеленый цвет, вы можете продолжать работу с еще большей уверенностью. И эта причина отнюдь не надумана. Разница между программированием «до» тестирования и «после» колоссальна, и проявляется она не только в долгосрочной выгоде. Тот заряд уверенности, который вы получаете, видя зеленый цвет, не сравнить ни с чем.

Однако существует опасность. Плохо организованное тестирование – это все равно, что розовые очки, сквозь которые вы смотрите на свою систему. Вы получаете ложную уверенность в том, что ваша система в порядке, – еще бы, ведь все тесты срабатывают. Вы продолжаете движение вперед, не подозревая, что оставляете позади себя ловушку. Как только вы в следующий раз пойдете этим же путем, ловушка может сработать. Решение? – Вспомним один из принципов Lean Software Development: Amplify Learning. Расширяя свой кругозор, постоянно усовершенствуясь, мы сможем увидеть истинный цвет очков на нашем носу.

Что тестировать, а что нет?

Очень хороший вопрос. Очень широкий вопрос. Смотрите по ситуации. Если вы предполагаете, что данная часть может не сработать – напишите тест. Однако это очень широкий ответ. Но вы ведь читаете это все, чтобы получить конкретные ответы? Задумайтесь о покрытии тестами следующих мест:

Не тестируйте чужой код, если у вас нет причин не доверять ему. В некоторых ситуациях недостатки внешнего кода заставляют добавлять дополнительную логику в разрабатываемый код. Надо ли тестировать поведение внешнего кода? Иногда стоит документировать ошибку внешнего кода при помощи теста, который перестанет срабатывать, если в следующей версии внешнего кода ошибка будет исправлена.

Сколько должно быть тестов?

Насколько емкой должна быть обратная связь? Если обстоятельства требуют, чтобы среднее время между сбоями было увеличено с 10 до 100 лет, значит, имеет смысл уделить время проверке самых маловероятных и чрезвычайно редко возникающих ситуаций (если, конечно, нельзя каким-либо иным образом доказать, что подобные ситуации никогда не могут возникнуть).

В рамках TDD [3] взгляд на тестирование прагматичен. В TDD тесты являются средством достижения цели. Целью является код, в корректности которого мы в достаточной степени уверены. Если знание особенностей реализации без какого-либо теста дает уверенность в том, что код работает правильно, не нужно писать тест.

Как писать хорошие тесты?

Gerard Meszaros написал книгу, полностью посвященную созданию хороших тестов: "xUnit Test Patterns" [4]. Книга уже стала классикой в кругах test-infected людей. Gerard очень извинялся за размер книги (833 страницы), но это действительно настоящая находка для разработчиков, практикующих автоматизированное тестирование.

Если у вас не найдется 60 часов (4 минуты на страницу), чтобы прочесть книгу от корки до корки, не отчаивайтесь. На Google TechTalk есть выступление Gerard’a продолжительностью всего-то 60 минут. Весьма стоящий доклад.

Еще один полезный ресурс: сайт, сопровождающий книгу [5]. Здесь можно найти ответы на самые разные вопросы, начиная от организации тестирующего кода и заканчивая паттернами тестирования баз данных.

Затрагивает ли написание тестов архитектурные вопросы?

Да, безусловно. В результате необходимости оттестировать модуль в изолированном окружении понижается сопряжение кода (loose coupling). Т.е. система становится более гибкой и податливой к изменениям.

Хорошо тестируемая архитектура обладает замечательной особенностью – она стремится к максимальной простоте. Все, что не нужно – отбрасывается (еще один принцип Lean Software Development – Eliminate Waste).

В этом пункте будет полезна классика.

Krzysztof Cwalina, program manager команды разработчиков .NET Framework и автор книги «Framework Design Guidelines», упоминал об одном хорошем подходе при создании framework’ов. Заключается метод в следующем: прежде чем написать код, реализующий какую-то функциональность во framework’е, напишите клиентский код, который будет его использовать. Так вы сможете быстро оценить удобство использования framework’а. Это хорошо коррелирует с подходом, принятым в TDD: сначала пишем тест, который использует некоторую функциональность (пусть даже еще не реализованную), а потом только заставляем программу скомпилироваться.

Как быть, если мы сопровождаем старый код?

Да, нам повезло сопровождать старый код, в котором и черт ногу сломит! Что уж там говорить о тестируемой архитектуре. В книге Michael Feathers’a “Working Effectively with Legacy Code” [9] можно найти ответы на следующие вопросы:

Case Studies

Case Study #1: Логика сосредоточена в хранимых процедурах.

Допустим, основная логика содержится в хранимых процедурах. Оправданных причин на то может быть много, например, увеличение производительности, обеспечение безопасности, сопровождение кода архитекторами баз данных и т.д., но как же оттестировать эту логику?

Решение: паттерн Stored Procedure Test.

Как это работает?

Мы пишем unit test’ы для хранимых процедур, в обход клиентского приложения. Это могут быть тесты, работающие с базой, минуя все приложение (через какой-нибудь back-door: см. Рисунок 1 (Авторские права на рисунки принадлежат xUnitPatterns.com.), либо же тесты, использующие публичный интерфейс классов доступа к данным.


Рисунок 1: Layer-Crossing тесты.

ПРИМЕЧАНИЕ

SUT – System Under Test, стандартная аббревиатура, принятая в тестировании. Как вы наверняка догадались, означает систему, которая тестируется.

Когда это стоит использовать?

Как только в хранимых процедурах появляется нетривиальная логика. Это позволяет проверить, что хранимые процедуры (в данном случае они же и являются SUT) работают правильно, независимо от клиентского приложения. Это особенно важно, когда хранимая процедура используется более чем в одном приложении, и нельзя гарантировать правильность их использования и покрытия тестами в других приложениях. Кроме того, использование Stored Procedure Tests помогает перечислить все условия, при которых может быть вызвана хранимая процедура.

Замечания по реализации

Существует два абсолютно разных способа реализовать паттерн Stored Procedure Tests. Первый – тесты, хранимые прямо в базе данных и, да, написанные на языке СУБД. Второй – использование того же языка программирования, на котором написан клиент, но при этом доступ к хранимым процедурам осуществляется при помощи паттерна GoF remote proxy.

Эти варианты – не взаимоисключающие. На самом деле, тесты, хранимые в базе данных, могут создаваться DBA, а разработчики клиента могут создать несколько acceptance test’ов.

Не забудьте также обратиться к части «Дальнейшее изучение проблемы», где приводятся ссылки на инструменты, существенно упрощающие создание тестового окружения в среде .NET.

Пример

Допустим, у нас есть хранимая процедура, выполняющая архи-сложную логику:

          Create
          Procedure [dbo].[Math_Add]
  @A  int, @B  int
ASBeginSelect @A + @B
End

Прежде чем запустить в меня тапком, вспомните, что это всего лишь пример :)...

Тест, хранимый в базе

Для создания этого теста мы воспользуемся tsqlunit (это член семейства xUnit, в обязанности которого входит Transact SQL). tsqlunit очень прост в обращении. Итак, пример теста:

          -- Здесь можно выполнить установку тестового окружения
          -- для всех методов в группе math (например, ut_math_add, ut_math_sub, etc.)
          CREATE
          PROCEDURE ut_math_setup ASBEGINPrint'Fixture Setup'-- Делаем все, что нужно дальше.END
GO

-- Тестовые методы помечаются приставкой "ut"CREATEPROCEDURE ut_math_Add ASBEGINDeclare @sumResultTable table (CalcResult int)

    InsertInto @sumResultTable
    EXEC dbo.[Math_Add] @A = 2, @B = 3
   
    Declare @result int;
    Select @result = CalcResult From @sumResultTable

    IF (@result <> 5) OR @result ISNULLEXEC tsu_failure 'Calculation does not work!'END
GO

-- В этом методе делайте все, что нужно сделать после выполнения тестаCREATEPROCEDURE ut_math_teardown ASBEGINPrint'Fixture Teardown'-- Делаем все, что нужно дальше.END
GO

Если мы нигде не ошиблись в процедуре Math_Add, то исполнение процедуры

          EXEC tsu_runTestst

даст в Output MS SQL Server такую картину:

================================================================================
 Run tests starts:Jul 12 2008  3:52PM
Fixture Setup
Fixture Teardown
================================================================================
Testsuite:  (1 tests ) execution time: 0 ms.
--------------------------------------------------------------------------------
 Run tests ends:Jul 12 2008  3:52PM
 Summary:
     1 tests, of which 0 failed and 0 had an error.
 
     SUCCESS!
--------------------------------------------------------------------------------
================================================================================

Тест удаленной хранимой процедуры

Поскольку еще далеко не во всех проектах используется Visual Studio 2008 Team System, в примере мы будем действовать по старинке. Но если вы один из счастливых обладателей Team System, непременно обратите внимание на специальный тип теста во вкладке Test -> Add New Test –> Database Unit Test (Рисунок 2, см. также «Дальнейшее изучение проблемы»).

Код, написанный вручную с использованием паттерна Remote Proxy, мог бы выглядеть так:

[TestMethod]
publicvoid SumAddTest()
{
    // Setup:
    MathAddProxy sut = new MathAddProxy();
    int a = 2, b = 3;

    // Exercise:int result = sut.MathAdd(a, b);

    // Verify:
    Assert.AreEqual(a + b, result);
}

Мы спрятали создание нового класса SqlCommand и компанию, за MathAddProxy, тем самым понижая сложность тестирующего метода до вызова MathAdd().


Рисунок 2: VS TS 2008 поддерживает DB Unit Test.

Дальнейшее изучение проблемы

Case Study #2: Наша система постоянно с кем-то сотрудничает!

Как можно проверить логику, если наш модуль:

  1. Забирает данные из базы данных.
  2. Рассылает почтовые уведомления через SMTP-сервер.
  3. Работает с MSMQ.
  4. Выполняет пункты 1, 2 и 3 одновременно, основываясь на внутренней логике.
  5. Работает очень медленно из-за пункта 4.

Мы заменяем компоненты, от которых зависит SUT (System Under Test), «дублерами», предназначенными для тестирования (см. Рисунок 3).


Рисунок 3: Дублеры для тестов

Как это работает?

В киноиндустрии, когда главному герою предстоит выполнить опасный трюк, обычно вызывают на помощь каскадера-дублера (конечно, если главный актер – не Джеки Чан). Каскадеру не обязательно обладать потрясающими актерскими способностями – это совершенно не важно. Важно, чтобы он смог эффектно и с минимальными потерями выполнить трюк. Насколько долго заменяется актер дублером, и насколько похожим должен быть дублер на актера – зависит от сцены.

Так и в тестировании – мы можем заменить компонент, от которого зависит SUT, «каскадером-дублером» для тестов (Test Double).

Дублеры предстают в нескольких, вероятно, уже знакомых, ипостасях (см. Рисунок 4).


Рисунок 4: Виды дублеров.

Сейчас мы быстро пройдемся по каждой из разновидностей дублеров, а затем перейдем к «примеру из жизни»: покроем тестами модуль, работающий с БД, SMTP и MSMQ. Если вы слышите о дублерах уже в сотый раз, смело переходите к примеру.

Test Stub

Мы используем stub’ы, чтобы заменить настоящие объекты, от которых зависит SUT (System Under Test), и получить возможность неявной передачи данных системе из теста (см. Рисунок 5.


Рисунок 5: Test Stub.

DOC – аббревиатура от depended-on component – компонент, от которого мы зависим.

Test Spy

Тест-Шпион – усовершенствованная версия test stub, которая отличается тем, что, помимо предоставления данных SUT, ведет журнал обращений от SUT к себе. После выполнения шага Exercise данные используются для проверки корректности работы SUT (см. Рисунок 6).


Рисунок 6: Тест-шпион в действии.

Mock Object

Mock-объекты используются для проверки неявных выходных данных и взаимодействий SUT по мере работы системы. Обычно Mock Object включает в себя обязанности Test Stub в том плане, что он возвращает какие-то данные системе:


Рисунок 7: Mock Object в действии.

Fake Object

Чтобы убрать зависимость от компонента, можно использовать подделку (Fake Object). Обычно Fake Object не проверяет ни косвенных запросов данных, ни косвенного получения данных (см. Рисунок 8). Основная причина использования Fake Object может заключаться в недоступности заменяемого компонента, или дурных последствий работы с ним (например, форматирование диска).


Рисунок 8: Fake Object в (без)действии.

Dummy Object

Если ни тест, ни SUT не заинтересованы в объекте, используются «пустышки». Например, при неком наборе параметров исполнение программы не пойдет по ветке, в которой используется объект. В таком случае Dummy Object может быть представлен обычным null или оbject.

Пример

На этот раз мы рассмотрим нечто отличное от тестов, проверяющих сумму двух чисел. Допустим, у нас есть следующий сценарий:

Сценарий уведомления по почте в случае сбоя проверки счета

Класс, реализующий этот сценарий, был разработан без учета необходимости модульного тестирования:

          public
          class InvoiceProcessor
{
    publicvoid ProcessInvoices(Invoice[] invoices)
    {
        InvoiceErrorReport report = new InvoiceErrorReport();
        foreach (Invoice invoice in invoices)
        {
            // Выполняем проверку бизнес-логики.
            InvoiceError[] invoiceErrors = 
                        this.ValidateQuantities(invoice);
            report.StoreErrors(invoice, invoiceErrors);
        }

        // Определяем, насколько все плохо.if (this.HasTooManyCriticalErrors())
        {
            MailMessage message = this.CreateErrorSummaryMessage(report);

            // Обращаемся к локальному SMTP и отсылаем email
            SmtpMail.Send(message);
        }
        // Проверяем, не возникли ли ошибкиelseif (this.HasTooManyErrors())
        {
            MailMessage message = this.CreateDetailsMessage(report);
            SmtpMail.Send(message);
        }
        else
        {
            // Счет обработан успешно. Шлем на дальнейшую обработку.
            MessageQueue queue = new 
                        MessageQueue(this.GetMessageQueuePath());
            queue.Send(invoices);
        }
    }
}

Можно было бы написать тест, запустить его, потом попросить получателей проверить, получали ли они письма (в одном из моих проектов примерно так выполнялось ручное тестирование системы оповещений – реальный сценарий :) ).

Самый простой способ написать тест в этом случае – просто проверить, был ли SMTP-сервер уведомлен о необходимости отослать письмо. Посмотрите на тот же класс после небольшого рефакторинга:

          public
          class InvoiceProcessor
{
    // "Dependency Inversion Principle"// Все зависимости теперь вынесены за интерфейсы.privatereadonly IEmailGateway _emailGateway;
    privatereadonly IMessagingGateway _messagingGateway;
    privatereadonly IUserInformationStore _userStore;

    // Используем "Constructor Injection" чтобы внедрить зависимости InvoiceProcessor’у.public InvoiceProcessor(
      IEmailGateway emailGateway, 
      IMessagingGateway messagingGateway, 
      IUserInformationStore userStore)
    {
        _emailGateway = emailGateway;
        _messagingGateway = messagingGateway;
        _userStore = userStore;
    }
    
    publicvoid ProcessInvoices(Invoice[] invoices)
    {
        InvoiceErrorReport report = new InvoiceErrorReport();

        foreach (Invoice invoice in invoices)
        {
            // Выполняем проверку бизнес-логики.
            InvoiceError[] invoiceErrors = 
                                    this.ValidateQuantities(invoice);
            report.StoreErrors(invoice, invoiceErrors);
        }

        // Определяем, насколько все плохо.if (this.HasTooManyCriticalErrors())
        {
            MailMessage message = this.CreateErrorSummaryMessage(report);
            _emailGateway.SendMail(message);
        }
        // Прверяем, не возникли ли ошибкиelseif (this.HasTooManyErrors())
        {
            MailMessage message = this.CreateDetailsMessage(report);
            _emailGateway.SendMail(message);
        }
        else
        {
            // Теперь мы не отвечаем за MSMQ. И вообще не знаем, что это MSMQ.
            _messagingGateway.SendInvoices(invoices);
        }
    }
    
    public MailMessage CreateErrorSummaryMessage(
                        InvoiceErrorReport report)
    {
        MailMessage email = new MailMessage();

        // Получаем адрес пользователя из хранилища пользователей 
        // (БД или Active Directory)
        email.To = _userStore.GetEmailAddressesForInvoices(report);
        email.Body = this.CreateErrorSummaryMessageBody(report);
        return email;
    }

    // Другие методы ...
}

publicinterface IMessagingGateway
{
    void SendInvoices(Invoice[] invoices);
}

publicinterface IEmailGateway
{
    void SendMail(MailMessage message);
}

Что же здесь произошло? Мы воспользовались принципом инверсии зависимостей: всех товарищей, от которых зависел наш модуль, мы вынесли за абстрактные интерфейсы. Теперь мы воспользуемся паттерном Mock Object. Для создания мок-объектов воспользуемся библиотекой Rhino Mocks (нет смысла писать mock’и руками, когда есть столько инструментов, упрощающих жизнь). Наш тест мог бы выглядеть так:

[TestMethod]
publicvoid TestInvoiceProcessor()
{
    // 1.) Создаем объекты Rhino Mocks
    MockRepository mocks = new MockRepository();

    IMessagingGateway messagingMock = 
                            mocks.CreateMock<IMessagingGateway>();
    IEmailGateway emailGateway = mocks.CreateMock<IEmailGateway>();
    IUserInformationStore userStoreMock = 
                            mocks.CreateMock<IUserInformationStore>();

    Invoice[] invoices = this.SetUpArrayOfInvoicesWithACriticalErrors();

    // 2.) Устанавливаем ожидания
    Expect.Call(delegate { emailGateway.SendMail(null); })
          .IgnoreArguments().Repeat.Any();
    Expect.Call(delegate { messagingMock.SendInvoices(null); })
          .IgnoreArguments().Repeat.Never();
    Expect.Call(userStoreMock.GetEmailAddressesForInvoices(null))
         .IgnoreArguments().Return("somebody@somewhere.com").Repeat.Any();

    mocks.ReplayAll();

    // 3.) Создаем InvoiceProcessor с экземплярами mock’ов.
    InvoiceProcessor processor = new InvoiceProcessor(
        emailGateway, messagingMock, userStoreMock
        );
    
    // 4.) Выполняем
    processor.ProcessInvoices(invoices);

    // 5.) Проверяем наши ожидания.
    mocks.VerifyAll();
}

Одним из преимуществ Rhino Mocks является строгая типизация. Если же вас устраивает нестрогая типизация при указании ожиданий, можно воспользоваться библиотекой NMocks – очень простой в установке (добавление reference). Вот как выглядит тот же тест, но с NMocks:

[TestMethod]
publicvoid TestInvoiceProcessor()
{
    // 1.) Создаем объекты NMocks 
    Mockery mocks = new Mockery();

    IMessagingGateway messagingMock = mocks.NewMock<IMessagingGateway>();
    IEmailGateway emailGateway = mocks.NewMock<IEmailGateway>();
    IUserInformationStore userStoreMock = 
                                    mocks.NewMock<IUserInformationStore>();

    Invoice[] invoices = this.SetUpArrayOfInvoicesWithACriticalErrors();

    // 2.) Устанавливаем ожидания.
    Expect.AtLeastOnce.On(emailGateway).Method("SendMail");

    Expect.Never.On(messagingMock).Method("SendInvoices");

    Expect.AtLeastOnce.On(userStoreMock)
        .Method("GetEmailAddressesForInvoices")
        .WithAnyArguments()
        .Will(Return.Value("somebody@somewhere.com"));

    // 3.) Создаем InvoiceProcessor с экземплярами моков.
    InvoiceProcessor processor = new InvoiceProcessor(
        emailGateway, messagingMock, userStoreMock
        );

    // 4.) Выполняем.
    processor.ProcessInvoices(invoices);

    // 5.) Проверяем наши ожидания.
    mocks.VerifyAllExpectationsHaveBeenMet();
}

Наши тесты проверяют, что при заданных условиях SUT взаимодействовала с «правильными» модулями.

Дальнейшее изучение проблемы

Case Study #3: Невозможно сделать этот модуль изолированным!

Генри Форд говорил: «Я наотрез отказываюсь считать что-нибудь невозможным».

Чтобы правильно оттестировать кусочек кода, мы обычно стремимся поместить тестируемый код в изолированное окружение. Но если приходится сопровождать старый код, мы нередко сталкиваемся с компонентами, настолько связанными друг с другом, что разорвать эти узы представляется невозможным: никакие Mock Object’ы, Test Stub’ы и Dummy Object’ы вместе взятые не помогают решить проблему.

Что же, и здесь есть выход: Test Hook.

Как это работает?

Мы модифицируем SUT (System Under Test) или DOC (dependent-on component) таким образом, чтобы во время тестов они вели себя должным образом (см. Рисунок 9).


Рисунок 9. Test Hook в действии.

Как так? Мы модифицируем production-код ради тестов?! Да. Смотрится дико, но иногда цель оправдывает средства. Test Hook – это тяжелая артиллерия, только для случаев, когда все остальное уже безрезультатно испробовано.

Когда это стоит использовать?

Использовать Test Hook стоит лишь в крайних случаях, когда мы хотим покрыть тестами еще не оттестированный код, но ни инверсия зависимостей, ни тесты-дублеры не решают проблему зависимостей.

Test Hook иногда рассматривается как переходный этап, обеспечивающий безопасность, между покрытием кода тестами и рефакторингом.

Замечания по реализации

Обычно достаточно одной константы, идентифицирующей, в каком режиме работает SUT. Можно положиться на оптимизацию компилятора, и ожидать, что в production-код не попадет тестовая логика. Если же язык поддерживает препроцессор, можно воспользоваться им, чтобы убрать код Test Hook’a из production-кода.

Пример

Посмотрим на следующий тест:

[TestMethod]
publicvoid TestDisplayCurrentTime_AtMidnight()
{
    // fixture setup
    TimeDisplay sut = new TimeDisplay();
    // exercise sut
    String result = sut.GetCurrentTimeAsHtmlFragment();

    // verify direct output
    String expectedTimeString = "<span class=\"tinyBoldText\">Midnight</span>";
    Assert.AreEqual(expectedTimeString, result);
}

Этот тест практически всегда будет падать, поскольку зависит от текущего времени, возвращаемого SUT неким компонентом DefaultTimeProvider. Мы не можем контролировать компонент из теста:

          public String GetCurrentTimeAsHtmlFragment()
{
    string theTime = new DefaultTimeProvider().GetTime();
    // etc. 
}

Решение 1: Test Hook в SUT’e

          public String GetCurrentTimeAsHtmlFragment()
{
    string theTime;
    if (TEST_MODE)
    {
        theTime = DefaultTimeProvider.Midnight;
    }
    else
    {
        theTime = new DefaultTimeProvider().GetTime();
        
    }
    // etc.
}

Здесь изменяется непосредственно SUT (System Under Test).

ПРИМЕЧАНИЕ

Безусловно, это подразумевает отдельные компиляции проекта для тестирования и для production-версии.

Решение 2: Test Hook в DOC’e

          public
          class DefaultTimeProvider
{
    publicstring GetTime()
    {
        string result;
        if (TEST_MODE)
        {
            result = DefaultTimeProvider.Midnight;
        }
        else
        {
            // Выполняем production-логику.
        }
    }
}

Теперь мы модифицировали DOC (dependent-on component). В некотором смысле это решение может быть даже лучше, поскольку мы не трогали SUT.

Дальнейшее изучение проблемы

Case Study #4: Код сильно привязан к окружению (диалоги, потоки, процессы)

Как быть, если мы столкнулись с диалоговым окном, разработанным в IDE, со сложной логикой в code behind’e? Что делать, если в нашей системе есть асинхронные операции, выполняемые разными потоками, а то и процессами? Как покрыть тестами такие системы?

К сожалению никак. Шутка (. Пожалуй, это одни из самых популярных сценариев. Именно такие сценарии заставляют разработчиков разочаровываться в unit test’ах и отступать. Так появляются неоттестированные компоненты – верные вестники грядущих багов.

Как это работает?

Мы извлекаем всю сложную для тестирования логику и помещаем ее в компонент, который можно протестировать при помощи синхронных тестов (без создания дополнительных потоков). Этот компонент реализует интерфейс, состоящий из методов сложно тестируемого компонента. Единственное отличие – доступ к извлеченным методам осуществляется синхронно. Нетестируемый компонент при этом превращается в очень тонкий слой: каждый раз, когда ему необходимо выполнить действие, он делегирует выполнение выделенной реализации. Отсюда и название Humble Object (скромный объект) – ответственность скромняги сводится к простому делегированию (см. Рисунок 10).


Рисунок 10: Humble Object.

Впрочем, если выделенной логике требуется какая-нибудь информация из контекста, то Humble Object предоставляет ее.

Когда это стоит использовать

Как только мы сталкиваемся с нетривиальной логикой в компоненте, экземпляр которого сложно создать (например, он сильно зависит от какого-нибудь Framework’a, или не имеет открытого конструктора), или доступ к которому осуществляется только асинхронно.

На самом деле, существует тысячи причин, из-за которых объект слабо поддается тестированию. Мы здесь рассматриваем лишь самые типичные: использование GUI-frameworks и многопоточная среда. Но будьте готовы к созданию собственной вариации Humble Object.

Замечания по реализации

Существует несколько типичных подходов к извлечению не поддающейся тестированию логики.

Poor Man's Humble Object

Самый простой способ выноса логики – воспользоваться рефакторингом Extract Method для выделения логики, и сделать выделенные методы видимыми для тестового окружения.

Extract Method. Если у вас есть кусочек кода, кусочек кода, отвечающий за некое конкретное действие – вынесите его в отдельный метод и дайте ему осмысленное название. C установленным Resharper’ом, жизнь становится еще проще: выделяем код и жмем Resharper -> Refactor -> Extract Method.

Этот подход будет работать нормально, если создание Humble Object не сопряжено с накладными расходами (нет открытого конструктора, неразрешимая зависимость, поток запускается сам и т.п.).

True Humble Object

Настоящий Humble Object подразумевает полный вынос логики в отдельный класс. Т.е. сначала мы применяем рефакторинг Extract Method, а затем и Extract Class.

Extract Class. Если один класс работает за двоих – разделяем его на два логически сгруппированных класса. Возможности рефакторинга, предлагаемые Resharper’ом и здесь могут оказаться полезными.

При использовании этого подхода, помимо написания тестов, проверяющих выделенный класс, стоит также проверить, что Humble Object обращается к выделенному компоненту корректно. Тут помогут тесты-двойники.

Если реализации необходима какая-либо информация из контекста – мы передаем ей ссылку на объект, реализующий необходимый интерфейс.

Пример

Здесь тестируется логика, работающая в собственном потоке. Поток обрабатывает запросы по мере поступления. В каждом тесте мы запускаем новый поток, отсылаем пару сообщений, ждем, пока поток успеет их обработать, и проверяем результат.

[TestClass]
publicclass RequestHandlerThreadTest
{
    privateconstint WAIT_TIME = 3000;

    [TestMethod]
    publicvoid TestWasInitialized_Async()
    {
        // Setup:
        RequestHandlerThread sut = new RequestHandlerThread();
        // Exercise:
        sut.Start();
        // Verify:
        Thread.Sleep(WAIT_TIME);
        Assert.IsTrue(sut.InitializedSuccessfully);
    }

    [TestMethod]
    publicvoid TestHandleOneRequest_Async()
    {
        // Setup:
        RequestHandlerThread sut = new RequestHandlerThread();
        sut.Start();
        // Exercise:
        EnqueRequest(MakeSimpleRequest());
        // Verify:
        Thread.Sleep(WAIT_TIME);

        Assert.AreEqual(1, sut.NumberOfRequestsCompleted);
        AssertResponseEquals(MakeSimpleResponse(), GetResponse());
    }
}

Но при таком подходе с задержками тест будет выполняться полминуты на дюжине тестовых методов. За полминуты мы бы смогли выполнить тысячи обычных тестов.

Вот код RequestHandlerThread:

          public
          class RequestHandlerThread : AsyncRunner
{
    privatebool _initializationCompleted = false;
    privateint _numberOfRequests = 0;

    publicbool InitializedSuccessfully
    {
        get { return _initializationCompleted; }
    }

    publicoverridevoid Run()
    {
        InitializeThread();
        ProcessRequestsForever();            
    }

    privatevoid ProcessRequestsForever()
    {
        Request request = NextMessage();
        do
        {
            Response response = ProcessOneRequest(request);
            
            if (response != null)
            {
                PutMsgOntoOutputQueue(response);
            }

            request = NextMessage();
        } while (request != null);
    }
}

Решение 1: Poor Man’s Humble Object

Вот тот же набор тестов, переписанных под Poor Man’s Humble Object.

[TestClass]
publicclass RequestHandlerThreadTest
{
    [TestMethod]
    publicvoid TestWasInitialized_Sync()
    {
        // Setup:
        RequestHandlerThread sut = new RequestHandlerThread();
        // Exercise:
        sut.InitializeThread();
        // Verify:
        Assert.IsTrue(sut.InitializedSuccessfully);
    }

    [TestMethod]
    publicvoid TestHandleOneRequest_Sync()
    {
        // Setup:
        RequestHandlerThread sut = new RequestHandlerThread();
        // Exercise:
        Response resonse = sut.ProcessOneRequest(MakeSimpleRequest());
        // Verify:
        Assert.AreEqual(1, sut.NumberOfRequestsCompleted);
        AssertResponseEquals(MakeSimpleResponse(), resonse);
    }
}

Здесь методы InitializeThread() и ProcessOneRequest() просто сделаны открытыми. Тем самым мы обеспечили себе возможность синхронного выполнения и убрали задержку в ожидании.

Этот подход будет работать до тех пор, пока нет проблем с созданием тестируемого объекта (RequestHandlerThread).

Решение 2: True Humble Object

Вот тот же самый тест, но с применением истинного Humble Object’a.

          public
          class HumbleRequestHandlerThread : AsyncRunner
{
    private RequestHandler _handler;

    publicbool InitializedSuccessfully
    {
        get { return _handler.InitializationCompleted; }
    }

    public HumbleRequestHandlerThread()
    {
        _handler = new RequestHandlerImpl();
    }

    publicoverridevoid Run()
    {
        _handler.InitializeThread();
        ProcessRequestsForever();
    }

    privatevoid ProcessRequestsForever()
    {
        Request request = NextMessage();
        do
        {
            Response response = _handler.ProcessOneRequest(request);

            if (response != null)
            {
                PutMsgOntoOutputQueue(response);
            }

            request = NextMessage();
        } while (request != null);
    }
}

Здесь мы вынесли всю логику в отдельный класс.

[TestClass]
publicclass RequestHandlerTest
{
    [TestMethod]
    publicvoid TestNotInitialized_Sync()
    {
        // Setup, Exercise:
        RequestHandler sut = new RequestHandlerImpl();
        // Verify:
        Assert.IsFalse(sut.InitializedSuccessfully);
    }

    [TestMethod]
    publicvoid TestWasInitialized_Sync()
    {
        // Setup:
        RequestHandler sut = new RequestHandlerImpl();
        // Exercise:
        sut.InitializeThread();
        // Verify:
        Assert.IsTrue(sut.InitializedSuccessfully);
    }

    [TestMethod]
    publicvoid TestHandleOneRequest_Sync()
    {
        // Setup:
        RequestHandler sut = new RequestHandlerImpl();
        // Exercise:
        Response resonse = sut.ProcessOneRequest(MakeSimpleRequest());
        // Verify:
        Assert.AreEqual(1, sut.NumberOfRequestsCompleted);
        AssertResponseEquals(MakeSimpleResponse(), resonse);
    }
}

Поскольку здесь используется делегирование, вероятно, стоит также проверить корректность делегирования:

[TestMethod]
publicvoid TestLogicCalled_Async()
{
    // Setup:
    RequestHandlerRecordingStub mockHandler = new RequestHandlerRecordingStub();
    HumbleRequestHandlerThread sut = new HumbleRequestHandlerThread();
    // Mock Installation:
    sut.SetHandler(mockHandler);
    sut.Start();
    // Exercise:
    EnqueRequest(MakeSimpleRequest());
    // Verify:
    Thread.Sleep(WAIT_TIME);
    Assert.IsTrue(mockHandler.InitializedSuccessfully, "Init failed");
    Assert.AreEqual(1, mockHandler.NumberOfRequestsDone);
}

Этот тест будет работать асинхронно, и нуждается в задержке. Но он будет лишь один. При желании тест можно даже исключить из списка тестов, выполняемых каждый раз при check-in’e кода, и запускать лишь на машине, где выполняется сборка, во время ночного прогона всех тестов.

Дальнейшее изучение проблемы

Case Study #5: Ваш случай или Вместо Заключения.

Если так случится, что во время модульного тестирования на своем проекте вы столкнетесь со сложной проблемой, и не найдете ответа на нее здесь, обращайтесь, пожалуйста, за поддержкой к нам, на RSDN. Уверен, здесь найдутся ребята, готовые попытаться помочь.

Большая часть примеров и советов, предложенных в этой части, базируется на труде Gerard Meszaros’a «xUnit Test Patterns», который доступен по адресу http://xunitpatterns.com. Если найдется минутка – не премините зайти на сайт к Gerard’у, он действительно стоит того.

Надеюсь, вам так же понравилось ходить по этому чтиву, как и мне понравилось писать его.

Отличного настроения и до скорых встреч в путешествиях, друзья ;)!

Литература

  1. Alberto Savoia The Way of Testivus - Unit Testing Wisdom From An Ancient Software Start-up
  2. Кент Бек – Экстремальное программирование.
  3. Кент Бек – TDD.
  4. Gerard Meszaros – xUnit Test Patterns.
  5. Gerard Meszaros - http://xunitpatterns.com/index.html
  6. Martin Fowler – Patterns of Enterprise Application Architecture.
  7. Martin Fowler – Refactoring.
  8. Steve McConnell – Code Complete.
  9. GoF – Design Patterns.
  10. Michael Feathers - Working Effectively with Legacy Code.


Эта статья опубликована в журнале RSDN Magazine #3-2008. Информацию о журнале можно найти здесь
    Сообщений 8    Оценка 446 [+2/-1]         Оценить