Интеграционные тесты с .net Aspire
В современном мире разработки приложения часто представляют собой сложные системы, состоящие из множества взаимосвязанных компонентов. Чтобы убедиться, что все эти части корректно взаимодействуют друг с другом, необходимо проводить интеграционное тестирование. Сегодня мы расскажем об упрощении тестирования для .net проектов с помощью .Net Aspire.
.NET Aspire, новая разработка от Microsoft, предлагает разработчикам эффективный инструмент для упрощения процесса интеграционного тестирования. С помощью неё можно легко развёртывать локальные окружения без использования таких инструментов, как Docker Compose, и одновременно проводить интеграционные тесты в этом окружении.
Немного о .NET Aspire
Aspire – это стек технологий и инструментов, которые разработчики могут использовать для создания облачных приложений и упрощения процесса локальной разработки, который поставляется в виде набора NuGet-пакетов.
Сценарий использования Aspire обычно таков: в новое или уже существующее приложение добавляется поддержка оркестратора .NET Aspire, необходимые ASP.NET Core-проекты импортируются в сборку AppHost (приложение-оркестратор), затем при помощи контейнеров или интеграций описывается окружение, необходимое для работы приложения.
Таким образом, имеем готовую конфигурацию, с помощью которой можно буквально одним кликом запустить все необходимые сервисы и само приложение, не прибегая к какой-либо ручной конфигурации (изображение 1). Это полезно не только тем разработчикам, которые уже работают на проекте, но и новым разработчикам, у которых процесс настройки и первого запуска приложения может занять до нескольких часов.

Изображение 1 — Пример запущенного окружения
Тестирование при помощи .NET Aspire
Помимо вышеперечисленного, .NET Aspire также предоставляет нативную поддержку тестирования. Разработчики могут выбрать один из следующих фреймворков для написания тестов:
- MSTest
- NUnit
- xUnit
Полный исходный код примеров из статьи можно посмотреть на гитхабе.
Как и в случаях с другими видами тестов в .NET-приложениях, тесты помещаются в отдельную сборку. .NET CLI предоставляет шаблоны для создания такого проекта под каждый из фреймворков, при этом все они ссылаются на пакет Aspire.Hosting.Testing.
Этот пакет интересен тем, что предоставляет класс DistributedApplicationTestingBuilder
, используя который можно выстроить логику запуска тестового окружения, а также получить доступ к ресурсам окружения в runtime. Сразу скажем, что ресурсы – это все составные части окружения: .NET-проекты, контейнеры, интеграции, облачные ресурсы, исполняемые файлы.
Как можем видеть в коде ниже, мы имеем доступ к списку ресурсов во время выполнения программы и можем с ними взаимодействовать, например, на основе информации о хосте и порте, можно создать клиент для коммуникации с ресурсом.
private ApiClient InitializeApiClient(IDistributedApplicationTestingBuilder appHost)
{
var apiResource = appHost.Resources
.FirstOrDefault(resource => resource.Name == Constants.ApiResourceName);
if (apiResource is null)
{
throw new InvalidOperationException("API resource was not found.");
}
apiResource.TryGetAnnotationsOfType(out var apiResourceAnnotations);
if (apiResourceAnnotations is null || !apiResourceAnnotations.Any())
{
throw new InvalidOperationException("API resource does not have endpoint annotation.");
}
var apiEndpointAnnotation = apiResourceAnnotations.First();
var apiBaseUrl = $"https://{apiEndpointAnnotation.TargetHost}:{apiEndpointAnnotation.TargetPort}";
return new ApiClient(apiBaseUrl, app.CreateHttpClient(Constants.ApiResourceName));
}
Интегрируем Aspire в приложение
После небольшого дискурса выше, давайте посмотрим на пример того, как это всё может быть применено на примере реального приложения. В качестве примера возьмем небольшое приложение, состоящее из нескольких частей:
- Backend — на ASP.NET Core Web API;
- Frontend — на Blazor;
- База данных — PostgreSQL;
- SMTP-сервер — Mailhog.
В приложении реализован простой CRUD-функционал для работы с заметками (Изображение 2).

