Интеграционные тесты, написанные программистом — это отличный способ обеспечить уверенность в своём веб-сервисе.
В мире .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#. Надеюсь, моя статья поможет сэкономить время и улучшить автоматизацию тестирования проекта.
Перед началом
Как появилась статья
Летом этого года я перешёл в TravelLine и занялся разработкой нового сервиса на C#.
-
В TravelLine принято так: разработчики пишут модульные и интеграционные тесты, а команда QA следит за общим уровнем качества продуктов компании — в том числе QA пишут сквозные (end-to-end) тесты
-
В новом сервисе я реализовал автоматизацию тестирования, опираясь на личный опыт и практики соседней команды, что и дало материал для статьи
Отдельное спасибо Роману Лопатину, который вёл мой онбординг в TravelLine и также провёл ревью данной статьи.
План статьи
Действовать будем так:
-
Возьмём примитивный API-сервис на C# 12 с ASP.NET Core 8
-
Составим тест-план
-
Напишем интеграционные тесты, решая проблемы по мере поступления
-
Обсудим, как запускать интеграционные тесты в Gitlab CI
-
В конце проведём ретроспективу
Несколько нюансов:
-
Хотя я сторонник подхода ATDD, тем не менее мы не будем разрабатывать тестируемую систему с нуля, чтобы статья не превратилась в полноценную книгу.
-
Кроме того, количество усилий по тестированию такой простой системы явно превышает отдачу от интеграционных тестов.
-
В реальном проекте всё наоборот: хороший фреймворк интеграционного тестирования требует минимум затрат и приносит максимум пользы команде.
Смотрим и кодим
Параллельно с этой статьёй написан пример: https://github.com/sergey-shambir/dotnet-integration-testing
Отличный способ попробовать новый подход — писать код по мере чтения статьи.
-
Вы можете клонировать пример и переключиться на ветку
baseline
, чтобы повторить все шаги этой статьи самостоятельно -
А можно просто прочитать статью 🙂
Тестируемая система
Это API-сервис в стиле CRUD с одной моделью и PostgreSQL в качестве базы данных. Он крайне примитивен: его диаграмма классов показана ниже
В реальном проекте я бы сделал иначе:
-
Модель была бы значительно сложнее — с агрегатами и нетривиальными бизнес-сценариями
-
Скорее всего я разделил бы проект на модули, а каждый модуль — на слои по принципам гексагональной архитектуры
-
Скорее всего я применил бы шаблоны 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, в нём нет неочевидных бизнес-сценариев.
Поэтому для уверенности в работоспособности сервиса достаточно трёх позитивных тестов:
-
Можем создать несколько продуктов
-
Можем создать несколько продуктов и обновить один
-
Можем создать несколько продуктов и удалить один
Зачем создавать несколько продуктов в каждом тестовом сценарии? Чтобы убедиться, что операция над одним продуктом не влияет на остальные.
Добавляем негативные тесты
Я считаю, что на всех уровнях автотестов начинать надо с позитивных тестовых сценариев.
А что делать с негативными?
-
В модульных тестах негативные сценарии тоже важны — иначе не будет веры в надёжность каждого тестируемого модуля
-
В интеграционных тестах лично я предпочитаю проверять только избранные негативные сценарии (например, сильно влияющие на пользователей или на бизнес) либо не проверять никакие
Можем воспользоваться метафорой — проверка веб-сервиса похожа на проверку дома из кирпичей:
-
В модульных тестах проверяем надёжность «кирпичиков» программы в самых разных условиях
-
В интеграционных тестах проверяем характеристики построенного дома, а не отдельных «кирпичиков»
Так стоит ли тратить время и деньги, тестируя поведение стёкол при забрасывании камнями окон? Думаю, нет.
Однако для полноты статьи мы добавим два негативных теста, и получим новый список:
-
Можем создать несколько продуктов
-
Можем создать несколько продуктов и обновить один
-
Можем создать несколько продуктов и удалить один
-
Нельзя добавить продукт с пустым кодом
-
Нельзя добавить продукт с нулевой либо отрицательной ценой
Реализуем первый тест
Создаём проект с 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
Мы хотим иметь чистую базу данных для каждого запуска набора тестов.
Есть три варианта:
-
Пересоздавать тестовую базу данных перед запуском тестов
-
Написать для 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();
Сколько раз запускать контейнер?
Запуск контейнера 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 между тестами:
-
Объекты класса, реализующего
IClassFixture
, создаются один раз на класс с тестами -
Объекты класса, реализующего
ICollectionFixture
, создаются один раз на явно определённую в коде коллекцию — коллекция может быть единственной на весь проект тестов -
Объекты класса, реализующего
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
? Они претерпят изменения:
-
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; // ... остальные методы }
Реализуем второй тест
Добавляем тестовый сценарий
Что дальше по списку тестовых сценариев? Посмотрим:
-
Можем создать несколько продуктов -
Можем создать несколько продуктов и обновить один
-
Можем создать несколько продуктов и удалить один
-
Нельзя добавить продукт с пустым кодом
-
Нельзя добавить продукт с нулевой либо отрицательной ценой
Добавим тестовый сценарий в 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, а ожидалось два
Дело в том, что нарушена изоляция тестов — они используют общую базу данных, в результате каждый тест оставляет за собой мусор, влияющий на другие тесты.
Изоляция тестов
Способы изоляции тестов
Есть пять способов изоляции тестов, использующих базу данных:
-
Пересоздавать базу данных для каждого сценария
-
сюда относится как
DROP DATABASE; CREATE DATABASE
, так и создание отдельного контейнера на каждый сценарий -
это сильно замедляет тесты — накладные расходы растут нелинейно по формуле
число_тестов × (число_миграций + число_таблиц)
-
-
Очищать таблицы после теста
-
каждый новый тест обрастает чистками, а при изменении тестируемой системы их порой нужно дорабатывать
-
за невнимательного программиста платит его коллега, который столкнётся с чужим мусором в своём тесте
-
-
Очищать таблицы перед тестом
-
каждый новый тест обрастает чистками, а при изменении тестируемой системы их порой нужно дорабатывать
-
невнимательный программист платит сам
-
-
Использовать фреймворк очистки данных, такой как Respawn
-
Использовать транзакцию с откатом, то есть
BEGIN...ROLLBACK
Варианты №4 и №5 привлекательны сочетанием двух особенностей:
-
Код тестов избавляется от очистки
-
За это не приходится платить сильным замедлением.
Взаимен появляются новые сложности:
-
Нужно перехватывать все соединения с базой данных
-
Нужно вовремя вызвать откат транзакции или компенсирующие действия
В случае Respawn сложности частично «решены» библиотекой, но только пока вы не упёрлись в границы её возможностей.
Я уверен, что транзакции с откатом лучше, чем Respawn:
-
Транзакции — один из ключевых механизмов реляционных СУБД, они максимально надёжны
-
Библиотека Reswpan не тривиальна — лёгкий старт в начале может смениться долгими часами борьбы с библиотекой в будущем
-
Наконец, транзакции СУБД всё равно быстрее, чем компенсирующие действия
Далее в статье мы применим вариант №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
Для решения проблемы мы можем встроиться в 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 переменные (атрибут |
Есть не только потоки 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:
-
Теперь это не настоящий 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 запускается очень быстро.
Возвращаемся к списку тестовых сценариев
Мы закрыли очередной тест. Это было непросто, но дальше будет легче.
-
Можем создать несколько продуктов -
Можем создать несколько продуктов и обновить один -
Можем создать несколько продуктов и удалить один
-
Нельзя добавить продукт с пустым кодом
-
Нельзя добавить продукт с нулевой либо отрицательной ценой
Реализуем третий тест
В файл 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); }
Запускаем тесты и… все тесты зелёные!
Это было просто, ведь всё необходимое уже есть в тестовом фреймворке.
Мы закрыли очередной тест:
-
Можем создать несколько продуктов -
Можем создать несколько продуктов и обновить один -
Можем создать несколько продуктов и удалить один -
Нельзя добавить продукт с пустым кодом
-
Нельзя добавить продукт с нулевой либо отрицательной ценой
Реализуем четвёртый тест
Добавляем негативный тест
-
Четвёртый тест отличается от предыдущих трёх — он негативный, то есть тестируемая система должна сообщить об ошибке.
-
Способ возврата ошибки важен: если тестируемая система вернёт 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
создали побочный эффект:
-
Допустим у нас есть позитивный тест на какой-то метод API
-
В коде возникла регрессия, из-за которой на шаге Когда возвращается HTTP-статус 500 Internal Server Error
-
При запуске теста шаг Когда не прерывает тестовый сценарий, а продолжает работу
Что будет дальше? Возможны варианты:
-
Если тест написан плохо, проверки на шаге Тогда могут пропустить ошибочный 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"); } }
Фиксируем результат
Мы закрыли очередной тест — вычеркнем его:
-
Можем создать несколько продуктов -
Можем создать несколько продуктов и обновить один -
Можем создать несколько продуктов и удалить один -
Нельзя добавить продукт с пустым кодом -
Нельзя добавить продукт с нулевой либо отрицательной ценой
Пятый тест
Повтор сценария с различными параметрами
Пятый тест включает в себя два случая:
-
нулевая цена
-
отрицательная цена
Если бы мы писали тест на «голом» XUnit, мы бы применили [Threory]
вместо [Fact]
, чтобы запустить один тот же тест несколько раз с различными параметрами.
Можно ли сделать подобное на языке Gherkin средствами библиотеки Reqnroll (Specflow)? Конечно же да:
-
Ключевая фраза
Структура сценария
(Scenario Outline
) задаёт шаблонизируемый сценарий, запускаемый несколько раз -
Ключевое слово
Примеры
(Examples
) задаёт значения для подстановки -
Подставляемые значения заключаются в угловые скобки, например:
<price>
Теперь можем добавить тест в файл ProductValidation.feature
:
@negative Структура сценария: Нельзя добавлять продукт с нулевой ценой Когда добавляем продукты: | Код | Описание | Цена | Количество | | A12345 | Фуфайка из льняного волокна | <цена> | 312 | Тогда получим ошибку валидации Примеры: | цена | | 0,00 | | -1,00 |
Фиксируем результат
Мы закрыли очередной тест — вычеркнем его:
-
Можем создать несколько продуктов -
Можем создать несколько продуктов и обновить один -
Можем создать несколько продуктов и удалить один -
Нельзя добавить продукт с пустым кодом -
Нельзя добавить продукт с нулевой либо отрицательной ценой
Ура, мы покрыли сервис достаточным набором интеграционных тестов!
Запускаем тесты в Gitlab CI
Возможности 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
Вариант с 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
и использует указанный хост для создания вспомогательных контейнеров
Вариант с обычными 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.
Ретроспектива сделанной работы
Тестирование ПО — практическая область
Я практик. Наверняка моя точка зрения на автоматизацию тестирования не совпадает с чьей-то ещё. С другой стороны, она подкреплена личным опытом — за последние четыре года я выбирал и реализовывал стратегию автоматизации тестирования четырёх разных проектов (включая текущий).
Вы пишете тесты по-другому? Здорово, если у Вас всё получается! Буду рад, если моя статья поможет что-то улучшить.
В любом случае, воспринимайте сказанное ниже через призму собственного опыта.
Уровни тестов с точки зрения внепроцессных зависимостей
Чёткие критерии разделения уровней тестов есть у Владимира Хорикова в прекрасной книге «Принципы Unit-тестирования»:
Уровень тестов |
Особенности |
---|---|
Модульные тесты (unit tests) |
не обращаются к внепроцессным зависимостям |
Интеграционные тесты (integration tests) |
используют только управляемые внепроцессные зависимости, полностью подчинённые тестируемой системе |
Сквозные тесты (end-to-end tests) |
запускаются на реальном окружении (тестовый стенд, staging или production), используют как управляемые, так и неуправляемые внепроцессные зависимости |
Примеры внепроцессных зависимостей с точки зрения управляемости:
Зависимость |
Способ использования |
Тип |
---|---|---|
PostgreSQL |
База данных сервиса (но не shared database) |
Управляемая зависимость |
Redis |
Кеш приложения |
Управляемая зависимость |
Minio |
Распределённое хранилище объектов (файлов) |
Управляемая зависимость |
RabbitMQ |
Общая шина интеграционных событий сервисов |
Неуправляемая зависимость |
Сервис соседней команды |
Обращаемся к их API |
Неуправляемая зависимость |
Сторонний сервис / API |
Обращаемся к их API |
Неуправляемая зависимость |
Свой почтовый сервер |
Обращаемся по SMTP для отправки почты |
Неуправляемая зависимость |
Идею можно проиллюстрировать так:
Подводные камни интеграционных тестов
Преимущества интеграционных тестов раскрываются только при соблюдении ряда условий:
-
Они проверяют все слои и все модули сервиса
-
Они подключают правильные управляемые внепроцессные зависимости правильной версии — если в Production используется PostgreSQL версии 16.3, не пытайтесь в тестах заменить его ни на In-Memory хранилище, ни на SQLite, ни даже на PostgreSQL более старой/новой версии
-
Они используют тестовые дублёры только для неуправляемых внепроцессных зависимостей (в данной статье мы это не рассматривали)
Есть другие паттерны и антипаттерны интеграционного тестирования — многие из них описаны у Владимира Хорикова в книге «Принципы Unit-тестирования» и в блоге Enterprise Craftmanship.
Чем интеграционные тесты лучше модульных?
Допустим, 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 — обычно эти затраты пропорциональны затратам на разработку, но при крупных рефакторингах могут резко возрастать
-
Для интеграционных тестов в начале проекта надо потратить усилия на удобный фреймворк тестирования — и тогда в дальшейшем их разработка и сопровождение будет обходиться даже дешевле, чем разработка и сопровождение модульных тестов
Другими словами, чем больше вы потрудитесь над интеграционными тестами в начале — тем легче будет команде потом.
Чем интеграционные тесты лучше сквозных?
Сквозные (end-to-end) тесты обычно запускаются на тестовом стенде, где весь внешний мир похож на production: есть другие сервисы вашей системы, часто подключены сторонние API.
Это позволяет сквозным тестам проверять сценарии целостной системы, включающие взаимодействие нескольких бэкенд-сервисов, фронтенда и внешних систем.
Тем не менее, в сравнении со сквозными тестами у интеграционных есть ряд плюсов:
-
Интеграционные тесты легко сделать абсолютно надёжными, а сквозные всегда сохраняют некоторую долю случайных неповторяющихся ошибок (это называется Flaky Tests или Brittle Tests)
-
Интеграционные тесты обычно работают намного быстрее — обычно на порядок
-
Интеграционные тесты обычно кратно дешевле в разработке и в обслуживании
Интеграционные тесты обычно проверяют сервис по его контракту и спецификации — если этого достаточно для уверенности в работоспособности сервиса и нет существенных технических препятствий, то вы можете сделать ставку на них.
Есть гибридный подход, позволяющий с пользой сочетать интеграционные и сквозные тесты:
-
Разработчики сервиса делают упор на интеграционные тесты для проверки по спецификации с использованием контракта
-
QA проверяют продуктовую линейку в целом сквозными тестами — без прицельной проверки отдельных сервисов, но с возможностью проверять взаимодействие сервисов между собой, с фронтендом и с внешним миром
Так какие тесты выбрать?
Увы, выбор конкретной стратегии в конкретном проекте — вопрос слишком обширный для одной статьи:
-
Ищите истории успеха в области, к которой принадлежит ваш проект
-
Обдумайте, можете ли вы применить интеграционные тесты и какой ценой
Оценить ещё раз пользу интеграционных тестов вам помогут три ссылки:
-
Mike Cohn: The Forgotten Layer of the Test Automation Pyramid
-
Kent Dodds: Write tests. Not too many. Mostly integration.
-
Владимир Хориков: How to Assert Database State?
ссылка на оригинал статьи https://habr.com/ru/articles/860932/
Добавить комментарий