Интеграционные тесты (англ. Integration tests) занимают промежуточное положение между модульными и сквозными. Они отлично подходят на роль приёмочных тестов backend-сервиса.

  • В отличии от модульных тестов (англ. Unit tests), в интеграционных можно и нужно использовать управляемые внепроцессные зависимости: СУБД, кеш, хранилище файлов / объектов.

  • В отличии от сквозных тестов (англ. End-to-end tests), в интеграционных не следует использовать неуправляемые внепроцессные зависимости: общую шину группы сервисов (например, RabbitMQ), API смежных сервисов или сторонние API

Но что делать, если тестируемый сервис активно использует внешние API? Заменять их тестовыми дублёрами.

  • В данной статье показано применение различных тестовых дублёров в качестве подмены внешних систем

  • Примером к статье служит API-сервис, написанный на .NET 8 (C# 12) с использованием ASP.NET Core 8.

  • В промежуточной версии тестов будут показаны дублёры Dummy и Spy

  • В финальной версии тестов они исчезнут, и останутся только Fake, Stub и Mock

Если вам незнаком термин «тестовый дублёр» (англ. Test Double), вы можете прочитать статью Типология Test Doubles

1. Как появилась статья

Меня зовут Сергей Шамбир, и я backend-разработчик в TravelLine (разрабатываю на C# / .NET).

  • Последние 4 года я занимаюсь разработкой внутренних backend-сервисов для автоматизации процессов бэк-офиса — в статье «Пять Миров» Джоэля Спольски это называется «внутреннее ПО» (англ. internal software)

  • В системах для бэк-офиса есть много интеграций с другими внутренними и внешними системами — причём смежные системы часто построены на разных технологических платформах

  • Я предпочитаю писать интеграционные приёмочные тесты, проверяющие работу сервиса с реальной СУБД, но в изоляции от неуправляемых внешних зависимостей — таким тестам недоступны ни API смежных сервисов, ни сторонние API

  • Для замены неуправляемых внепроцессных зависимостей я использую тестовые дублёры различных типов: Fake, Mock (Spy) и Stub.

2. Тестируемая система

Цель статьи — написать интеграционные тесты готовой системы.

  • Конечно, тесты следует писать в процессе разработки — непосредственно перед изменениями в коде тестируемой системы либо сразу после них

  • Мы поступим иначе, потому что наша цель — разобраться с техническими решениями, а не испытать в деле практики TDD / ATDD

2.1. Продукт, включающий тестируемую систему

Тестируемая система, конечно, искусственная.

Она является частью условного продукта, который можно описать так:

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

Игрушечная реализация сервиса есть в репозитории: https://github.com/sergey-shambir/dotnet-integration-testing-3rdparty-api.

Вы можете клонировать репозиторий, переключиться на ветку baseline и воспроизвести все шаги статьи (сделайте это, чтобы оценить подход на практике):

git clone git@github.com:sergey-shambir/dotnet-integration-testing-3rdparty-api.git

cd dotnet-integration-testing-3rdparty-api

git checkout baseline

2.2. API-сервис DailyRates

Тестируемая система — это API-сервис DailyRates (см. src/DailyRates.WebService), который является ключевым компонентом продукта и взаимодействует с другими компонентами:

  1. DailyRates взаимодействует с сервисом MailSubscription (см. src/MailSubscription.WebService), который управляет почтовыми подписками

  2. DailyRates получает валюты через API Центробанка России согласно документации: Получение данных, используя XML

  3. DailyRates также взаимодействует с указанным в конфигурации SMTP сервером, который используется для отправки почты

Способ взаимодействия можно описать таблицей:

Смежный компонент

Тип взаимодействия

Протокол

MailSubscription

чтение и запись данных

HTTP (REST API)

API Центробанка России

чтение данных

HTTP (REST API)

SMTP-сервер

запись данных

SMTP

Зачем нужен сервис MailSubscription?

Воспринимайте MailSubscription как «сервис смежной команды, написанный на другом стеке технологий и доступный через REST API».

  • В репозитории есть реализация на этого сервиса на JavaScript + Express.js. Она тривиальна и не пригодна для Production.

  • В реальном продукте сервис MailSubscription мог бы хранить подписки в БД, отправлять на почту email для подтверждения согласия на подписку и обрабатывать запрос на отказ от подписки (unsubscribe).

2.3. Сценарии использования DailyRates

Первый сценарий — клиент подписывается на ежедневную рассылку:

Диаграмма последовательности для создания подписки
Диаграмма последовательности для создания подписки

Второй сценарий — некий планировщик задач отправляет запрос на отправку рассылки:

Диаграмма последовательности для рассылки писем
Диаграмма последовательности для рассылки писем
Почему письма отправляются вызовом API?

В реальном проекте планировщик вряд ли станет вызывать метод API:

  • Вместо этого будет использоваться либо встроенный планировщик (такой как класс RecurrentJob из пакета Hangfire), либо CLI-интерфейс, вызываемый через CronJob в Kubernetes или обычный демон cron

  • Однако интеграционное тестирование проекта с Hangfire выходит за рамки этой статьи, и для демонстрации идеи нам достаточно API-вызова.

3. Добавим тест на языке Gherkin

Если вы не использовали ни Specflow, ни Reqnroll для тестирования ASP.NET приложений, то вам стоит прочитать статью Интеграционные тесты для ASP.NET Core

Мы добавим ровно один тест на языке Gherkin и позже реализуем его.

3.1. Создаём проект tests/DailyRates.Specs/

Создадим проект:

# Новый проект из шаблона XUnit
dotnet new xunit -o tests/DailyRates.Specs
dotnet sln add tests/DailyRates.Specs

# Удалим лишние файлы
rm tests/DailyRates.Specs/GlobalUsings.cs
rm tests/DailyRates.Specs/UnitTest1.cs

Далее можно установить dotnet-outdated-tool и воспользоваться им для обновления версий зависимостей:

# Устанавливаем инструмент
dotnet tool install --global dotnet-outdated-tool --version 4.6.4

# Обновляем версии зависимостей
dotnet outdated --upgrade

Добавим в новый проект зависимость от Reqnroll (предоставляет поддержку языка Gherkin в тестах):

dotnet add tests/DailyRates.Specs package Reqnroll
dotnet add tests/DailyRates.Specs package Reqnroll.xUnit

3.2. Добавляем приёмочный тест

Добавим в проект файл specflow.json, чтобы писать тесты на русском языке:

{
  "language": {
    "feature": "ru-RU"
  }
}

Создадим в проекте тестов файл Features/DailyRates.feature, в который добавим единственный тест:

Функциональность: сервис позволяет ежедневно получать письмо с данными о курсах валют от ЦБ РФ

    Сценарий: пользователи получают курсы валют за 2024-07-10
        Пусть пользователи подписались на обновления курсов валют:
          | Имя               | Email                              | Коды валют    |
          | Фёдор Достоевский | fedor.dostoevsky@public.mail.local | EUR, USD, CNY |
          | Александр Раскин  | a.ruskin@company.local             | AED, BRL, INR |

        Когда загружаем курсы валют за "2024-07-10" и рассылаем письма

        Тогда "Фёдор Достоевский" получит письмо "Курсы валют на 2024-07-10" с текстом:
        """
        Доброе утро, Фёдор Достоевский!
        Курсы валют на сегодня:
        - 1 Евро = 95,3447 Рублей
        - 1 Доллар США = 88,0031 Рублей
        - 1 Китайский юань = 11,9469 Рублей
        """

        И "Александр Раскин" получит письмо "Курсы валют на 2024-07-10" с текстом:
        """
        Доброе утро, Александр Раскин!
        Курсы валют на сегодня:
        - 1 Дирхам ОАЭ = 23,9627 Рублей
        - 1 Бразильский реал = 16,0833 Рублей
        - 1 Индийских рупий = 1,0541 Рублей
        """

Этот тест проверяет единственный бизнес-сценарий и задействует оба системных сценария:

  1. Подписаться на ежедневные письма с курсами валют (путём вызова POST /api/currency-rates/subscribe)

  2. Разослать письма с курсами валют (путём вызова POST /api/currency-rates/send-mails)

Что такое бизнес-сценарий и системный сценарий?

В продуктах класса B2B и в корпоративном ПО сценарии использования бывают двух видов:

  • Бизнес-сценарий описывает бизнес-процесс, выполняемый одним или несколькими сотрудниками организации

  • Системный сценарий описывает использование функции системы

  • То есть один бизнес-сценарий реализуется путём использования одного или нескольких системных сценариев

4. Два принципа

При реализации шагов единственного теста мы будем заменять тестовыми дублёрами все неуправляемые внепроцессные зависимости, а именно:

  1. Сервис MailSubscription (условный «сервис смежной команды»)

  2. API Центробанка России

  3. SMTP-сервер отправки почты

Заранее оговорим два принципа, которым будем следовать при написании тестовых дублёров.

4.1. Избегаем слепых зон

Тесты проверяют часть тестируемой системы (англ. SUT — System Under Test). При этом внепроцессные зависимости заменяются дублёрами, но сделать это можно по-разному.

Допустим, работа с внешним API для получения курсов валют изолирована в классе CurrencyRatesDataSource. При написании теста мы можем пойти двумя способами:

Способ подстановки дублёра

Последствия

Выделить интерфейс ICurrencyRatesDataSource и создать для него тестовый дублёр StubCurrencyRatesDataSource

Класс CurrencyRatesDataSource и его границы не покрыты интеграционными тестами

Оставить CurrencyRatesDataSource в нетронутом состоянии, но подсунуть ему особенный HttpClient, который будет взаимодействовать с тестовым дублёром

Интеграционные тесты запускают весь код, написанный программистом

Отказ от запуска определённых частей системы в тестах всегда создаёт слепые зоны:

  • Допустим, что вероятность появления ошибки в строке кода примерно одинакова вне зависимости от местоположения кода (хотя есть факторы, которые её повышают — например, динамическая типизация или непривычная область задач)

  • Ошибки в слепых зонах выявляются только ручным тестированием

  • Если целый слой сервиса попадает в слепую зону, то все бизнес-сценарии сервиса под угрозой: каждый из них может содержать баги именно в слепой зоне

  • Чем выше изменчивость слепой зоны, тем больше проблем возникает

Также заметим, что с точки зрения ошибок в программе нет более важных или менее важных слоёв. Вы можете считать Domain самым важным слоем, но ошибка в слое доступа к данным (англ. Data Access) прервёт всю операцию либо вызовет скрытую порчу данных, и идеальный слой Domain никак не поможет.

Вывод: надо стремиться покрыть тестами все слои тестируемой системы, не допуская слепых зон, но не забывая о компромиссах.

  • Закрытие слепых зон оправдано, даже если повышается стоимость написания и сопровождения тестов — до определённой степени

  • Если же устранение слепой зоны тестов потребует неоправданно больших затрат, то её можно оставить (не забывая поставить в известность команду QA)

4.2. Используем Separation of Concerns

Если для написания тест-кейса нам приходится заново вникать в настройку тестового дублёра — значит, мы решаем одновременно две разные проблемы и не соблюдаем принцип Separation of Concerns.

Что такое Separation of Concerns

Принцип Separation of Concerns (рус. Разделение Проблем либо Разделение Забот) описал Эдсгер Дейкстра в 1982 году в статье «On the role of scientific thought»:

  • Суть принципа в том, что программисту следует заботиться о разных характеристиках программы в разное время.

  • Например, в один день программист заботится о корректности программы, а в другой день — о производительности.

В современном Интернете принцип Separation of Concerns обычно упоминают только как обоснование для разделения программы на слои.

Однако деление программы на слои — это лишь одна из множества вещей, необходимых для достижения Sepration of Concerns.

Поэтому следует разделять написание тестовых дублёров и их использование:

  1. Сначала программист пишет хороший тестовый дублёр, инвестируя в него своё время

  2. Затем программист пишет тест кейсы, практически не трогая тестовый дублёр

Такой подход снижает стоимость дальнейшего сопровождения тестов и мотивирует разработчика и его коллег писать больше тест-кейсов.

5. Реализуем шаг «Пусть»

5.1. Класс DailyRatesStepDefinitions

Добавим в проект тестов файл Steps/DailyRatesStepDefinitions.cs с пустой реализацией шагов теста:

Файл Steps/DailyRatesStepDefinitions.cs
using Reqnroll;

namespace DailyRates.Specs.Steps;

[Binding]
public class DailyRatesStepDefinitions
{
    [Given(@"пользователи подписались на обновления курсов валют:")]
    public void ПустьПользователиПодписалисьНаОбновленияКурсовВалют(Table table)
    {
        ScenarioContext.StepIsPending();
    }

    [When(@"загружаем курсы валют за ""(.*)"" и рассылаем письма")]
    public void КогдаЗагружаемКурсыВалютЗаИРассылаемПисьма(DateOnly date)
    {
        ScenarioContext.StepIsPending();
    }

    [Then(@"""(.*)"" получит письмо ""(.*)"" с текстом:")]
    public void ТогдаПолучитПисьмоСТекстом(string name, string mailSubject, string mailContentPlainText)
    {
        ScenarioContext.StepIsPending();
    }
}

5.2. Готовим TestServer для ASP.NET Core

Тестируемая система будет запускаться с помощью TestServer, предоставляемого отдельным пакетом фреймворка ASP.NET Core.

Установим пакет Microsoft.AspNetCore.Mvc.Testing:

dotnet add tests/DailyRates.Specs/ package Microsoft.AspNetCore.Mvc.Testing --version 8.0.11

Добавим ссылку из проекта тестов на тестируемую систему:

dotnet add tests/DailyRates.Specs/ reference src/DailyRates.WebService/

Добавим в проект файл Fixtures/TestServerFixture.cs с описанием класса TestServerFixture:

Файл Fixtures/TestServerFixture.cs

Класс TestServerFixture является тестовым приспособлением (англ. Fixture) для инициализации тестируемой системы внутри процесса, выполняющего тест.

using Microsoft.AspNetCore.Mvc.Testing;

namespace DailyRates.Specs.Fixture;

public class TestServerFixture: IDisposable
{
    private readonly WebApplicationFactory<Program> _factory = new();

    public HttpClient HttpClient { get; }

    public IServiceProvider ServiceProvider { get; }

    public TestServerFixture()
    {
        HttpClient = _factory.CreateClient();
        ServiceProvider = _factory.Services;
    }

    public void Dispose()
    {
        HttpClient.Dispose();
        _factory.Dispose();
    }
}

5.3. Готовим Test Driver

Воспользуемся шаблоном проектирования Test Driver, то есть определим вспомогательный класс WebServiceDriver, инкапсулирующий способ взаимодействия с тестируемой системой:

Файл Drivers/WebServiceDriver.cs
using System.Net.Http.Json;

namespace DailyRates.Specs.Drivers;

public class WebServiceDriver(HttpClient httpClient)
{
    public async Task Subscribe(string name, string email, List<string> currencyCodes)
    {
        HttpResponseMessage response = await httpClient.PostAsJsonAsync(
            "/api/currency-rates/subscribe",
            new
            {
                Name = name,
                Email = email,
                CurrencyCodes = currencyCodes,
            }
        );
        await EnsureSuccessStatusCode(response);
    }

    private async Task EnsureSuccessStatusCode(HttpResponseMessage response)
    {
        if (!response.IsSuccessStatusCode)
        {
            string content = await response.Content.ReadAsStringAsync();
            throw new HttpRequestException(
                $"HTTP Status code {response.StatusCode}: {content}",
                null,
                response.StatusCode
            );
        }
    }
}

Метод EnsureSuccessStatusCode

В этом классе есть метод EnsureSuccessStatusCode(), который решает две задачи:

  1. Бросить исключение, если вызов API завершился ошибкой (то есть HTTP Status Code лежит в диапазоне [400..599])

  2. В текст исключения добавить тело ответа

В случае ошибки тело ответа тестируемой системы содержит дополнительную информацию благодаря этой строке в Program.cs:

builder.Services.AddProblemDetails();

5.4. Реализуем шаг

Внесём изменения в класс DailyRatesStepDefinitions, определяющий шаги теста:

  1. Добавим зависимость как параметр конструктора: TestServerFixture fixture

  2. Добавим драйвер через композицию, то есть добавим поле WebServiceDriver _driver

  3. Реализуем метод ПустьПользователиПодписалисьНаОбновленияКурсовВалют

В реализованном методе сделаем следующее:

  • Прочитаем таблицу с данными, используя метод CreateSet<T>() — подробности в статье DataTable Helpers документации Reqnroll

  • Полученные данные используем для отправки API-запросов через драйвер.

Новая версия класса DailyRatesStepDefinitions
using DailyRates.Specs.Drivers;
using DailyRates.Specs.Fixture;
using Reqnroll;
using Reqnroll.Assist.Attributes;

namespace DailyRates.Specs.Steps;

[Binding]
public class DailyRatesStepDefinitions(TestServerFixture fixture)
{
    private readonly WebServiceDriver _driver = new(fixture.HttpClient);

    [Given(@"пользователи подписались на обновления курсов валют:")]
    public async Task ПустьПользователиПодписалисьНаОбновленияКурсовВалют(Table table)
    {
        List<SubscribeRequest> requests = table.CreateSet<SubscribeRequest>().ToList();
        foreach (SubscribeRequest request in requests)
        {
            await _driver.Subscribe(request.Name, request.Email, request.GetCurrencyCodesList());
        }
    }

    [When(@"загружаем курсы валют за ""(.*)"" и рассылаем письма")]
    public void КогдаЗагружаемКурсыВалютЗаИРассылаемПисьма(DateOnly date)
    {
        ScenarioContext.StepIsPending();
    }

    [Then(@"""(.*)"" получит письмо ""(.*)"" с текстом:")]
    public void ТогдаПолучитПисьмоСТекстом(string name, string mailSubject, string mailContentPlainText)
    {
        ScenarioContext.StepIsPending();
    }

    private class SubscribeRequest
    {
        [TableAliases("Имя")]
        public string Name { get; init; } = string.Empty;

        public string Email { get; init; } = string.Empty;

        [TableAliases("Коды валют")]
        public string CurrencyCodes { get; init; } = string.Empty;

        public List<string> GetCurrencyCodesList()
        {
            return CurrencyCodes.Split(',').Select(x => x.Trim()).ToList();
        }
    }
}

5.5. Запускаем тест

Запустим тест и получим ошибку:

HTTP Status code InternalServerError: {"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"System.Net.Http.HttpRequestException","status":500,"detail":"Connection refused (localhost:5025)","traceId":"00-b4a776f9c526db15659ce475b5a2637a-83ee8269e87ce9c9-00","exception":{"details":"System.Net.Http.HttpRequestException: Connection refused (localhost:5025)\n ...

Первопричина ошибки — ошибка подключения к хосту localhost:5025.

  • Данный адрес соответствует сервису MailSubscription. Это условный «сервис смежной команды», API которого использует тестируемая система

  • Мы пишем интеграционные тесты и заменяем тестовыми дублёрами все неуправляемые внепроцессные зависимости

  • Следовательно, обращение к MailSubscription надо заменить тестовым дублёром

6. Fake-объект вместо сервиса MailSubscription

6.1. Ищем шов либо точку расширения

В книге «Эффективная работа с унаследованным кодом» Майкла Физерса и Чада Фаулера описывается метафора «шва»:

Шов — это место в программе, где можно внести изменения с минимальным воздействием на остальную часть системы

Впрочем, иногда не нужно даже вносить изменений в тестируемую систему, если есть подходящая точка расширения:

Точка расширения — это место в программе, где можно добавить новую функциональность или изменить поведение без изменения ранее написанного кода программы

Поищем подходящие для тестов точки расширения на UML-диаграмме подмножества классов, используемых при создании подписки на рассылку:

Диаграмма классов сжата для краткости
Диаграмма классов сжата для краткости

Здесь видны две интересные для нас точки расширения:

  • Интерфейс IMailSubscriptionApiClient — зависимость класса CurrencyRatesMailService

  • Класс HttpClient — зависимость класса MailSubscriptionApiClient

Эти точки расширения имеют два необходимых нам признака:

  • Они инкапсулируют детали реализации, а не бизнес-логику, то есть подмена этих зависимостей не приведёт к подмене бизнес-логики тестируемой системы

  • Их можно заменить путём настройки DI-контейнера (речь IServicesCollection, который служит DI-контейнером в Microsoft.Extensions.DependencyInjection)

Какая из точек расширения лучше? Вспомним два принципа.

Избегаем слепых зон

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

  1. Класс CurrencyRatesController и его граница с CurrencyRatesMailService

  2. Класс CurrencyRatesMailService и его граница с IMailSubscriptionApiClient

  3. Класс MailSubscriptionApiClient и его граница с HttpClient

Если мы подменяем IMailSubscriptionApiClient, то мы лишаемся тестирования 1/3 написанных классов.

  • Написание клиента к API — не столь тривиальная задача, как создание класса-DTO или конструктора

  • Значит, мы теряем уверенность в работоспособности примерно 1/3 кода

Вывод: чтобы убрать слепые зоны, следует подменять HttpClient.

Separation of Concerns

Подмена HttpClient будет сложнее, чем подмена IMailSubscriptionApiClient. Однако вспомним принцип разделения проблем (Separation of Concerns):

  • Сначала следует позаботиться о хорошем Fake-объекте

  • Потом следует завершить первый тест, после чего можно писать новые тесты, не тратя времени на доработки Fake-объекта

HttpClient и его делегат

Как подменить HttpClient? А надо ли его подменять?

В C# класс HttpClient уже имеет прекрасную точку расширения: в его конструктор передаётся объект класса HttpMessageHandler.

  • Изначально HttpMessageHandler предназначен для кроссплатформенности

    • Стандартная реализация HttpClientHandler реализует отправку HTTP-запроса и получение HTTP-ответа средствами платформы

  • Мы можем определить в тестах свой обработчик, который станет поддельной реализацией сервера

    • Он будет принимать HTTP-запросы и возвращать HTTP-ответы непосредственно в памяти программы, без обращения к сетевым интерфейсам

6.2. Создаём делегат для HttpClient

Создадим каталог TestDoubles/Modules/MailSubscription/ и добавим в нём класс FakeMailSubscriptionApiServer.

Класс обрабатывает HttpRequestMessage и возвращает HttpResponseMessage таким образом, чтобы тестируемая система не отличила его от реального сервиса

  • Реализация поддельная: любые детали, которые тестируемая система всё равно не сможет заметить, должны быть убраны

  • При реализации Fake-объекта следует глядеть на код класса MailSubscriptionApiClient и делать только то, что заметно клиенту

Класс FakeMailSubscriptionApiServer
  • Метод SendAsync выполняет примитивный роутинг в соответствующий приватный метод.

  • Переданные данные складываются в словарь, а затем извлекаются из него.

  • Класс наследуется от DelegatingHandler, причина объясняется на следующем шаге

using System.Collections.Specialized;
using System.Net;
using System.Net.Http.Json;
using System.Web;

namespace DailyRates.Specs.TestDoubles.Modules.MailSubscription;

public class FakeMailSubscriptionApiServer : DelegatingHandler
{
    private readonly Dictionary<string, List<MailSubscription>> _subscriptionsByTypeMap = [];

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken
    )
    {
        if (request.Method == HttpMethod.Post && request.RequestUri!.AbsolutePath == "/mail-subscription/")
        {
            return await AddUnconfirmedMailSubscription(request, cancellationToken);
        }

        if (request.Method == HttpMethod.Get && request.RequestUri!.AbsolutePath == "/mail-subscription/")
        {
            return ListMailSubscriptions(request);
        }

        throw new NotImplementedException(
            $"Fake does not support API method {request.Method} {request.RequestUri!.AbsolutePath}"
        );
    }

    private async Task<HttpResponseMessage> AddUnconfirmedMailSubscription(
        HttpRequestMessage request,
        CancellationToken cancellationToken
    )
    {
        AddMailSubscriptionRequest data = await ReadFromJsonRequestBody<AddMailSubscriptionRequest>(
            request,
            cancellationToken
        );
        MailSubscription mailSubscription = new()
        {
            Email = data.Email,
            Name = data.Name,
            CustomData = data.CustomData
        };

        if (!_subscriptionsByTypeMap.TryGetValue(data.Type, out List<MailSubscription>? subscriptions))
        {
            subscriptions = [];
            _subscriptionsByTypeMap[data.Type] = subscriptions;
        }

        int index = subscriptions.FindIndex(subscription => subscription.Email == data.Email);
        if (index >= 0)
        {
            subscriptions[index] = mailSubscription;
        }
        else
        {
            subscriptions.Add(mailSubscription);
        }

        return new HttpResponseMessage(HttpStatusCode.OK);
    }

    private HttpResponseMessage ListMailSubscriptions(HttpRequestMessage request)
    {
        NameValueCollection query = HttpUtility.ParseQueryString(request.RequestUri!.Query);
        string type = query["type"] ?? string.Empty;

        if (!_subscriptionsByTypeMap.TryGetValue(type, out List<MailSubscription>? subscriptions))
        {
            subscriptions = [];
        }

        return new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = JsonContent.Create(subscriptions),
        };
    }

    private async Task<T> ReadFromJsonRequestBody<T>(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        T? result = await request.Content!.ReadFromJsonAsync<T>(cancellationToken: cancellationToken);
        return result ?? throw new NullReferenceException();
    }

    private class AddMailSubscriptionRequest : MailSubscription
    {
        public string Type { get; set; } = string.Empty;
    }

    private class MailSubscription
    {
        public string Name { get; set; } = string.Empty;
        public string Email { get; set; } = string.Empty;
        public object? CustomData { get; set; }
    }
}

6.3. Подставляем делегат

Для точечной подстановки HttpClient, инициализированного Fake-объектом, нам нужно настроить DI-контейнер следующим образом:

  • Вызвать .AddHttpClient<IMailSubscriptionApiClient, MailSubscriptionApiClient>(name), чтобы получить IHttpClientBuilder

    • В качестве name следует передать какой-то достаточно уникальный идентификатор, например, nameof(MailSubscriptionApiClient)

  • Вызвать .AddHttpMessageHandler<FakeMailSubscriptionApiServer>()

    • Тип-параметр метода AddHttpMessageHandler<> должен быть наследником DelegatingHandler

Добавим файл TestDoubles/ServiceCollectionExtensions.cs с классом расширения для IServicesCollection:

Файл TestDoubles/ServiceCollectionExtensions.cs
using DailyRates.Modules.MailSubscription.Application;
using DailyRates.Modules.MailSubscription.Infrastructure.ApiClient;
using DailyRates.Modules.MailSubscription.TestDoubles.Modules.MailSubscription;
using Microsoft.Extensions.DependencyInjection;

namespace DailyRates.Specs.TestDoubles;

public static class ServiceCollectionExtensions
{
    public static void AddTestDoubles(this IServiceCollection services)
    {
        services.AddMailSubscriptionModuleTestDoubles();
    }

    private static void AddMailSubscriptionModuleTestDoubles(this IServiceCollection services)
    {
        services.AddSingleton<FakeMailSubscriptionApiServer>();
        services.AddHttpClient<IMailSubscriptionApiClient, MailSubscriptionApiClient>(nameof(MailSubscriptionApiClient))
            .AddHttpMessageHandler<FakeMailSubscriptionApiServer>();
    }
}

Далее создадим подкласс CustomWebApplicationFactory класса WebApplicationFactory, чтобы модифицировать DI-контейнер до запуска тестируемой системы:

Файл Fixture/CustomWebApplicationFactory.cs
using DailyRates.Specs.TestDoubles;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;

namespace DailyRates.Specs.Fixture;

public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.AddTestDoubles();
        });
    }
}

