Тестування

Integration Testing — Частина 1 [Теорія та WebApplicationFactory]

Що таке інтеграційне тестування і де його межі. Чому unit тестів недостатньо. Глибоке розуміння WebApplicationFactory — як вона працює всередині, як підмінити залежності, налаштувати середовище та організувати тести для ASP.NET Minimal API.

Integration Testing. Частина 1: Теорія та WebApplicationFactory

Межа між unit і integration тестами

Протягом попередніх статей ми досліджували unit тести — ізольовані перевірки окремого класу чи методу, де всі зовнішні залежності замінені моками. Unit тести — швидкі, стабільні та дешеві. Але вони мають принципове обмеження: вони перевіряють компоненти ізольовано від одне одного.

Уявіть, що кожен музикант у оркестрі відмінно грає свою партію на репетиції соло. Але чи означає це, що оркестр звучатиме чудово разом? Не обов'язково — виникнуть питання темпу, взаємодії між інструментами, загальної динаміки, що неможливо перевірити окремо.

Саме тут з'являються інтеграційні тести: вони перевіряють як компоненти працюють разом. Вони відповідають на питання, на які unit тести відповісти не можуть:

  • Чи правильно налаштований Dependency Injection Container? Чи мій сервіс взагалі резолвиться?
  • Чи правильно маршрутизує ASP.NET мої ендпоінти? Чи /api/orders/{id} дійсно викликає правильний handler?
  • Чи правильно серіалізується JSON відповідь? Чи поле createdAt відправляється у форматі ISO 8601?
  • Чи коректно відпрацьовує middleware — авторизація, логування, обробка помилок?
  • Чи правильно обробляються HTTP заголовки — Content-Type, Accept, Authorization?

Жоден з цих аспектів неможливо перевірити, тестуючи сервіси та репозиторії в ізоляції.


Чому конфігурація DI — це ризик

Одна з найпоширеніших помилок у .NET проєктах — реєстрація залежностей у Program.cs без тестування того, що ці реєстрації коректні. Розглянемо конкретний приклад провалу.

Розробник додає новий сервіс:

// OrderService тепер потребує нового IShippingCalculator
public class OrderService(IOrderRepository repo, IShippingCalculator shipping) { }

У Program.cs забуває зареєструвати IShippingCalculator. Unit тести для OrderService проходять — тому що там IShippingCalculator замокований. Але в runtime при першому запиті до /api/orders додаток падає з InvalidOperationException: Unable to resolve service for type 'IShippingCalculator'.

Це класична ситуація, яка трапляється щоразу, коли розробники покладаються виключно на unit тести. Інтеграційний тест, що просто надсилає GET запит до /api/orders, одразу виявив би цю проблему ще до деплою.

Практика показує, що "DI misconfiguration" — одна з найчастіших причин production помилок у .NET проєктах. І вона невидима для unit тестів.


Що тестують integration тести в ASP.NET

Коли ми кажемо "інтеграційний тест для ASP.NET Minimal API", ми маємо на увазі тест, що:

1. Піднімає весь ASP.NET додаток — зі всіма middleware, ендпоінтами, фільтрами, health checks.

2. Надсилає реальний HTTP запит — через HttpClient, який взаємодіє зі справжнім HTTP pipeline, а не напряму викликає метод.

3. Отримує HTTP відповідь і перевіряє:

  • Status code (200 OK, 201 Created, 404 Not Found, 422 Unprocessable Entity)
  • Заголовки (Content-Type, Location при redirect)
  • Тіло відповіді (JSON десеріалізація і перевірка полів)

4. Але при цьому може замінити зовнішні залежності (база даних, email сервіс, платіжний шлюз) на тестові дублі або in-memory реалізації.

Ось що відрізняє integration тест від end-to-end тест: e2e тест піднімає реальний сервер з реальною базою і реальним email — integration тест замінює те, що не потрібно перевіряти.


WebApplicationFactory: архітектурне рішення Microsoft

До появи WebApplicationFactory у ASP.NET Core 2.1 розробники мали кілька незручних варіантів для integration тестів:

  • Піднімати реальний HTTP сервер (Kestrel) на конкретному порту — проблеми з паралелізмом, конфліктами портів
  • Тестувати через TestServer безпосередньо — громіздкий код
  • Взагалі відмовитись від integration тестів