Изображение 2 — Frontend и Swagger-документация приложения
Начнём интеграцию .NET Aspire с того, что добавим его в решение при помощи шаблона .NET CLI.
dotnet new aspire-starter --output AspireTesting
В результате, в решение было добавлено две новых сборки: AppHost
и ServiceDefaults
. Сразу же добавим отдельную сборку, в которой будут располагаться тесты. В качестве фреймворка будем использовать xUnit.
dotnet new aspire-xunit -output AspireTesting.IntegrationTests
Теперь структура решения выглядит как на изображении 3.

Изображение 3 — Структура решения после интеграции .NET Aspire
Следующим шагом опишем окружение, необходимое для работы приложения в файле AspireTesting.AppHost/Program.cs
.
using AspireTesting.AppHost.Integrations.MailHog;
var builder = DistributedApplication.CreateBuilder(args);
var databaseConfiguration = builder.AddPostgres(name: "database", port: 5433)
.WithEnvironment("POSTGRES_DB", "Testing");
databaseConfiguration = SetupData(args, databaseConfiguration);
var database = databaseConfiguration.AddDatabase(name: "AppDatabase", databaseName: "Testing");
var mailhog = builder.AddMailHog("Smtp")
.WithPorts(httpPort: 8026, smtpPort: 1025)
.FromAddress("test@example.com")
.UseSsl();
var api = builder.AddProject(Constants.ApiResourceName)
.WithReference(database)
.WithReference(mailhog)
.WaitFor(database)
.WaitFor(mailhog);
var frontend = builder.AddProject(Constants.FrontendResourceName)
.WithReference(api);
builder.Build().Run();
IResourceBuilder SetupData(string[] args,
IResourceBuilder databaseConfiguration)
{
if (args.Any(arg => arg == Constants.IntegrationTest))
{
databaseConfiguration = databaseConfiguration
.WithBindMount("./data", "/docker-entrypoint-initdb.d");
}
else
{
databaseConfiguration = databaseConfiguration.WithDataVolume();
}
return databaseConfiguration;
}
В текущем контексте нам важен только один момент из всей конфигурации – заполнение базы данных. Так как мы используем AppHost одновременно для двух целей: для локальной разработки и для запуска тестов, то необходимо выделить два способа заполнения базы: для отладки – используем Data Volume, чтобы сохранять данные между запусками приложения, для тестов – заполнять базу определённым набором тестовых данных. Задача несложная, особенно учитывая, что интеграция PostgreSQL предоставляет API для работы с Data Volume. Единственный вопрос – как понять, когда использовать volume, а когда использовать тестовые данные?
Для этого передадим в AppHost аргументы с помощью DistributedApplicationTestingBuilder
. В данном случае, мы создали строковую константу IntegrationTest
, которая и используется для этих целей.
Для заполнения базы тестовыми данными в данном случае используется backup, что было сделано исключительно для упрощения создания примера. Такой подход затрудняет поддержку и обновление тестовых данных по мере добавления новых/изменения старых тестов. Гораздо более правильным подходом было бы написать seeder, в котором заполнение данных описано кодом.
Fixture
В данный момент не существует общепринятого подхода к написанию тестов при помощи .NET Aspire. Пример, появляющийся при создании тестовой сборки, мало отражает то, как можно воспользоваться Aspire для тестирования.
В мире автотестов одним из ключевых понятий является фикстура. В рамках .NET, фикстура – это класс, который ответственен за запуск тестового окружения и предоставляет API, который может потребоваться при написании тестов. Ранее мы уже касались фикстуры, когда рассматривали функционал по доступу к ресурсам в runtime, а конкретно её метода, который настраивает для нас HttpClient
для взаимодействия с бэкенд-частью приложения. Давайте рассмотрим оставшуюся часть. Начнём с того, что фикстура реализует интерфейс IAsyncLifetime
, с помощью которого и будет запущена.
public sealed class ApiFixture : IAsyncLifetime
Экземпляр запущенного AppHost сохраним в отдельном поле, чтобы иметь возможность очистить ресурсы при Dispose. А также выставим наружу публичное свойство типа HttpClient
, которым можно пользоваться в рамках самих тестов для взаимодействия с ресурсом. В данном случае, это не просто HttpClient, а клиент, сгенерированный на основе swagger-документации – взаимодействовать с backend-ресурсом таким образом гораздо проще.
private DistributedApplication app;
public IApiClient ApiClient { get; private set; } = null!;
Наконец, реализация интерфейса IAsyncLifetime
– метод InitializeAsync
. Здесь создаём экземпляр AppHost, не забывая передать аргумент, который покажет, что мы запускаем AppHost для прогона тестов, запускаем приложение, и на основе полученных ресурсов инициализируем HttpClient.
public async Task InitializeAsync()
{
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync([Constants.IntegrationTest]);
appHost.Services.ConfigureHttpClientDefaults(clientBuilder =>
{
clientBuilder.AddStandardResilienceHandler();
});
app = await appHost.BuildAsync();
await app.StartAsync();
ApiClient = InitializeApiClient(appHost);
}
Пишем тесты
Все приготовления пройдены – время писать тесты! Будем объединять тесты по классам. xUnit предоставляет интерфейс IClassFixture
, который позволяет получить в конструкторе экземпляр фикстуры.
public class ApiTests(ApiFixture fixture) : IClassFixture
Рассмотрим простенький тест в качестве примера. Как видим, тест построен по стандартному принципу «Arrange – Act – Assert». Сгенерированный API-клиент позволяет взаимодействовать с эндпоинтами как с простыми методами.
[Fact]
public async Task CreateCorrectNote()
{
var note = new Note()
{
Text = "Second Note"
};
var createdNoteId = await fixture.ApiClient.CreateNoteAsync(note);
var createdNote = await fixture.ApiClient.GetNoteByIdAsync(createdNoteId);
Assert.Equal(note.Text, createdNote.Text);
}
Запустить тесты можно также, как и обычные юнит-тесты: из IDE или CLI-вызовы.