Применим этот класс в TestServerFixture:

public class TestServerFixture: IDisposable
{
    private readonly CustomWebApplicationFactory _factory = new();

    // ... остальные поля, свойства и методы
}

Запустим тесты... теперь 1-й шаг теста проходит успешно, но тест завершается ошибкой из-за вызова ScenarioContext.StepIsPending(); на 2-м шаге:

Пусть пользователи подписались на обновления курсов валют:
          | Имя               | Email                              | Коды валют    |
          | Фёдор Достоевский | fedor.dostoevsky@public.mail.local | EUR, USD, CNY |
          | Александр Раскин  | a.ruskin@company.local             | AED, BRL, INR |

# -> Ошибка: шаг "Когда" не реализован
Когда загружаем курсы валют за "2024-07-10" и рассылаем письма
...

7. Реализуем шаг «Когда»

Напомним, как выглядит этот шаг в тесте на Gherkin:

Когда загружаем курсы валют за "2024-07-10" и рассылаем письма

Предельно кратко, не правда ли?

7.1. Реализуем логику шага

В классе DailyRatesStepDefinitions реализуем метод отправки писем:

[Binding]
public class DailyRatesStepDefinitions(TestServerFixture fixture)
{
    // ... другие поля и методы

    [When(@"загружаем курсы валют за ""(.*)"" и рассылаем письма")]
    public async Task КогдаЗагружаемКурсыВалютЗаИРассылаемПисьма(DateOnly date)
    {
        await _driver.SendMails(date);
    }
}