WebApplicationFactory<TEntryPoint> вирішила всі ці проблеми. Ключові рішення:

1. In-process тестовий сервер. Замість запуску реального HTTP сервера (Kestrel), WebApplicationFactory піднімає додаток у тому ж процесі, що і тест. Запити HTTP проходять через весь реальний pipeline, але без мережевих викликів — це значно швидше і без проблем з портами.

2. Повна ізоляція між тестами. Кожна фабрика — окрема ізольована інстанція додатку. Ніякого shared state між тест-класами.

3. Гнучка кастомізація. Через WithWebHostBuilder або ConfigureWebHost можна замінити будь-які сервіси, конфігурацію, middleware — не змінюючи production код.

4. TEntryPoint — клас-точка входу. Зазвичай це Program (з partial class trick) або будь-який клас у вашій сборці, що дозволяє знайти конфігурацію WebApplication.

Простіше кажучи, WebApplicationFactory - це спосіб запустити ваш ASP.NET-додаток у тесті так, ніби він уже працює, але без справжнього веб-сервера і без відкритого порту. Ви не викликаєте методи напряму і не підробляєте весь HTTP вручну. Ви просто надсилаєте реальний запит і дивитесь, як додаток на нього відповів.

Документація: WebApplicationFactory<TEntryPoint>

CreateClient()
method
Створює HttpClient, який відправляє запити у TestServer, а не в реальний мережевий порт.
WithWebHostBuilder(...)
method
Повертає похідну фабрику з локальними правками DI, конфігурації або middleware.
ConfigureWebHost(...)
override
Основна точка розширення для тестового запуску ASP.NET додатку.
Services
property
Доступ до контейнера залежностей запущеного тестового хосту.
Server
property
Посилання на TestServer, якщо потрібні низькорівневі перевірки.

Як WebApplicationFactory працює всередині

Коли ви пишете new WebApplicationFactory<Program>(), відбувається наступне:

Крок 1: Ініціалізація. WebApplicationFactory знаходить Program клас і читає конфігурацію WebApplication.CreateBuilder() з вашого Program.cs. Вона "імітує" повний старт додатку — з UseSqlite/UseNpgsql, AddAuthentication, UseAuthorization, mapper'ами, всіма сервісами.

Крок 2: ConfigureWebHost. Після базової ініціалізації викликається метод ConfigureWebHost (або перевизначений CreateWebApplicationBuilder), де ви можете замінити реєстрації сервісів, змінити конфігурацію, додати тестові middleware.

Крок 3: Запуск TestServer. Замість Kestrel запускається TestServer — in-process HTTP сервер з Microsoft.AspNetCore.TestHost. Він обробляє запити синхронно (або асинхронно) без реального мережевого стеку.

Крок 4: CreateClient(). Фабрика повертає HttpClient, налаштований для відправки запитів до TestServer. Цей HttpClient — не звичайний, що відкриває TCP з'єднання. Він через спеціальний HttpMessageHandler напряму передає запит у TestServer pipeline.

Що це означає практично: ваш HTTP запит проходить через весь реальний middleware pipeline — routing, authentication, authorization, model binding, endpoint handler, response formatting — але без мережі і без реального HTTP сервера. Результат: максимально реалістичний тест при мінімальному оверхеді.

Якщо спростити ще більше, то CreateClient() дає вам не “справжній інтернет-клієнт”, а тестовий клієнт, який ходить усередину вашого додатку. Саме тому такий тест ловить помилки маршрутизації, DI, middleware та серіалізації, але залишається швидким і стабільним.

Документація: ключові вузли pipeline

HttpClient
role
Роль клієнта: генерує HTTP-запит у тестовий хост.
TestServer
role
Роль сервера: обробляє запит in-process і повертає відповідь.
Middleware pipeline
concept
Ланцюжок обробки, через який проходять routing, auth, authorization, model binding і endpoint handler.
ConfigureTestServices
hook
Хук для заміни production-реєстрацій на тестові.

Підготовка Program.cs для тестів

Найпоширеніша помилка при першому знайомстві з WebApplicationFactory — помилка компіляції: Program не доступний з тестового проєкту.