Изображение 4 — Пройденные тесты в Visual Studio
Запуск тестов в CI/CD
Один из этапов CI/CD-пайплайна – это прогон тестов. Мы уже отметили, что написанные при помощи Aspire тесты можно запускать точно так же, как и любые другие, например, юнит-тесты. Таким образом, и интеграционные тесты из Aspire можно запустить в пайплайне.
В качестве примера возьмём GitHub Actions. Создадим отдельный workflow для запуска интеграционных тестов. Единственное, что нам нужно сделать – установить .NET Aspire Workload. Также, может возникнуть проблема, если использовать какой-то кастомный runner, т.к. там может не оказаться docker runtime`а, однако в runner`ах от GitHub уже предустановлен Podman, так что проблем возникнуть не должно.
name: integration-test
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Setup dotnet
uses: actions/setup-dotnet@v1
with:
dotnet-version: '8.0.x'
- name: Checkout Source
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Install Aspire Workload
run: dotnet workload install aspire
- name: Tests
run: dotnet test AspireTesting.sln
Теперь при каждом пуше в ветку main будут запускаться интеграционные тесты.

Изображение 5 — Результат работы интеграционных тестов
Заключение
Резюмируя, .NET Aspire представляет собой удобный инструмент, который значительно облегчает процесс интеграционного тестирования .NET-приложений. В роли оркестратора он помогает разработчикам не только организовывать рабочий процесс, но и эффективно интегрировать тестирование непосредственно в процесс разработки. Aspire предоставляет нативную поддержку тестов, позволяя не прибегать к сторонним инструментам. Это даёт возможность создавать комплексные интеграционные тесты, охватывающие различные части приложения и их взаимодействие, а также обеспечивает высокую степень автоматизации, уменьшая вероятность ошибок, связанных с человеческим фактором.
Полный исходный текст примера можно посмотреть на гитхабе.