В классе WebServiceDriver добавим отправку запроса к тестируемой системе:

public class WebServiceDriver(HttpClient httpClient)
{
    public async Task SendMails(DateOnly? currentDate)
    {
        HttpResponseMessage response = await httpClient.PostAsJsonAsync(
            "/api/currency-rates/send-mails",
            new
            {
                CurrentDate = currentDate,
            }
        );
        await EnsureSuccessStatusCode(response);
    }
}

Но мы ещё не закончили! Метод CurrencyRatesController.SendMails задействует код, который обращается к трём неуправляемым внепроцессным зависимостям:

  1. API Центробанка РФ (чтение XML с курсами валют)

  2. API сервиса MailSubscription, который мы уже подменили Fake-объектом

  3. STMP сервер, отправляющий письма

7.2. Подмена API Центробанка РФ

Построим сокращённую UML-диаграмму классов, участвующих в получении курсов валют от центробанка перед отправкой писем:

Как и в случае сервиса MailSubscription, здесь есть две точки расширения:

  1. Интерфейс ICurrencyRatesDataService — зависимость CurrencyRatesMailService

  2. Объект HttpClient — зависимость CbrCurrencyRatesDataSource

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

Минутка unit-тестирования

Внутри CbrCurrencyRatesDataSource использует класс CbrRuCurrencyRatesParser, выполняющий парсинг XML-документа в упрощённую модель курсов валют.