Це відбувається тому, що у .NET 6+ Program.cs використовує top-level statements, і клас Program генерується як internal за замовчуванням. Тестовий проєкт зовні не бачить internal типи.

Рішення: public partial class Program

Документація: Program у Minimal API

public partial class Program { }
trick
Робить згенерований entry point видимим для тестового assembly без зміни логіки додатку.
InternalsVisibleTo
alternative
Альтернативний механізм доступу до internal типів, якщо треба відкрити кілька класів одразу.
top-level statements
concept
Синтаксис, у якому Program.cs не містить явного Main, а компілятор генерує entry point автоматично.

Додайте в самому кінці Program.cs, після app.Run(), один рядок:

// Весь код Minimal API налаштування...
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
// ... інше

var app = builder.Build();
app.MapGet("/api/orders", GetOrders);
// ... інші ендпоінти

app.Run();

// Ця одна строка робить Program доступним для тестів
public partial class Program { }

Чому partial? Тому що Program вже визначений у machine-generated коді для top-level statements. Додаючи partial, ми розширюємо той самий клас у другій partial частині, що дозволяє йому стати public.

Альтернативний підхід — InternalsVisibleTo у основному .csproj:

<ItemGroup>
  <InternalsVisibleTo Include="MyProject.Tests" />
  <!-- Також потрібно для Moq dynamic proxy -->
  <InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>

Обидва підходи коректні. public partial class Program — простіший і не вимагає зміни .csproj.

У практиці це важливо не через сам Program, а через доступність усього entry point. Тестовий проєкт має вміти “побачити” точку старту застосунку, щоб WebApplicationFactory міг підняти той самий хост, який ви запускаєте локально або на CI.


Перший integration тест: крок за кроком

Розглянемо повний приклад покроково. Є простий Minimal API:

// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddDbContext<AppDbContext>(opts =>
    opts.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

var app = builder.Build();

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

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

app.MapPost("/api/products", async (CreateProductDto dto, IProductService service) =>
{
    var product = await service.CreateAsync(dto);
    return Results.Created($"/api/products/{product.Id}", product);
});

app.Run();
public partial class Program { }

Тепер тестовий проєкт:

Документація: ProductEndpointsTests

ProductEndpointsTests(WebApplicationFactory<Program> factory)
constructor
Отримує фабрику від xUnit і створює клієнт для всіх тестів класу.
_factory
field
Зберігає фабрику, щоб за потреби отримати доступ до DI контейнера або створити варіації клієнта.
_client
field
Основний інструмент тесту: через нього відправляються GET/POST запити.
GetProducts_ReturnsOkWithEmptyList()
test
Перевіряє 200 OK і порожній список продуктів.
GetProduct_ByNonExistentId_Returns404()
test
Показує, що відсутній ресурс коректно повертає 404 Not Found.
CreateProduct_WithValidData_Returns201WithLocationHeader()
test
Документує очікувану REST-поведінку для створення ресурсу: 201 Created + Location.
// ProductEndpointsTests.cs
public class ProductEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;

    public ProductEndpointsTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetProducts_ReturnsOkWithEmptyList()
    {
        // Відправляємо реальний HTTP GET запит через весь ASP.NET pipeline
        var response = await _client.GetAsync("/api/products");

        // Перевіряємо HTTP статус
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        // Перевіряємо Content-Type
        response.Content.Headers.ContentType?.MediaType
            .Should().Be("application/json");

        //Десеріалізуємо JSON тіло відповіді
        var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
        products.Should().NotBeNull();
        products.Should().BeEmpty();
    }

    [Fact]
    public async Task GetProduct_ByNonExistentId_Returns404()
    {
        var nonExistentId = Guid.NewGuid();
        var response = await _client.GetAsync($"/api/products/{nonExistentId}");

        // Перевіряємо що Minimal API повертає 404 для відсутнього ресурсу
        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }

    [Fact]
    public async Task CreateProduct_WithValidData_Returns201WithLocationHeader()
    {
        var dto = new CreateProductDto
        {
            Name = "MacBook Pro 16",
            Price = 85000m,
            Category = "Electronics"
        };

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

        // 201 Created — критично для REST API
        response.StatusCode.Should().Be(HttpStatusCode.Created);

        // Location header повинен вказувати на новий ресурс
        response.Headers.Location.Should().NotBeNull();
        response.Headers.Location!.ToString().Should().StartWith("/api/products/");

        // Десеріалізуємо та перевіряємо тіло відповіді
        var created = await response.Content.ReadFromJsonAsync<ProductDto>();
        created.Should().NotBeNull();
        created!.Name.Should().Be("MacBook Pro 16");
        created.Price.Should().Be(85000m);
        created.Id.Should().NotBeEmpty();
    }
}

