Приветствую читателей!
Хочу поделиться своими достижениями в налаживании контроля покрытия кода при модульном тестировании приложений под Windows Phone. Примечательно, что при решении этой задачки пришлось столкнуться с некоторыми аспектами «правильного» проектирования приложений. Поэтому этот пост можно рассматривать как небольшое учебное пособие.
Постановка задачи
Дано:
Начинается разработка небольшого приложения под Windows Phone. Приложение типовое — забирает какие-то данные со своего сервера и в каком-то виде их показывает пользователю.
Требуется:
Спроектировать архитектуру приложения так, чтобы при непрерывной интеграции максимум кода приложения, отвечающего за логику работы, был закрыт тестами с возможностью контролировать это покрытие.
Общий подход
Как я уже писал, в рантайме Windows Phone не было средств контроля покрытия кода. По советам Nagg и halkar я вынес код с функционалом (логикой работы) приложения в Portable Class Library. Это дало возможность выполнять тесты в среде обычного .NET Framework с контролем покрытия. Кроме того, такой подход избавляет он шаманств с системой непрерывной интеграции, а также уменьшает время выполнения тестов.
Таким образом, упрощённо приложение можно представить в виде трёх компонент:
Вся логика приложения находится в PCL, не зависимой ни от рантайма Windows Phone, ни от окружения тестирования.
Приложение Windows Phone использует эту библиотеку и содержит XAML-разметку и минимум кода.
DLL-ка с тестами также ссылается на PCL и содержит собственно код тестов. Я использовал MSTest в качестве фреймворка тестирования, но это не принципиально.
Логика приложения не сможет работать в вакууме — нужно взаимодействовать с пользователем, с web-сервером и другим, непосредственно связанным с Windows Phone. Вместе с тем при выполнении тестов это взаимодействие должно как-то эмулироваться уже в среде обычного .NET Framework.
В итоге из PCL было вынесено следующее:
- работа с web-сервером (отправка запроса и приём ответа)
- работа с файлами (сохранение, чтение, получение списка)
- локализация строковых ресурсов
- проверка доступности соединения с Интернет
- ещё кое-что
Рассмотрим это немного подробнее. Обобщённая схема работы приложения выглядит так:
- Отправляется HTTP-запрос на сервер
- Принимается ответ
- Ответ разбирается/обрабатывается
- Результаты разборок выводятся на экран
Логика, подлежащая тестированию, находится только на этапе 3. Логично в этом случае код, отвечающий за все остальные этапы, вынести из PCL.
В случае вывода на экран для Windows Phone такой вынос происходит весьма прозрачно — в PCL находятся ViewModels, в XAML описывается привязка (binding) свойств элементов управления к свойствам ViewModels. При тестировании мы можем просто дёргать нужные свойства и методы ViewModels, имитируя действия пользователя и проверяя реакцию на них.
Теперь рассмотрим работу с сервером. PCL имеет реализацию клиента HTTP (HttpWebRequest
), и кажется логичным написать код работы с web-сервером прямо в PCL.
Однако нельзя забывать, что наша цель — протестировать логику приложения. Если мы намертво пришьём код отправки запросов на web-сервер к PCL, то для тестирования нужно будет поднимать отдельный web-сервер или как-то перехватывать запросы, имитируя работу сервера.
Идём дальше. В процессе обработки данные могут кэшироваться — сохраняться в виде файлов на устройстве. Однако в PCL нет законченных средств работы с файлами. Это связано с тем, что разные среды исполнения используют разные механизмы организации хранилища. Так, в привычном нам .NET это «обычные» файлы (например, File
), а в Windows Phone это уже IsolatedStorageFile
. Нельзя забывать, что при тестировании нам также понадобится имитировать такое хранилище.
Собственно отвязка делалась простым способом — в PCL мы объявляем интерфейсы для работы с web-запросами, файлами, локализацией и пр., а реализуем их уже в приложении Windows Phone со своей спецификой. Соответственно для целей тестирования тоже нужно реализовать эти интерфейсы так, чтобы более-менее достоверно имитировать работу «живого» окружения.
Inversion of Control
С некоторой долей приближения такая развязка называется по научному инверсией управления. Не буду влезать в дебри, потому что сам не знаю материала на эту тему в сети предостаточно, опишу лишь основные моменты. Заранее прощу прощения у гуру за возможные искажения смысла — жду комментариев, если вдруг так.
Посмотрим для примера на интерфейс работы с файлами:
public interface IFileStorage
{
Stream GetWriteFileStream(string fileName);
Stream GetReadFileStream(string fileName);
bool IsFileExists(string fileName);
List<string> GetAllFileNames(string path);
}
Этого интерфейса достаточно, чтобы оперировать с файлами, не вдаваясь в детали их хранения. Соответственно именно об этом интерфейсе и ни о чём более знает PCL. Реализован же он в приложении Windows Phone, и реализация использует IsolatedStorageFile
.
Также этот интерфейс реализуется в тестовой DLL-ке, причём совсем примитивно — все файлы хранятся в памяти в виде Dictionary<string, MemoryStream>
. Таким образом и логика программы довольна, думая, что работает с реальными файлами, и мы довольны, так как заодно решаем проблему с независимостью тестов. Новый тест — новый экземпляр класса — чистая «файловая система».
Но помимо файлов, нам нужно ещё развязать работу c web, локализацию и другое. Для удобства объединим это всё в другой интерфейс:
public interface IContainer
{
IFileStorage FileStorage { get; }
IDataRequest DataRequest { get; }
ILocalizer Localizer { get; }
IUiExtenter Extender { get; }
DateTime Now { get; }
}
Я обозвал его «контейнером», хотя в терминологии шаблона «Инверсия управления» это, возможно, не совсем корректно.
Этот интерфейс даёт нашей логике, заключённой в PCL, средства для работы с файлами (FileStorage
), запросов к серверу (DataRequest
), локализации (Localizer
) и других вещей (Extender
), объединённых в один интерфейс для лаконичности.
Отдельно отмечу свойство Now. Оно нужно исключительно для тестирования. Дело в том, что во многих местах логика завязана на текущее время, и для правильной эмуляции тестового окружения это время надо менять. Изменять системное время — идея плохая, поэтому и появилось это свойство. Главным было помнить, что вместо DateTime.Now
нужно использовать это свойство.
Контейнер имеет две реализации — в приложении Windows Phone и в тестовой DLL. Каждый контейнер «подсовывал» свои реализации интерфейсов. Так, контейнер в DLL с тестами реализовывал IDataRequest
в виде заглушки, позволяющую тестам имитировать ответы сервера и проблемы со связью.
Объект, реализующий IContainer
, создаётся в начале работы приложения. При тестировании для каждого теста создаётся новый такой объект. Классы, заведующие логикой приложения, должны иметь ссылку на этот объект. Простой способ обеспечить это — требовать ссылку в конструкторах всех классов.
Конечно, есть множество готовых велосипедов схем реализации инверсии управления, например Unity Application Block, но я выбрал именно такой вариант, в первую очередь в образовательных целях. Во «взрослых» проектах, конечно, лучше использовать готовые решения.
Проблемы
В теории всё звучит хорошо и красиво, однако на практике пришлось столкнуться с несколькими проблемами. Строго говоря, вынести всю логику в PCL не получилось. Так, навигация между страницами, код для создания Dependency Property и некоторые другие вещи остались в проекте приложения Windows Phone и таким образом оказались не закрытыми тестами. Теоретически большую часть этого кода можно было перенести в PCL, но это привело бы к неоправданному (ИМХО конечно) раздуванию абстракций и как следствие усложнению поддержки кода в дальнейшем.
Ещё одной проблемой оказался байндинг свойств ViewModels на свойства элементов управления в XAML. Многие из последних имеют специфические типы (Brush
, Visibility
), недоступные в PCL. Проблема красиво решается через механизм конверсии, однако нужно помнить, что при большом количестве элементов управления такая конверсия может сильно замедлить работу приложения.
Ещё кое-что
Есть пару вещей, которые, как мне кажется, можно реализовать правильнее, но я не могу додуматься, как. Буду признателен тем, кто наставит на путь истинный.
Вещь первая. По логике работы системы, в определённом месте алгоритма нужно показать пользователю диалог с выбором, а затем продолжить алгоритм в зависимости от выбора пользователя. Я не придумал ничего лучше чем реализовать в одном из интерфейсов контейнера метод ShowDialog и вызывать его прямо из кода в PCL. Соответственно «боевая» реализация метода показывала реальный диалог и ждала выбора пользователя, а тестовая — возвращала заранее настроенное значение.
Вещь вторая. В логике предусматривается обновление состояния элемента управления по таймеру. Но доступный в PCL класс Timer
вызывает обработчик окончания интервала в отдельном потоке. Попытка обновить состояние элемента в этом случае оканчивается плачевно. Моё лобовое решение — описать в интерфейсе метод с делегатом в качестве аргумента. В «боевой» реализации этот метод вызывает BeginInvoke
для синхронизации в основном потоке, а в тестовой — просто вызывает делегата.
Ещё одна вещь, которую я поленился делать до которой не дошли руки — это эмуляция того самого таймера в тестовом окружении. Сейчас в паре тестов есть принудительные задержки, что некрасиво.
Итоги
В остатке тестами оказалась закрыта большая часть функциональности. Код тестов ViewModels теперь походит на описание реальных тест-кейсов («нажать сюда»-«ввести такое-то число сюда»-«сравнить значение с ожидаемым»).
Написание таких тестов — хороший способ мотивировать тестировщиков, стремящихся в разработчики. И им приятно писать код вместо кликанья, и тестирование идёт :)
Когда идеи со сценариями и баги иссякли, осталось только уверенность в том, что при зелёном глазке TeamCity приложение можно прямо запускать на Марс в бой. А сколько раз тесты ловили проблемы, когда нужно было «прикрутить» какую-нибудь фишечку — знает только build statistics…
Самый главный итог — это то, после выполнения кода мы можем видеть, по каким строчкам тесты «прошлись», а какие остались нетронутыми. Эти данные показываются в TeamCity как в виде статистики (по «затронутым» классам, методам и строчкам кода с разными уровнями детализации), а также в виде исходников с красно-зелёной раскраской.
Весь код тестами всё равно закрыть не получилось бы. Так, код code-behind остаётся по определению не закрытым, равно как и реализации интерфейсов контейнера. Однако то, что находилось внутри PCL (а это большая часть кода программы, отвечающего непосредственно за её функциональность), покрывается и контролируется очень хорошо.
Поскольку я маньяк, я настроил билд на «падение» в случае менее чем 100% покрытия методов (т.е. если в контролируемом коде был хотя бы один метод, включая лямбда-выражения, в который тесты не заходили). Хотя для нормальных людей такой контроль, наверное, будет перебором.
В любом случае, покрытие кода коррелирует с покрытием функционала тестами, и изучение красных строчек частенько наталкивало на мысли о тестовых сценариях, которые следовало бы закодировать.
Главный вывод — для Windows Phone можно организовать unit-тестирование с контролем покрытия кода.
Использовать метрики покрытия формально, конечно, нонсенс. Впрочем, это относится к формальному использованию любых других метрик. Но если рассматривать покрытие не как раздражающий KPI, который хочется накрутить, а как инструмент для повышения качества, то это может быть действительно мощной штукой.
Автор: lvr