Интеграционные тесты для ASP.NET Core

от автора

Интеграционные тесты, написанные программистом — это отличный способ обеспечить уверенность в своём веб-сервисе.

В мире .NET для разработки веб-сервисов обычно используют ASP.NET Core, но интеграционное тестирование часто упускают из виду либо делают не очень качественно.

Статья покажет полноценный подход к организации интеграционных тестов на языке Gherkin для API-сервиса, написанного на C# 12 с ASP.NET Core 8 и использующего PostgreSQL.

Также в статье будут:

  1. Применение пакета Microsoft.AspNetCore.Mvc.Testing для запуска тестируемой системы в режиме эмуляции отправки HTTP-запросов

  2. Запуск PostgreSQL в docker-контейнере

  3. Изоляция тестов с помощью отката транзакций (BEGIN...ROLLBACK)

  4. Описание способа запуска тестов в Gitlab CI

Я потратил не меньше недели, чтобы преодолеть сложность первоначальной настройки приёмочных интеграционных тестов для нового сервиса на C#. Надеюсь, моя статья поможет сэкономить время и улучшить автоматизацию тестирования проекта.

Перед началом

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

Летом этого года я перешёл в TravelLine и занялся разработкой нового сервиса на C#.

  • В TravelLine принято так: разработчики пишут модульные и интеграционные тесты, а команда QA следит за общим уровнем качества продуктов компании — в том числе QA пишут сквозные (end-to-end) тесты

  • В новом сервисе я реализовал автоматизацию тестирования, опираясь на личный опыт и практики соседней команды, что и дало материал для статьи

Отдельное спасибо Роману Лопатину, который вёл мой онбординг в TravelLine и также провёл ревью данной статьи.

План статьи

Действовать будем так:

  1. Возьмём примитивный API-сервис на C# 12 с ASP.NET Core 8

  2. Составим тест-план

  3. Напишем интеграционные тесты, решая проблемы по мере поступления

  4. Обсудим, как запускать интеграционные тесты в Gitlab CI

  5. В конце проведём ретроспективу

Несколько нюансов:

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

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

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

Смотрим и кодим

Параллельно с этой статьёй написан пример: https://github.com/sergey-shambir/dotnet-integration-testing

Отличный способ попробовать новый подход — писать код по мере чтения статьи.

  • Вы можете клонировать пример и переключиться на ветку baseline, чтобы повторить все шаги этой статьи самостоятельно

  • А можно просто прочитать статью 🙂

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

Это API-сервис в стиле CRUD с одной моделью и PostgreSQL в качестве базы данных. Он крайне примитивен: его диаграмма классов показана ниже

Диаграмма классов

Диаграмма классов

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

  1. Модель была бы значительно сложнее — с агрегатами и нетривиальными бизнес-сценариями

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

  3. Скорее всего я применил бы шаблоны Service Layer и Repository

Также в тестируемой системе не будет:

  • исходящих запросов к другим сервисам — например, к собственным сервисам или к сторонним API ваших партнёров или вендоров

  • асинхронных сообщений между сервисом — например, через RabbitMQ и MassTransit

  • запуска задач по расписанию — например, через Hangfire

  • автоматической генерации для тестов клиента API тестируемой системы

Эти вещи интересны, но не вошли в рамки статьи.

Пишем интеграционные тесты

Список тестов

Оцениваем бизнес-сценарии

В тестируемой системе есть 4 метода API:

Метод API

Описание

GET /api/products

Выдаёт список продуктов

POST /api/products

Добавляет продукт

PUT /api/products/{productId}

Обновляет продукт

DELETE /api/products/{productId}

Удаляет продукт

Это простой API в стиле CRUD, в нём нет неочевидных бизнес-сценариев.

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

  1. Можем создать несколько продуктов

  2. Можем создать несколько продуктов и обновить один

  3. Можем создать несколько продуктов и удалить один

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

Добавляем негативные тесты

Я считаю, что на всех уровнях автотестов начинать надо с позитивных тестовых сценариев.

А что делать с негативными?

  • В модульных тестах негативные сценарии тоже важны — иначе не будет веры в надёжность каждого тестируемого модуля

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

Можем воспользоваться метафорой — проверка веб-сервиса похожа на проверку дома из кирпичей:

  • В модульных тестах проверяем надёжность «кирпичиков» программы в самых разных условиях

  • В интеграционных тестах проверяем характеристики построенного дома, а не отдельных «кирпичиков»

Так стоит ли тратить время и деньги, тестируя поведение стёкол при забрасывании камнями окон? Думаю, нет.

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

  1. Можем создать несколько продуктов

  2. Можем создать несколько продуктов и обновить один

  3. Можем создать несколько продуктов и удалить один

  4. Нельзя добавить продукт с пустым кодом

  5. Нельзя добавить продукт с нулевой либо отрицательной ценой

Реализуем первый тест

Создаём проект с Reqnroll и XUnit

Создадим пустой проект XUnit:

dotnet new xunit -o tests/WebService.Specs dotnet sln add tests/WebService.Specs/  # Удаляем пустой тест шаблона XUnit rm tests/WebService.Specs/UnitTest1.cs 

Мы будем использовать язык Gherkin для описания тестов по-человечески. Для этого мы добавим библиотеку Reqnroll.

Почему не Specflow? Потому что новые владельцы проекта Specflow перестали его сопровождать, и оригинальный автор проекта создал форк — Reqnroll.

Подробности в статье From SpecFlow to Reqnroll: Why and How.

dotnet add tests/WebService.Specs/ package Reqnroll --version 2.2.1 dotnet add tests/WebService.Specs/ package Reqnroll.xUnit --version 2.2.1 

Библиотеки Reqnroll и Specflow обрабатывают файлы *.feature и генерируют файлы *.feature.cs. Генерируемые файлы добавим в .gitiginore:

# ... другие правила .gitignore  *.feature.cs

Мы будем использовать русский диалект Gherkin для написания тестов. Для этого добавим файл tests/WebService.Specs/specflow.json:

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

В файл tests/WebService.Specs/WebService.Specs.csproj добавим:

  <ItemGroup>     <Content Include="specflow.json" CopyToOutputDirectory="PreserveNewest" />   </ItemGroup>

Теперь можно использовать русские ключевые слова языка Gherkin:

Eng.

Рус.

Feature

Функциональность, Функция, Функционал, Свойство

Scenario

Сценарий, Пример

Scenario outline

Структура сценария, Шаблон сценария

Background

Контекст, Предыстория

Given

Пусть, Дано, Допустим

When

Когда, Если

Then

Тогда, То, Затем

And

И, Также, К тому же

But

Иначе, Но, А

Examples

Примеры, Значения

Rule

Правило

Добавляем тест

Дальше будем работать с проектом tests/WebService.Specs/.

Добавим файл Features/Products.feature с приёмочным тестом:

Функциональность: управление списком продуктов      Контекст: сервис хранит список реализуемых товаров и предоставляет API для CRUD-операций с ними      Сценарий: Можем создать несколько продуктов         Когда добавляем продукты:           | Код    | Описание                    | Цена   | Количество |           | A12345 | Фуфайка из льняного волокна | 49,90  | 312        |           | A56789 | Женский ободок для волос    | 157,00 | 7          |          Тогда получим список продуктов:           | Код    | Описание                    | Цена   | Количество |           | A12345 | Фуфайка из льняного волокна | 49,90  | 312        |           | A56789 | Женский ободок для волос    | 157,00 | 7          |

Свойства экземпляров модели Product описываются таблицей — это одна из возможностей языка Gherkin.

Добавляем описание шагов

В IDE Rider доступен плагин Reqnroll Rider, который позволяет генерировать описание шага, применяя фикс в feature-файле

Добавим файл Steps/ProductStepDefinitions.cs с реализацией шагов, описанных в feature-файле:

using Reqnroll;  namespace WebService.Specs.Steps;  [Binding] public class ProductStepDefinitions {     [When(@"добавляем продукты:")]     public void КогдаДобавляемПродукты(Table table)     {         ScenarioContext.StepIsPending();     }      [Then(@"получим список продуктов:")]     public void ТогдаПолучимСписокПродуктов(Table table)     {         ScenarioContext.StepIsPending();     } } 

Запустим тесты командой dotnet test и получим сообщение:

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

Это сообщение возникло, потому что в реализации шага вызывается метод ScenarioContext.StepIsPending().

Добавляем фальшивую реализацию