Внимательный читатель заметит, что для этого класса написан модульный тест (см. CbrRuCurrencyRatesParserTest.cs).

  • Парсинг всегда имеет повышенную вероятность ошибок программиста

  • Поэтому парсинг сразу был покрыт модульными тестами

  • Модульное тестирование класса не отменяет интеграционного тестирования связанных с ним сценариев использования.

Мою стратегию тестирования бэкенд-сервисов в процессе разработки можно описать так:

  1. Все сценарии использования должны быть покрыты приёмочными тестами (обычно интеграционными)

  2. Отдельные места, для которых характерна повышенная вероятность ошибок, покрываются модульными тестами

Какие места стоит отдельно покрыть unit-тестами

Трудно собрать полный список, навскидку есть пять разновидностей:

  1. Код с цикломатической сложностью выше среднего. Пример — любой нетривиальный алгоритм (даже бинарный поиск, написанный вручную)

  2. Код с большим числом инвариантов (взаимосвязей) для входных/выходных данных. Примеры — расчёт цен / скидок / даты окончания оплаченного периода

  3. Класс, взаимодействующий с большим количеством объектов — чаще всего это «запах», сигнализирующий о потребности в рефакторинге, но иногда сложность неизбежна

  4. Обёртка над библиотечным кодом, поведение которого может сильно отличаться от ожиданий программиста. Пример — встроенные типы даты/времени в любом языке программирования

  5. Код, который должен быть понятен абсолютно всем — например, свой класс для сборки простых SQL-запросов, для которого unit-тесты послужат документацией

