Тестовые дублёры (англ. Test Doubles) — это объекты или модули, используемые в автоматизированных тестах в качестве замены некоторых частей тестируемой системы (англ. SUT, System Under Test).
Многие программисты называют все тестовые дублёры одним словом: Mock.
Другие выделяют ровно два типа: Stub и Mock — вероятно, они вдохновляются статьёй Mocks Aren't Stubs Мартина Фаулера
Третьи различают целых пять типов тестовых дублёров — именно столько описано в книге XUnit Test Patterns.
В этой статье мы обсудим пять типов тестовых дублёров: Dummy, Stub, Spy, Mock, Fake.
Как появилась статья
Меня зовут Сергей Шамбир, и я backend-разработчик в TravelLine (разрабатываю на C# / .NET).
Так получилось, что последние 4 года я занимаюсь разработкой внутренних backend-сервисов для автоматизации процессов бэк-офиса.
В этой области есть две характерные черты:
Много бизнес-логики для автоматизации внутренних бизнес-процессов
Много интеграций с другими внутренними и внешними системами — причём смежные системы часто построены на разных технологических платформах, что создаёт зоопарк протоколов взаимодействия
Статья Мартина Фаулера на тему интеграций: You Can't Buy Integration.
Эти особенности влияют на разные уровни тестов:
Вследствие бо́льшей значимости интеграций с различными API в моих интеграционных тестах много тестовых дублёров, заменяющих неуправляемые внепроцессные зависимости
Также я использую тестовые дублёры в модульных тестах, заменяя все Collaborator Objects тестируемого класса (если, конечно, он с кем-то взаимодействует)
По интеграционным тестам расскажу подробнее: я фокусируюсь на интеграционных приёмочных тестах. Этот тип тестов можно кратко описать так:
Они написаны языком, понятным экспертам предметной области, и используют язык Gherkin (с помощью библиотеки Reqnroll); при этом тесты документируют требования к сервису на языке, понятном не только разработчику
Следовательно, это приёмочные тесты (англ. Acceptance Tests, или Customer Tests в терминах Extreme Programming)
Они тестируют систему в основном как чёрный ящик (Black Box), то есть разработчик стремится проверять наблюдаемое поведение
Следовательно, это функциональные тесты (англ. Functional Tests)
Они используют реальную СУБД в docker-контейнере, но заменяют тестовыми дублёрами все неуправляемые внешние зависимости (включая API сервисов)
Следовательно, это интеграционные тесты по определениям книги «Принципы Unit-тестирования» Владимира Хорикова
По модели Test Sizes от Google, они называются тестами среднего размера (англ. Medium Size)
Модульные тесты и статический анализ служат дополнительным инструментом, однако основной вклад в уверенность в коде и в улучшение процесса разработки вносят именно интеграционные приёмочные тесты.
Как читать статью
Я полагаю, что у читателя уже есть какой-то опыт использования библиотек для создания Mock-объектов или написания тестовых дублёров вручную.
А если такого опыта нет, то пролистайте статью вниз до примеров и изучите их, а потом вернитесь в начало.
Что такое тестовый дублёр
Термин «тестовый дублёр» (англ. Test Double) происходит от кинематографа:
в английском языке дублёров реальных актёров называют double
каскадёров, в частности, называют stunt double
Пять типов кратко
Для объектно-ориентированных программ определения типов тестовых дублёров можно сформулировать так:
Dummy — простейший дублёр, который реализует интерфейс заменяемого объекта, но ничего не делает и не возвращает осмысленных данных
Stub — дублёр, обеспечивающий контроль над опосредованным вводом (indirect input) тестируемой системы
Spy — дублёр, записывающий весь опосредованный вывод (indirect output) тестируемой системы
Mock — дублёр, сравнивающий опосредованный вывод с заранее настроенными ожиданиями
Fake — полноценная и самостоятельная, но фальшивая реализация интерфейса заменяемого объекта
Термины «опосредованный ввод» и «опосредованный вывод» описаны подробно далее.
Примеры дублёров
Тип дублёра | Пример класса | Описание примера |
Dummy | DummyLogger | Реализует ILogger, но ничего не делает. |
Stub | StubCurrencyDataSource | Источник данных о валютах, возвращающий статические данные. |
Spy | SpyMailSender | Реализует IMailSender. Ничего не отправляет, но запоминает переданные письма и позволяет тесту проверить их. |
Mock | MockMailSender | Реализует IMailSender. Настраивается тестом на шаге Arrange (Given), а на шаге Act (When) проверяет ожидания по вызываемым методам и их параметрам. |
Fake | FakeUserRepository | Реализует IUserRepository. С точки зрения тестируемой системы, неотличим от настоящего репозитория. Однако это фальшивка: он хранит данные не в БД, а в оперативной памяти. |
Различные шаги тестового сценария — Arrange, Act, Assert (они же Given, When, Then в терминах BDD) описаны подробно далее.
Абстрактные модели, связанные с тестовыми дублёрами
1. Модель опосредованного ввода/вывода
Эта модель помогает выбрать тип дублёра, подходящий к определённой ситуации.
Опосредованный ввод / вывод (англ. indirect input / output) — это та часть входных и выходных данных, которыми тестируемая система (SUT) обменивается с другими объектами (Collaborators):
Опосредованный ввод — данные, которые тестируемая система читает из других объектов / систем
Опосредованный вывод — данные, которые тестируемая система пишет в другие объекты / системы
Очевидно, что взаимодействие с другим объектом / системой бывает трёх типов:
Только чтение данных из другого объекта / системы — одностороннее взаимодействие, т.е. только опосредованный ввод
Только запись данных данных в другой объекта / систему — одностороннее взаимодействие, т.е. только опосредованный вывод
Чтение и запись данных — двустороннее взаимодействие
Конкретные примеры
Раскидаем примеры выше по типу взаимодействия тестируемой системы с дублёром:
Пример класса | Способ взаимодействия с дублёром | Что реально делает дублёр |
DummyLogger | Запись данных | Никуда ничего не пишет |
StubCurrencyDataSource | Чтение данных | Возвращает статические данные |
SpyMailSender | Запись данных | Запоминает данные, чтобы тест прочитал их |
MockMailSender | Запись данных | Сравнивает вызовы методов и параметры с ожиданиями |
FakeUserRepository | Двустороннее (чтение/запись) | Хранит данные в памяти (например, в словаре) |
Общие правила
Можем обобщить, переходя от конкретных примеров к общим правилам:
Тип дублёра | Способ взаимодействия с дублёром |
Dummy | любой, обычно запись данных |
Stub | чтение данных |
Spy | запись данных либо двустороннее (Spy+Stub) |
Mock | запись данных либо двустороннее (Mock+Stub) |
Fake | любой, обычно двустороннее |
Spy+Stub и Mock+Stub
Дублёры типов Spy и Mock могут одновременно принадлежать типу Stub, то есть:
Если дублёр сочетает в себе Spy и Stub, то он записывает весь вывод и возвращает статически определённый ввод
Если дублёр сочетает в себе Mock и Stub, то он сравнивает вызываемые методы и параметры с ожиданиями и возвращает в ответ данные, заданные при настройке Mock
В этом случае имена классов дублёров обычно содержат слово Spy или Mock, но не содержат слова Stub.
2. Модель Arrange-Act-Assert
Разные типы дублёров могут использоваться на разных шагах теста.
Модульные и интеграционные тесты удобно пишутся по модели AAA: Arrange, Act, Assert
На шаге Arrange настраивается состояние до вызова тестируемых действий (методов)
На шаге Act происходит вызов тестируемого действия (метода)
На шаге Assert происходит проверка результатов
Некоторые авторы выделяют четвёртый шаг — Cleanup или Teardown — выполняющий освобождение ресурсов и откат изменений, способных повлиять на другие тесты.
Минутка BDD
Существует подход Behavior Driven Development, в рамках которого в том числе происходит смена названий: вместо Arrange / Act / Assert используются глаголы Given / When / Then.
Когда-то в прошлом я думал, что смена глаголов — это и есть суть BDD (ха-ха!). Как следствие, я считал BDD не очень полезной методикой.
Суть BDD конечно не в глаголах, а в описании приёмочных тестов (функциональных тестов) на языке, понятном всем членам продуктовой команды — product owner, системным аналитикам, тестировщикам, разработчикам
Если BDD сочетается с разработкой через User Story (со списком Acceptance Criteria для каждой Истории), то возникает огромный позитивный эффект для коммуникаций и процессов как внутри команды, между смежными командами и между командой и представителями заказчика
Если BDD сочетается с языком Gherkin, то тесты будут лаконичными и устойчивыми к рефакторингу
О тестах на Gherkin вы можете прочитать в статье Интеграционные тесты для ASP.NET Core, а ниже показан простой пример такого теста:
Сценарий: Можем создать несколько продуктов и обновить один
Пусть добавили продукты:
| Код | Описание | Цена | Количество |
| A12345 | Что-то из льняного волокна | 49,90 | 300 |
| B99999 | Женский ободок для волос | 99,00 | 12 |
Когда обновляем продукты:
| Код | Описание | Цена | Количество |
| A12345 | Фуфайка из льняного волокна | 49,90 | 400 |
Тогда получим список продуктов:
| Код | Описание | Цена | Количество |
| A12345 | Фуфайка из льняного волокна | 49,90 | 400 |
| B99999 | Женский ободок для волос | 99,00 | 12 |
Типы дублёров и шаги AAA
Вернёмся к нашим дублёрам и рассмотрим их взаимосвязь с шагами AAA.
Все типы дублёров так или иначе внедряются как зависимости — через DI-контейнер, через конструктор либо через параметры.
Однако есть различия во взаимодействии дублёра с разными шагами теста.
Тип дублёра | Взаимодействующие шаги AAA |
Dummy | — |
Stub | На шаге Arrange в Stub могут быть записаны данные |
Spy | На шаге Assert из Spy могут быть прочитаны данные |
Mock | Mock полностью настраивается на шаге Arrange (либо в методе Setup), и срабатывает автоматически на шаге Act. Иногда проверки откладываются до шага Assert (если вызываются явно). |
Fake | Fake должен работать и без настройки в тесте. В некоторых случаях тест взаимодействует с Fake, изменяя его начальное состояние на шаге Arrange либо проверяя финальное состояние на шаге Assert. |
Примеры реализации дублёров на C#
Общие принципы реализации всех типов тестовых дублёров:
Все типы дублёров не используются в нормальном режиме работы тестируемой системы
В тестируемый код все типы дублёров так или иначе внедряются как зависимости — через DI-контейнер, через конструктор либо через параметры вызываемых методов
Следует исключить использование дублёров в production — для этого их можно поместить в пакет, подключаемый только в тестах, или иным образом сделать недоступными для тестируемой системе при сборке/запуске в production
Пример Dummy
Dummy-объекты реализуют шаблон проектирования Null Object и отличаются тем, что предназначены только для тестов.
Класс DummyLogger
/// <summary>
/// Реализует ILogger, но ничего не делает.
/// </summary>
public class DummyLogger : ILogger
{
public void Log(Level level, string message, params object[] arguments)
{
// Улыбаемся и машем.
}
}
Пример Stub
Stub-объекты содержат в себе статические данные либо загружают их с диска.
Кроме того, Stub-объект может иметь методы для записи данных на шаге Arrange (Given) теста.
Класс StubCurrencyDataSource
/// <summary>
/// Источник данных о валютах, возвращающий статические данные.
/// </summary>
public class StubCurrencyDataSource: ICurrencyDataSource
{
private readonly List<CurrencyData> _currencies = [
new CurrencyData("Российский рубль", "RUB"),
new CurrencyData("Китайский юань", "CNY"),
new CurrencyData("Бразильский реал", "BRL"),
new CurrencyData("Индийская рупия", "INR"),
new CurrencyData("Южноафриканский рэнд", "ZAR"),
];
public IEnumerable<string> GetSupportedCurrencyCodes()
{
return _currencies.Select(data => data.Code());
}
public string GetNameByCode(string code)
{
return _currencies.First(data => data.Code == code).Name;
}
private record CurrencyData(
string Name,
string Code
);
}
Mock-объекты
Mock обычно настраивается специальными библиотеками с помощью DSL (Domain Specific Language), при этом синтаксис такого DSL может быть абсолютно разным для разных языков программирования или библиотек. Поэтому примера Mock не будет, вместо этого см. страницу Quickstart в wiki пакета Moq.
Примеры Spy
Spy-объекты схожи на Mock по своему назначению, но отличаются в реализации:
Mock строится на библиотечном коде, а тест лишь настраивает его на шаге Arrange (Given) на определённые ожидания о том, какие методы и с какими параметрами будут вызваны, после чего Mock-объект сверяет ожидания и фактические вызовы/параметры либо сразу на шаге Act (When), либо позже на шаге Assert (Then)
Spy пишется вручную и просто сохраняет параметры вызовов на шаге Act (When), позволяя тесту прочитать их на шаге Assert (Then)
Реализация Spy может делегировать работу оригинальному объекту либо ничего не делать, но запоминание полученных данных происходит в любом случае.
Класс SpyMailSender
/// <summary>
/// Ничего не отправляет, но запоминает переданные письма
/// и позволяет тесту проверить их.
/// </summary>
public class SpyMailSender : IMailSender
{
private readonly List<MimeMessage> _sentMails = [];
public Task SendAsync(MimeMessage mail)
{
_sentMails.Add(x);
return Task.CompletedTask; // Усё готово, шеф!
}
public MimeMessage FindMailByToName(string toName)
{
return _sentMails.Single(
message => message.To.Any(x => x.Name == toName)
);
}
}
Пример Fake
Fake-объекты не требуют предварительной настройки (но могут позволять тесту записать дополнительные данные).
Ключевой критерий удачного Fake: для тестируемой системы он неотличим от подменяемого объекта, но упускает ключевой аспект поведения — в нём нет взаимодействия с внепроцессными зависимостями.
Класс FakeUserRepository
Fake-репозитории обычно строятся на двух инструментах:
Словари (ассоциативные массивы) вместо реляционных таблиц, при этом ключ словаря соответствует первичному ключу реляционной таблицы, а поиск по другим ключам выполняется линейно (потому что в тесте мало данных)
Простая генерация ID — например, путём инкремента целочисленной переменной или генерации UUID библиотечными средствами
/// <summary>
/// Фальшивка: не обеспечивает персистентности.
/// Хранит данные в оперативной памяти вместо реальной БД.
/// </summary>
public class FakeUserRepository : IUserRepository
{
private Dictionary<string, User> _users = new();
public User? Find(string id)
{
return _users.GetOrDefault(id);
}
public List<User> FindAllByJobTitle(string jobTitle)
{
return _users.Values
.Where(u => u.JobTitle == jobTitle)
.ToList();
}
public void Add(User user)
{
_users[user.Id] = user;
}
public void Update(User user)
{
_users[user.Id] = user;
}
public void Delete(User user)
{
_users.Remove(user.Id);
}
}
Доводы в пользу Fake-объектов
По моему личному опыту, Fake часто оказывался выгоднее, чем комбинация Mock и Stub или Spy и Stub:
Не требующий настройки Fake-объект делает тесты устойчивее к изменениям структуры программы, не меняющим её наблюдаемое поведение (будь то рефакторинг или оптимизация)
Процесс написания Fake-объекта даёт программисту намного лучшее понимание подменяемого объекта или внешней системы
Готовый Fake понижает сложность написания дополнительных тест-кейсов, что мотивирует коллег писать достаточно много тестов и полноценно сопровождать ранее написанные тесты
С хорошо написанным Fake-объектом тест имеет больше шансов найти потенциальные ошибки, поскольку точнее имитирует поведение подменяемой системы
Поэтому Fake — мой любимый тип тестового дублёра.
Этимология слов
Чтобы лучше запомнить разницу между типами тестовых дублёров, полезно разобраться с нюансами соответствующих английских слов:
Слово | Значение слова |
Dummy | 1) Детская пустышка; 2) Грубо сделанный макет предмета, например, учебная мишень из дерева 3) Манекен, муляж |
Stub | 1) Остаток (стены); 2) Корешок (в отрывной чековой книжке) |
Spy | Тайный агент, собирающий сведения |
Mock | Имитация, мимикрирующая под оригинал |
Fake | Фальшивая копия — подделка под оригинал, которая выглядит настоящей |
Для понимания разницы между Mock и Fake сравним два выражения:
Mock exam — репетиция экзамена, приближённая к настоящему экзамену
Fake exam — поддельный экзамен, разновидность мошенничества
Подытожим
Мы разобрали пять типов тестовых дублёров:
Dummy — простейший дублёр, который реализует интерфейс заменяемого объекта, но ничего не делает и не возвращает осмысленных данных
Stub — дублёр, обеспечивающий контроль над опосредованным вводом (indirect input) тестируемой системы
Spy — дублёр, записывающий весь опосредованный вывод (indirect output) тестируемой системы
Mock — дублёр, сравнивающий опосредованный вывод с заранее настроенными ожиданиями
Fake — полноценная и самостоятельная, но фальшивая реализация интерфейса заменяемого объекта
Есть вопросы, которые не затронуты в этой статье:
Как выбирать тестовые дублёры для модульных тестов?
Как выбирать тестовые дублёры для интеграционных тестов?
Когда следует отказаться от дублёров и использовать оригинальные объекты?
Вопросы №1 и №3 хорошо раскрыты в книге «Принципы Unit-тестирования» Владимира Хорикова.
Вопрос №2 я планирую раскрыть детальнее в своих следующих статьях.