Теперь постараемся скорее получить «зелёный» тест, для этого сделаем фальшивую реализацию шагов теста:

  • добавим вспомогательную модель TestProductData — всем свойствам модели поставим атрибут TableAliases["Название свойства в Gherkin"], чтобы использовать русский язык и в таблицах с данными

  • добавим поле List<TestProductData> _actual

  • на шаге Когда добавляем продукты прочитаем продукты из таблицы и сохраним в поле _actual

  • на шаге Тогда получим список продуктов прочитаем продукты и сравним со списком в поле _actual

Для чтения списка продуктов из таблицы применим DataTable Helpers:

using Reqnroll; using Reqnroll.Assist.Attributes;  namespace WebService.Specs.Steps;  [Binding] public class ProductStepDefinitions {     private List<TestProductData> _actual = [];      [When(@"добавляем продукты:")]     public void КогдаДобавляемПродукты(Table table)     {         _actual = table.CreateSet<TestProductData>().ToList();     }      [Then(@"получим список продуктов:")]     public void ТогдаПолучимСписокПродуктов(Table table)     {         List<TestProductData> expected = table.CreateSet<TestProductData>().ToList();         Assert.Equivalent(_actual, expected);     }      public class TestProductData(         string code,         string description,         decimal price,         uint stockQuantity     )     {         [TableAliases("Код")]         public string Code { get; init; } = code;              [TableAliases("Описание")]         public string Description { get; init; } = description;                  [TableAliases("Цена")]         public decimal Price { get; init; } = price;              [TableAliases("Количество")]         public uint StockQuantity { get; init; } = stockQuantity;     } } 

Запустим тесты командой dotnet test — теперь тест «зелёный».

Также надо проверить, что тест способен выявлять ошибки:

  • поменяйте одно из значений в файле Products.feature, чтобы ожидание и результат стали разными — при запуске тест должен стать «красным» (т.е. сообщить об ошибке)

  • верните значение обратно — тест должен снова стать «зелёным»

Поднимаем TestServer

На этом шаге тестовые сценарии не изолированы. Изоляцией займёмся потом.

Для ASP.NET есть готовый пакет Microsoft.AspNetCore.Mvc.Testing, позволяющий поднять сервис In-Memory и эмулировать отправку HTTP-запросов.

Добавим пакет в проект тестов:

dotnet add tests/WebService.Specs package Microsoft.AspNetCore.Mvc.Testing --version 8.0.10 

Кроме того, проект тестов будет использовать классы проекта сервиса:

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

Добавим файл Fixture/TestServerFixture.cs:

  • класс TestServerFixture реализует шаблон Fixture, то есть «фиксирует» тестируемую систему в заданных границах

  • он использует WebApplicationFactory<> для запуска сервиса в режиме эмуляции HTTP-запросов

  • также он позволяет получить объект HttpClient

using Microsoft.AspNetCore.Mvc.Testing;  namespace WebService.Specs.Fixture;  public class TestServerFixture {     public HttpClient HttpClient { get; }      public TestServerFixture()     {         WebApplicationFactory<Program> factory = new();         HttpClient = factory.CreateClient();     } } 

Объект класса TestServerFixture будет получен через Dependency Injection библиотеки Reqnroll. Подробности в документации Reqnroll: Context Injection.