Класс для подмены API Центробанка

Создадим каталог TestDoubles/Modules/CurrencyRates/ и добавим в него файл StubCbrCurrencyRatesServer.cs.

Класс StubCbrCurrencyRatesServer ведёт себя как «огрызок» API со статическими данными:

  1. Читает из HTTP-запроса Query-параметр "date_req" как дату в формате "dd/MM/yyyy"

  2. Преобразует дату в относительный путь к файлу в формате "yyyy-MM-dd/XML_daily.xml"

  3. Обращается к классу TestDataLoader, чтобы прочитать готовый файл с диска, и полученный файл добавляет в объект HttpResponseMessage

Класс StubCbrCurrencyRatesServer

using System.Collections.Specialized;
using System.Globalization;
using System.Net;
using System.Web;
using DailyRates.Specs.Data;

namespace DailyRates.Specs.TestDoubles.Modules.CurrencyRates;

public class StubCbrCurrencyRatesServer : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken
    )
    {
        if (request.Method == HttpMethod.Get && request.RequestUri!.AbsolutePath == "/scripts/XML_daily.asp")
        {
            return Task.FromResult(ReadXmlDailyFile(request));
        }

        throw new NotImplementedException(
            $"Fake does not support API method {request.Method} {request.RequestUri!.AbsolutePath}"
        );
    }

    private static HttpResponseMessage ReadXmlDailyFile(HttpRequestMessage request)
    {
        NameValueCollection query = HttpUtility.ParseQueryString(request.RequestUri!.Query);
        DateOnly date = ParseDateFromQuery(query, "date_req", "dd/MM/yyyy");
        string path = Path.Combine(date.ToString("yyyy-MM-dd"), "XML_daily.xml");
        Stream content = TestDataLoader.OpenFileForReading(path);

        return new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StreamContent(content)
            {
                Headers =
                {
                    { "Content-Type", "application/xml; charset=windows-125" },
                }
            },
        };
    }

    private static DateOnly ParseDateFromQuery(NameValueCollection query, string parameterName, string format)
    {
        string dateString = query[parameterName] ?? string.Empty;
        DateTime dateTime = DateTime.ParseExact(dateString, format, CultureInfo.InvariantCulture);
        return DateOnly.FromDateTime(dateTime);
    }
}

