Интеграционные тесты с .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).

Frontend и Swagger-документация приложения

Изображение 2 — Frontend и Swagger-документация приложения

Начнём интеграцию .NET Aspire с того, что добавим его в решение при помощи шаблона .NET CLI.


dotnet new aspire-starter --output AspireTesting

В результате, в решение было добавлено две новых сборки: AppHost и ServiceDefaults. Сразу же добавим отдельную сборку, в которой будут располагаться тесты. В качестве фреймворка будем использовать xUnit.


dotnet new aspire-xunit -output AspireTesting.IntegrationTests

Теперь структура решения выглядит как на изображении 3.

Структура решения после интеграции .NET Aspire

Изображение 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-вызовы.

Пройденные тесты в Visual Studio

Изображение 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 предоставляет нативную поддержку тестов, позволяя не прибегать к сторонним инструментам. Это даёт возможность создавать комплексные интеграционные тесты, охватывающие различные части приложения и их взаимодействие, а также обеспечивает высокую степень автоматизации, уменьшая вероятность ошибок, связанных с человеческим фактором.

Полный исходный текст примера можно посмотреть на гитхабе.

Читайте также

Наверх