Що відбувається при запуску цього тесту:

  1. xUnit ініціалізує WebApplicationFactory<Program> один раз (завдяки IClassFixture)
  2. Фабрика читає Program.cs і піднімає весь додаток — DI, middleware, ендпоінти
  3. factory.CreateClient() повертає HttpClient підключений до TestServer
  4. _client.GetAsync("/api/products") проходить через весь реальний ASP.NET pipeline:
    • Routing ( розпізнає маршрут /api/products)
    • DI (розраховує IProductService і його залежності)
    • Endpoint handler (викликається)
    • JSON serialization (відповідь серіалізується)
  5. Ми отримуємо реальну HttpResponseMessage і перевіряємо її

Тут важливо помітити логіку: тест не знає нічого про внутрішню реалізацію сервісу, але він бачить результат роботи всього ланцюжка. Саме тому один такий тест може одразу зловити проблему, яка в unit-тестах виглядала б нормально, бо там були замокані всі залежності.


Підміна залежностей: ConfigureTestServices

Найважливіша можливість WebApplicationFactory — замінити production реєстрації на тестові. Це дозволяє уникнути реальної бази даних або зовнішніх сервісів у integration тестах.

Є кілька способів це зробити. Найчистіший — через WithWebHostBuilder:

WithWebHostBuilder зручний тоді, коли вам потрібно змінити тільки один або кілька сервісів для конкретного сценарію. Ідея проста: базовий хост лишається тим самим, але перед створенням клієнта ви вносите тестову правку в контейнер залежностей. Це краще, ніж намагатися змінювати production-код лише заради одного тесту.

Документація: ConfigureTestServices

WithWebHostBuilder(...)
method
Створює похідну фабрику для одного тесту або невеликої групи тестів.
ConfigureTestServices(...)
hook
Дозволяє переозначити сервіси після Program.cs, не торкаючись production-коду.
services.Remove(descriptor)
operation
Видаляє реєстрацію, яку потрібно замінити, особливо важливо для DbContextOptions<T>.
services.AddDbContext(...)
operation
Додає тестову реалізацію БД, зазвичай SQLite in-memory або іншу ізольовану базу.
services.AddScoped<IEmailService, FakeEmailService>()
operation
Підміняє зовнішню інтеграцію на контрольований fake-об'єкт.
public class ProductEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ProductEndpointsTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                // Видаляємо реальний DbContext
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
                if (descriptor != null)
                    services.Remove(descriptor);

                // Додаємо SQLite in-memory замість реального PostgreSQL
                services.AddDbContext<AppDbContext>(opts =>
                    opts.UseSqlite("Data Source=:memory:"));

                // Замінюємо реальний email сервіс на fake
                services.AddScoped<IEmailService, FakeEmailService>();

                // Замінюємо payment gateway на mock
                services.AddScoped<IPaymentGateway, FakePaymentGateway>();
            });
        })
        .CreateClient();
    }
}

Важливо розуміти порядок виконання: ConfigureTestServices виконується після всіх реєстрацій з Program.cs. Це означає, що ви можете overrideнути будь-який сервіс. Реєстрація сервісу двічі у DI Container — не помилка, використовується остання (для AddScoped/AddTransient/AddSingleton). Але для DbContextOptions необхідно явно видалити старий descriptor перед додаванням нового.

Тобто ConfigureTestServices - це не окрема конфігурація “десь збоку”, а саме фінальний шар поверх production-налаштувань. Через це він такий корисний: ви спочатку отримуєте реальний додаток, а потім точково міняєте тільки те, що заважає тесту бути детермінованим, наприклад базу даних або зовнішній API.


CustomWebApplicationFactory: чистіший підхід

Якщо підміни залежностей однакові для кількох тест-класів, варто винести їх у власний підклас WebApplicationFactory:

Це вже не просто зручність, а нормальна організація тестового коду. Коли ви бачите повторювані заміни сервісів у кількох тестах, значить ця логіка належить не в самі тести, а в окрему тестову фабрику. По суті це ваш “тестовий startup”, де зібрані всі правила для запуску app у тестовому режимі.

Документація: TestWebApplicationFactory

ConfigureWebHost(IWebHostBuilder builder)
override
Центральна точка, де збираються всі глобальні тестові налаштування фабрики.

::field{name="UseEnvironment("Testing")" type="method"} Перемикає додаток у тестове середовище, щоб production-branching міг поводитися інакше. ::

ReplaceDbContext(IServiceCollection services)
helper method
Виносить заміну БД в окремий метод для читабельності і повторного використання.
Dispose(bool disposing)
override
Точка для очистки ресурсів після завершення тестового набору.
// TestWebApplicationFactory.cs
public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // Встановлюємо тестове середовище — впливає на конфігурацію та middleware
        builder.UseEnvironment("Testing");

        builder.ConfigureTestServices(services =>
        {
            // Замінюємо DbContext
            ReplaceDbContext(services);

            // Реєструємо fake сервіси
            services.AddScoped<IEmailService, FakeEmailService>();
            services.AddScoped<IPaymentGateway, FakePaymentGateway>();
            services.AddScoped<IExternalShippingApi, FakeShippingApi>();

            // Додаємо тестову аутентифікацію (про це нижче)
            services.AddAuthentication("Test")
                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                    "Test", options => { });
        });
    }

    private static void ReplaceDbContext(IServiceCollection services)
    {
        // Видаляємо всі реєстрації, пов'язані з реальним DbContext
        var descriptorsToRemove = services
            .Where(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>)
                     || d.ServiceType == typeof(AppDbContext))
            .ToList();

        foreach (var descriptor in descriptorsToRemove)
            services.Remove(descriptor);

        // Додаємо SQLite in-memory
        services.AddDbContext<AppDbContext>(opts =>
            opts.UseSqlite("Data Source=testdb;Mode=Memory;Cache=Shared"));
    }

    // Ініціалізація схеми та seed даних
    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        // Очистка ресурсів після всіх тестів
    }
}
// Тест-клас використовує кастомну фабрику
public class ProductEndpointsTests : IClassFixture<TestWebApplicationFactory>
{
    private readonly TestWebApplicationFactory _factory;
    private readonly HttpClient _client;

    public ProductEndpointsTests(TestWebApplicationFactory factory)
    {
        _factory = factory;
        _client = factory.CreateClient();
    }
    // ...
}

Ініціалізація тестових даних

Проблема: перш ніж тестувати GET /api/products/{id}, потрібно мати продукт у базі. Є кілька підходів.

Підхід 1: Через HTTP API (реалістичний)

[Fact]
public async Task GetProduct_AfterCreation_ReturnsCorrectData()
{
    // Arrange: спочатку створюємо через API
    var createDto = new CreateProductDto { Name = "Test Product", Price = 100m };
    var createResponse = await _client.PostAsJsonAsync("/api/products", createDto);
    createResponse.EnsureSuccessStatusCode();

    var created = await createResponse.Content.ReadFromJsonAsync<ProductDto>();
    var productId = created!.Id;

    // Act: тепер читаємо
    var getResponse = await _client.GetAsync($"/api/products/{productId}");

    // Assert
    getResponse.StatusCode.Should().Be(HttpStatusCode.OK);
    var product = await getResponse.Content.ReadFromJsonAsync<ProductDto>();
    product!.Name.Should().Be("Test Product");
}

Підхід 2: Через DbContext напряму (швидший)

[Fact]
public async Task GetProduct_WithSeededData_ReturnsCorrectData()
{
    // Arrange: seed через DbContext з DI container
    using var scope = _factory.Services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

    var product = new Product
    {
        Id = Guid.NewGuid(),
        Name = "Seeded Product",
        Price = 500m
    };
    db.Products.Add(product);
    await db.SaveChangesAsync();

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

    // Assert
    response.StatusCode.Should().Be(HttpStatusCode.OK);
    var dto = await response.Content.ReadFromJsonAsync<ProductDto>();
    dto!.Name.Should().Be("Seeded Product");
}

Другий підхід значно швидший, але тестує лише GET endpoint. Перший підхід тестує весь цикл — POST і GET разом. Обидва правомірні залежно від мети тесту.


Тестування аутентифікації та авторизації

Одна з найскладніших частин integration тестів — перевірка захищених ендпоінтів. Реальна JWT-аутентифікація потребує реального токену, що ускладнює тести.

Рішення — TestAuthHandler: власна тестова схема аутентифікації, яку ми свідомо створюємо в тестовому проєкті та реєструємо через DI. Важливо підкреслити, що це не вбудований клас ASP.NET Core і не частина production-коду. Це лише тестова реалізація контракту аутентифікації, яка дозволяє контрольовано підставити користувача без JWT, cookies або зовнішнього identity provider.

У термінах ASP.NET Core AuthenticationHandler є конкретним обробником для певної схеми аутентифікації. Схема має назву, наприклад "Test", а handler відповідає за те, як саме визначити, чи є запит аутентифікованим. У нашому випадку рішення просте: якщо у запиті є спеціальний заголовок, ми вважаємо користувача валідним і повертаємо ClaimsPrincipal. Якщо заголовка немає, повертаємо NoResult(), і далі запит поводиться як anonymous.

Хто викликає цей handler? Не тест напряму, а сам ASP.NET pipeline. Коли запит проходить через UseAuthentication() та UseAuthorization(), фреймворк звертається до зареєстрованої схеми, а та, у свою чергу, делегує роботу нашому TestAuthHandler. Саме тому такий підхід є “нативним” для ASP.NET Core: ми не обходимо систему, а підміняємо лише механізм отримання користувача.

Назва схеми, наприклад "Test", є проектною домовленістю. Фреймворк не вимагає саме такого імені, але воно має бути однаково використане в AddAuthentication("Test"), у реєстрації handler-а та в місцях, де ви створюєте клієнт для тесту. Сам клас також може мати будь-яку назву, наприклад TestAuthHandler або LimitedTestAuthHandler; важливо не ім'я, а відповідність між реєстрацією і фактичною поведінкою.

Ідея тут не в тому, щоб перевіряти реальний провайдер ідентичності. Навпаки, ми хочемо прибрати зовнішній шум: токени, refresh flow, підписування, expiration, зовнішній identity provider. Для integration тесту нам зазвичай важливо лише одне - чи додаток правильно реагує на вже аутентифікованого користувача з певними claims і ролями.

Документація: TestAuthHandler

HandleAuthenticateAsync()
override
Перевіряє тестовий заголовок, створює ClaimsPrincipal і повертає успішну або порожню аутентифікацію.
AuthHeaderName
constant
Назва заголовка, який активує тестову аутентифікацію.
TestUserId
constant
Базовий ідентифікатор тестового користувача для прикладів і сценаріїв.
ClaimsIdentity
concept
Носій claims, який визначає ім'я, ролі та інші атрибути користувача.
AuthenticationScheme
concept
Логічне ім'я реєстрації, яке зв'язує middleware, handler і виклик AddAuthentication("Test").
AuthenticateResult
result
Результат, який повідомляє pipeline, чи користувач аутентифікований, не аутентифікований або чи сталася помилка.
// TestAuthHandler.cs
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public const string TestUserId = "test-user-001";
    public const string AuthHeaderName = "X-Test-Auth";

    public TestAuthHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder)
        : base(options, logger, encoder) { }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // Аутентифікуємо лише якщо є спеціальний тестовий заголовок
        if (!Request.Headers.ContainsKey(AuthHeaderName))
            return Task.FromResult(AuthenticateResult.NoResult());

        var userId = Request.Headers[AuthHeaderName].ToString();

        // Створюємо ClaimsPrincipal з потрібними ролями та claims
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, userId),
            new Claim(ClaimTypes.Name, "Test User"),
            new Claim(ClaimTypes.Role, "Admin"),
            new Claim(ClaimTypes.Role, "User"),
        };

        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "Test");

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