Класс TestDataLoader и тестовые данные

Тестовые данные будут лежать в каталоге Data/, а класс TestDataLoader упростит доступ к ним.

Создадим каталог Data/ и в нём файл TestDataLoader.cs:

Класс TestDataLoader
  • Для поиска файлов данных класс TestDataLoader должен получить путь к своему каталогу

  • Для этого используется приватный метод с параметром, на который установлен атрибут [CallerFilePath]

  • Компилятор автоматически подставляет путь к текущему файлу *.cs в качестве аргумента при вызове

using System.Runtime.CompilerServices;

namespace DailyRates.Specs.Data;

public static class TestDataLoader
{
    public static Stream OpenFileForReading(string relativePath)
    {
        string filePath = Path.Combine(GetDataDirectoryPath(), relativePath);
        return File.OpenRead(filePath);
    }

    private static string GetDataDirectoryPath([CallerFilePath] string path = "")
    {
        return Path.GetDirectoryName(path)
               ?? throw new ArgumentException($"Cannot get directory path from {path}");
    }
}

Создадим подкаталог Data/2024-07-10/ и в него скопируем XML-файл, доступный по ссылке: https://cbr.ru/scripts/XML_daily.asp?date_req=10/07/2024

7.3. Отключение отправки писем

Для отправки писем на слое Application введена абстракция IMailSender. Нас пока не интересуют отправленные письма, поэтому временно заменим интерфейс на Dummy.

