В этой статье будет показано, как правильно организовать интеграционное тестирование с применением Testcontainers на платформе .NET. В качестве примера возьмём веб-API, который будет обмениваться информацией с SQL Server через EF Core.
Что такое Testcontainers?
Это легковесные инстансы распределённых баз данных, которые можно считать «расходными», а также веб-браузеры Selenium или любые другие инструменты, которые могут работать в контейнере Docker.
Testcontainers — это бесплатная библиотека с открытым исходным кодом, предназначенная для того, чтобы упорядочить управление контейнерами для целей тестирования. Эта цель достигается путём активного использования контейнеров Docker сразу во всех поддерживаемых версиях .NET. Эта библиотека, выстроенная на основе удалённого API для .NET Docker, серьёзно упрощает работу и позволяет создать изощрённую и гибкую экосистему тестирования для различных сценариев. С её помощью можно конфигурировать, создавать и удалять ресурсы Docker, настраивать и инициализировать тесты, а также очищать от них память после завершения. Чтобы использовать Testcontainers при работе с нашими тестами, в системе необходимо установить Docker. В библиотеке предусмотрено несколько заранее подготовленных конфигураций, в частности, для Postgres, Microsoft SQL Server, MySQL, Redis, RabbitMQ и несколькими другими.
Зачем нам требуются Testcontainers?
Как понятно из названия, акцент при интеграционном тестировании таков: нужно проверить, как различные компоненты приложения взаимодействуют друг с другом. Притом, насколько незаменимы эти тесты для обеспечения устойчивости системы, создание таких тестов — работа сложная и времязатратная, особенно в тех случаях, когда код взаимодействует с важными элементами инфраструктуры, как то базами данных, шинами сообщений, кэшами и пр.
В качестве иллюстрации рассмотрим пример ‘Resident API’, при котором предлагается выполнять CRUD-операции с использованием SQL Server посредством Entity Framework Core. В реалистичном сценарии у нашего проекта могли бы быть многочисленные зависимости, но для простоты не будем выходить за рамки проекта с .NET 8 веб-API, на бэкенде которого работает SQL Server.
Теперь рассмотрим, какие подходы применимы для написания тестов, проверяющих зависимости базы данных.
- База данных EF Core, действующая в оперативной памяти
- База данных SQLite, действующая в оперативной памяти
- Имитация методов репозитория
- Использование реальной базы данных из тестовой среды
- Имитация или создание заглушек для DbContext и DbSet
Имитация работает при модульных тестах, но всё равно, занимаясь ею, нужно учитывать несколько важных пунктов, которые перечислены ниже:
- Различия, связанные с чувствительностью к регистру — провайдеры разных баз данных, в частности, SQL Server, MySQL или PostgreSQL могут по-разному реализовывать те или иные поведения, связанные с чувствительностью к регистру. Таким образом, LINQ-запрос, работающий в одной базе данных, в другой может показать себя иначе, например, сравнивая чувствительные и нечувствительные к регистру записи.
- Методы, специфичные для отдельных провайдеров — некоторые провайдеры баз данных предлагают специфичные функции или методы, которые уникальны именно для их систем. Такие «специфичные для провайдера» методы не получится эффективно протестировать, если пользоваться шаблонными тестами, поскольку такие тесты могут оказаться недоступны или просто несовместимы от провайдера к провайдеру.
- Тестирование ссылочной целостности — ссылочная целостность гарантирует, что между таблицами будут поддерживаться правильные взаимосвязи, как правило, это делается при помощи ограничений, накладываемых при помощи внешних ключей. Протестировать этот аспект можно в ограниченном объёме, особенно, в системах объектно-реляционного отображения (например, Entity Framework Core) а также абстрагирует некоторые базовые ограничения, присущие базам данных.
- Поддержка необработанного SQL — в некоторых ORM-системах, например, Entity Framework Core, можно выполнять запросы на необработанном SQL наряду с LINQ-запросами. Правда, степень поддержки SQL в таком виде может варьироваться от ORM к ORM и у разных провайдеров. Некоторые возможности или синтаксис могут поддерживаться не в полной мере или не очень согласованно от провайдера к провайдеру. Поэтому эффективность тестирования необработанных SQL-запросов остаётся невысокой.
Примеры кода
Я не буду рассматривать здесь весь проект Web API, а покажу упрощённый вариант с одним контроллером:
using Microsoft.AspNetCore.Mvc;
using ResidentApi.Logic.Domain;
using ResidentApi.Logic.Repositories;
using ResidentApi.Models;
namespace ResidentApi.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ResidentController : ControllerBase
{
private readonly IResidentRepository _residentRepository;
public ResidentController(IResidentRepository residentRepository)
{
_residentRepository = residentRepository;
}
[HttpPost("CreateResident")]
public async Task<ActionResult<GetResidentResponse>>
CreateResidentAsync(CreateResidentRequest request)
{
var resident = new Resident(request.firstName, request.lastName,
request.age);
await _residentRepository.CreateResidentAsync(resident);
var residentResponse = new GetResidentResponse(
resident.Id,
resident.FirstName,
resident.LastName,
resident.Age);
return CreatedAtAction(
"GetResident",
new { id = residentResponse.Id },
residentResponse);
}
[HttpDelete("DeleteResident/{id}")]
public async Task<ActionResult> DeleteCatAsync(Guid id)
{
await _residentRepository.DeleteResidentAsync(id);
return Ok();
}
[HttpGet("GetResident/{id}")]
public async Task<ActionResult<GetResidentResponse>>
GetResidentAsync(Guid id)
{
var resident = await _residentRepository.GetResidentByIdAsync(id);
if (resident is null)
{
return NotFound();
}
var residentResponse = new GetResidentResponse(
resident.Id,
resident.FirstName,
resident.LastName,
resident.Age);
return Ok(residentResponse);
}
[HttpGet("GetAllResidents")]
public async Task<ActionResult<GetAllResidentsResponse>>
GetAllCatsAsync()
{
var residents = await
_residentRepository.GetAllResidentsAsync();
var residentResps = new List<GetResidentResponse>();
foreach (var resident in residents)
{
residentResps.Add(
new GetResidentResponse(
resident.Id,
resident.FirstName,
resident.LastName,
resident.Age));
}
var allResResponse = new
GetAllResidentsResponse(residentResps);
return Ok(allResResponse);
}
[HttpPut("UpdateResident")]
public async Task<ActionResult<GetResidentResponse>>
UpdateResidentAsync(UpdateResidentRequest request)
{
var resident = new Resident(request.firstName,
request.lastName, request.age);
await _residentRepository.UpdateResidentAsync(resident);
var residentResponse = new GetResidentResponse(
resident.Id,
resident.FirstName,
resident.LastName,
resident.Age);
return Ok(residentResponse);
}
}
}
Теперь давайте обустроим тестовый проект и посмотрим, как можно создавать тесты. В данном случае для тестирования воспользуемся фреймворком NUnit. Создадим тестовый проект и установим следующие пакеты NuGet.
dotnet add package Testcontainers
dotnet add package Testcontainers.MsSql
dotnet add package FluentAssertions
dotnet add package Microsoft.AspNetCore.Mvc.Testing
Класс WebApplicationFactory позволяет работать с репликой вашего приложения, расположенной в оперативной памяти. Для этого применяется тестировочный веб-хост и сервер. Данный функционал обеспечивается при помощи пакета NuGet Microsoft.AspNetCore.Mvc.Testing, в котором применяется аутентичная конфигурация вашего приложения, внедрение зависимостей, регистрация сервисов и конвейер промежуточного ПО.
Скоро продолжим, но перед этим нужно дописать одну строку в программу Web Api /файл запуска, вот эту:
public partial class Program { }
Далее приведён пример тестового класса, в котором мы инициализируем веб-хост и выполняем тест, который должен достучаться до API. Этот API, в свою очередь, достучится до тестового контейнера, работающего с sql-сервером.
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ResidentApi.Logic.Database;
using ResidentApi.Models;
using System.Text.Json;
namespace ResidentApi.Tests
{
[TestFixture]
public class ResidentControllerTests
{
private const string Database = "master";
private const string Username = "sa";
private const string Password = "$trongPassword";
private const ushort MsSqlPort = 1433;
private WebApplicationFactory<Program> _factory;
private IContainer _container;
[OneTimeSetUp]
public async Task OneTimeSetUp()
{
// Настраиваем Testcontainers для SQL Server
_container = new ContainerBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPortBinding(MsSqlPort, true)
.WithEnvironment("ACCEPT_EULA", "Y")
.WithEnvironment("SQLCMDUSER", Username)
.WithEnvironment("SQLCMDPASSWORD", Password)
.WithEnvironment("MSSQL_SA_PASSWORD", Password)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvail-
able(MsSqlPort))
.Build();
//Пуск контейнера
await _container.StartAsync();
var host = _container.Hostname;
var port = _container.GetMappedPublicPort(MsSqlPort);
// Замена строки соединения в DbContext
var connectionString = $"Server={host},{port};Database={Data-
base};User Id={Username};Password={Password};TrustServerCertificate=True";
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddDbContext<ResidentDbContext>(options =>
options.UseSqlServer(connectionString));
});
});
// Инициализация базы данных
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<Resi-
dentDbContext>();
dbContext.Database.Migrate();
}
[OneTimeTearDown]
public async Task OneTimeTearDown()
{
await _container.StopAsync();
await _container.DisposeAsync();
_factory.Dispose();
}
[Test]
public async Task GetAllResidents_ReturnsEmptyList()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/Resident/GetAllResi-
dents");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var allresidentsResponse = JsonSerializer.Deserialize<Get-
AllResidentsResponse>(content);
allresidentsResponse.Should().NotBeNull();
allresidentsResponse.residents.Should().NotBeNull();
allresidentsResponse.residents.Should().BeEmpty();
}
}
}
Далее разберём фрагменты этого кода по отдельности. Следующий код собирает и запускает контейнер для sql server со всеми переменными окружения, которые будут использоваться в наших тестах.
// Настраиваем Testcontainers для SQL Server
_container = new ContainerBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPortBinding(MsSqlPort, true)
.WithEnvironment("ACCEPT_EULA", "Y")
.WithEnvironment("SQLCMDUSER", Username)
.WithEnvironment("SQLCMDPASSWORD", Password)
.WithEnvironment("MSSQL_SA_PASSWORD", Password)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(MsSqlPort))
.Build();
//Пуск контейнера
await _container.StartAsync();
Следующий код использует этот контейнер, запускает веб-хост и осуществляет миграцию базы данных.
var host = _container.Hostname;
var port = _container.GetMappedPublicPort(MsSqlPort);
// Замена строки соединения в DbContext
var connectionString = $"Server={host},{port};Database={Database};User
Id={Username};Password={Password};TrustServerCertificate=True";
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddDbContext<ResidentDbContext>(options =>
options.UseSqlServer(connectionString));
});
});
// Инициализация базы данных
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ResidentDbContext>();
dbContext.Database.Migrate();
Поскольку нам ещё до выполнения каких-либо тестов требуется убедиться, что все эти операции сделаны правильно, мы выполняем этот код с компонентом OneTimeSetUp, который гарантирует, что до завершения настройки никакие тесты выполняться не будут. Аналогично, мы всё сносим в методе OneTimeTearDown при помощи следующего кода.
await _container.StopAsync();
await _container.DisposeAsync();
_factory.Dispose();
Когда вы выполняете эти тесты, как раз создаются ваши контейнеры, а по завершении тестирования мы из них выходим так, как показано ниже.
Отмечу ещё одну важную вещь: все эти операции можно выполнять и на сборочных серверах, если там установлен docker. В этой статье исследованы только возможности библиотеки Testcontainers, в частности, как с её помощью эффективнее тестировать .NET-приложения, работающие с Docker. Работая с Testcontainers, можно без труда настраивать контейнеры для тестирования баз данных, зачастую для этого требуется написать всего несколько строк кода.
Кроме того, пакет Testcontainers предлагает далеко не только контейнеризацию. Библиотека поможет обустроить необходимую инфраструктуру внутри самих тестов, упростит доступ к ключевым ресурсам. Чтобы не полагаться на предоставление ресурсов извне через отдельный конвейер CI/CD (и не ввязываться во все сложности, связанные с совместным использованием ресурсов), Testcontainers выравнивает этот процесс, встраивая контейнер в сам тест. Доступ к информации при этом исключительно упрощается. В наше время, когда при тестировании приложений требуется обеспечивать всё более высокую степень надёжности, библиотека Testcontainers оказывается особенно мощным инструментом для превосходной контейнеризации.
Весь исходный код к этой статье выложен здесь:
https://github.com/CheyPenmetsa/TestcontainersDemo?source=post_page-----520e8911d081--------------------------------
P.S Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.