- BrainTools - https://www.braintools.ru -
Есть две основные категории тестов [1]: модульные (или юнит-тесты) и интеграционные. Модульные тесты — маленькие, быстрые и изолированные. Они проверяют одну единицу кода, обычно функцию или метод, отдельно от остальной системы. Интеграционные тесты, наоборот, проверяют, как разные части системы работают вместе. Обычно они крупнее и могут выполняться медленнее, чем модульные. Поскольку интеграционные тесты охватывают больше сценариев, для них требуется более сложная подготовка окружения, и это препятствие приходится преодолевать.
Тем не менее я предпочитаю писать интеграционные тесты, а не модульные, потому что они дают мне больше уверенности в том, что система работает как задумано. Поэтому я хочу показать, что написание интеграционных тестов не всегда должно быть сложной и утомительной задачей.
Почти все приложения взаимодействуют с внешними системами: базами данных, SMTP-клиентами, брокерами сообщений, сторонними API. Это нормально и часто необходимо, но вы наверняка не хотите, чтобы тесты зависели от этих внешних систем, потому что тогда они станут медленными, нестабильными и потенциально опасными. Представьте сервис отправки писем, где вы не хотите отправлять тестовые письма реальным (или тестовым) пользователям. Так делать не стоит: пользователи не оценят это, даже если вы используете тестовых пользователей, а со временем это может дорого обойтись.
Чтобы этого избежать, тесты нужно изолировать от внешних систем. Сделать это можно разными способами. Можно создать моки или использовать пакеты, которые предоставляют альтернативную реализацию внешней системы (например, базу данных «в памяти»). Но в таком случае вы не тестируете реальную интеграцию с настоящей внешней системой. Хуже того, можно столкнуться с проблемами из-за того, что мок ведет себя иначе, чем реальный экземпляр.
Другой подход — использовать постоянно работающий тестовый экземпляр, к которому подключаются все тесты, и во время тестирования переключать соединение приложения на этот экземпляр. Однако из-за того, что тесты (а также разработчики и CI-пайплайны) разделяют один и тот же работающий экземпляр, это не лучший вариант: тесты могут мешать друг другу. Кроме того, этот подход применим не ко всем внешним системам. И меня всегда беспокоит риск случайно подключиться не к тому экземпляру, не дай бог к боевой среде.
Есть вариант лучше — использовать Testcontainers [2].
Вот как Testcontainers описывает себя на своем сайте:
format_quoteTestcontainers — это библиотека с открытым исходным кодом, которая предоставляет одноразовые, легковесные экземпляры баз данных, брокеров сообщений, браузеров или вообще чего угодно, что можно запустить в Docker-контейнере. Больше не нужны моки или сложные конфигурации окружения. Опишите зависимости тестов как код, затем просто запустите тесты, и контейнеры будут созданы, а затем удалены.
Ключевой момент здесь в том, что инфраструктура описывается как код. Это упрощает поднятие и удаление инфраструктуры, нужной для тестов. Без Testcontainers это было бы утомительной задачей.
Так вы получаете много преимуществ, но без минусов других подходов. Для меня главная ценность в том, что я могу запускать тесты на реальном экземпляре и тем самым убедиться, что интеграция действительно работает как ожидается.
Давайте разберем и остальные преимущества:
Изоляция: у каждого теста может быть свой собственный контейнер, так что тесты или люди не будут мешать друг другу.
Согласованность: тесты всегда выполняются в одном и том же окружении, которое поднимается заново при каждом запуске.
Простота: не нужно создавать и поддерживать моки.
Предварительные требования
Чтобы использовать Testcontainers, необходимо соблюдение одного условия: на машине должен быть установлен и запущен Docker.
Testcontainers доступен для нескольких языков программирования и предоставляет контейнеры для многих популярных внешних систем. Полный список поддерживаемых модулей можно найти на сайте [3] Testcontainers.
В этой статье мы для простоты ограничимся базой данных PostgreSQL. Чтобы установить пакет Testcontainers для PostgreSQL, выполните в проекте следующую команду:
dotnet add package Testcontainers.PostgreSql
Теперь, когда пакет установлен, мы можем настроить контейнер PostgreSQL в тестах, и для этого достаточно пары строк кода.
Чтобы создать контейнер, Testcontainers использует паттерн builder для его конфигурации. Здесь можно, например, указать образ (это может быть полезно, если у вас собственный образ) и многое другое. Пока мы просто возьмем образ по умолчанию.
content_paste
var postgresContainer = new PostgreSqlBuilder()
// .WithImage("...")
.Build();
Этот контейнер можно запустить, и всё готово. Для примера давайте подключимся к базе и выполним простой запрос. Тест:
создает и запускает контейнер PostgreSQL
создает подключение к базе данных (чтобы получить строку подключения, используйте метод GetConnectionString)
выполняет запрос
[Test]
public async Task Executes_a_query()
{
var postgreSqlContainer = new PostgreSqlBuilder().Build();
await postgreSqlContainer.StartAsync();
var connectionString = postgreSqlContainer.GetConnectionString();
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
var command = dataSource.CreateCommand("SELECT 1");
var result = await command.ExecuteNonQueryAsync();
await Assert.That(result).IsEqualTo(-1);
await postgreSqlContainer.StopAsync();
await postgreSqlContainer.DisposeAsync();
}
Код выше дает представление о том, как пользоваться Testcontainers, но на практике это не очень удобно. Мы не хотим повторять [4] подготовку и очистку контейнера в каждом тесте.
В интеграционных тестах ASP.NET [5] нам также нужно заменить настройку подключения приложения к базе данных на строку подключения тестового контейнера.
Чтобы этого добиться, можно создать собственный WebApplicationFactory [6] и настроить приложение для тестирования.
В зависимости от тестового фреймворка код будет немного отличаться, но идея одна и та же. Через WebApplicationFactory мы поднимаем ASP.NET [5]-приложение. Кроме того, WebApplicationFactory предоставляет метод для конфигурации сервисов, что удобно для переопределения отдельных сервисов или настроек в тестовом окружении. Мы воспользуемся этим, чтобы заменить строку подключения к базе на строку подключения тестового контейнера.
Ниже пример WebApplicationFactory, который поднимает тестовый контейнер PostgreSQL и подменяет строку подключения к базе данных. В качестве тестового фреймворка используется TUnit [7].
В примере ниже класс CustomerApiWebApplicationFactory наследуется от WebApplicationFactory<Program>, где Program является точкой входа ASP.NET [5]-приложения. С помощью интерфейсов IAsyncInitializer и IAsyncDisposable контейнер PostgreSQL запускается до начала выполнения тестов и удаляется после завершения тестов. В методе ConfigureWebHost строка подключения заменяется на строку подключения тестового контейнера.
CustomerApiWebApplicationFactory
public class CustomerApiWebApplicationFactory : WebApplicationFactory<Program>, IAsyncInitializer, IAsyncDisposable
{
private readonly PostgreSqlContainer _postgreSqlContainer = new PostgreSqlBuilder().Build();
public async Task InitializeAsync()
{
await _postgreSqlContainer.StartAsync();
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await _postgreSqlContainer.DisposeAsync();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.UseSetting("ConnectionStrings:sandbox-db", _postgreSqlContainer.GetConnectionString());
builder.UseEnvironment("IntegrationTest");
}
}
Используя этот WebApplicationFactory, мы теперь можем писать интеграционные тесты, которые запускаются на изолированном экземпляре PostgreSQL. Но так мы получаем просто пустую базу, а это не очень полезно. Чтобы база стала пригодной для тестов, во время настройки тестового контейнера можно применить миграции и заполнить данные.
Чтобы применить миграции EF Core, я также настраиваю контекст базы данных в методе ConfigureServices так, чтобы он включал сборку с миграциями (в моем приложении миграции вынесены в отдельный проект и выполняются отдельно). После запуска контейнера применяются миграции.
CustomerApiWebApplicationFactory
public class CustomerApiWebApplicationFactory : WebApplicationFactory<Program>, IAsyncInitializer, IAsyncDisposable
{
private readonly PostgreSqlContainer _postgreSqlContainer = new PostgreSqlBuilder().Build();
public async Task InitializeAsync()
{
await _postgreSqlContainer.StartAsync();
_ = Server;
using var scope = Server.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<CustomerDbContext>();
await dbContext.Database.MigrateAsync();
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await _postgreSqlContainer.DisposeAsync();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.UseSetting("ConnectionStrings:sandbox-db", _postgreSqlContainer.GetConnectionString());
builder.ConfigureServices(services =>
{
services.AddDbContext<CustomerDbContext>(
options =>
options.UseNpgsql(
_postgreSqlContainer.GetConnectionString(),
x => x.MigrationsAssembly(typeof(migrations.Program).Assembly.GetName().Name)
)
);
});
builder.UseEnvironment("IntegrationTest");
}
}
При желании это можно расширить и добавить заполнение данными, что бывает полезно для тестирования. Если это занимает много времени, возможно, стоит подумать о создании базового образа, где база данных уже заранее заполнена.
Теперь, когда у нас есть CustomerApiWebApplicationFactory, можно писать интеграционные тесты, которые запускаются на изолированном экземпляре PostgreSQL с примененными актуальными миграциями. Чтобы ускорить тесты, можно переиспользовать один и тот же экземпляр WebApplicationFactory в нескольких тестах. В TUnit это очень легко и наглядно настраивается с помощью атрибута ClassDataSource.
Тест создает новый HTTP-клиент для нашего API, создает нового клиента через POST-эндпоинт и получает клиента через GET-эндпоинт. В конце он проверяет, что ответ соответствует ожиданиям.
CustomerApiTests
[ClassDataSource<CustomerApiWebApplicationFactory>(Shared = SharedType.PerTestSession)]
public class CustomerApiTests(CustomerApiWebApplicationFactory WebAppFactory)
{
[Test]
public async Task GetCustomer_WithValidId_Returns_OkWithCustomer()
{
using var client = WebAppFactory.CreateClient();
var createCustomerCommand = new CreateCustomer.Command(
FirstName: "Individual",
LastName: "Customer",
BillingAddress: new CreateCustomer.BillingAddress(
Street: "789 Pine St",
City: "TestCity",
ZipCode: "54321"
),
ShippingAddress: null
);
var createResponse = await client.PostAsJsonAsync("/customers", createCustomerCommand);
var customerId = await createResponse.Content.ReadFromJsonAsync<CustomerId>();
var response = await client.GetAsync(new Uri($"/customers/{customerId}", UriKind.Relative));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var customer = await response.Content.ReadFromJsonAsync<GetCustomer.Response>();
await Assert.That(customer).IsNotNull();
await Assert.That(customer!.FirstName).IsEqualTo("Individual");
await Assert.That(customer.LastName).IsEqualTo("Customer");
await Assert.That(customer.BillingAddresses.Count()).IsEqualTo(1);
await Assert.That(customer.ShippingAddresses.Count()).IsEqualTo(0);
}
}
Можно заметить, что в этом тесте больше проверок (одна при создании клиента, другая при его получении), чем в модульном тесте. Но мне такой подход нравится, потому что он покрывает весь сценарий, опираясь только на внешние интерфейсы приложения. Это помогает не лезть в детали внутренней реализации, чего я очень хочу избежать.
Когда вы заменяете все внешние системы тестовыми контейнерами, набор тестов становится самодостаточным. Благодаря этому их легко запускать где угодно, в том числе в CI-пайплайне.
Как и в локальном окружении разработки, требуется только одно: на машине, где выполняются тесты, должен быть установлен и запущен Docker. К счастью, на большинстве облачных CI-агентов (включая Azure DevOps и GitHub) Docker уже установлен, так что беспокоиться не о чем. Если вы используете собственные агенты, установите Docker на эту машину.
В заключение
Лично я считаю, что интеграционные тесты дают много пользы и уверенности в том, что система работает как ожидается. Поэтому для приложений и эндпоинтов, где нет большого количества логики, я предпочитаю писать интеграционные тесты, а не модульные.
Важно помнить, что при написании интеграционных тестов нужно следить, чтобы они не зависели от реальных внешних систем. Иначе можно случайно повлиять на реальные данные и пользователей. Раньше это было не всегда просто, но с Testcontainers становится намного легче.
Тестовый контейнер — это Docker-контейнер, который можно программно запускать и останавливать. Это позволяет быстро поднимать и удалять инфраструктуру, нужную для тестов, без лишней возни. Testcontainers также обеспечивает изолированное окружение для каждого теста, чтобы тесты не мешали друг другу. Благодаря этому набор тестов можно запускать параллельно, что может дать заметный прирост скорости.
Если коротко, тестовый контейнер упрощает написание надежных и воспроизводимых интеграционных тестов, которые дают уверенность, что приложение работает как ожидается.
Полный исходный код примеров из этой статьи можно посмотреть на GitHub [8].

Идеальная точка входа в автоматизацию тестирования на Java — специализация от Отус «Инженер по автоматизации тестирования». [9] Если на работе нет времени на самообразование, курс закрывает проблему системности: что учить, в каком порядке и как закреплять на практике. Чтобы узнать больше о формате обучения [10] и познакомиться с преподавателями, приходите на бесплатные уроки:
17 марта, 20:00. «Работа с данными и сетями в Docker». Записаться [11]
23 марта, 20:00. «Искусственный интеллект [12] в автотестах: помощник или угроза?». Записаться [13]
25 марта, 20:00. «Механизмы блокировок в PostgreSQL». Записаться [14]
Полный список бесплатных уроков марта смотрите в дайджесте. [15]
Автор: kmoseenk
Источник [16]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/27207
URLs in this post:
[1] тестов: https://otus.pw/h4Lc/
[2] Testcontainers: https://www.testcontainers.com/
[3] на сайте: https://testcontainers.com/modules/
[4] повторять: http://www.braintools.ru/article/4012
[5] ASP.NET: http://ASP.NET
[6] WebApplicationFactory: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.testing.webapplicationfactory-1?view=aspnetcore-9.0&WT.mc_id=DT-MVP-5004452
[7] TUnit: https://github.com/thomhurst/TUnit
[8] на GitHub: https://github.com/timdeschryver/Sandbox/blob/main/Sandbox.Modules.CustomerManagement.IntegrationTests/CustomerApiTests.cs
[9] «Инженер по автоматизации тестирования».: https://otus.pw/JVis/
[10] обучения: http://www.braintools.ru/article/5125
[11] Записаться: https://otus.pw/uYKL/
[12] интеллект: http://www.braintools.ru/article/7605
[13] Записаться: https://otus.pw/xjvS6/
[14] Записаться: https://otus.pw/k1e8s/
[15] смотрите в дайджесте.: https://otus.pw/k4S0/
[16] Источник: https://habr.com/ru/companies/otus/articles/1010786/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1010786
Нажмите здесь для печати.