На этом шаге появится ошибка: класс Program не определён

  • причина: в тестируемой системе нет явного определения класса Program с методом Main (в C# это называется Top-level Statements)

  • решение: определим Program как пустой partial-класс

В конец файла src/WebService/Program.cs добавим:

public partial class Program; 

Добавляем Test Driver

Теперь применим шаблон Driver — это вспомогательный объект, поддерживающий принцип Separation of Concerns:

  • ProductStepDefinitions содержит реализацию шагов тестового сценария, написанного на языке Gherkin

  • ProductApiTestDriver будет отвечать за логику взаимодействия с тестируемой системой

Принцип Separation of Concerns ввёл в 1982 году Эдсгер Дейкстра в статье «On the role of scientific thought».

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

Вспомогательную модель TestProductData перенесём в отдельный файл Drivers/TestProductData.cs, потому что она будет использоваться повторно:

using Reqnroll.Assist.Attributes;  namespace WebService.Specs.Drivers;  public class TestProductData(     string code,     string description,     decimal price,     uint stockQuantity ) {     [TableAliases("Код")]     public string Code { get; init; } = code;      [TableAliases("Описание")]     public string Description { get; init; } = description;      [TableAliases("Цена")]     public decimal Price { get; init; } = price;      [TableAliases("Количество")]     public uint StockQuantity { get; init; } = stockQuantity; }

Создадим файл Drivers/ProductApiTestDriver.cs:

  • конструктор принимает объект HttpClient

  • вспомогательный метод EnsureSuccessStatusCode предотвращает ситуацию, когда вызов API «тихо и незаметно» вернул ошибку

using System.Net.Http.Json; using Newtonsoft.Json;  namespace WebService.Specs.Drivers;  public class ProductApiTestDriver(HttpClient httpClient) {     public async Task<List<TestProductData>> ListProducts()     {         var response = await httpClient.GetAsync("/api/products");         await EnsureSuccessStatusCode(response);          string content = await response.Content.ReadAsStringAsync();         return JsonConvert.DeserializeObject<List<TestProductData>>(content)                ?? throw new ArgumentException($"Unexpected JSON response: {content}");     }      public async Task AddProduct(TestProductData product)     {         var response = await httpClient.PostAsJsonAsync("/api/products", product);         await EnsureSuccessStatusCode(response);     }      private static async Task EnsureSuccessStatusCode(HttpResponseMessage response)     {         if (!response.IsSuccessStatusCode)         {             string content = await response.Content.ReadAsStringAsync();             Assert.Fail($"HTTP status code {response.StatusCode}: {content}");         }     } } 

Перепишем класс ProductStepDefinitions:

using Reqnroll; using WebService.Specs.Drivers; using WebService.Specs.Fixture;  namespace WebService.Specs.Steps;  [Binding] public class ProductStepDefinitions(TestServerFixture fixture) {     private readonly ProductApiTestDriver _driver = new(fixture.HttpClient);      [When(@"добавляем продукты:")]     public async Task КогдаДобавляемПродукты(Table table)     {         List<TestProductData> products = table.CreateSet<TestProductData>().ToList();         foreach (TestProductData product in products)         {             await _driver.AddProduct(product);         }     }      [Then(@"получим список продуктов:")]     public async Task ТогдаПолучимСписокПродуктов(Table table)     {         List<TestProductData> expected = table.CreateSet<TestProductData>().ToList();         List<TestProductData> actual = await _driver.ListProducts();         Assert.Equivalent(expected, actual);     } } 

Запустим тесты командой dotnet run — если мы запустили PostgreSQL командой docker-compose up, то мы получим «зелёный» тест.

Подводный камень

Почему тест стал «зелёным»? Потому что при первом запуске база данных чиста:

  • Контейнер с PostgreSQL был ранее запущен командой docker-compose up

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

Но почему тест остаётся «зелёным» при повторном запуске на той же базе данных? Порассуждаем:

  • База данных не очищается между тестами, и список продуктов всё время растёт

  • Если мы запустили тест 3 раза, то в БД должно быть 6 продуктов, но на шаге Тогда в тесте ожидается только 2 — тест должен упасть

  • А тест проходит, потому что в XUnit метод Assert.Equivalent для коллекций проверяет, что все элементы множества expected входят в множество actual (expected ⊆ actual)

Заставим тест «покраснеть», добавив проверку равенства размеров коллекций:

    [Then(@"получим список продуктов:")]     public async Task ТогдаПолучимСписокПродуктов(Table table)     {         List<TestProductData> expected = table.CreateSet<TestProductData>().ToList();         List<TestProductData> actual = await _driver.ListProducts();         Assert.Equal(expected.Count, actual.Count);         Assert.Equivalent(expected, actual);     } 

Теперь тест падает.

Добавляем контейнер PostgreSQL

Библиотека TestContainers

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

Есть три варианта:

  1. Пересоздавать тестовую базу данных перед запуском тестов

  2. Написать для docker-compose отдельный файл tests-docker-compose.yml

    • для каталога с данными PostgreSQL монтировать volume с tpmfs

    • для запуска написать скрипт на Bash, PowerShell или Python

  3. Использовать библиотеку TestContainers, чтобы запускать контейнер с PostgreSQL прямо из теста

В прошлом у меня был успешный опыт внедрения 2-го варианта.

Но мы выберем третий путь как наименее трудоёмкий и при этом более понятный для C#-разработчика.

Установим пакет Testcontainers.PostgreSql:

dotnet add tests/WebService.Specs package Testcontainers.PostgreSql --version 4.0.0 

Библиотека позволяет управлять жизненным циклом контейнера PostgreSQL прямо в коде:

  1. Создаём объект класса PostgreSqlContainer

  2. До первого использования вызываем у него метод StartAsync()

  3. Получаем строку с параметрами соединения с БД методом GetConnectionString()

  4. В конце останавливаем контейнер методом DisposeAsync()

Контейнер создаётся с помощью класса PostgreSqlBuilder, реализующего шаблон проектирования Builder:

  • укажем конкретную версию СУБД — в идеале это версия, которая будет на production

  • укажем имя базы данных (не обязательно)

  • ради ускорения примонтируем tmpfs volume в каталог, где PostgreSQL хранит данные (tpmfs может хранить данные в оперативной памяти)

PostgreSqlContainer container = new PostgreSqlBuilder()     .WithImage("postgres:16.4-alpine")     .WithDatabase("warehouse")     .WithTmpfsMount("/var/lib/postgresql/data")     .Build(); 

Сколько раз запускать контейнер?

Запуск контейнера PostgreSQL — дорогое удовольствие.

Что если мы будем запускать PostgreSQL перед каждым тестовым сценарием?

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

  • но и цена велика — к времени выполнения каждого теста добавляются запуск СУБД, запуск миграций и остановка СУБД

Есть более дешёвые способы изоляции тестов, работающих с БД — мы обсудим их позже.

Поэтому будем запускать контейнер PostgreSQL один раз на один проект тестов.

Как передать Connection String приложению

  • В тестируемой системе есть конфиг appsettings.json, в котором прописана определённая строка подключения к БД.

  • В тестах после добавления TestContainers эта строка становится динамической.

Как быть? Унаследоваться от WebApplicationFactory — сделаем это в файле Fixture/CustomWebApplicationFactory.cs:

using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting;  namespace WebService.Specs.Fixture;  public class CustomWebApplicationFactory<TEntryPoint>(string dbConnectionString) : WebApplicationFactory<TEntryPoint>     where TEntryPoint : class {     // Меняет конфигурацию до создания объекта Program, см. https://github.com/dotnet/aspnetcore/issues/37680     protected override IHost CreateHost(IHostBuilder builder)     {         builder.ConfigureHostConfiguration(configurationBuilder =>         {             configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>             {                 { "ConnectionStrings:MainConnection", dbConnectionString }             });         });          return base.CreateHost(builder);     } }

Повторно используемый Fixture

Фреймворк XUnit предлагает несколько способов повторно использовать Fixture между тестами:

  1. Объекты класса, реализующего IClassFixture, создаются один раз на класс с тестами

  2. Объекты класса, реализующего ICollectionFixture, создаются один раз на явно определённую в коде коллекцию — коллекция может быть единственной на весь проект тестов

  3. Объекты класса, реализующего IAssemblyFixture, создаются один раз на проект тестов

Кроме того, в XUnit есть интерфейс IAsyncLifetime, позволяющий выполнять асинхронные действия как при инициализации, так и при освобождении ресурсов Fixture.

Это всё прекрасно! Но не работает ни в Specflow, ни в Reqnroll:

Что делать? Запилить костыль ad-hoc решение!

Ad-hoc решение на основе хуков

Мы будем использовать:

  • шаблон проектирования Singleton, очень полезный для ad-hoc решений

  • хуки (Hooks), а именно хуки BeforeTestRun и AfterTestRun

    • оба хука применяются только к статическим методам

    • оба хука могут быть асинхронными

Так будет выглядеть объект-Singleton в файле Fixture/TestServerFixtureCore.cs:

[Binding] public class TestServerFixtureCore {     public static readonly TestServerFixtureCore Instance = new();      [BeforeTestRun]     public static Task BeforeTestRun() => Instance.InitializeAsync();      [AfterTestRun]     public static Task AfterTestRun() => Instance.DisposeAsync();      /// ... остальные поля, свойства, методы } 

Перенесём в новый класс всё, что было в TestServerFixture, и добавим управление классом PostgreSqlContainer:

using Reqnroll; using Testcontainers.PostgreSql;  namespace WebService.Specs.Fixture;  [Binding] public class TestServerFixtureCore {     public static readonly TestServerFixtureCore Instance = new();      private HttpClient? _httpClient;      private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()         .WithImage("postgres:16.4-alpine")         .WithDatabase("warehouse")         .WithTmpfsMount("/var/lib/postgresql/data")         .Build();      public HttpClient HttpClient => _httpClient ?? throw new InvalidOperationException("Fixture was not initialized");      private TestServerFixtureCore()     {     }      [BeforeTestRun]     public static Task BeforeTestRun() => Instance.InitializeAsync();      [AfterTestRun]     public static Task AfterTestRun() => Instance.DisposeAsync();      private async Task InitializeAsync()     {         await _container.StartAsync();          CustomWebApplicationFactory<Program> factory = new(_container.GetConnectionString());         _httpClient = factory.CreateClient();     }      private async Task DisposeAsync()     {         _httpClient = null;         await _container.DisposeAsync();     } } 

Fixture и его интерфейс

А что с классами TestServerFixture и ProductApiTestDriver? Они претерпят изменения:

  1. Fixture больше ничего не инициализирует — он обращается к Singleton TestServerFixtureCore

  2. TestServer будет инициализироваться асинхронно, и возможно состояние гонки — ProductApiTestDriver больше не сможет получать объект HttpClient прямо в конструкторе

  3. Поэтому мы выделим интерфейс из класса Fixture и будем передавать его в ProductApiTestDriver

Новый интерфейс в файле Fixture/ITestServerFixture.cs:

namespace WebService.Specs.Fixture;  public interface ITestServerFixture {namespace WebService.Specs.Fixture;  public interface ITestServerFixture {     public HttpClient HttpClient { get; } }      public HttpClient HttpClient { get; } } 

Обновлённый класс TestServerFixture:

namespace WebService.Specs.Fixture;  public class TestServerFixture : ITestServerFixture {     public HttpClient HttpClient => TestServerFixtureCore.Instance.HttpClient; } 

Изменения в ProductApiTestDriver:

public class ProductApiTestDriver(ITestServerFixture fixture) {     private HttpClient HttpClient => fixture.HttpClient;      // ... остальные методы } 

Реализуем второй тест

Добавляем тестовый сценарий

Что дальше по списку тестовых сценариев? Посмотрим:

  1. Можем создать несколько продуктов

  2. Можем создать несколько продуктов и обновить один

  3. Можем создать несколько продуктов и удалить один

  4. Нельзя добавить продукт с пустым кодом

  5. Нельзя добавить продукт с нулевой либо отрицательной ценой

Добавим тестовый сценарий в feature-файл:

    Сценарий: Можем создать несколько продуктов и обновить один         Пусть добавили продукты:           | Код    | Описание                   | Цена  | Количество |           | A12345 | Что-то из льняного волокна | 49,90 | 300        |           | B99999 | Женский ободок для волос   | 99,00 | 12         |          Когда обновляем продукты:           | Код    | Описание                    | Цена  | Количество |           | A12345 | Фуфайка из льняного волокна | 49,90 | 400        |          Тогда получим список продуктов:           | Код    | Описание                    | Цена  | Количество |           | A12345 | Фуфайка из льняного волокна | 49,90 | 400        |           | B99999 | Женский ободок для волос    | 99,00 | 12         |

На первый шаг мы не будем добавлять отдельный метод — добавим атрибут Given к существующему:

[Given(@"добавили продукты:")] [When(@"добавляем продукты:")] public async Task КогдаДобавляемПродукты(Table table) 

Со вторым шагом сложнее: для обновления продукта нужен ID, откуда его взять?

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

  1. Добавим в класс StepDefinitions приватное поле Dictionary _codeToIdMap

  2. На шаге добавления продукта запомним его ID

  3. На шаге обновления продукта получим ID по коду

Листинг изменений в ProductApiTestDriver

Изменения в ProductApiTestDriver:

  1. Метод AddProduct теперь читает и возвращает ID продукта

  2. Появляется митод UpdateProduct

public class ProductApiTestDriver(ITestServerFixture fixture) {     // ... другие поля, свойства, методы      public async Task<int> AddProduct(TestProductData product)     {         var response = await HttpClient.PostAsJsonAsync("/api/products", product);         await EnsureSuccessStatusCode(response);          string content = await response.Content.ReadAsStringAsync();         AddProductResult result = JsonConvert.DeserializeObject<AddProductResult>(content)                                   ?? throw new FormatException($"Unexpected response: {content}");          return result.Id;     }      public async Task UpdateProduct(int productId, TestProductData product)     {         var response = await HttpClient.PutAsJsonAsync($"/api/products/{productId}", product);         await EnsureSuccessStatusCode(response);     }      // ... другие методы      private record AddProductResult(int Id); } 

Листинг изменений в ProductStepDefinitions

Изменения в ProductStepDefinitions:

  1. Появляется поле Dictionary _codeToIdMap

  2. В методе КогдаДобавляемПродукты записываем ID в это поле

  3. Добавляем метод КогдаОбновляемПродукты, который получает ID по коду

[Binding] public class ProductStepDefinitions(TestServerFixture fixture) {     // ...      private readonly Dictionary<string,int> _codeToIdMap = new();      [Given(@"добавили продукты:")]     [When(@"добавляем продукты:")]     public async Task КогдаДобавляемПродукты(Table table)     {         List<TestProductData> products = table.CreateSet<TestProductData>().ToList();         foreach (TestProductData product in products)         {             int productId = await _driver.AddProduct(product);             _codeToIdMap[product.Code] = productId;         }     }      [When(@"обновляем продукты:")]     public async Task КогдаОбновляемПродукты(Table table)     {         List<TestProductData> products = table.CreateSet<TestProductData>().ToList();         foreach (TestProductData product in products)         {             if (!_codeToIdMap.TryGetValue(product.Code, out int productId))             {                 throw new ArgumentException($"Unexpected product code {product.Code}");             }              await _driver.UpdateProduct(productId, product);         }     }      // ... } 

Запустим тесты, и что мы видим? Тест Можем создать несколько продуктов стал «красным»?!!

Xunit.Sdk.EqualException Assert.Equal() Failure Expected: 2 Actual:   4 

Как это получилось:

  1. Первым запустился тест Можем создать несколько продуктов и обновить один, он добавил в БД два продукта

  2. Вторым запустился тест Можем создать несколько продуктов и добавил ещё два продукта

  3. Итого продукта 4, а ожидалось два

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

Изоляция тестов

Способы изоляции тестов

Есть пять способов изоляции тестов, использующих базу данных:

  1. Пересоздавать базу данных для каждого сценария

    • сюда относится как DROP DATABASE; CREATE DATABASE, так и создание отдельного контейнера на каждый сценарий

    • это сильно замедляет тесты — накладные расходы растут нелинейно по формуле число_тестов × (число_миграций + число_таблиц)

  2. Очищать таблицы после теста

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

    • за невнимательного программиста платит его коллега, который столкнётся с чужим мусором в своём тесте

  3. Очищать таблицы перед тестом

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

    • невнимательный программист платит сам

  4. Использовать фреймворк очистки данных, такой как Respawn

  5. Использовать транзакцию с откатом, то есть BEGIN...ROLLBACK

Варианты №4 и №5 привлекательны сочетанием двух особенностей:

  1. Код тестов избавляется от очистки

  2. За это не приходится платить сильным замедлением.

Взаимен появляются новые сложности:

  1. Нужно перехватывать все соединения с базой данных

  2. Нужно вовремя вызвать откат транзакции или компенсирующие действия

В случае Respawn сложности частично «решены» библиотекой, но только пока вы не упёрлись в границы её возможностей.

Я уверен, что транзакции с откатом лучше, чем Respawn:

  1. Транзакции — один из ключевых механизмов реляционных СУБД, они максимально надёжны

  2. Библиотека Reswpan не тривиальна — лёгкий старт в начале может смениться долгими часами борьбы с библиотекой в будущем

  3. Наконец, транзакции СУБД всё равно быстрее, чем компенсирующие действия

Далее в статье мы применим вариант №5: транзакции с откатом. В своём проекте вы можете пойти иным путём.

Создаём транзакцию с откатом

В тестируемой системе используется EntityFramework. Взаимодействие с базой данных выполняется через WarehouseDbContext.

Наша цель — откат всех изменений, внесённых EntityFramework, в конце теста.

Прежде чем делать это, обсудим подход.

В начале каждого сценария потребуется создать соединение и транзакцию:

NpgsqlConnection connection = new(dbConnectionString); await connection.OpenAsync();  NpgsqlTransaction transaction = await connection.BeginTransactionAsync(); 

Затем созданный DbContext потребуется связать с соединением и транзакцией:

dbContext.Database.SetDbConnection(_connection); dbContext.Database.UseTransaction(_transaction); 

В конце необходимо откатить транзакцию и закрыть соединение:

await _transaction.RollbackAsync(); await _connection.CloseAsync(); 

Однако здесь есть неочевидный нюанс, связанный с жизненным циклом DbContext.

Service Scope в ASP.NET Core

Нам нужно модифицировать объекты DbContext в тестах.

  • Первое, что приходит на ум — получить контекст БД из IServicesProvider и вызвать его методы

  • Контекст БД добавлен как Scoped сервис — потребуется IServiceScope

using IServiceScope s = services.CreateScope(); WarehouseDbContext c = s.GetRequiredService(); c.Database.SetDbConnection(...) c.Database.UseTransaction(...) 

Проблема в том, что при обработке HTTP-запроса в ASP.NET Core создаётся отдельный Scope, в которым будет создан новый экземпляр WarehouseDbContext.

Создание Scope на каждый HTTP-запрос — нормальное поведение для ASP.NET Core, и нет надёжного способа повлиять на это.

Проблема разных Scope в виде диаграммы

Иллюстрация - различные Scope

Иллюстрация — различные Scope

Решение проблемы разных Scope

Для решения проблемы мы можем встроиться в DI-контейнер, заменив способ создания DbContext.

  • Ниже показан пример доработки класса CustomWebApplicationFactory, но это ещё не финальный код

  • Для встраивания в DI-контейнер используем метод IWebHostBuilder.ConfigureTestServices(Action services)

    • он будет косвенно вызван, когда выполнение Program.cs дойдёт до строки var app = builder.Build();

  • Метод DecorateDbContext(...) поддерживает не все варианты создания сервиса

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

  • Метод DecorateDbContext(...) является generic-методом, чтобы поддержать ситуацию, когда в проекте есть несколько DbContext

private override void ConfigureWebHost(IWebHostBuilder builder) {     base.ConfigureWebHost(builder);     builder.ConfigureTestServices(DecorateDbContext); }  /// <summary> ///  Применяет метод AttachDbContext() /// </summary> private void DecorateDbContext(IServiceCollection services)     where T : DbContext {     ServiceDescriptor descriptor = services.Single(d => d.ServiceType == typeof(T));     if (descriptor.ImplementationFactory != null)     {         throw new NotImplementedException($"Cannot decorate {typeof(T)} which uses implementation factory");     }      if (descriptor.ImplementationInstance != null)     {         throw new NotImplementedException($"Cannot decorate {typeof(T)} which uses implementation instance");     }      services.AddScoped(serviceProvider =>     {         T dbContext = ActivatorUtilities.CreateInstance(serviceProvider);         AttachDbContext(dbContext);         return dbContext;     }); }  private void AttachDbContext(DbContext dbContext) {     dbContext.Database.SetDbConnection(_connection);     dbContext.Database.UseTransaction(_transaction); } 

Управление транзакцией

Ещё один сложный вопрос — когда начинать и когда завершать транзакцию?

Для этого можно использовать хуки (Hooks):

[Binding] public class TestServerFixture : ITestServerFixture {     /// ... другие поля, свойства, методы      [BeforeScenario]     public async Task BeforeScenario()     {         await _fixtureCore.InitializeScenario();     }      [AfterScenario]     public async Task AfterScenario()     {         await _fixtureCore.ShutdownScenario();     } } 

Однако попытка сделать это на практике приведёт к проблеме:

  • По умолчанию Reqnroll в связке с XUnit запускает тесты параллельно

  • При этом для Reqnroll+XUnit все сценарии одной Feature выполняются последовательно, а вот разные Feature могут выполняться параллельно

Проблема с многопоточностью тестов

Проблема с многопоточностью тестов

Решение проблемы с многопоточностью

Сейчас у нас только один Feature и мы могли бы отложить решение проблемы — однако в реальном проекте обойтись одним Feature-файлом едва ли возможно, а отключать параллелизм тестов вредно для молодого организма сервиса.

Для решения проблемы надо учесть, как устроен параллелизм для Reqnroll+XUnit.

Согласно статье Parallel Execution из документации:

  • Для XUnit используется Thread Parallelism на уровне разных фич

  • В каждом потоке XUnit сценарии одной или нескольких фич выполняются последовательно

  • Между потоками XUnit изолировано только локальное состояние потока (thread-local state)

Как в нашем коде обеспечить изоляцию состояний для разных потоков XUnit?

Вижу два способа:

Способы

Нюансы

Резолюция

1. Thread-local переменные (атрибут [ThreadLocal])

Есть не только потоки XUnit, но и потоки тестируемой системы, и они не увидят thread-local полей

Не делаем

2. ConcurrentDictionary и получение объекта по ID потока

Нужен ровно один объект FixtureCore на один поток XUnit, нельзя запрашивать его из потоков тестируемой системы

Делаем

Мы воспользуемся вторым способом и получим аналог шаблона Singleton, но с нюансом: каждый поток теперь получает свой объект при обращении к TestServerFixtureCore.Instance.

[Binding] public class TestServerFixtureCore : IAsyncDisposable {     private static readonly ConcurrentDictionary InstanceMap = [];      public static TestServerFixtureCore Instance => InstanceMap.GetOrAdd(         Environment.CurrentManagedThreadId,         _ => new TestServerFixtureCore()     );      // ... другие поля, свойства, методы } 

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

public class TestServerFixture : ITestServerFixture {     private readonly TestServerFixtureCore _fixtureCore = TestServerFixtureCore.Instance;      // ... другие поля, свойства, методы } 

Собираем всё вместе

Листинг нового класса ScenarioTransaction

Новый класс ScenarioTransaction инкапсулирует работу с соединением и откатываемой транзакцией — так мы снимаем лишнюю обязанность с класса TestServerFixtureCore.

Добавим файл Fixture/ScenarioTransaction.cs:

using Microsoft.EntityFrameworkCore; using Npgsql;  namespace WebService.Specs.Fixture;  public class ScenarioTransaction : IAsyncDisposable {     private readonly NpgsqlConnection _connection;     private readonly NpgsqlTransaction _transaction;      public static async Task<ScenarioTransaction> Create(string dbConnectionString)     {         NpgsqlConnection connection = new(dbConnectionString);         await connection.OpenAsync();         try         {             NpgsqlTransaction transaction = await connection.BeginTransactionAsync();             return new ScenarioTransaction(connection, transaction);         }         catch (Exception)         {             await connection.CloseAsync();             throw;         }     }      public void AttachDbContext(DbContext dbContext)     {         dbContext.Database.SetDbConnection(_connection);         dbContext.Database.UseTransaction(_transaction);     }      public async ValueTask DisposeAsync()     {         await _transaction.RollbackAsync();         await _connection.CloseAsync();     }      private ScenarioTransaction(NpgsqlConnection connection, NpgsqlTransaction transaction)     {         _connection = connection;         _transaction = transaction;     } } 

Листинг изменений в TestServerFixtureCore

Изменения в TestServerFixtureCore:

  1. Теперь это не настоящий Singleton: каждый поток XUnit получит свой экземпляр объекта

  2. Удалён метод BeforeTestRun() — инициализация объекта TestServerFixtureCore происходит перед началом работы с ним

  3. Метод AfterTestRun() изменился — теперь он пробегает по ConcurrentDictionary, вызывая DisposeAsync() у каждого экземпляра TestServerFixtureCore

  4. Новый метод InitializeScenario() создаёт транзакцию для тестового сценария — а перед этим выполняет асинхронную часть инициализации объекта TestServerFixtureCore

  5. Новый метод ShutdownScenario() откатывает транзакцию тестового сценария

  6. Новый метод AttachDbContext() проверяет состояние самого TestServerFixtureCore и затем делегирует настройку DbContext классу ScenarioTransaction

using System.Collections.Concurrent; using Microsoft.EntityFrameworkCore; using Reqnroll; using Testcontainers.PostgreSql;  namespace WebService.Specs.Fixture;  [Binding] public class TestServerFixtureCore : IAsyncDisposable {     private static readonly ConcurrentDictionary<int,TestServerFixtureCore> InstanceMap = [];      private HttpClient? _httpClient;     private ScenarioTransaction? _scenarioTransaction;     private bool _initialized;      private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()         .WithImage("postgres:16.4-alpine")         .WithDatabase("warehouse")         .WithTmpfsMount("/var/lib/postgresql/data")         .Build();      public static TestServerFixtureCore Instance => InstanceMap.GetOrAdd(         Environment.CurrentManagedThreadId,         _ => new TestServerFixtureCore()     );      public HttpClient HttpClient => _httpClient ?? throw new InvalidOperationException("Fixture was not initialized");      public async Task InitializeScenario()     {         if (!_initialized)         {             await _container.StartAsync();             CustomWebApplicationFactory<Program> factory = new(AttachDbContext, _container.GetConnectionString());             _httpClient = factory.CreateClient();             _initialized = true;         }          _scenarioTransaction = await ScenarioTransaction.Create(_container.GetConnectionString());     }      public async Task ShutdownScenario()     {         if (_scenarioTransaction == null)         {             throw new InvalidOperationException("Test scenario is not running");         }          await _scenarioTransaction!.DisposeAsync();         _scenarioTransaction = null;     }      private TestServerFixtureCore()     {     }      [AfterTestRun]     public static async Task AfterTestRun()     {         foreach (TestServerFixtureCore instance in InstanceMap.Values)         {             await instance.DisposeAsync();         }     }      public async ValueTask DisposeAsync()     {         _httpClient = null;         await _container.DisposeAsync();     }      private void AttachDbContext(DbContext dbContext)     {         if (!_initialized)         {             return;         }          if (_scenarioTransaction == null)         {             throw new InvalidOperationException("Test scenario is not running");         }          _scenarioTransaction.AttachDbContext(dbContext);     } } 

Листинг изменений в TestServerFixture

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

using Reqnroll;  namespace WebService.Specs.Fixture;  [Binding] public class TestServerFixture : ITestServerFixture {     private readonly TestServerFixtureCore _fixtureCore = TestServerFixtureCore.Instance;      public HttpClient HttpClient => _fixtureCore.HttpClient;      [BeforeScenario]     public async Task BeforeScenario()     {         await _fixtureCore.InitializeScenario();     }      [AfterScenario]     public async Task AfterScenario()     {         await _fixtureCore.ShutdownScenario();     } } 

Листинг изменений в CustomWebApplicationFactory

Изменения в CustomWebApplicationFactory:

  1. Перегрузили метод ConfigureWebHost, чтобы вызвать builder.ConfigureTestServices

  2. Добавили метод DecorateDbContext, который встраивает вызов функтора attachDbContext в процесс создания всех DbContext тестируемой системы

using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using WebService.Database;  namespace WebService.Specs.Fixture;  public class CustomWebApplicationFactory<TEntryPoint>(Action<DbContext> attachDbContext, string dbConnectionString)     : WebApplicationFactory<TEntryPoint>     where TEntryPoint : class {     // Меняет конфигурацию до создания объекта Program, см. https://github.com/dotnet/aspnetcore/issues/37680     protected override IHost CreateHost(IHostBuilder builder)     {         builder.ConfigureHostConfiguration(configurationBuilder =>         {             configurationBuilder.AddInMemoryCollection(new Dictionary<string,string?>             {                 { "ConnectionStrings:MainConnection", dbConnectionString }             });         });          return base.CreateHost(builder);     }      protected override void ConfigureWebHost(IWebHostBuilder builder)     {         base.ConfigureWebHost(builder);         builder.ConfigureTestServices(DecorateDbContext<WarehouseDbContext>);     }      private void DecorateDbContext<T>(IServiceCollection services)         where T : DbContext     {         ServiceDescriptor descriptor = services.Single(d => d.ServiceType == typeof(T));         if (descriptor.ImplementationFactory != null)         {             throw new NotImplementedException($"Cannot decorate {typeof(T)} which uses implementation factory");         }          if (descriptor.ImplementationInstance != null)         {             throw new NotImplementedException($"Cannot decorate {typeof(T)} which uses implementation instance");         }          services.AddScoped(serviceProvider =>         {             T dbContext = ActivatorUtilities.CreateInstance(serviceProvider);             attachDbContext(dbContext);             return dbContext;         });     } } 

Это решение можно улучшить: вместо создания контейнера PostgreSQL на каждый поток XUnit можно создать один контейнер, а для каждого потока создавать отдельный DATABASE (не забывая применить к нему миграции и модифицировать Connection String).

С другой стороны, docker-контейнер PostgreSQL запускается очень быстро.

Возвращаемся к списку тестовых сценариев

Мы закрыли очередной тест. Это было непросто, но дальше будет легче.

  1. Можем создать несколько продуктов

  2. Можем создать несколько продуктов и обновить один

  3. Можем создать несколько продуктов и удалить один

  4. Нельзя добавить продукт с пустым кодом

  5. Нельзя добавить продукт с нулевой либо отрицательной ценой

Реализуем третий тест

В файл Products.feature добавим тест:

    Сценарий: Можем создать несколько продуктов и удалить один         Пусть добавили продукты:           | Код    | Описание                    | Цена   | Количество |           | A12345 | Фуфайка из льняного волокна | 49,90  | 300        |           | B99999 | Женский ободок для волос    | 99,00  | 12         |           | C777   | Косоворотка для мальчика    | 100,00 | 3          |          Когда удаляем продукт с кодом "B99999"          Тогда получим список продуктов:           | Код    | Описание                    | Цена   | Количество |           | A12345 | Фуфайка из льняного волокна | 49,90  | 300        |           | C777   | Косоворотка для мальчика    | 100,00 | 3          |

В класс ProductStepDefinitions добавим метод для описания шага:

    [When(@"удаляем продукт с кодом ""(.*)""")]     public async Task КогдаУдаляемПродуктСКодом(string productCode)     {         if (!_codeToIdMap.TryGetValue(productCode, out int productId))         {             throw new ArgumentException($"Unexpected product code {productCode}");         }         await _driver.DeleteProduct(productId);     } 

В класс ProductApiTestDriver добавим метод удаления продукта:

public async Task DeleteProduct(int productId) {     var response = await HttpClient.DeleteAsync($"/api/products/{productId}");     await EnsureSuccessStatusCode(response); } 

Запускаем тесты и… все тесты зелёные!

Это было просто, ведь всё необходимое уже есть в тестовом фреймворке.

Мы закрыли очередной тест:

  1. Можем создать несколько продуктов

  2. Можем создать несколько продуктов и обновить один

  3. Можем создать несколько продуктов и удалить один

  4. Нельзя добавить продукт с пустым кодом

  5. Нельзя добавить продукт с нулевой либо отрицательной ценой

Реализуем четвёртый тест

Добавляем негативный тест

  • Четвёртый тест отличается от предыдущих трёх — он негативный, то есть тестируемая система должна сообщить об ошибке.

  • Способ возврата ошибки важен: если тестируемая система вернёт 500 Internal Server Error — это не нормально.

Разместим первый негативный тест в новом файле ProductValidation.feature:

Функциональность: валидация при добавлении продуктов      Контекст: сервис хранит список реализуемых товаров и предоставляет API для CRUD-операций с ними      Сценарий: Нельзя добавлять продукт с пустым кодом         Когда добавляем продукты:           | Код | Описание                    | Цена  | Количество |           |     | Фуфайка из льняного волокна | 49,90 | 312        |          Тогда получим ошибку валидации

Тест падает, потому что не реализован шаг Тогда получим ошибку валидации.

Реализовать этот шаг не так просто:

  • В классе ProductApiTestDriver при получении HTTP-статуса 400 Bad Request будет ошибка проверки утверждения

  • При этом код бросает исключение Xunit.Sdk.FailException

Это происходит в методе EnsureSuccessStatusCode:

private static async Task EnsureSuccessStatusCode(HttpResponseMessage response) {     if (!response.IsSuccessStatusCode)     {         string content = await response.Content.ReadAsStringAsync();         Assert.Fail($"HTTP status code {response.StatusCode}: {content}");     } } 

Простейший способ реализации

Как поддержать негативные сценарии, ожидающие 400 Bad Request от тестируемой системы? Можно так:

  • При отправке HTTP-запроса к тестируемой системе запоминать ошибочные HTTP-статусы

  • Проверять факт получения ошибочного HTTP-статуса на шаге Тогда получим ошибку валидации

Куда сохранять ошибочный HTTP-статус? Вспомним, какая ответственность возложена на классы:

  • ProductStepDefinitions содержит реализацию шагов тестового сценария, написанного на языке Gherkin

  • ProductApiTestDriver будет отвечать за логику взаимодействия с тестируемой системой

На мой взгляд, класс ProductApiTestDriver должен вести себя как типичный клиент к API — то есть бросать исключение при получении ошибочных HTTP-статусов.

Вопрос в том, какое исключение бросать — и если в исключении будет HTTP-статус, этого достаточно для написания теста.

Добавим новый класс в файле Drivers/ApiClientException.cs:

using System.Net;  namespace WebService.Specs.Drivers;  public class ApiClientException(HttpStatusCode code, string? message, Exception? innerException = null)     : Exception($"HTTP status code {code}: {message}", innerException) {     public HttpStatusCode HttpStatusCode { get; } = code; } 

Изменим метод ProductApiTestDriver.EnsureSuccessStatusCode(...):

private static async Task EnsureSuccessStatusCode(HttpResponseMessage response) {     if (!response.IsSuccessStatusCode)     {         string content = await response.Content.ReadAsStringAsync();         throw new ApiClientException(response.StatusCode, content);     } } 

В классе ProductStepDefinitions добавим приватное поле Exception? _lastException, и будем перехватывать исключения во всех методах, вызывающих методы API.

Например, так это выглядит в методе добавления продукта:

[Given(@"добавили продукты:")] [When(@"добавляем продукты:")] public async Task КогдаДобавляемПродукты(Table table) {     try     {         List<TestProductData> products = table.CreateSet<TestProductData>().ToList();         foreach (TestProductData product in products)         {             int productId = await _driver.AddProduct(product);             _codeToIdMap[product.Code] = productId;         }     }     catch (Exception e)     {         _lastException = e;     } } 

Наконец, реализуем шаг Тогда получим ошибку валидации:

  • Проверим, что мы получили исключение типа ApiClientException

  • Проверим, что у этого исключения свойство HttpStatusCode имеет значение HttpStatusCode.BadRequest

[Then(@"получим ошибку валидации")] public void ТогдаПолучимОшибкуВалидации() {     Assert.IsType<ApiClientException>(_lastException);     if (_lastException is ApiClientException e)     {         Assert.Equal(HttpStatusCode.BadRequest, e.HttpStatusCode);     } } 
Листинг класса ProductStepDefinitions
using System.Net; using Reqnroll; using WebService.Specs.Drivers; using WebService.Specs.Fixture;  namespace WebService.Specs.Steps;  [Binding] public class ProductStepDefinitions(TestServerFixture fixture) {     private readonly ProductApiTestDriver _driver = new(fixture);     private readonly Dictionary<string, int> _codeToIdMap = new();      private Exception? _lastException;      [Given(@"добавили продукты:")]     [When(@"добавляем продукты:")]     public async Task КогдаДобавляемПродукты(Table table)     {         try         {             List<TestProductData> products = table.CreateSet<TestProductData>().ToList();             foreach (TestProductData product in products)             {                 int productId = await _driver.AddProduct(product);                 _codeToIdMap[product.Code] = productId;             }         }         catch (Exception e)         {             _lastException = e;         }     }      [When(@"обновляем продукты:")]     public async Task КогдаОбновляемПродукты(Table table)     {         try         {             List<TestProductData> products = table.CreateSet<TestProductData>().ToList();             foreach (TestProductData product in products)             {                 if (!_codeToIdMap.TryGetValue(product.Code, out int productId))                 {                     throw new ArgumentException($"Unexpected product code {product.Code}");                 }                  await _driver.UpdateProduct(productId, product);             }         }         catch (Exception e)         {             _lastException = e;         }     }      [When(@"удаляем продукт с кодом ""(.*)""")]     public async Task КогдаУдаляемПродуктСКодом(string productCode)     {         try         {             if (!_codeToIdMap.TryGetValue(productCode, out int productId))             {                 throw new ArgumentException($"Unexpected product code {productCode}");             }              await _driver.DeleteProduct(productId);         }         catch (Exception e)         {             _lastException = e;         }     }      [Then(@"получим список продуктов:")]     public async Task ТогдаПолучимСписокПродуктов(Table table)     {         List<TestProductData> expected = table.CreateSet<TestProductData>().ToList();         List<TestProductData> actual = await _driver.ListProducts();         Assert.Equal(expected.Count, actual.Count);         Assert.Equivalent(expected, actual);     }      [Then(@"получим ошибку валидации")]     public void ТогдаПолучимОшибкуВалидации()     {         Assert.IsType<ApiClientException>(_lastException);         if (_lastException is ApiClientException e)         {             Assert.Equal(HttpStatusCode.BadRequest, e.HttpStatusCode);         }     } }

Запускаем тест — он зелёный! Однако есть недоработка, которую не стоит оставлять.

Устраняем побочный эффект негативных тестов

Наши изменения в ProductStepDefinitions создали побочный эффект:

  1. Допустим у нас есть позитивный тест на какой-то метод API

  2. В коде возникла регрессия, из-за которой на шаге Когда возвращается HTTP-статус 500 Internal Server Error

  3. При запуске теста шаг Когда не прерывает тестовый сценарий, а продолжает работу

Что будет дальше? Возможны варианты:

  • Если тест написан плохо, проверки на шаге Тогда могут пропустить ошибочный HTTP-статус

  • Даже если тест написан хорошо, он не заметит ошибочного HTTP-статуса, если в тестируемой системе ошибка возникает после записи изменений в базу данных

  • Если же проверки на шаге Тогда сработали, они сообщают о какой-нибудь ошибке сравнения результатов и совершенно ничего не говорят о 500 Internal Server Error

Всё перечисленное — побочные эффекты перехвата исключений ради негативных тестов.

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

Разделяем логику позитивных и негативных тестов

В Reqnroll (Specflow) есть механизм тегов

  • Этот механизм используется, например, для ограничения области видимости шагов тестов (см. Scoped Bindings)

  • Мы воспользуемся тегами для разной логики обработки исключений в позитивных и негативных тестах

Сначала добавим тег @negative в негативный сценарий:

  @negative   Сценарий: Нельзя добавлять продукт с пустым кодом       Когда добавляем продукты:         | Код | Описание                    | Цена  | Количество |         |     | Фуфайка из льняного волокна | 49,90 | 312        |        Тогда получим ошибку валидации

Затем внесём ряд доработок в класс ProductStepDefinitions.

Во-первых добавим зависимость ScenarioContext scenarioContext и метод IsNegativeScenario():

[Binding] public class ProductStepDefinitions(TestServerFixture fixture, ScenarioContext scenarioContext) {     // ... другие поля, классы, методы      private bool IsNegativeScenario()     {         return scenarioContext.ScenarioInfo.Tags.Contains("negative");     } } 

Во-вторых доработаем другие методы, чтобы в позитивных тестах перехвата исключений не было:

  • для этого применим конструкцию catch (...) when (...)

  • такая конструкция в C# называется фильтры исключений — англ. Exception Filters

try {         // ...действие } catch (Exception e) when (IsNegativeScenario()) {     _lastException = e; } 

Наконец, добавим метод AfterScenario(), вызываемый после завершения каждого тестового сценария:

  • Для негативных тестов он проверяет наличие исключения в поле _lastException

  • Для позитивных тестов он бросает исключение _lastException, если оно есть — такого не должно быть благодаря фильтрам, однако почему бы и не сделать на всякий случай?

[AfterScenario] private void AfterScenario() {     if (IsNegativeScenario())     {         Assert.NotNull(_lastException);     }     else if (_lastException != null)     {         throw _lastException;     } } 
Листинг класса ProductStepDefinitions
using System.Net; using Reqnroll; using WebService.Specs.Drivers; using WebService.Specs.Fixture;  namespace WebService.Specs.Steps;  [Binding] public class ProductStepDefinitions(TestServerFixture fixture, ScenarioContext scenarioContext) {     private readonly ProductApiTestDriver _driver = new(fixture);     private readonly Dictionary<string, int> _codeToIdMap = new();      private Exception? _lastException;      [Given(@"добавили продукты:")]     [When(@"добавляем продукты:")]     public async Task КогдаДобавляемПродукты(Table table)     {         try         {             List<TestProductData> products = table.CreateSet<TestProductData>().ToList();             foreach (TestProductData product in products)             {                 int productId = await _driver.AddProduct(product);                 _codeToIdMap[product.Code] = productId;             }         }         catch (Exception e) when (IsNegativeScenario())         {             _lastException = e;         }     }      [When(@"обновляем продукты:")]     public async Task КогдаОбновляемПродукты(Table table)     {         try         {             List<TestProductData> products = table.CreateSet<TestProductData>().ToList();             foreach (TestProductData product in products)             {                 if (!_codeToIdMap.TryGetValue(product.Code, out int productId))                 {                     throw new ArgumentException($"Unexpected product code {product.Code}");                 }                  await _driver.UpdateProduct(productId, product);             }         }         catch (Exception e) when (IsNegativeScenario())         {             _lastException = e;         }     }      [When(@"удаляем продукт с кодом ""(.*)""")]     public async Task КогдаУдаляемПродуктСКодом(string productCode)     {         try         {             if (!_codeToIdMap.TryGetValue(productCode, out int productId))             {                 throw new ArgumentException($"Unexpected product code {productCode}");             }              await _driver.DeleteProduct(productId);         }         catch (Exception e) when (IsNegativeScenario())         {             _lastException = e;         }     }      [Then(@"получим список продуктов:")]     public async Task ТогдаПолучимСписокПродуктов(Table table)     {         List<TestProductData> expected = table.CreateSet<TestProductData>().ToList();         List<TestProductData> actual = await _driver.ListProducts();         Assert.Equal(expected.Count, actual.Count);         Assert.Equivalent(expected, actual);     }      [Then(@"получим ошибку валидации")]     public void ТогдаПолучимОшибкуВалидации()     {         Assert.IsType<ApiClientException>(_lastException);         if (_lastException is ApiClientException e)         {             Assert.Equal(HttpStatusCode.BadRequest, e.HttpStatusCode);         }     }      [AfterScenario]     private void AfterScenario()     {         if (IsNegativeScenario())         {             Assert.NotNull(_lastException);         }         else if (_lastException != null)         {             throw _lastException;         }     }      private bool IsNegativeScenario()     {         return scenarioContext.ScenarioInfo.Tags.Contains("negative");     } }

Фиксируем результат

Мы закрыли очередной тест — вычеркнем его:

  1. Можем создать несколько продуктов

  2. Можем создать несколько продуктов и обновить один

  3. Можем создать несколько продуктов и удалить один

  4. Нельзя добавить продукт с пустым кодом

  5. Нельзя добавить продукт с нулевой либо отрицательной ценой

Пятый тест

Повтор сценария с различными параметрами

Пятый тест включает в себя два случая:

  • нулевая цена

  • отрицательная цена

Если бы мы писали тест на «голом» XUnit, мы бы применили [Threory] вместо [Fact], чтобы запустить один тот же тест несколько раз с различными параметрами.

Можно ли сделать подобное на языке Gherkin средствами библиотеки Reqnroll (Specflow)? Конечно же да:

  1. Ключевая фраза Структура сценария (Scenario Outline) задаёт шаблонизируемый сценарий, запускаемый несколько раз

  2. Ключевое слово Примеры (Examples) задаёт значения для подстановки

  3. Подставляемые значения заключаются в угловые скобки, например: <price>

Теперь можем добавить тест в файл ProductValidation.feature:

    @negative     Структура сценария: Нельзя добавлять продукт с нулевой ценой         Когда добавляем продукты:           | Код    | Описание                    | Цена   | Количество |           | A12345 | Фуфайка из льняного волокна | <цена> | 312        |          Тогда получим ошибку валидации          Примеры:           | цена  |           | 0,00  |           | -1,00 |

Фиксируем результат

Мы закрыли очередной тест — вычеркнем его:

  1. Можем создать несколько продуктов

  2. Можем создать несколько продуктов и обновить один

  3. Можем создать несколько продуктов и удалить один

  4. Нельзя добавить продукт с пустым кодом

  5. Нельзя добавить продукт с нулевой либо отрицательной ценой

Ура, мы покрыли сервис достаточным набором интеграционных тестов!

Запускаем тесты в Gitlab CI

Возможности Gitlab CI

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

  • Необязательность запуска тестов провоцирует саботаж автоматизации тестирования разработчиками

  • У разработчиков уже есть десятки способов саботировать написание тестов — не стоит давать ещё один способ

Если у вас в качестве билд-сервера используется Gitlab CI, то сборка скорее всего выполняется в docker-контейнерах:

  • По умолчанию docker-контейнер не имеет возможности обращаться к демону docker и создавать новые контейнеры

  • В то же время наши тесты используют библиотеку TestContainers, которая как раз обращается к Docker Remote API для создания контейнеров

У Gitlab есть готовое решение: Services.

  • Services представляют собой docker-контейнеры, запускаемые параллельно с контейнером, выполняющим шаг сборки

  • Они по сути являются заменой либо дополнением для TestContainers — об этом ниже

  • Они описываются в разделе services: тех шагов сборки, которым нужны вспомогательные контейнеры

Существует два способа применить Gitlab CI Services:

  1. Docker-in-Docker: запустить вспомогательный контейнер из образа docker:dind, указать на него библиотеке TestContainers

  2. Обычные Docker-контейнеры: при запуске в Gitlab CI использовать описанные там же контейнеры вместо библиотеки TestContainers

Вариант с Docker-in-Docker

Этот вариант описан в документации TestContainers: см. Continuous Integration

tests:dotnet:   # ... другие свойства шага запуска тестов   services:     - docker:dind   variables:     DOCKER_HOST: tcp://docker:2375 

Объясним, что здесь происходит:

  1. В services: используется компактный вариант описания — указываем образ docker:dind, и Gitlab CI создаст контейнер из этого образа

  2. Контейнер будет доступен под именем docker, которое получено из имени образа по правилами из документации Gitlab CI

  3. Переменная окружения DOCKER_HOST передаётся всем контейнерам шага сборки — как основному, так и запущенным через services:

  4. Библиотека TestContainers обнаруживает переменную DOCKER_HOST и использует указанный хост для создания вспомогательных контейнеров

Вариант с обычными Docker-контейнерами

У Вас могут быть разные причины не использовать TestContainers и Docker-in-Docker в Gitlab CI. Мы не будем их обсуждать, а просто оставим пример использования.

В этом случае в Gitlab CI опишем непосредственно контейнер postgresql и пробросим в тесты готовый ConnectionString через переменную окружения:

tests:dotnet:   # ... другие свойства шага запуска тестов   services:     - name: postgres:16.4-alpine       alias: warehouse-db       variables:         POSTGRES_USER: warehouse-tests-db         POSTGRES_PASSWORD: heu6du2E         POSTGRES_DB: warehouse_tests   variables:     TESTS_MAIN_CONNECTION: "Host=warehouse-db;Database=warehouse_tests;Username=warehouse-tests-db;Password=heu6du2E" 

Теперь доработаем тесты, чтобы получить два разных варианта поведения в Gitlab CI и локально:

  1. Добавим интерфейс ITestContainersHost, который скрывает за собой набор вспомогательных контейнеров для тестов

  2. Первая реализация DefaultTestContainersHost будет использовать TestContainers и нужна для запуска на машине разработчика

  3. Вторая реализация ExternalTestContainersHost будет использовать параметры подключения к контейнерам, запущенным Gitlab CI

Здесь листинга не будет — смотрите соответствующие классы в примере на githab.

Ретроспектива сделанной работы

Тестирование ПО — практическая область

Я практик. Наверняка моя точка зрения на автоматизацию тестирования не совпадает с чьей-то ещё. С другой стороны, она подкреплена личным опытом — за последние четыре года я выбирал и реализовывал стратегию автоматизации тестирования четырёх разных проектов (включая текущий).

Вы пишете тесты по-другому? Здорово, если у Вас всё получается! Буду рад, если моя статья поможет что-то улучшить.

В любом случае, воспринимайте сказанное ниже через призму собственного опыта.

Уровни тестов с точки зрения внепроцессных зависимостей

Чёткие критерии разделения уровней тестов есть у Владимира Хорикова в прекрасной книге «Принципы Unit-тестирования»:

Уровень тестов

Особенности

Модульные тесты (unit tests)

не обращаются к внепроцессным зависимостям

Интеграционные тесты (integration tests)

используют только управляемые внепроцессные зависимости, полностью подчинённые тестируемой системе

Сквозные тесты (end-to-end tests)

запускаются на реальном окружении (тестовый стенд, staging или production), используют как управляемые, так и неуправляемые внепроцессные зависимости

Примеры внепроцессных зависимостей с точки зрения управляемости:

Зависимость

Способ использования

Тип

PostgreSQL

База данных сервиса (но не shared database)

Управляемая зависимость

Redis

Кеш приложения

Управляемая зависимость

Minio

Распределённое хранилище объектов (файлов)

Управляемая зависимость

RabbitMQ

Общая шина интеграционных событий сервисов

Неуправляемая зависимость

Сервис соседней команды

Обращаемся к их API

Неуправляемая зависимость

Сторонний сервис / API

Обращаемся к их API

Неуправляемая зависимость

Свой почтовый сервер

Обращаемся по SMTP для отправки почты

Неуправляемая зависимость

Идею можно проиллюстрировать так:

Уровни пирамиды тестирования

Уровни пирамиды тестирования

Подводные камни интеграционных тестов

Преимущества интеграционных тестов раскрываются только при соблюдении ряда условий:

  1. Они проверяют все слои и все модули сервиса

  2. Они подключают правильные управляемые внепроцессные зависимости правильной версии — если в Production используется PostgreSQL версии 16.3, не пытайтесь в тестах заменить его ни на In-Memory хранилище, ни на SQLite, ни даже на PostgreSQL более старой/новой версии

  3. Они используют тестовые дублёры только для неуправляемых внепроцессных зависимостей (в данной статье мы это не рассматривали)

Есть другие паттерны и антипаттерны интеграционного тестирования — многие из них описаны у Владимира Хорикова в книге «Принципы Unit-тестирования» и в блоге Enterprise Craftmanship.

Чем интеграционные тесты лучше модульных?

Допустим, API-сервис имеет классическую гексагональную архитектуру (она же Ports and Adapters) без разделения на модули:

  • Есть четыре слоя: Domain, Application, Infrastructure, Presentation

  • Слой Presentation зависит от Application

  • Зависимости между Domain/Application и Infrastructure инвертированы — то есть слой Infrastructure реализует абстракции, описанные в виде интерфейсов на слоях Domain и Application

Потратив немало усилий на написание Test Doubles, мы можем получить модульные тесты, которые проверят слои Domain и Application:

Покрытие интеграционными тестами

Покрытие интеграционными тестами

Потратив фиксированное количество усилий на фреймворк тестирования, мы можем написать интеграционные тесты и проверять все слои:

Покрытие интеграционными тестами

Покрытие интеграционными тестами

Интеграционные тесты имеют ряд приятных особенностей в сравнении с модульными:

  1. Могут выявлять ошибки на уровнях Infrastructure и Presentation

  2. Могут выявлять ошибки на стыке слоёв и модулей

  3. Не требуют усилий на написание и поддержку Test Doubles

  4. Намного реже ломаются при рефакторинге, не меняющем поведение системы

Какую цену за это приходится заплатить?

  • Для модульных тестов потребуется добавлять и дорабатывать Test Doubles — обычно эти затраты пропорциональны затратам на разработку, но при крупных рефакторингах могут резко возрастать

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

Другими словами, чем больше вы потрудитесь над интеграционными тестами в начале — тем легче будет команде потом.

Чем интеграционные тесты лучше сквозных?

Сквозные (end-to-end) тесты обычно запускаются на тестовом стенде, где весь внешний мир похож на production: есть другие сервисы вашей системы, часто подключены сторонние API.

Это позволяет сквозным тестам проверять сценарии целостной системы, включающие взаимодействие нескольких бэкенд-сервисов, фронтенда и внешних систем.

Тем не менее, в сравнении со сквозными тестами у интеграционных есть ряд плюсов:

  1. Интеграционные тесты легко сделать абсолютно надёжными, а сквозные всегда сохраняют некоторую долю случайных неповторяющихся ошибок (это называется Flaky Tests или Brittle Tests)

  2. Интеграционные тесты обычно работают намного быстрее — обычно на порядок

  3. Интеграционные тесты обычно кратно дешевле в разработке и в обслуживании

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

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

  1. Разработчики сервиса делают упор на интеграционные тесты для проверки по спецификации с использованием контракта

  2. QA проверяют продуктовую линейку в целом сквозными тестами — без прицельной проверки отдельных сервисов, но с возможностью проверять взаимодействие сервисов между собой, с фронтендом и с внешним миром

Так какие тесты выбрать?

Увы, выбор конкретной стратегии в конкретном проекте — вопрос слишком обширный для одной статьи:

  • Ищите истории успеха в области, к которой принадлежит ваш проект

  • Обдумайте, можете ли вы применить интеграционные тесты и какой ценой

Оценить ещё раз пользу интеграционных тестов вам помогут три ссылки:

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

Как вы тестируете сервисы, использующие СУБД?

12.5% Не пишем интеграционные тесты2
18.75% Используем In-Memory / SQLite в тестах3
31.25% Используем СУБД как на проде с помощью TestContainers5
25% Используем СУБД как на проде с помощью docker-compose4
12.5% Используем СУБД, развёрнутую на отдельной машине2
0% Мы делаем тесты иначе0

Проголосовали 16 пользователей. Воздержавшихся нет.

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

Как изолируете тестовые сценарии, влияющие на базу данных?

14.29% Нет таких тестов2
14.29% Не изолируем (сценарии видят изменения друг друга)2
28.57% Пересоздаём БД перед каждым сценарием4
21.43% Чистим часть таблиц перед сценарием3
7.14% Чистим часть таблиц после сценария1
14.29% Используем BEGIN…ROLLBACK2
0% Используем библиотеку Respawn или похожую0

Проголосовали 14 пользователей. Воздержавшихся нет.

ссылка на оригинал статьи https://habr.com/ru/articles/860932/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *