Тестування

Інтеграційне тестування — Практика

Покроковий посібник зі створення Minimal API застосунку з нуля та написання для нього справжніх інтеграційних тестів за допомогою WebApplicationFactory.

Практика: Інтеграційне тестування Minimal API

У цій практичній роботі ми застосуємо отримані знання про WebApplicationFactory для обкатання реального сценарію. Ми створимо невеликий застосунок з управління товарами (Products), розіб'ємо його архітектуру на логічні складові та напишемо інтеграційні тести, які перевірятимуть роботу API "ззовні" — посилаючи справжні HTTP запити до нашого сервера.

Щоб не ускладнювати практику налаштуванням бази даних чи Entity Framework, ми реалізуємо сервіс управління товарами "в пам'яті" (In-Memory). Основний фокус даного матеріалу — базовий сетап тестового середовища та перевірка HTTP-відповідей.

Крок 1: Створення робочого простору

Спочатку нам потрібно створити рішення (solution), головний проєкт Minimal API та проєкт для зберігання тестів. Відкрийте термінал та виконайте наступні команди:

Встановлення середовища
$ mkdir IntegrationTestingLab && cd IntegrationTestingLab
$ dotnet new sln -n StoreApp
The template "Solution File" was created successfully.
$ dotnet new web -n StoreApi
The template "ASP.NET Core Empty" was created successfully.
$ dotnet new xunit -n StoreApi.Tests
The template "xUnit Test Project" was created successfully.
$ dotnet sln add StoreApi/StoreApi.csproj StoreApi.Tests/StoreApi.Tests.csproj
Project `StoreApi/StoreApi.csproj` added to the solution.
Project `StoreApi.Tests/StoreApi.Tests.csproj` added to the solution.

Крок 2: Розробка Minimal API

Щоб наш проєкт мав структуру, наближену до реальних застосунків, ми не будемо скидати всю логіку в Program.cs. Натомість, ми виділимо сутності, DTO та окремий сервіс обробки товарів.

В теці StoreApi створіть відповідну структуру папок та файлів:

Тепер налаштовуємо наш Program.cs. Окрім реєстрації сервісів та ендпоінтів, критично важливо не забути відкрити видимість класу Program для тестового проєкту.

StoreApi/Program.cs
using Microsoft.AspNetCore.Mvc;
using StoreApi.DTOs;
using StoreApi.Services;

var builder = WebApplication.CreateBuilder(args);

// Реєструємо сервіс як Singleton для in-memory збереження даних між запитами
builder.Services.AddSingleton<IProductService, ProductService>();

var app = builder.Build();

app.MapGet("/api/products", (IProductService service) =>
{
    return Results.Ok(service.GetAll());
});

app.MapGet("/api/products/{id:guid}", (Guid id, IProductService service) =>
{
    var product = service.GetById(id);
    return product is not null ? Results.Ok(product) : Results.NotFound();
});

app.MapPost("/api/products", ([FromBody] CreateProductDto dto, IProductService service) =>
{
    var product = service.Create(dto);
    return Results.Created($"/api/products/{product.Id}", product);
});

app.Run();

// Робимо Program видимим для StoreApi.Tests
public partial class Program { }

Крок 3: Налаштування тестового проєкту

Щоб проєкт з тестами "бачив" наш API і міг піднімати його у фоновому режимі (in-process), нам потрібно дати посилання на цей проєкт та завантажити два важливі пакети.

  • Microsoft.AspNetCore.Mvc.Testing — містить саму WebApplicationFactory.
  • FluentAssertions — популярна бібліотека для зручних та "людиночитабельних" Assert'ів.
Налаштування StoreApi.Tests
$ dotnet add StoreApi.Tests/StoreApi.Tests.csproj reference StoreApi/StoreApi.csproj
Reference `..\StoreApi\StoreApi.csproj` added to the project.
$ dotnet add StoreApi.Tests/StoreApi.Tests.csproj package Microsoft.AspNetCore.Mvc.Testing
$ dotnet add StoreApi.Tests/StoreApi.Tests.csproj package FluentAssertions

Крок 4: Написання інтеграційних тестів

