Всем привет! Меня зовут Александр Кулик, я .NET-разработчик из команды checkout в Тинькофф. Занимаюсь бэкенд-разработкой по интеграции платежных решений, внешних сервисов и созданию собственных разработок для B2B-сферы.

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

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

Постановка проблемы

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

Некоторые системы, полагаясь на приставку «микро», искусственно разделяли единую предметную область на несколько проектов, так что почти каждый чих приводил к межсервисному взаимодействию. Это приводило к трудно поддерживаемому коду, часто подверженному гонке состояний, которая оборачивалась полным отсутствием согласованности между данными. Такие архитектуры получили общепринятое название «распределенный монолит», или distributed monolith.

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

Мы пришли к тому, что есть проекты с множеством взаимодействующих между собой приложений. Чтобы контролировать их качество, нужно тестировать с помощью определенных приемов. 

В классическом понимании можно опираться на принцип пирамиды тестирования:

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

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

Пример и реализация

В качестве наглядного примера возьмем репозиторий с рядом микросервисов и реализованные компонентные тесты для них. 

У нас есть предметная область, отражающая некоторый интернет-магазин, разделенный на три микросервиса:

1. Shop — бэкенд магазина.

2. Goods — сервис управления складскими остатками.

3. Orders — сервис платежей.

Пути пользователей представим несколькими методами, а необходимые авторизационные права реализуем двумя видами: Customer — клиент и Accounting — внутренние пользователи бухгалтерии, policy с ролью accounting.

API-метод «создание корзины» служит для инициализации корзины товарами, стоимость которых определяет сервис Goods. Может вызываться обычными пользователями.

API-метод «получение корзины» позволяет пользователю получать данные по коду корзины:

API-метод «оплата заказа» инициирует платежный механизм для всех товаров, лежащих в корзине.

API-метод «проверка статуса платежа» вызывается c пользователями с учетными данными, у которых есть роль бухгалтера. Служит для проверки статуса платежа и при необходимости снятия резерва с оплачиваемых товаров. Метод выглядит надуманно, потому что в реальных системах такие оповещения реализуются через очереди сообщений и не требуют участия человека. Но для примера он пригодится.

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

Проект с реализованными тестами

Все тесты находятся в проекте и реализованы с помощью фреймворка XUnit. Для успешной работы необходимо несколько внешних зависимостей:

1. База данных postgress — ее будет использовать наш сервис Shop:

shop-postgres: 
    container_name: shop-postgres
    image: postgres:12
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres 

2. Служба заглушек для запросов к внешним микросервисам. В проекте используется сервис mock-server. Он будет конфигурироваться в процессе выполнения шагов тестирования на принятие и ответ соответствующих http-запросов. Более подробно мы ее рассмотрим в следующих разделах: 

shop-mock-server:
    container_name: shop-mock-server
    image: mockserver/mockserver:5.13.2
    ports:
      - 1080:1080

Конфигурация типов, таких как DBContext, HttpClient и клиент к Mock-server, будет проводиться с помощью стандартного механизма DI asp.net core. Чтобы переиспользовать настроенный контейнер типов, воспользуемся механизмом Class Fixtures, его реализуем в классе ServiceContext и там же проведем сопряжение с конфигурацией тестов: строки подключения, адреса сервисов и так далее. 

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

global using Xunit;
global using Shop.ComponentTests.ApiClients.Goods;
[assembly:CollectionBehavior(CollectionBehavior.CollectionPerAssembly)]

Для начала знакомства можно запустить интеграционные тесты локально, скачав репозиторий и собрав образы через docker-compose. Полная инструкция лежит в корне репозитория.

Аутентификация и авторизация

Наиболее болезненная вещь, с которой сталкивается разработчик при написании компонентных тестов, — механизмы аутентификации, в частности — как получить обязательные токены с необходимыми атрибутами. Некоторое время назад решения этой проблемы зависели от используемой системы в проекте — от ручного создания токенов через JwtSecurityTokenHandler до реализации подменных ответов используемых серверов проверки подлинности.

Недавно команда разработчиков dotnet представила новую утилиту user-jwts, которая позволяет создать Jwt-токены и сконфигурировать проект так, что с этими токенами можно будет вызывать методы, закрытые авторизацией. Под капотом утилита использует механизм секретов и позволяет широко настраивать много атрибутов токенов. 

В нашем проекте используется два типа пользователей: обычный, к которому нет требований к атрибутам токенов, и пользователи бухгалтерии, закрытые под Policy с требованием роли accounting в виде объявления требований для этой policy: Program.cs

builder.Services.AddAuthorization(cfg =>
    cfg.AddPolicy("accounting_policy", policy => { policy.RequireRole("accounting"); }));

Сгенерированные токены хранятся в файле конфигурации tokens.json. Для локального тестирования этот файл нужно заполнить самостоятельно, а вот в контейнере он заполняется автоматически — с помощью скрипта заполнения значений для тестовых JWT токенов: 

RUN TOKEN_CUSTOMER=$(dotnet user-jwts create --name Customer --output token) \
		&& TOKEN_ACCOUNTING=$(dotnet user-jwts create --name Accounting --role accounting --output token) \
		&& echo "{\"TestJwtTokens\":{\"Customer\":\"${TOKEN_CUSTOMER}\", \"Accounting\":\"${TOKEN_ACCOUNTING}\"}}" > ./jwts/tokens.json

Во время запуска тестов, перед каждым вызовом метода, разработчик определяет контекст аутентификации, через вызов одного из методов интерфейса IAuthContext, например, как в PaymentTests.cs:

//Act
ServicesContext.AuthContext.AsCustomer();
var task = () => client.InternalCheckOrderStatusAsync(Guid.NewGuid().ToString());

HttpClient сервиса Shop регистрируется с handler, который и добавляет Bearer-токены в запрос:

Код
using System.Net.Http.Headers;
namespace Shop.ComponentTests.ApiClients;
public class AuthByCurrentContextRequestHandler : DelegatingHandler
{
    private readonly ICurrentAuthToken _currentAuthToken;

    public AuthByCurrentContextRequestHandler(ICurrentAuthToken currentAuthToken)
    {
        _currentAuthToken = currentAuthToken;
    }
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        string token = _currentAuthToken.CurrentToken;
        if (!string.IsNullOrEmpty(token)) {
            AuthenticationHeaderValue authenticationHeaderValue = new AuthenticationHeaderValue("Bearer", token); request.Headers.Authorization = authenticationHeaderValue;
        }
        return await base.SendAsync(request, cancellationToken);
    }
}

Взаимодействие с СУБД

Одной из частых зависимостей в проекте является та или иная СУБД. В нашем примере сервис Shop хранит данные в PostgreSQL. В принципе сюда можно поставить любую СУБД, от этого характер взаимодействия не изменится.

Микросервис Shop имеет базу и взаимодействует с ней через ORM Entity Framework. Как и в большинстве случаев, управление структурой работает с помощью миграций данных. Для упрощения примера миграции применяются каждый раз при старте приложения в Program.cs:

app.Services.CreateDb();
app.Run();

Нам нужен механизм, чтобы взаимодействовать с базой микросервиса в проекте тестов. Среди возможных инструментов выберем подход DatabaseFirst, он же — реконструирование. При нем управление структурой остается на стороне владельца — проекта Shop, а вот все изменения при необходимости считываются проектом тестов.

Чтобы первый раз затянуть сущности из БД, нужно выполнить команду:

dotnet ef dbcontext scaffold "Host=localhost;Port=5432;Database=shop;Username=postgres;Password=postgres" Npgsql.EntityFrameworkCore.PostgreSQL -o Db/Models --context-dir Db -c ShopContext --no-onconfiguring

Флаг no-onconfiguring нужен, чтобы утилита ef не заполняла переопределенный метод OnConfiguring со строкой подключения, мы сделаем это в контексте сервисов самостоятельно, взяв ее из конфигурации:

servicecollection.AddDbContextFactory<ShopContext>(opt => { opt.UseNpgsql(DatabaseConfig.ConnectionString); });

При изменении структуры в целевой базе подтянуть изменения можно через флаг –force той же команды, которая была при первоначальном создании контекста.

Еще можно настроить автоматическое удаление данных из базы путем перечисления таблиц в порядке их зависимостей в конфигурации проекта тестирования: 

{
  "DatabaseConfig": {
    "Connectionstring": "Host=localhost;Port=5432;Database=shop;Username=postgres;Password=postgres;Include Error Detail=true",
    "TablesToClean": "OrderItems, Orders, Baskets, Users"
  },
  "TestsConfig": {
    "ShopUrl": "http://localhost:5179",
    "MockServerUrl": "http://localhost:1080"
  }
}

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

Подмена зависимых сервисов

При синхронном взаимодействии микросервисы общаются друг с другом через http-запросы. Происходит простой вызов удаленного метода стандартными средствами, обычно httpClient, который может собираться вручную или быть автосгенерированным по json-схеме утилитами типа nswag. В компонентных тестах нам интересно только одно целевое приложение, все остальное неважно. Главное — отдать ответ, ожидаемый по сценарию, тоже в виде json. 

Задача стандартная, поэтому для ее решения создано много инструментов, с некоторыми из них я уже работал: mountebank и mock server. Для базовых вещей они предлагают почти идентичные методы: можно сконфигурировать эндпоинт, который имеет свой сегмент пути, свой HTTP-метод или какие-либо другие атрибуты и сделать так, чтобы он возвращал определенный json-объект или определенный HTTP-код ошибки. Для обоих mock-серверов существуют клиентские библиотеки для .NET — их можно посмотреть на Github:

.NET-клиент для Mountebank или .NET-клиент для Mock Server.  

Мы в примере используем mock-server, запуск которого не требует многих усилий и заключается в скачивании соответствующего образа и расшаривании порта:

shop-mock-server:
    container_name: shop-mock-server 
    image: mockserver/mockserver:5.13.2
    ports:
      - 1080:1080

Теперь можем перейти в веб-интерфейс mock-server, если он развернут локально, и увидеть текущее состояние, с настроенными путями для запросов и ответами. Эта веб-форма помогает при отладке компонентных тестов: в ней отображается много системной информации, а главное — логи попыток подключения.

Создать HttpClient и типы запросов и ответов для конечных точек сервиса можно вручную, но можно и создать типы на основе схемы OpenApi информации каждого сервиса. Для этого существует утилита dotnet openapi. О том, как ее установить, можно прочитать в официальной документации. 

После установки в проекте тестов нужно подтянуть этой утилитой соответствующие схемы, чтобы средства sdk создали типизированный HttpClient. При этом целевой сервис должен быть доступен:

dotnet openapi add url -p Shop.csproj --output-file ApiClients\GoodsApi.json  http://localhost:5180/swagger/v1/swagger.json

В проекте нужно указать для клиента Namespace и некоторые параметры для NSwag:

<ItemGroup>
<OpenApiReference Include="ApiClients\Shop\ShopApi.json" SourceUrl="http://localhost:5179/swagger/v1/swagger.json" Namespace="Shop.ComponentTests.ApiClients.Shop" Options="/UseBaseUrl:false" />
  </ItemGroup>

Еще нужно добавить ссылки на другие сервисы Goods и Orders, чтобы у нас были готовые типы для запросов и ответов. Тут важно помнить, что NSwag пытается создать общие классы только один раз, поэтому нужно указать глобальный namespace для сгенерированных HTTP-клиентов, в котором и будут располагаться общие типы:

global using Xunit;
global using Shop.ComponentTests.ApiClients.Goods;
[assembly:CollectionBehavior(CollectionBehavior.CollectionPerAssembly)]

Для обновления внесенных изменений в схемы данных или методы конечных точек используем refresh:

dotnet openapi refresh -p Shop.csproj http://localhost:5180/swagger/v1/swagger.json

После предварительного билда типизированный клиент можно добавить в общий контекст и навесить на него наш HTTP-делегат Bearer-токена:

  servicecollection.AddHttpClient<ShopApiClient>
                (cfg => cfg.BaseAddress = TestsConfig.ShopUrl)
            .AddHttpMessageHandler<AuthByCurrentContextRequestHandler>();

Примеры для конфигурации mock-ответов сервисов можно посмотреть в реализованных тестах. В них через библиотеку MockServer.Net путем задания основных параметров монтируются конечные точки для приема запросов с возвратом ожидаемых ответов:

Код
await ServicesContext.MockServerClient.When(new HttpRequest()
                .WithMethod(HttpMethod.Get)
                .WithPath("/payment/.*")
            , Times.Unlimited()
        ).RespondAsync(new HttpResponse()
            .WithStatusCode(HttpStatusCode.OK)
            .WithBody(new JsonObjectContent<PaymentDto>(paymentResponse)));
await ServicesContext.MockServerClient.When(new HttpRequest()
                .WithMethod(HttpMethod.Post)
                .WithPath("/storage/release")
            , Times.Unlimited()
        ).RespondAsync(new HttpResponse()
            .WithStatusCode(HttpStatusCode.OK)
            .WithBody(new JsonObjectContent<ReleaseResponse>(new ReleaseResponse
            {
                Total = 2
            })));

Mock-server позволяет проверять осуществленные вызовы и переданные при этом параметры в виде json-объекта, например:

Код
await ServicesContext.MockServerClient.VerifyAsync(new HttpRequest()
                .WithMethod(HttpMethod.Get)
                .WithPath($"/payment/{basket.Order.Id}"),
            VerificationTimes.Once());
await ServicesContext.MockServerClient
            .VerifyAsync(new HttpRequest().WithMethod(HttpMethod.Post)
                .WithPath($"/storage/release")
                .WithBody(new JsonObjectMatcher<ReleaseRequest>(new ReleaseRequest
                {
                    Items = new List<ReservationItem>
                    {
                        new()
                        {
                            Count = 2, Name = "Name2"
                        }
                    }
                })), VerificationTimes.Once());

Мы в специально сформированном запросе, который потом сериализуется в виде json, передаем службе параметры запроса и его тело, а уже само приложение определяет, был ли подобный вызов и сколько раз.

Метод Prepare, вызванный в начале теста не, только очищает данные в базе, но и сбрасывает все настройки mock server до дефолтных, что очень хорошо для отладки любого компонентного теста.

Заключение

Я надеюсь, что рассмотренный демонстрационный проект даст фундамент для самостоятельного создания компонентных тестов, которые помогут улучшить качество продуктов. 

Мы не рассмотрели аспект мокирования брокера сообщений. По опыту скажу, что тут необходимо рассматривать каждую архитектуру отдельно: разные системы обмена сообщениями требуют своего подхода. Например, мне приходилось создавать отдельную небольшую библиотеку для работы с RabbitMq, основанную на проекте masstransit. Тут главное — правильно сконфигурировать сам ресурс брокера для взаимодействия с тестируемым приложением, а паблишить или читать сообщения — дело техники. Благо есть множество готовых решений.

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

И напоследок: компонентные тесты — это не серебряная пуля, которая может решить все проблемы контроля качества, и тем более не нужно ими замещать юнит- или E2E-тесты. Этот инструмент — скорее дополнение ко всему, и если вы будете использовать его по назначению, результаты не заставят себя долго ждать. А если у вас остались вопросы или вы хотите что-то обсудить — жду в комментариях.

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


  1. saneks222
    05.09.2023 10:51
    +1

    Asserts в тестах хорошо было бы обернуть в AssertionScope


  1. ritorichesky_echpochmak
    05.09.2023 10:51

    А как в таком кейсе учитывать test coverage?


    1. alexs0ff Автор
      05.09.2023 10:51

      Есть мнение, что на такие виды тестов из-за сложности, не считают test code coverage. Т.е. компонентные тесты это не замена unit, в которых как раз отлично считается этот показатель, а некоторая надстройка, которая может дублировать, но не заменять white box тестирование.


      1. ritorichesky_echpochmak
        05.09.2023 10:51

        Это проблемка, т.к. непонятно, всё ли прикрыто такими тестами, плюс дублирование и сильное усложнение проекта. При этом e2e и мануальщиков всё ещё не омтеяет.


        1. alexs0ff Автор
          05.09.2023 10:51

          Да, насчет замены e2e я описал - это не замена, а дополнение. Просто немного дешевле поддерживаемое.


  1. microuser
    05.09.2023 10:51

    Зачем запускать тесты последовательно?


    1. alexs0ff Автор
      05.09.2023 10:51
      +1

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