Интеграционные тесты, написанные программистом — это отличный способ обеспечить уверенность в своём веб-сервисе.
В мире .NET для разработки веб-сервисов обычно используют ASP.NET Core, но интеграционное тестирование часто упускают из виду либо делают не очень качественно.
Статья покажет полноценный подход к организации интеграционных тестов на языке Gherkin для API-сервиса, написанного на C# 12 с ASP.NET Core 8 и использующего PostgreSQL.
Также в статье будут:
Применение пакета
Microsoft.AspNetCore.Mvc.Testing
для запуска тестируемой системы в режиме эмуляции отправки HTTP-запросовЗапуск PostgreSQL в docker-контейнере
Изоляция тестов с помощью отката транзакций (
BEGIN...ROLLBACK
)Описание способа запуска тестов в Gitlab CI
Я потратил не меньше недели, чтобы преодолеть сложность первоначальной настройки приёмочных интеграционных тестов для нового сервиса на C#. Надеюсь, моя статья поможет сэкономить время и улучшить автоматизацию тестирования проекта.
1. Перед началом
1.1. Как появилась статья
Летом этого года я перешёл в TravelLine и занялся разработкой нового сервиса на C#.
В TravelLine принято так: разработчики пишут модульные и интеграционные тесты, а команда QA следит за общим уровнем качества продуктов компании — в том числе QA пишут сквозные (end-to-end) тесты
В новом сервисе я реализовал автоматизацию тестирования, опираясь на личный опыт и практики соседней команды, что и дало материал для статьи
Отдельное спасибо Роману Лопатину, который вёл мой онбординг в TravelLine и также провёл ревью данной статьи.
1.2. План статьи
Действовать будем так:
Возьмём примитивный API-сервис на C# 12 с ASP.NET Core 8
Составим тест-план
Напишем интеграционные тесты, решая проблемы по мере поступления
Обсудим, как запускать интеграционные тесты в Gitlab CI
В конце проведём ретроспективу
Несколько нюансов:
Хотя я сторонник подхода ATDD, тем не менее мы не будем разрабатывать тестируемую систему с нуля, чтобы статья не превратилась в полноценную книгу.
Кроме того, количество усилий по тестированию такой простой системы явно превышает отдачу от интеграционных тестов.
В реальном проекте всё наоборот: хороший фреймворк интеграционного тестирования требует минимум затрат и приносит максимум пользы команде.
1.3. Смотрим и кодим
Параллельно с этой статьёй написан пример: https://github.com/sergey-shambir/dotnet-integration-testing
Отличный способ попробовать новый подход — писать код по мере чтения статьи.
Вы можете клонировать пример и переключиться на ветку
baseline
, чтобы повторить все шаги этой статьи самостоятельноА можно просто прочитать статью :)
1.3.1. Тестируемая система
Это API-сервис в стиле CRUD с одной моделью и PostgreSQL в качестве базы данных. Он крайне примитивен: его диаграмма классов показана ниже
В реальном проекте я бы сделал иначе:
Модель была бы значительно сложнее — с агрегатами и нетривиальными бизнес-сценариями
Скорее всего я разделил бы проект на модули, а каждый модуль — на слои по принципам гексагональной архитектуры
Скорее всего я применил бы шаблоны Service Layer и Repository
Также в тестируемой системе не будет:
исходящих запросов к другим сервисам — например, к собственным сервисам или к сторонним API ваших партнёров или вендоров
асинхронных сообщений между сервисом — например, через RabbitMQ и MassTransit
запуска задач по расписанию — например, через Hangfire
автоматической генерации для тестов клиента API тестируемой системы
Эти вещи интересны, но не вошли в рамки статьи.
2. Пишем интеграционные тесты
2.1. Список тестов
2.1.1. Оцениваем бизнес-сценарии
В тестируемой системе есть 4 метода API:
Метод API | Описание |
---|---|
GET /api/products | Выдаёт список продуктов |
POST /api/products | Добавляет продукт |
PUT /api/products/{productId} | Обновляет продукт |
DELETE /api/products/{productId} | Удаляет продукт |
Это простой API в стиле CRUD, в нём нет неочевидных бизнес-сценариев.
Поэтому для уверенности в работоспособности сервиса достаточно трёх позитивных тестов:
Можем создать несколько продуктов
Можем создать несколько продуктов и обновить один
Можем создать несколько продуктов и удалить один
Зачем создавать несколько продуктов в каждом тестовом сценарии? Чтобы убедиться, что операция над одним продуктом не влияет на остальные.
2.1.2. Добавляем негативные тесты
Я считаю, что на всех уровнях автотестов начинать надо с позитивных тестовых сценариев.
А что делать с негативными?
В модульных тестах негативные сценарии тоже важны — иначе не будет веры в надёжность каждого тестируемого модуля
В интеграционных тестах лично я предпочитаю проверять только избранные негативные сценарии (например, сильно влияющие на пользователей или на бизнес) либо не проверять никакие
Можем воспользоваться метафорой — проверка веб-сервиса похожа на проверку дома из кирпичей:
В модульных тестах проверяем надёжность «кирпичиков» программы в самых разных условиях
В интеграционных тестах проверяем характеристики построенного дома, а не отдельных «кирпичиков»
Так стоит ли тратить время и деньги, тестируя поведение стёкол при забрасывании камнями окон? Думаю, нет.
Однако для полноты статьи мы добавим два негативных теста, и получим новый список:
Можем создать несколько продуктов
Можем создать несколько продуктов и обновить один
Можем создать несколько продуктов и удалить один
Нельзя добавить продукт с пустым кодом
Нельзя добавить продукт с нулевой либо отрицательной ценой
2.2. Реализуем первый тест
2.2.1. Создаём проект с 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 | Правило |
2.2.2. Добавляем тест
Дальше будем работать с проектом 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.
2.2.3. Добавляем описание шагов
В 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()
.
2.2.4. Добавляем фальшивую реализацию
Теперь постараемся скорее получить «зелёный» тест, для этого сделаем фальшивую реализацию шагов теста:
добавим вспомогательную модель
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
, чтобы ожидание и результат стали разными — при запуске тест должен стать «красным» (т.е. сообщить об ошибке)верните значение обратно — тест должен снова стать «зелёным»
2.2.5. Поднимаем 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;
2.2.6. Добавляем Test Driver
Теперь применим шаблон Driver — это вспомогательный объект, поддерживающий принцип Separation of Concerns:
ProductStepDefinitions
содержит реализацию шагов тестового сценария, написанного на языке GherkinProductApiTestDriver
будет отвечать за логику взаимодействия с тестируемой системой
Принцип 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
, то мы получим «зелёный» тест.
2.2.7. Подводный камень
Почему тест стал «зелёным»? Потому что при первом запуске база данных чиста:
Контейнер с 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);
}
Теперь тест падает.
2.3. Добавляем контейнер PostgreSQL
2.3.1. Библиотека TestContainers
Мы хотим иметь чистую базу данных для каждого запуска набора тестов.
Есть три варианта:
Пересоздавать тестовую базу данных перед запуском тестов
Написать для docker-compose отдельный файл
tests-docker-compose.yml
для каталога с данными PostgreSQL монтировать
volume
сtpmfs
для запуска написать скрипт на Bash, PowerShell или Python
Использовать библиотеку TestContainers, чтобы запускать контейнер с PostgreSQL прямо из теста
В прошлом у меня был успешный опыт внедрения 2-го варианта.
Но мы выберем третий путь как наименее трудоёмкий и при этом более понятный для C#-разработчика.
Установим пакет Testcontainers.PostgreSql:
dotnet add tests/WebService.Specs package Testcontainers.PostgreSql --version 4.0.0
Библиотека позволяет управлять жизненным циклом контейнера PostgreSQL прямо в коде:
Создаём объект класса
PostgreSqlContainer
До первого использования вызываем у него метод
StartAsync()
Получаем строку с параметрами соединения с БД методом
GetConnectionString()
В конце останавливаем контейнер методом
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();
2.3.2. Сколько раз запускать контейнер?
Запуск контейнера PostgreSQL — дорогое удовольствие.
Что если мы будем запускать PostgreSQL перед каждым тестовым сценарием?
это позволило бы полностью изолировать тесты — один сценарий не сможет замусорить базу данных, повлияв на другой
но и цена велика — к времени выполнения каждого теста добавляются запуск СУБД, запуск миграций и остановка СУБД
Есть более дешёвые способы изоляции тестов, работающих с БД — мы обсудим их позже.
Поэтому будем запускать контейнер PostgreSQL один раз на один проект тестов.
2.3.3. Как передать 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);
}
}
2.3.4. Повторно используемый Fixture
Фреймворк XUnit предлагает несколько способов повторно использовать Fixture между тестами:
Объекты класса, реализующего
IClassFixture
, создаются один раз на класс с тестамиОбъекты класса, реализующего
ICollectionFixture
, создаются один раз на явно определённую в коде коллекцию — коллекция может быть единственной на весь проект тестовОбъекты класса, реализующего
IAssemblyFixture
, создаются один раз на проект тестов
Кроме того, в XUnit есть интерфейс IAsyncLifetime
, позволяющий выполнять асинхронные действия как при инициализации, так и при освобождении ресурсов Fixture.
Это всё прекрасно! Но не работает ни в Specflow, ни в Reqnroll:
Что делать? Запилить костыль ad-hoc решение!
2.3.5. 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();
}
}
2.3.6. Fixture и его интерфейс
А что с классами TestServerFixture
и ProductApiTestDriver
? Они претерпят изменения:
Fixture больше ничего не инициализирует — он обращается к Singleton
TestServerFixtureCore
TestServer будет инициализироваться асинхронно, и возможно состояние гонки —
ProductApiTestDriver
больше не сможет получать объектHttpClient
прямо в конструктореПоэтому мы выделим интерфейс из класса 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;
// ... остальные методы
}
2.4. Реализуем второй тест
2.4.1. Добавляем тестовый сценарий
Что дальше по списку тестовых сценариев? Посмотрим:
Можем создать несколько продуктовМожем создать несколько продуктов и обновить один
Можем создать несколько продуктов и удалить один
Нельзя добавить продукт с пустым кодом
Нельзя добавить продукт с нулевой либо отрицательной ценой
Добавим тестовый сценарий в 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, откуда его взять?
Можно использовать код продукта как ключ, уникальный в пределах тестового сценария:
Добавим в класс StepDefinitions приватное поле
Dictionary _codeToIdMap
На шаге добавления продукта запомним его ID
На шаге обновления продукта получим ID по коду
Листинг изменений в ProductApiTestDriver
Изменения в ProductApiTestDriver
:
Метод
AddProduct
теперь читает и возвращает ID продуктаПоявляется митод
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
:
Появляется поле
Dictionary _codeToIdMap
В методе
КогдаДобавляемПродукты
записываем ID в это полеДобавляем метод
КогдаОбновляемПродукты
, который получает 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
Как это получилось:
Первым запустился тест Можем создать несколько продуктов и обновить один, он добавил в БД два продукта
Вторым запустился тест Можем создать несколько продуктов и добавил ещё два продукта
Итого продукта 4, а ожидалось два
Дело в том, что нарушена изоляция тестов — они используют общую базу данных, в результате каждый тест оставляет за собой мусор, влияющий на другие тесты.
2.5. Изоляция тестов
2.5.1. Способы изоляции тестов
Есть пять способов изоляции тестов, использующих базу данных:
Пересоздавать базу данных для каждого сценария
сюда относится как
DROP DATABASE; CREATE DATABASE
, так и создание отдельного контейнера на каждый сценарийэто сильно замедляет тесты — накладные расходы растут нелинейно по формуле
число_тестов × (число_миграций + число_таблиц)
Очищать таблицы после теста
каждый новый тест обрастает чистками, а при изменении тестируемой системы их порой нужно дорабатывать
за невнимательного программиста платит его коллега, который столкнётся с чужим мусором в своём тесте
Очищать таблицы перед тестом
каждый новый тест обрастает чистками, а при изменении тестируемой системы их порой нужно дорабатывать
невнимательный программист платит сам
Использовать фреймворк очистки данных, такой как Respawn
Использовать транзакцию с откатом, то есть
BEGIN...ROLLBACK
Варианты №4 и №5 привлекательны сочетанием двух особенностей:
Код тестов избавляется от очистки
За это не приходится платить сильным замедлением.
Взаимен появляются новые сложности:
Нужно перехватывать все соединения с базой данных
Нужно вовремя вызвать откат транзакции или компенсирующие действия
В случае Respawn сложности частично «решены» библиотекой, но только пока вы не упёрлись в границы её возможностей.
Я уверен, что транзакции с откатом лучше, чем Respawn:
Транзакции — один из ключевых механизмов реляционных СУБД, они максимально надёжны
Библиотека Reswpan не тривиальна — лёгкий старт в начале может смениться долгими часами борьбы с библиотекой в будущем
Наконец, транзакции СУБД всё равно быстрее, чем компенсирующие действия
Далее в статье мы применим вариант №5: транзакции с откатом. В своём проекте вы можете пойти иным путём.
2.5.2. Создаём транзакцию с откатом
В тестируемой системе используется 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
.
2.5.3. 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, и нет надёжного способа повлиять на это.
2.5.4. Проблема разных Scope в виде диаграммы
2.5.5. Решение проблемы разных 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);
}
2.5.6. Управление транзакцией
Ещё один сложный вопрос — когда начинать и когда завершать транзакцию?
Для этого можно использовать хуки (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 могут выполняться параллельно
2.5.7. Решение проблемы с многопоточностью
Сейчас у нас только один Feature и мы могли бы отложить решение проблемы — однако в реальном проекте обойтись одним Feature-файлом едва ли возможно, а отключать параллелизм тестов вредно для молодого организма сервиса.
Для решения проблемы надо учесть, как устроен параллелизм для Reqnroll+XUnit.
Согласно статье Parallel Execution из документации:
Для XUnit используется Thread Parallelism на уровне разных фич
В каждом потоке XUnit сценарии одной или нескольких фич выполняются последовательно
Между потоками XUnit изолировано только локальное состояние потока (thread-local state)
Как в нашем коде обеспечить изоляцию состояний для разных потоков XUnit?
Вижу два способа:
Способы | Нюансы | Резолюция |
---|---|---|
1. Thread-local переменные (атрибут | Есть не только потоки 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;
// ... другие поля, свойства, методы
}
2.5.8. Собираем всё вместе
Листинг нового класса 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:
Теперь это не настоящий Singleton: каждый поток XUnit получит свой экземпляр объекта
Удалён метод
BeforeTestRun()
— инициализация объектаTestServerFixtureCore
происходит перед началом работы с нимМетод
AfterTestRun()
изменился — теперь он пробегает поConcurrentDictionary
, вызываяDisposeAsync()
у каждого экземпляраTestServerFixtureCore
Новый метод
InitializeScenario()
создаёт транзакцию для тестового сценария — а перед этим выполняет асинхронную часть инициализации объектаTestServerFixtureCore
Новый метод
ShutdownScenario()
откатывает транзакцию тестового сценарияНовый метод
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:
Перегрузили метод
ConfigureWebHost
, чтобы вызватьbuilder.ConfigureTestServices
Добавили метод
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 запускается очень быстро.
2.5.9. Возвращаемся к списку тестовых сценариев
Мы закрыли очередной тест. Это было непросто, но дальше будет легче.
Можем создать несколько продуктовМожем создать несколько продуктов и обновить одинМожем создать несколько продуктов и удалить один
Нельзя добавить продукт с пустым кодом
Нельзя добавить продукт с нулевой либо отрицательной ценой
2.6. Реализуем третий тест
В файл 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);
}
Запускаем тесты и... все тесты зелёные!
Это было просто, ведь всё необходимое уже есть в тестовом фреймворке.
Мы закрыли очередной тест:
Можем создать несколько продуктовМожем создать несколько продуктов и обновить одинМожем создать несколько продуктов и удалить одинНельзя добавить продукт с пустым кодом
Нельзя добавить продукт с нулевой либо отрицательной ценой
2.7. Реализуем четвёртый тест
2.7.1. Добавляем негативный тест
Четвёртый тест отличается от предыдущих трёх — он негативный, то есть тестируемая система должна сообщить об ошибке.
Способ возврата ошибки важен: если тестируемая система вернёт 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}");
}
}
2.7.2. Простейший способ реализации
Как поддержать негативные сценарии, ожидающие 400 Bad Request от тестируемой системы? Можно так:
При отправке HTTP-запроса к тестируемой системе запоминать ошибочные HTTP-статусы
Проверять факт получения ошибочного HTTP-статуса на шаге Тогда получим ошибку валидации
Куда сохранять ошибочный HTTP-статус? Вспомним, какая ответственность возложена на классы:
ProductStepDefinitions
содержит реализацию шагов тестового сценария, написанного на языке GherkinProductApiTestDriver
будет отвечать за логику взаимодействия с тестируемой системой
На мой взгляд, класс 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);
}
}
}
Запускаем тест — он зелёный! Однако есть недоработка, которую не стоит оставлять.
2.7.3. Устраняем побочный эффект негативных тестов
Наши изменения в ProductStepDefinitions
создали побочный эффект:
Допустим у нас есть позитивный тест на какой-то метод API
В коде возникла регрессия, из-за которой на шаге Когда возвращается HTTP-статус 500 Internal Server Error
При запуске теста шаг Когда не прерывает тестовый сценарий, а продолжает работу
Что будет дальше? Возможны варианты:
Если тест написан плохо, проверки на шаге Тогда могут пропустить ошибочный HTTP-статус
Даже если тест написан хорошо, он не заметит ошибочного HTTP-статуса, если в тестируемой системе ошибка возникает после записи изменений в базу данных
Если же проверки на шаге Тогда сработали, они сообщают о какой-нибудь ошибке сравнения результатов и совершенно ничего не говорят о 500 Internal Server Error
Всё перечисленное — побочные эффекты перехвата исключений ради негативных тестов.
На мой взгляд, в интеграционном тестировании разработчик должен прежде всего проверять основные бизнес-сценарии позитивными тестами. Значит, тестовый фреймворк должен быть удобным именно для позитивных тестов. Следовательно, побочные эффекты перехвата исключений недопустимы.
2.7.4. Разделяем логику позитивных и негативных тестов
В 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");
}
}
2.7.5. Фиксируем результат
Мы закрыли очередной тест — вычеркнем его:
Можем создать несколько продуктовМожем создать несколько продуктов и обновить одинМожем создать несколько продуктов и удалить одинНельзя добавить продукт с пустым кодомНельзя добавить продукт с нулевой либо отрицательной ценой
2.8. Пятый тест
2.8.1. Повтор сценария с различными параметрами
Пятый тест включает в себя два случая:
нулевая цена
отрицательная цена
Если бы мы писали тест на «голом» XUnit, мы бы применили [Threory]
вместо [Fact]
, чтобы запустить один тот же тест несколько раз с различными параметрами.
Можно ли сделать подобное на языке Gherkin средствами библиотеки Reqnroll (Specflow)? Конечно же да:
Ключевая фраза
Структура сценария
(Scenario Outline
) задаёт шаблонизируемый сценарий, запускаемый несколько разКлючевое слово
Примеры
(Examples
) задаёт значения для подстановкиПодставляемые значения заключаются в угловые скобки, например:
<price>
Теперь можем добавить тест в файл ProductValidation.feature
:
@negative
Структура сценария: Нельзя добавлять продукт с нулевой ценой
Когда добавляем продукты:
| Код | Описание | Цена | Количество |
| A12345 | Фуфайка из льняного волокна | <цена> | 312 |
Тогда получим ошибку валидации
Примеры:
| цена |
| 0,00 |
| -1,00 |
2.8.2. Фиксируем результат
Мы закрыли очередной тест — вычеркнем его:
Можем создать несколько продуктовМожем создать несколько продуктов и обновить одинМожем создать несколько продуктов и удалить одинНельзя добавить продукт с пустым кодомНельзя добавить продукт с нулевой либо отрицательной ценой
Ура, мы покрыли сервис достаточным набором интеграционных тестов!
3. Запускаем тесты в Gitlab CI
3.1. Возможности Gitlab CI
Как только мы написали интеграционные тесты, мы тут же должны найти способ запускать их на билд-сервере:
Необязательность запуска тестов провоцирует саботаж автоматизации тестирования разработчиками
У разработчиков уже есть десятки способов саботировать написание тестов — не стоит давать ещё один способ
Если у вас в качестве билд-сервера используется Gitlab CI, то сборка скорее всего выполняется в docker-контейнерах:
По умолчанию docker-контейнер не имеет возможности обращаться к демону docker и создавать новые контейнеры
В то же время наши тесты используют библиотеку TestContainers, которая как раз обращается к Docker Remote API для создания контейнеров
У Gitlab есть готовое решение: Services.
Services представляют собой docker-контейнеры, запускаемые параллельно с контейнером, выполняющим шаг сборки
Они по сути являются заменой либо дополнением для TestContainers — об этом ниже
Они описываются в разделе
services:
тех шагов сборки, которым нужны вспомогательные контейнеры
Существует два способа применить Gitlab CI Services:
Docker-in-Docker: запустить вспомогательный контейнер из образа
docker:dind
, указать на него библиотеке TestContainersОбычные Docker-контейнеры: при запуске в Gitlab CI использовать описанные там же контейнеры вместо библиотеки TestContainers
3.2. Вариант с Docker-in-Docker
Этот вариант описан в документации TestContainers: см. Continuous Integration
tests:dotnet:
# ... другие свойства шага запуска тестов
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375
Объясним, что здесь происходит:
В
services:
используется компактный вариант описания — указываем образdocker:dind
, и Gitlab CI создаст контейнер из этого образаКонтейнер будет доступен под именем
docker
, которое получено из имени образа по правилами из документации Gitlab CIПеременная окружения
DOCKER_HOST
передаётся всем контейнерам шага сборки — как основному, так и запущенным черезservices:
Библиотека TestContainers обнаруживает переменную
DOCKER_HOST
и использует указанный хост для создания вспомогательных контейнеров
3.3. Вариант с обычными 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 и локально:
Добавим интерфейс
ITestContainersHost
, который скрывает за собой набор вспомогательных контейнеров для тестовПервая реализация
DefaultTestContainersHost
будет использовать TestContainers и нужна для запуска на машине разработчикаВторая реализация
ExternalTestContainersHost
будет использовать параметры подключения к контейнерам, запущенным Gitlab CI
Здесь листинга не будет — смотрите соответствующие классы в примере на githab.
4. UPD: Исправление ошибок
В первой версии статьи были две ошибки в поддержке многопоточного выполнения тестов:
Изначально предполагалось, что тесты запускаются на фиксированных потоках, у которых можно получить
ManagedThreadId
. На практике для Reqnroll (Specflow) и XUnit потоки, в которых запускаются тесты, могут выбираться произвольно и меняться в процессе выполнения (если сопрограмма — то есть async-функция — восстанавливается на другом потоке)Режим запуска для Gitlab CI (см.
ExternalTestContainersHost
) не был рассчитан на многопоточный запуск — при запуске в нескольких потоках использовался один объект DATABASE в PostgreSQL
Обе проблемы решаемы, и сейчас мы этим займёмся.
Почему вообще возникли ошибки? Потому что подход в статье описан до внедрения в реальном проекте, а позже при добавлении в реальный проект ошибки были выявлены, обдуманы и устранены.
4.1. Создание временных объектов DATABASE
Применим решение, предложенное ранее в статье:
Вместо создания контейнера PostgreSQL на каждый поток XUnit можно создать один контейнер, а для каждого потока создавать отдельный DATABASE (не забывая применить к нему миграции и модифицировать Connection String).
Для этого введём вспомогательный интерфейс, абстрагирующий контейнер с PostgreSQL:
public interface IPostgresContainer
{
public string GetConnectionString();
public Task ExecuteSql(string sql);
}
Используя этот интерфейс, мы можем описать класс для объектов, управляющих временем жизни DATABASE, создаваемых для потока XUnit:
public sealed class TemporaryDatabase(IPostgresContainer container, string databaseName) : IAsyncDisposable
{
public string ConnectionString { get; } = BuildConnectionString(container, databaseName);
public static async Task<TemporaryDatabase> Create(IPostgresContainer container, string databaseName)
{
TemporaryDatabase database = new(container, databaseName);
await container.ExecuteSql($"CREATE DATABASE \"{databaseName}\"");
return database;
}
public async ValueTask DisposeAsync()
{
await container.ExecuteSql($"DROP DATABASE \"{databaseName}\"");
}
private static string BuildConnectionString(IPostgresContainer container, string databaseName)
{
NpgsqlConnectionStringBuilder builder = new(container.GetConnectionString())
{
Database = databaseName,
};
return builder.ConnectionString;
}
}
Интерфейс ITestContainersHost
изменится: вместо метода GetConnectionString()
появляется метод CreateDatabase(string databaseName)
, создающий временную базу данных
Обратите внимание: временная база данных создаётся не на каждый тест, а на каждый поток выполнения тестов в XUnit.
Для изоляции отдельных тестов по-прежнему используется
BEGIN..ROLLBACK
. Это позволяет экономить время, избегая повторного выполнения миграций в созданном DATABASE и повторной инициализации TestServer для ASP.NET..
/// <summary>
/// Абстрагирует способ запуска вспомогательных контейнеров интеграционных тестов.
/// </summary>
public interface ITestContainersHost
{
public Task StartAsync();
public Task DisposeAsync();
public Task<TemporaryDatabase> CreateDatabase(string databaseName);
}
Добавим новый метод в оба класса, реализующих интерфейс ITestContainersHost
:
Новая версия DefaultTestContainersHost
/// <summary>
/// Использует TestContainers для запуска вспомогательных контейнеров.
/// </summary>
public class DefaultTestContainersHost : ITestContainersHost
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:16.4-alpine")
.WithDatabase("warehouse")
.WithTmpfsMount("/var/lib/postgresql/data")
.Build();
public async Task StartAsync()
{
await _container.StartAsync();
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
public Task<TemporaryDatabase> CreateDatabase(string databaseName)
{
return TemporaryDatabase.Create(new PostgresContainerProxy(_container), databaseName);
}
private class PostgresContainerProxy(PostgreSqlContainer container) : IPostgresContainer
{
public string GetConnectionString()
{
return container.GetConnectionString();
}
public Task ExecuteSql(string sql)
{
return container.ExecScriptAsync(sql);
}
}
}
Новая версия ExternalTestContainersHost
/// <summary>
/// Использует переданные через переменные окружения параметры подключения к вспомогательным контейнерам.
/// </summary>
public class ExternalTestContainersHost : ITestContainersHost
{
private readonly string _mainConnectionString;
private const string MainConnectionEnvVar = "TESTS_MAIN_CONNECTION";
public static ExternalTestContainersHost? TryCreate()
{
string? mainConnectionString = Environment.GetEnvironmentVariable(MainConnectionEnvVar);
if (mainConnectionString == null)
{
return null;
}
return new ExternalTestContainersHost(mainConnectionString);
}
private ExternalTestContainersHost(string mainConnectionString)
{
_mainConnectionString = mainConnectionString;
}
Task ITestContainersHost.StartAsync()
{
return Task.CompletedTask;
}
Task ITestContainersHost.DisposeAsync()
{
return Task.CompletedTask;
}
Task<TemporaryDatabase> ITestContainersHost.CreateDatabase(string databaseName)
{
return TemporaryDatabase.Create(new ExternalPostgresContainer(_mainConnectionString), databaseName);
}
private class ExternalPostgresContainer(string connectionString) : IPostgresContainer
{
public string GetConnectionString()
{
return connectionString;
}
public async Task ExecuteSql(string sql)
{
await using NpgsqlConnection connection = new NpgsqlConnection(connectionString);
await using NpgsqlCommand command = new(sql, connection);
await command.ExecuteNonQueryAsync();
}
}
}
Реализация ITestContainersHost
теперь должна быть доступна в единственном экземпляре — мы используем один контейнер с PostgreSQL, создавая в нём изолированные повторно используемые объекты DATABASE.
Для этого создадим класс с единственным статическим полем:
public static class TestContainersProvider
{
public static readonly ITestContainersHost Instance =
ExternalTestContainersHost.TryCreate()
?? (ITestContainersHost)new DefaultTestContainersHost();
}
Для инициализации и освобождения контейнера PostgreSQL надо однократно вызывать методы StartAsync()
и DisposeAsync()
у объекта ITestContainersHost
.
Этим займётся новый класс, использующий хуки Reqnroll:
[Binding]
public static class TestRunHooks
{
[BeforeTestRun]
public static async Task BeforeTestRun()
{
await TestContainersProvider.Instance.StartAsync();
}
[AfterTestRun]
public static async Task AfterTestRun()
{
await TestServerFixture.HostPool.DisposeAsync();
await TestContainersProvider.Instance.DisposeAsync();
}
}
Существенно изменится класс TestServerFixtureCore
, объекты которого создаются по одному на каждый поток выполнения тестов XUnit:
Изменения в TestServerFixtureCore
Список изменений:
Добавлен параметр конструктора
int instanceId
Вместо поля
ITestContainersHost _testContainersHost
появилось полеTemporaryDatabase? _database
.Выделен метод
InitializeInstanceOnce()
для однократной инициализации объекта.
Новая версия класса:
using System.Collections.Concurrent;
using Microsoft.EntityFrameworkCore;
using Reqnroll;
using WebService.Specs.Fixture.Containers;
namespace WebService.Specs.Fixture;
public class TestServerFixtureCore(int instanceId) : IAsyncDisposable
{
private static readonly ConcurrentDictionary<int, TestServerFixtureCore> InstanceMap = [];
private TemporaryDatabase? _database;
private HttpClient? _httpClient;
private ScenarioTransaction? _scenarioTransaction;
private bool _initialized;
private Exception? _initializationException;
public static TestServerFixtureCore Instance => InstanceMap.GetOrAdd(
Environment.CurrentManagedThreadId,
instanceId => new TestServerFixtureCore(instanceId)
);
public HttpClient HttpClient => _httpClient ?? throw new InvalidOperationException("Fixture was not initialized");
public async Task InitializeScenario()
{
await InitializeInstanceOnce();
_scenarioTransaction = await ScenarioTransaction.Create(_database!.ConnectionString);
}
public async Task ShutdownScenario()
{
if (_scenarioTransaction == null)
{
throw new InvalidOperationException("Test scenario is not running");
}
await _scenarioTransaction!.DisposeAsync();
_scenarioTransaction = null;
}
public static async Task DisposeInstances()
{
foreach (TestServerFixtureCore instance in InstanceMap.Values)
{
await instance.DisposeAsync();
}
InstanceMap.Clear();
}
public async ValueTask DisposeAsync()
{
_httpClient = null;
if (_database is not null)
{
await _database.DisposeAsync();
_database = null;
}
}
private async Task InitializeInstanceOnce()
{
if (_initializationException is not null)
{
throw _initializationException;
}
if (!_initialized)
{
try
{
await InitializeInstance();
}
catch (Exception e)
{
_initializationException = e;
throw;
}
_initialized = true;
}
}
private async Task InitializeInstance()
{
_database = await TestContainersProvider.Instance.CreateDatabase("test_" + instanceId);
CustomWebApplicationFactory<Program> factory = new(AttachDbContext, _database.ConnectionString);
_httpClient = factory.CreateClient();
}
private void AttachDbContext(DbContext dbContext)
{
if (!_initialized)
{
return;
}
if (_scenarioTransaction == null)
{
throw new InvalidOperationException("Test scenario is not running");
}
_scenarioTransaction.AttachDbContext(dbContext);
}
}
После исправлений запустим тесты — они должны завершиться успешно. Практика показывает, что указанное решение работает в Gitlab CI.
Ещё раз подчеркну: для поддержки Gitlab CI не обязательно добавлять интерфейс ITestContainersHost и ExternalTestContainersHost.
Более простое решение, использующее docker-in-docker, описано в Статье Continuous Integration документации TestContainers.
В этой статье намеренно выбран более сложный альтернативный подход
Однако создание временных DATABASE может ускорить тесты в любом случае.
4.2. Применение пула ресурсов для Fixture
Хотя XUnit и ограничивает количество XUnit Test Runners, тем не менее тесты запускаются на произвольных потоках, и мы не можем использовать Environment.CurrentManagedThreadId
в качестве ключа для доступа к повторно используемым объектам класса TestServerFixtureCore
.
Вместо этого применим шаблон проектирования Resource Pool (пул ресурсов):
Переименуем
TestServerFixtureCore
вTestServerHost
и удалим из него статическое полеConcurrentDictionary<int, TestServerFixtureCore> InstanceMap
.Добавим класс
TestServerHostPool
, предоставляющий пул объектовTestServerHost
Научим класс
TestServerFixture
обращаться к пулу ресурсов вместо словаряInstanceMap
.
Новый класс TestServerHostPool должен поддерживать конкурентный доступ из разных потоков. Для этого сделаем следующее:
Добавим классу два метода:
TestServerHost Acquire()
для получения ресурса иvoid Release(TestServerHost host)
для освобожденияСоздадим список объектов
TestServerHost
, доступных для повторного использования:ConcurrentQueue<TestServerHost> _freeHosts
Добавим поле
int _lastInstanceId
, чтобы получать разные ID для объектовTestServerHost
.Если мы хотим ограничить размер пула, мы можем использовать
Semaphore
. Если вам это не нужно, то просто удалитеSemaphore
и параметр конструктора size из листинга ниже.
Класс использует конкурентные структуры данных и примитивы синхронизации, но в этой статье я не буду объяснять стоящие за ними принципы. Просто напомню, что написание корректного код для многопоточной среды требует хорошей квалификации и ответственного подхода.
Листинг класса TestServerHostPool
:
public class TestServerHostPool(int size) : IAsyncDisposable
{
private readonly ConcurrentQueue<TestServerHost> _freeHosts = [];
private readonly Semaphore _semaphore = new(initialCount: size, maximumCount: size);
private int _lastInstanceId;
public TestServerHost Acquire()
{
_semaphore.WaitOne();
if (!_freeHosts.TryDequeue(out TestServerHost? host))
{
host = new TestServerHost(Interlocked.Increment(ref _lastInstanceId));
}
return host;
}
public void Release(TestServerHost host)
{
_freeHosts.Enqueue(host);
_semaphore.Release();
}
public async ValueTask DisposeAsync()
{
while (_freeHosts.TryDequeue(out TestServerHost? host))
{
await host.DisposeAsync();
}
_semaphore.Dispose();
}
}
Классы TestServerFixture
и TestRunHooks
будут обращаться к TestServerHostPool
.
Новая версия TestServerFixture
выглядит так:
[Binding]
public class TestServerFixture : ITestServerFixture, IDisposable
{
public static readonly TestServerHostPool HostPool = new(size: 2);
private readonly TestServerHost _host = HostPool.Acquire();
public HttpClient HttpClient => _host.HttpClient;
[BeforeScenario]
public async Task BeforeScenario()
{
await _host.InitializeScenario();
}
[AfterScenario]
public async Task AfterScenario()
{
await _host.ShutdownScenario();
}
public void Dispose()
{
HostPool.Release(_host);
}
}
После исправлений запустим тесты — они должны завершиться успешно.
Запустим тесты ещё 3 раза, или 10 раз, или 100 раз. Они должны завершаться успешно независимо от количества запусков — а если вдруг упадут, значит, в нашем конкурентном коде есть ошибка.
Впрочем, для конкурентного кода отсутствие падений после N запусков ещё не гарантирует корректности
¯\_(ツ)_/¯
.
5. Ретроспектива сделанной работы
5.1. Тестирование ПО — практическая область
Я практик. Наверняка моя точка зрения на автоматизацию тестирования не совпадает с чьей-то ещё. С другой стороны, она подкреплена личным опытом — за последние четыре года я выбирал и реализовывал стратегию автоматизации тестирования четырёх разных проектов (включая текущий).
Вы пишете тесты по-другому? Здорово, если у Вас всё получается! Буду рад, если моя статья поможет что-то улучшить.
В любом случае, воспринимайте сказанное ниже через призму собственного опыта.
5.2. Уровни тестов с точки зрения внепроцессных зависимостей
Чёткие критерии разделения уровней тестов есть у Владимира Хорикова в прекрасной книге «Принципы Unit-тестирования»:
Уровень тестов | Особенности |
---|---|
Модульные тесты (unit tests) | не обращаются к внепроцессным зависимостям |
Интеграционные тесты (integration tests) | используют только управляемые внепроцессные зависимости, полностью подчинённые тестируемой системе |
Сквозные тесты (end-to-end tests) | запускаются на реальном окружении (тестовый стенд, staging или production), используют как управляемые, так и неуправляемые внепроцессные зависимости |
Примеры внепроцессных зависимостей с точки зрения управляемости:
Зависимость | Способ использования | Тип |
---|---|---|
PostgreSQL | База данных сервиса (но не shared database) | Управляемая зависимость |
Redis | Кеш приложения | Управляемая зависимость |
Minio | Распределённое хранилище объектов (файлов) | Управляемая зависимость |
RabbitMQ | Общая шина интеграционных событий сервисов | Неуправляемая зависимость |
Сервис соседней команды | Обращаемся к их API | Неуправляемая зависимость |
Сторонний сервис / API | Обращаемся к их API | Неуправляемая зависимость |
Свой почтовый сервер | Обращаемся по SMTP для отправки почты | Неуправляемая зависимость |
Идею можно проиллюстрировать так:
5.3. Подводные камни интеграционных тестов
Преимущества интеграционных тестов раскрываются только при соблюдении ряда условий:
Они проверяют все слои и все модули сервиса
Они подключают правильные управляемые внепроцессные зависимости правильной версии — если в Production используется PostgreSQL версии 16.3, не пытайтесь в тестах заменить его ни на In-Memory хранилище, ни на SQLite, ни даже на PostgreSQL более старой/новой версии
Они используют тестовые дублёры только для неуправляемых внепроцессных зависимостей (в данной статье мы это не рассматривали)
Есть другие паттерны и антипаттерны интеграционного тестирования — многие из них описаны у Владимира Хорикова в книге «Принципы Unit-тестирования» и в блоге Enterprise Craftmanship.
5.4. Чем интеграционные тесты лучше модульных?
Допустим, API-сервис имеет классическую гексагональную архитектуру (она же Ports and Adapters) без разделения на модули:
Есть четыре слоя: Domain, Application, Infrastructure, Presentation
Слой Presentation зависит от Application
Зависимости между Domain/Application и Infrastructure инвертированы — то есть слой Infrastructure реализует абстракции, описанные в виде интерфейсов на слоях Domain и Application
Потратив немало усилий на написание Test Doubles, мы можем получить модульные тесты, которые проверят слои Domain и Application:
Потратив фиксированное количество усилий на фреймворк тестирования, мы можем написать интеграционные тесты и проверять все слои:
Интеграционные тесты имеют ряд приятных особенностей в сравнении с модульными:
Могут выявлять ошибки на уровнях Infrastructure и Presentation
Могут выявлять ошибки на стыке слоёв и модулей
Не требуют усилий на написание и поддержку Test Doubles
Намного реже ломаются при рефакторинге, не меняющем поведение системы
Какую цену за это приходится заплатить?
Для модульных тестов потребуется добавлять и дорабатывать Test Doubles — обычно эти затраты пропорциональны затратам на разработку, но при крупных рефакторингах могут резко возрастать
Для интеграционных тестов в начале проекта надо потратить усилия на удобный фреймворк тестирования — и тогда в дальшейшем их разработка и сопровождение будет обходиться даже дешевле, чем разработка и сопровождение модульных тестов
Другими словами, чем больше вы потрудитесь над интеграционными тестами в начале — тем легче будет команде потом.
5.5. Чем интеграционные тесты лучше сквозных?
Сквозные (end-to-end) тесты обычно запускаются на тестовом стенде, где весь внешний мир похож на production: есть другие сервисы вашей системы, часто подключены сторонние API.
Это позволяет сквозным тестам проверять сценарии целостной системы, включающие взаимодействие нескольких бэкенд-сервисов, фронтенда и внешних систем.
Тем не менее, в сравнении со сквозными тестами у интеграционных есть ряд плюсов:
Интеграционные тесты легко сделать абсолютно надёжными, а сквозные всегда сохраняют некоторую долю случайных неповторяющихся ошибок (это называется Flaky Tests или Brittle Tests)
Интеграционные тесты обычно работают намного быстрее — обычно на порядок
Интеграционные тесты обычно кратно дешевле в разработке и в обслуживании
Интеграционные тесты обычно проверяют сервис по его контракту и спецификации — если этого достаточно для уверенности в работоспособности сервиса и нет существенных технических препятствий, то вы можете сделать ставку на них.
Есть гибридный подход, позволяющий с пользой сочетать интеграционные и сквозные тесты:
Разработчики сервиса делают упор на интеграционные тесты для проверки по спецификации с использованием контракта
QA проверяют продуктовую линейку в целом сквозными тестами — без прицельной проверки отдельных сервисов, но с возможностью проверять взаимодействие сервисов между собой, с фронтендом и с внешним миром
5.6. Так какие тесты выбрать?
Увы, выбор конкретной стратегии в конкретном проекте — вопрос слишком обширный для одной статьи:
Ищите истории успеха в области, к которой принадлежит ваш проект
Обдумайте, можете ли вы применить интеграционные тесты и какой ценой
Оценить ещё раз пользу интеграционных тестов вам помогут три ссылки:
Mike Cohn: The Forgotten Layer of the Test Automation Pyramid
Kent Dodds: Write tests. Not too many. Mostly integration.
Владимир Хориков: How to Assert Database State?