Существует категория классов, которые тестировать весьма просто. Если класс зависит только от примитивных типов данных и не имеет никаких связей с другими бизнес-сущностями, то достаточно создать экземпляр этого класса, «пнуть» его некоторым образом путем изменения свойства или вызова метода и проверить ожидаемое состояние.
Это самый простой и эффективный способ тестирования, и любой толковый дизайн отталкивается от подобных классов, которые являются «строительными блоками» нижнего уровня, на основе которых затем уже строятся более сложные абстракции. Но количество классов, которые живут в такой «изоляции» не много по своей природе. Даже если мы по нормальному выделили всю логику по работе с базой данных (или сервисом) в отдельный класс (или набор классов), то рано или поздно появится кто-то, кто эти классы будет использовать для получения более высокоуровневого поведения и этого «кого-то» тоже нужно будет тестировать.
Но для начала давайте рассмотрим более типичный случай, когда логика по работе с базой данных или внешним сервисом, а также логика обработки этих данных сосредоточена в одном месте.
// Модель представления, предназначенная для управления входом // пользователя в систему public class LoginViewModel {
public LoginViewModel()
{
// Читаем имя последнего пользователя UserName = ReadLastUserName();
}
// Имя пользователя; может быть изменено пользователем public string UserName { get; set; }
// Логиним пользователя UserName public void Login()
{
// Не обращаем внимание на дополнительную логику, которая должна быть // выполнена. Считаем что нам достаточно просто сохранить имя текущего // пользователя SaveLastUserName(UserName);
}
// Читаем имя последнего залогиненного пользователя private string ReadLastUserName()
{
// Не важно, как она на самом деле реализована ... // Просто возвращаем что-нибудь, чтобы компилятор не возражал return "Jonh Doe";
}
// Сохраняем имя последнего пользователя private void SaveLastUserName(string lastUserName)
{
// Опять таки, нам не интересно, как она реализована } }
Когда речь заходит о тестировании подобных классов, то обычно эта вью-модель помещается на форму, которая затем тестируется руками Если вместо вью-модели подобное смешивание логики происходит при реализации серверных компонент, то они тестируются путем создания простого консольного приложения, которое будет вызывать необходимые высокоуровневые функции, тестируя, таким образом, весь модуль целиком. В обоих случаях такой вариант тестирования нельзя назвать очень уж автоматическим.
ПРИМЕЧАНИЕ
Не нужно бросать в меня камнями с криками «Да кто сегодня вообще такую хрень написать можно? Ведь уже столько всего написано о вреде такого подхода, да и вообще, у нас есть юнити-шмунити и другие полезности, так что это нереальный баян двадцатилетней давности!». Кстати, да, это баян, но, во-первых, речь не юнитях и других контейнерах, а о базовых принципах, а во-вторых, подобное «интеграционное» тестирование все еще невероятно популярно, во всяком случае, среди многих моих «зарубежных» коллег.
Создания «швов» для тестирования приложения
Даже если не задумываться о том, какое количество новомодных принципов проектирования нарушает наша вью-модель, четко видно, что ее дизайн несколько … убог. Ведь даже если проектировать старым дедовским бучевским методом, то становится понятно, что всю работу по сохранению имени последнего пользователя, логику по работе с базой данных (или другим внешним источником данных) нужно спрятать подальше с глаз долой и сделать это «проблемой» кого-то другого и использовать уже этого «кого-то» в качестве «строительного блока» для получения более высокоуровневого поведения:
internal class LastUsernameProvider {
// Читаем имя последнего пользователя из некоторого источника данных public string ReadLastUserName() { return "Jonh Doe"; }
// Сохраняем это имя, откуда его можно будет впоследствии прочитать public void SaveLastUserName(string userName) { } } public class LoginViewModel {
// Добавляем поле для получения и сохранения имени последнего пользователя private readonly LastUsernameProvider _provider =
new LastUsernameProvider();
public LoginViewModel()
{
// Теперь просто вызываем функцию нового вспомогательного класса UserName = _provider.ReadLastUserName();
}
public string UserName { get; set; }
public void Login()
{
// Все действия по сохранению имени последнего пользователя также // делегируем новому классу _provider.SaveLastUserName(UserName);
} }
Пока что написание модульного теста все еще остается затруднительным, но становится понятным, как можно достаточно просто «подделать» реальную реализацию класса LastUsernameProvider и сымитировать нужное для нас поведение. Достаточно выделить методы этого класса в отдельный интерфейс или просто сделать их виртуальными и переопределить в наследнике. После чего останется лишь «прикрутить» нужный нам объект в нашу вью-модель.
ПРИМЕЧАНИЕ
Честно говоря, я не большой фанат изменений в дизайне только ради «тестируемости» кода. Как показывает практика, нормальный ОО дизайн либо уже является достаточно «тестируемым» или же требует лишь минимальных телодвижений, чтобы сделать его таковым. Некоторые дополнительные мысли по этому поводу можно найти в заметке «Идеальная архитектура».
Даже не прибегая ни к каким сторонним библиотекам для «инджекта» зависимостей мы можем сделать это самостоятельно несколько простыми способами. Нужную зависимость можно передать через дополнительный конструктор, через свойство или создать фабричный метод, который будет возвращать интерфейс ILastUsernmameProvider.
Давайте рассмотрим вариант с конструктором, который является довольно простым и популярным (при небольшом количестве внешних зависимостей он работает просто прекрасно).
// Выделяем методы в интерфейс internal interface ILastUsernameProvider {
string ReadLastUserName();
void SaveLastUserName(string userName); } internal class LastUsernameProvider : ILastUsernameProvider {
// Читаем имя последнего пользователя из некоторого источника данных public string ReadLastUserName() { return "Jonh Doe"; }
// Сохраняем это имя, откуда его можно будет впоследствии прочитать public void SaveLastUserName(string userName) { } } public class LoginViewModel {
private readonly ILastUsernameProvider _provider;
// Единственный открытый конструктор создает реальный провайдер public LoginViewModel()
: this(new LastUsernameProvider())
{}
// "Внутренний" предназначен только для тестирования и может принимать "фейк" internal LoginViewModel(ILastUsernameProvider provider)
{
_provider = provider;
UserName = _provider.ReadLastUserName();
}
public string UserName { get; set; }
public void Login()
{
_provider.SaveLastUserName(UserName);
} }
Поскольку дополнительный конструктор является внутренним (internal), то он доступен только внутри этой сборке, а также «дружеской» сборке юнит-тестов. Конечно, если тестируемые классы являются внутренними, то проблемы не будет ни какой, но поскольку все «клиенты» внутреннего класса находятся в одной сборке, то и контролировать их проще. Подобный подход, основанный на добавлении внутреннего метода для установки «фальшивого» поведения является разумным компромиссом упрощения тестирования кода, не налагая ограничения на использования более сложных механизмов управления зависимостями, типа IoC контейнеров.
ПРИМЕЧАНИЕ
Одним из недостатков при работе с интерфейсами является падение читабельности, поскольку не понятно, сколько реализаций интерфейса существует и где находится реализация того или иного метода интерфейса. Такие инструменты, как Решарпер существенно смягчают эту проблему, поскольку поддерживают не только навигацию к объявлению метода (Go To Declaration), но также и навигацию к реализации метода (Go To Implementation):
Проверка состояния vs проверка поведения
Теперь давайте попробуем написать юнит-тест вначале для конструктора класса LoginViewModel, который получает имя последнего залогиненного пользователя, а потом юнит-тест для метода Login, после выполнения которого, имя последнего пользователя должно быть сохранено.
Для нормальной реализации этих тестов нам нужна «фейковая» реализация интерфейса, при этом в первом случае, нам нужно вернуть произвольное имя последнего пользователя в методе ReadLastUserName, а во втором случае – удостовериться, что вызван метод SaveLastUserName.
Именно в этом и отличаются два типа «фейковых» классов: стабы предназначены для получения нужного состояния тестируемого объекта, а моки применяются для проверки ожидаемого поведения тестируемого объекта.
Стабы никогда не применяются в утверждениях, они простые «слуги», которые лишь моделируют внешнее окружение тестового класса; при этом в утверждениях проверяется состояние именно тестового класса, которое зависит от установленного состояния стаба.
// Стаб возвращающее указанное имя последнего пользователя internal class LastUsernameProviderStub : ILastUsernameProvider {
// Добавляем публичное поле, для простоты тестирования и // возможности повторного использования этого класса public string UserName;
// Реализация метода очень простая - просто возвращаем UserName public string ReadLastUserName()
{
return UserName;
}
// Этот метод в данном случае вообще не интересен public void SaveLastUserName(string userName) { } } [TestFixture] public class LoginViewModelTests {
// Тестовый метод для проверки правильной реализации конструктора вью-модели [Test]
public void TestViewModelConstructor()
{
var stub = new LastUsernameProviderStub();
// "моделируем" внешнее окружение stub.UserName = "Jon Skeet"; // Ух-ты!!
var vm = new LoginViewModel(stub);
// Проверяем состояние тестируемого класса Assert.That(vm.UserName, Is.EqualTo(stub.UserName));
}
}
У моков же другая роль. Моки «подсовываются» тестируемому объекту, но не для того, чтобы создать требуемое окружение (хотя они могут выполнять и эту роль), а прежде всего для того, чтобы потом можно было проверить, что тестируемый объект выполнил требуемые действия. (Именно поэтому такой вид тестирования называется behavior testing, в отличие от стабов, которые применяются для state-based testing).
// Мок позволяет проверить, что метод SaveLastUserName был вызван // с определенными параметрами internal class LastUsernameProviderMock : ILastUsernameProvider {
// Теперь в этом поле будет сохранятся имя последнего сохраненного пользователя public string SavedUserName;
// Нам все еще нужно вернуть правильное значение из этого метода, // так что наш "мок" также является и "стабом" public string ReadLastUserName() { return "Jonh Skeet";}
// А вот в этом методе мы сохраним параметр в SavedUserName для public void SaveLastUserName(string userName)
{
SavedUserName = userName;
} } // Проверяем, что при вызове метода Login будет сохранено имя последнего пользователя [Test] public void TestLogin() {
var mock = new LastUsernameProviderMock();
var vm = new LoginViewModel(mock);
// Изменяем состояние вью-модели путем изменения ее свойства vm.UserName = "Bob Martin";
// А затем вызываем метод Login vm.Login();
// Теперь мы проверяем, что был вызван метод SaveLastUserName Assert.That(mock.SavedUserName, Is.EqualTo(vm.UserName)); }
А зачем мне знать об этих отличиях?
Действительно, разница в понятиях может показаться незначительной, особенно если вы реализуете подобные «фейки» руками. В этом случае знание этих паттернов лишь позволит говорить с другими разработчиками на одном языке и упростит наименование фейковых классов.
Однако рано или поздно вам может надоесть это чудесное занятие по ручной реализации интерфейсов и вы обратите свое внимание на один из Isolation фреймворков, таких как Rhino Mocks, Moq или Microsoft Moles. Там эти термины встретятся обязательно и понимание отличий между этими типами фейков вам очень пригодится.
Я осознанно не касался ни одного из этих фреймворков, поскольку каждый из них заслуживает отдельной статьи и ИМО лишь усложнит понимание этих понятий. Но если вам все же интересно посмотреть на некоторые из этих фреймворков более подробно, то об одном из них я писал более подробно: “Microsoft Moles”.
Этот комментарий был удален автором.
ОтветитьУдалитьДа, очепятался. Поправил, спасибо!
ОтветитьУдалитьКонечно, если тестируемые классы являются внутренними не будет не какой, но поскольку все «клиенты» ...
ОтветитьУдалитьУ вас слово пропущено.
@Monsignor: спасибо, поправил.
ОтветитьУдалитьЧто-то из представленных примеров не создается у меня ощущения где Stub, а где Mock. Никогда особо не отличал эти два понятия, но те ощущения, что сложились очень схожи с написаным. Правда stub'ы - заглушки, я привык считать чем-то, что позволяет тестируемому объекту функционировать в более изолированном окружении. А вот mock'и (такое слово встретилось позже) - больше как средства контроля взаимодействия с внешними объектами.
ОтветитьУдалитьТ.е. LastUsernameProviderStub сохранял бы состояние и позволял бы, соответственно, его проверить и особо не важно сколько и каких действий сделает VM.
А LastUsernameProviderMock имел бы, например сценарий: VM вызовет ReadLastUserName и получит "Jonh Skeet", а потом вызовет SaveLastUserName с "Bob Martin". И любое отклонение (лишний запрос) может быть расценен как нарушение спецификации.
Т.е. Mock'и являются частью теста и они, обычно, очень простые, а Stub'ы - не обязательно. Stub можно применять в процессе разработки для эмуляции какого-то оборудования или провайдера протокола (например снифф реального траффика между сервером и клиентом разбитый на кусочки)
Этот комментарий был удален автором.
ОтветитьУдалить@Nikolay: стаб - stateless хелпер, он лишь формирует нужное окружение и он никогда не участвует в ассертах. Мок - более сложный хелпер, который может сохранять состояние (запоминать действия тестируемого объекта), который потом может участвовать в ассертах.
ОтветитьУдалить@Vest: к сожалению, с веб-логикой - это не ко мне. Я вообще, в основном по серверной части, ну и десктоп еще (хотя несколько в меньшей степени), так что с вебом помочь не смогу:)
Mocks are not Stubs by Martin Fowler. Must read.
ОтветитьУдалитьНе уверен, что нужно так строго говорить, что вот это вот моки, а вот это вот стабы. В большинстве случаев - об этом просто не задумываешься, так как - не надо. Есть отличная( на мой взгляд) статья об этом разработчика Moq - http://blogs.clariusconsulting.net/kzu/do-you-really-care-about-stub-vs-mock/. Важно понимать, что ты хочешь тестировать и что ты хочешь проверять. Я вообще в последнее время склоняюсь к тому, что тестировать состояние - не очень правильно. Утверждать в тесте - что вызовется какая-то функция - ну это надо только в случае если есть задокументированный workflow. В других случаях, лучше проверять выхлоп(данные), так как важно, чтобы получались правильные данные, а не вызывались какие-то функции (business value нет никакого от этого). Конечно, все это строго IMHO.
ОтветитьУдалитьЕще хотел добавить. Все же не стоит писать о тестах начиная с тестовых моков и стабов. Лично видел, как сопровождение тестовых классов превращалось в кошмар (они ни разу не маленькие были). Причем что интересно, для разных тестов их поведение должно было быть разным и это приводило к большим проблемам. Поэтому использование Rhino или Moq , как в моем случае привело к поразительным результатам. Когда народ понял, что надо писать моки, с утвереждениями только для теста - дело пошло не в пример лучше. Для меня сейчас mock environment - это вообще code smell. Да не спорю, для нормальных тестов надо знать какой-то из frameworks, понимать разницу между strict mock, dynamic mock or partial mock, НО лучше день потерять, за час долететь.