Добавим каталог TestDoubles/Modules/Mailing/ и в нём разместим класс DummyMailSender:

Класс DummyMailSender
using DailyRates.Modules.Mailing.Application;

namespace DailyRates.Specs.TestDoubles.Modules.Mailing;

public class DummyMailSender : IMailSender
{
    public Task SendEmail(MailMessage mail, CancellationToken cancellationToken = default)
    {
        return Task.CompletedTask;
    }
}

7.4. Настройка DI-контейнера

Добавим два новых метода в класс расширений ServiceCollectionExtensions:

Изменения в ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
    public static void AddTestDoubles(this IServiceCollection services)
    {
        services.AddCurrencyRatesModuleTestDoubles();
        services.AddMailingModuleTestDoubles();
        services.AddMailSubscriptionModuleTestDoubles();
    }

    private static void AddCurrencyRatesModuleTestDoubles(this IServiceCollection services)
    {
        services.AddSingleton<StubCbrCurrencyRatesServer>();
        services.AddHttpClient<ICurrencyRatesDataSource, CbrCurrencyRatesDataSource>(nameof(CbrCurrencyRatesDataSource))
            .AddHttpMessageHandler<StubCbrCurrencyRatesServer>();
    }

    private static void AddMailingModuleTestDoubles(this IServiceCollection services)
    {
        services.AddScoped<IMailSender, DummyMailSender>();
    }

    private static void AddMailSubscriptionModuleTestDoubles(this IServiceCollection services)
    {
        services.AddSingleton<FakeMailSubscriptionApiServer>();
        services.AddHttpClient<IMailSubscriptionApiClient, MailSubscriptionApiClient>(nameof(MailSubscriptionApiClient))
            .AddHttpMessageHandler<FakeMailSubscriptionApiServer>();
    }
}

7.5. Проверка теста

Запускаем тесты... и вновь видим ошибку незаконченного шага:

Reqnroll.xUnit.ReqnrollPlugin.XUnitPendingStepException: Test pending: One or more step definitions are not implemented yet.

Шаги «Пусть» и «Когда» проходят успешно — осталось реализовать шаг «Тогда».

8. Реализуем шаг «Тогда»

Напомним, как выглядит этот шаг в тесте на Gherkin:

Тогда "Фёдор Достоевский" получит письмо "Курсы валют на 2024-07-10" с текстом:
"""
Доброе утро, Фёдор Достоевский!
Курсы валют на сегодня:
- 1 Евро = 95,3447 Рублей
- 1 Доллар США = 88,0031 Рублей
- 1 Китайский юань = 11,9469 Рублей
"""

И "Александр Раскин" получит письмо "Курсы валют на 2024-07-10" с текстом:
"""
Доброе утро, Александр Раскин!
Курсы валют на сегодня:
- 1 Дирхам ОАЭ = 23,9627 Рублей
- 1 Бразильский реал = 16,0833 Рублей
- 1 Индийских рупий = 1,0541 Рублей
"""

8.1. Шпион заместо SMTP-сервера

Чтобы реализовать шаг «Тогда», нам нужно прочитать отправленные письма. А для этого их надо перехватить, и мы сделаем это с помощью дублёра типа Spy.

Переименуем DummyMailSender в SpyMailSender:

  • Научим его складывать отправленные письма в список

  • Добавим метод для выборки письма по имени отправителя

public class SpyMailSender : IMailSender
{
    private List<MailMessage> _sentMails = [];

    public MailMessage FindMailByToName(string toName)
    {
        return _sentMails.Single(message => message.ToName == toName);
    }
    
    public Task SendEmail(MailMessage mail, CancellationToken cancellationToken = default)
    {
        _sentMails.Add(mail);
        return Task.CompletedTask;
    }
}

В классе ServiceCollectionExtensions внесём два изменения

  1. Поменяем тип сервиса IMailSender со Scoped на Signleton, потому что внутри ASP.NET для обработки запроса создаётся отдельный Scope, а объект-шпион должен пронести письма через границу запроса

  2. Зарегистрируем отдельно тип SpyMailSender, чтобы получать объект-шпион в тесте через IServiceProvider

public static class ServiceCollectionExtensions
{
    // ... другие методы

    private static void AddMailingModuleTestDoubles(this IServiceCollection services)
    {
        services.AddSingleton<SpyMailSender>();
        services.AddSingleton<IMailSender>(provider => provider.GetRequiredService<SpyMailSender>());
    }
}

8.2. Реализуем логику шага

В классе DailyRatesStepDefinitions внесём два изменения:

  • Добавим вспомогательное свойство для получения объекта SpyMailSender из fixture.ServiceProvider

  • Добавим поиск и проверку отправленного письма в метод ТогдаПолучитПисьмоСТекстом

[Binding]
public class DailyRatesStepDefinitions(TestServerFixture fixture)
{
    private readonly WebServiceDriver _driver = new(fixture.HttpClient);

    private SpyMailSender SpyMailSender => fixture.ServiceProvider.GetRequiredService<SpyMailSender>();

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