Документація: LimitedTestAuthHandler

Purpose
scenario
Дає аутентифікованого користувача без Admin ролі, щоб протестувати 403 Forbidden.
Claims
difference
Відрізняється від повного тестового handler-а лише набором ролей у ClaimsPrincipal.
Use case
scenario
Підходить для перевірки політик доступу, де користувач має бути logged-in, але не має права на дію.
У прикладі нижче LimitedTestAuthHandler - це варіація TestAuthHandler, яка повертає менше claims.

У практиці цей клас використовується як тестовий substitute для ролей і політик доступу. Наприклад, один handler може створювати адміністратора, інший - звичайного користувача, третій - анонімний запит. Це дає змогу окремо перевіряти authentication і authorization без залучення реального сервера авторизації.

Тепер у тестах ми просто додаємо заголовок:

У практиці це зручно, бо ви одним заголовком керуєте “особою” користувача у тесті. Якщо заголовка немає - запит anonymous. Якщо заголовок є - handler створює ClaimsPrincipal, і далі вже спрацьовують ваші правила авторизації, policies та roles.

[Fact]
public async Task GetOrder_AuthenticatedUser_ReturnsOwnOrders()
{
    // Authenticate як конкретний юзер
    _client.DefaultRequestHeaders.Add("X-Test-Auth", "user-123");

    var response = await _client.GetAsync("/api/orders");

    response.StatusCode.Should().Be(HttpStatusCode.OK);
}

[Fact]
public async Task GetOrder_UnauthenticatedUser_Returns401()
{
    // БЕЗ заголовку — не аутентифікований
    var response = await _client.GetAsync("/api/orders");

    response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task DeleteOrder_NonAdminUser_Returns403()
{
    // Аутентифікований, але без Admin ролі
    var limitedClient = _factory.WithWebHostBuilder(builder =>
    {
        builder.ConfigureTestServices(services =>
        {
            // Замінюємо auth handler на один без Admin ролі
            services.AddAuthentication("Test")
                .AddScheme<AuthenticationSchemeOptions, LimitedTestAuthHandler>(
                    "Test", o => { });
        });
    }).CreateClient();

    limitedClient.DefaultRequestHeaders.Add("X-Test-Auth", "regular-user");

    var response = await limitedClient.DeleteAsync($"/api/orders/{Guid.NewGuid()}");
    response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

Scope та параллелізм: важливі нюанси

Оскільки xUnit виконує тест-класи паралельно за замовчуванням, а тести в одному класі послідовно — необхідно правильно розуміти ізоляцію даних.

Проблема: якщо два тест-класи поділяють одну WebApplicationFactory через IClassFixture<TestWebApplicationFactory>, але фабрика є IClassFixture (прив'язана до класу), то кожен клас отримує власну інстанцію фабрики — проблем немає.

Але якщо використовується ICollectionFixture — один екземпляр фабрики на кілька класів. Тоді спільна база даних стає проблемою паралелізму.

У цьому місці важлива не тільки xUnit-деталь, а й життєвий цикл даних. Тестова БД має бути передбачуваною: якщо один тест записав дані, інший не повинен раптово на них натрапити, якщо це не було задумано. Інакше тести стають крихкими й починають падати не через код, а через порядок запуску.

Рішення для паралельних integration тестів:

// Варіант 1: Унікальна база для кожного тест-класу
public class ProductTests : IClassFixture<TestWebApplicationFactory>
{
    public ProductTests(TestWebApplicationFactory factory)
    {
        // Кожен запуск фабрики = своя SQLite in-memory база
        // SQLite "Data Source=:memory:" при кожній новій factory дає ізольовану базу
    }
}

// Варіант 2: Reset даних перед кожним тестом у класі
public class OrderTests : IClassFixture<TestWebApplicationFactory>
{
    private readonly TestWebApplicationFactory _factory;

    public OrderTests(TestWebApplicationFactory factory)
    {
        _factory = factory;
        // Очищаємо дані через DI
        ResetDatabase();
    }

    private void ResetDatabase()
    {
        using var scope = _factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        db.Orders.RemoveRange(db.Orders);
        db.SaveChanges();
    }
}

Практичні завдання

Рівень 1: Перші integration тести

Завдання 1.1 — Базові CRUD integration тести

Для простого Minimal API з ресурсом Category (назва, опис) напишіть integration тести для всіх ендпоінтів:

  • GET /api/categories — порожній список та список з елементами
  • GET /api/categories/{id} — знайдений та не знайдений (404)
  • POST /api/categories — успіх (201 + Location header), invalid data (400 або 422)
  • PUT /api/categories/{id} — оновлення та 404 для відсутнього
  • DELETE /api/categories/{id} — видалення та 404

Кожен тест — це окремий [Fact]. Разом має вийти мінімум 10 тест-кейсів.

Завдання 1.2 — Перевірка JSON відповіді

Для ендпоінту GET /api/products/{id} напишіть тест, що перевіряє кожне поле JSON відповіді:

  • Правильний тип кожного поля (number, string, boolean, nullable)
  • Формат дати (ISO 8601)
  • Відсутність зайвих полів (якщо використовується System.Text.Json)
  • Правильна серіалізація enum (рядок, не число)

Рівень 2: Аутентифікація та авторизація

Завдання 2.1 — TestAuthHandler повна реалізація

Реалізуйте TestAuthHandler для вашого проєкту. Він повинен підтримувати:

  • Аутентифікацію через заголовок X-Test-User-Id
  • Вибір ролі через заголовок X-Test-Role (Admin, Manager, User)
  • Аутентифікацію без заголовку — 401 Unauthorized

Напишіть по 3 тести для кожного рольового сценарію:

  • Admin може DELETE, PUT
  • Manager може POST, PUT, але не DELETE
  • User може тільки GET
  • Anonymous — тільки публічні ендпоінти

Завдання 2.2 — Тест middleware

Для вашого API додайте rate limiting middleware (обмеження 5 запитів на хвилину). Напишіть integration тест, що:

  • Надсилає 5 запитів — всі 200 OK
  • Надсилає 6-й запит — 429 Too Many Requests
  • Перевіряє заголовок Retry-After

Рівень 3: CustomWebApplicationFactory

Завдання 3.1 — Повна тестова інфраструктура

Реалізуйте ApiTestFixture — повну тестову інфраструктуру для вашого Minimal API:

  • ApiTestFixture : WebApplicationFactory<Program> з заміною всіх зовнішніх залежностей
  • ApiTestFixture.CreateAuthenticatedClient(string role) — хелпер для отримання клієнта з роллю
  • ApiTestFixture.SeedAsync<T>(IEnumerable<T> entities) — хелпер для seed даних
  • ApiTestFixture.ResetAsync() — очистка між тест-класами
  • Інтеграція з Respawn для очистки БД

Напишіть 15+ тестів різних сценаріїв, що використовують цю інфраструктуру.


Підсумок

Ключові думки цієї статті:
  • Unit тести не перевіряють DI конфігурацію, routing, middleware, серіалізацію JSON, заголовки. Саме тут integration тести додають цінність.
  • WebApplicationFactory піднімає весь ASP.NET додаток in-process. Запити проходять через реальний pipeline без мережі.
  • public partial class Program { } — один рядок у кінці Program.cs, що робить тип доступним для WebApplicationFactory з тестового проєкту.
  • ConfigureTestServices викликається після реєстрацій з Program.cs і дозволяє overrideнути будь-який сервіс. Для DbContext потрібно спочатку видалити старий descriptor.
  • TestAuthHandler — auth схема, що аутентифікує через спеціальний тестовий заголовок. Дозволяє тестувати захищені ендпоінти без реальних JWT токенів.
  • Seed даних: через HTTP API (реалістично) або через DbContext з DI scope (швидко). Обидва підходи правомірні.
  • Паралелізм: кожна IClassFixture<WebApplicationFactory> — окрема ізольована фабрика. При ICollectionFixture потрібен явний reset даних. ::
У Частині 2 розглянемо: продвинуті PatternMap для тестування складних сценаріїв бізнес-логіки через HTTP, повне тестування validation, ProblemDetails, global error handling, та WebApplicationFactory разом із Testcontainers для реальної PostgreSQL.