Перейдіть у каталог StoreApi.Tests та видаліть створений за замовчуванням UnitTest1.cs. Натомість, створіть файл ProductEndpointsTests.cs.

Ми використовуватимемо IClassFixture<WebApplicationFactory<Program>>, яка гарантує, що застосунок буде піднятий лише один раз для всіх тестів у цьому класі.

StoreApi.Tests/ProductEndpointsTests.cs
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StoreApi.DTOs;

namespace StoreApi.Tests;

public class ProductEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ProductEndpointsTests(WebApplicationFactory<Program> factory)
    {
        // Створюємо клієнт, що відправлятиме запити в наш in-memory сервер
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetProducts_WhenEmpty_ReturnsOkAndEmptyList()
    {
        // Act
        var response = await _client.GetAsync("/api/products");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        
        var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
        products.Should().NotBeNull();
        products.Should().BeEmpty();
    }

    [Fact]
    public async Task CreateProduct_WithValidData_ReturnsCreated()
    {
        // Arrange
        var newProduct = new CreateProductDto("Test Product", 199.99m);

        // Act
        var response = await _client.PostAsJsonAsync("/api/products", newProduct);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);
        
        // Перевіряємо заголовок Location
        response.Headers.Location.Should().NotBeNull();
        response.Headers.Location!.ToString().Should().Contain("/api/products/");

        // Перевіряємо тіло відповіді
        var createdProduct = await response.Content.ReadFromJsonAsync<ProductDto>();
        createdProduct.Should().NotBeNull();
        createdProduct!.Name.Should().Be("Test Product");
        createdProduct.Price.Should().Be(199.99m);
        createdProduct.Id.Should().NotBeEmpty();
    }

    [Fact]
    public async Task GetProductById_WhenProductDoesNotExist_ReturnsNotFound()
    {
        // Arrange
        var invalidId = Guid.NewGuid();

        // Act
        var response = await _client.GetAsync($"/api/products/{invalidId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }
}
Реєстрація сервісу як AddSingleton була використана спеціально для цього прикладу. Оскільки тести можуть виконуватися послідовно в одному інстансі сервера, першим може відпрацювати метод створення ресурсу POST. Це не скасує перевірку на порожній масив, адже кожен метод може мати непередбачуваний порядок виконання. Задля ідеальної ізоляції станів між тестами, краще скидати стан в IAsyncLifetime або створювати нові інстанси з кастомними WebApplicationFactory, проте це виходить за межі даного базового прикладу.

Крок 5: Запуск тестів та перевірка

Настав час перевірити, чи все ми зробили правильно. Запустіть тести з кореневої папки (з рівня файлу .sln).

dotnet test
$ dotnet test
Determining projects to restore...
All projects are up-to-date for restore.
StoreApi -> /Users/student/IntegrationTestingLab/StoreApi/bin/Debug/net8.0/StoreApi.dll
StoreApi.Tests -> /Users/student/IntegrationTestingLab/StoreApi.Tests/bin/Debug/net8.0/StoreApi.Tests.dll
Test run for /Users/student/IntegrationTestingLab/StoreApi.Tests/bin/Debug/net8.0/StoreApi.Tests.dll (.NETCoreApp,Version=v8.0)
Microsoft (R) Test Execution Command Line Tool Version 17.10.0
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3, Duration: 401 ms - StoreApi.Tests.dll (net8.0)

🎉 Вітаємо! Ви щойно з нуля підняли тестову інфраструктуру та написали справжні інтеграційні тести, які звертаються до "живого" HTTP pipeline, але не вимагають зовнішнього TCP сервера.


Підсумок

Ви навчились:
  • Налаштовувати .csproj та файли проєкту для підтримки інтеграційного тестування.
  • Робити обхідний шлях для доступу до Program.cs (public partial class Program).
  • Використовувати WebApplicationFactory для ініціалізації тестового сервера клієнтом.
  • Формувати та відправляти JSON об'єкти за допомогою HttpClient (PostAsJsonAsync).
  • Перевіряти HTTP StatusCode і десеріалізувати відповідь (FluentAssertions).