    [Then(@"""(.*)"" получит письмо ""(.*)"" с текстом:")]
    public void ТогдаПолучитПисьмоСТекстом(string name, string mailSubject, string mailContentPlainText)
    {
        MailMessage mailMessage = SpyMailSender.FindMailByToName(name);
        Assert.Equal(mailSubject, mailMessage.Subject);
        Assert.Equal(mailContentPlainText.Trim(), mailMessage.ContentPlainText.Trim());
    }
}

Запускаем тесты... тест пройден!

9. Убираем слепую зону

9.1. Где слепая зона?

После предыдущего шага тест проходит, но у нас осталась слепая зона:

Не покрыты тестами SmtpMailSender и MimeMessageFactory
Не покрыты тестами SmtpMailSender и MimeMessageFactory

Чтобы достичь полного покрытия сценариев использования интеграционным тестом, потребуется подменять ISmtpClient, а не IMailSender.

Проблема в том, что интерфейс ISmtpClient имеет около 40 методов и свойств, хотя наш код использует только 6. Если мы просто реализуем его вручную, то будет очень много «шума».

9.2. Добавляем Mock-объект с помощью Moq

Упростить подмену можно с помощью библиотек для создания Mock-объектов. Для C# мы применим Moq:

dotnet add tests/DailyRates.Specs/ package Moq

Однако мы не станем создавать Mock прямо в тесте. Вместо этого создадим вспомогательный класс, управляющий созданием Mock и выполняющий функцию «шпиона», переносящего письма через границу запроса/

Новый класс MockSmtpClientProvider добавим в каталог TestDoubles/Modules/Mailing/:

Класс MockSmtpClientProvider.cs

Класс предоставляет:

  1. Публичное свойство SmtpClient для получения mock-объекта, реализующего ISmtpClient

  2. Публичный метод MimeMessage FindMailByToName(string toName), позволяющий найти отправленное письмо по имени отправителя

using MailKit;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using Moq;

namespace DailyRates.Specs.TestDoubles.Modules.Mailing;

public class MockSmtpClientProvider
{
    private readonly List<MimeMessage> _sentMails;
    private readonly Mock<ISmtpClient> _smtpClientMock;
    private bool _isConnected;

    public ISmtpClient SmtpClient => _smtpClientMock.Object;

    public MockSmtpClientProvider()
    {
        _sentMails = [];
        _smtpClientMock = new Mock<ISmtpClient>(MockBehavior.Strict);

        _smtpClientMock.Setup(
            x => x.ConnectAsync(
                It.IsAny<string>(),
                It.IsAny<int>(),
                It.IsAny<SecureSocketOptions>(),
                It.IsAny<CancellationToken>()
            )
        ).Callback<string, int, SecureSocketOptions, CancellationToken>(
            (_, _, _, _) => _isConnected = true
        ).Returns(Task.CompletedTask);
        _smtpClientMock.Setup(x => x.IsConnected).Returns(() => _isConnected);

        _smtpClientMock.Setup(
            x => x.AuthenticateAsync(
                It.IsAny<string>(),
                It.IsAny<string>(),
                It.IsAny<CancellationToken>()
            )
        ).Returns(Task.CompletedTask);
        _smtpClientMock.Setup(
            x => x.SendAsync(
                It.IsAny<MimeMessage>(),
                It.IsAny<CancellationToken>(),
                It.IsAny<ITransferProgress>()
            )
        ).Callback<MimeMessage, CancellationToken, ITransferProgress>(
            (x, _, _) => _sentMails.Add(x)
        ).Returns(Task.FromResult(string.Empty));

        _smtpClientMock.Setup(x => x.Dispose());
    }

    public MimeMessage FindMailByToName(string toName)
    {
        return _sentMails.Single(message => message.To.Any(x => x.Name == toName));
    }
}

Далее удалим старый класс SpyMailSender, а новый класс MockSmtpClientProvider добавим в DI-контейнер:

public static class ServiceCollectionExtensions
{
    // ... другие методы и свойства

    private static void AddMailingModuleTestDoubles(this IServiceCollection services)
    {
        services.AddSingleton<MockSmtpClientProvider>();
        services.AddSingleton<ISmtpClient>(provider =>
            provider.GetRequiredService<MockSmtpClientProvider>().SmtpClient
        );
    }
}

Внесём изменения в DailyRatesStepDefinitions:

[Binding]
public class DailyRatesStepDefinitions(TestServerFixture fixture)
{
    private readonly WebServiceDriver _driver = new(fixture.HttpClient);

    private MockSmtpClientProvider MockSmtpClientProvider =>
        fixture.ServiceProvider.GetRequiredService<MockSmtpClientProvider>();

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

    [Then(@"""(.*)"" получит письмо ""(.*)"" с текстом:")]
    public void ТогдаПолучитПисьмоСТекстом(string name, string mailSubject, string mailContentPlainText)
    {
        MimeMessage mailMessage = MockSmtpClientProvider.FindMailByToName(name);
        Assert.Equal(mailSubject, mailMessage.Subject);
        Assert.Equal(mailContentPlainText.Trim(), mailMessage.TextBody.Trim());
    }
}

Запускаем тесты... тест снова пройден успешно!

10. Подведём итоги

Подытожим:

  1. Интеграционные тесты (англ. integration tests) подходят для приёмочных тестов backend-сервисов, занимая промежуточное положение между уровнем модульных тестов (англ. unit tests) и сквозных тестов (англ. end-to-end tests)

  2. В интеграционных тестах не следует использовать неуправляемые внепроцессные зависимости, прежде всего API смежных сервисов и сторонние API

  3. Если тестируемый сервис активно использует внешние API, можно использовать тестовые дублёры — при этом лучше всего себя показывают дублёры типов Fake, Stub и Mock

  4. Интеграционные тесты могут внести основной вклад в уверенность в коде, но для этого следует сокращать слепые зоны интеграционных тестов

Опишем преимущества подхода, показанного в статье:

  • Мы тестируем границы сервиса, но не выходим за эти границы

  • Тестирование границ взаимодействующих сервисов вполне достаточно, если есть стабильный контракт API или вовсе генерация кода транспортного слоя из схемы (GRPC, OpenAPI)

Спасибо за внимание!

Комментарии (0)