Тестування

Integration Testing — Частина 2 [Просунуті Сценарії та Testcontainers]

Глибоке тестування validation, ProblemDetails та глобальної обробки помилок. Тестування складних бізнес-сценаріїв через HTTP. WebApplicationFactory разом із Testcontainers для реальної PostgreSQL. Організація великої тест-сюїти.

Integration Testing. Частина 2: Просунуті Сценарії та Testcontainers

Тестування Validation та ProblemDetails

Одна з найбільш занедбаних областей integration тестування — перевірка того, як API реагує на невалідні дані. Розробники часто пишуть тести на happy path (валідні дані → 200 OK), але залишають без уваги сотні можливих сценаріїв невалідного введення.

Чому це важливо? Уявіть, що ваш POST ендпоінт приймає CreateOrderDto. Ви визначили FluentValidation правила: ім'я обов'язкове (Required), сума має бути більше нуля (GreaterThan(0)), email має бути у правильному форматі. Але хто перевіряє, що ці правила справді застосовуються до HTTP запиту? Unit тест валідатора перевіряє логіку валідатора. Але чи правильно ваш middleware підключений? Чи повертається 400 Bad Request а не 500? Чи правильна структура тіла помилки?

Саме на ці питання відповідають integration тести для validation.

Структура ProblemDetails

ASP.NET Core за замовчуванням повертає помилки у форматі RFC 7807 ProblemDetails. Це стандартний формат для HTTP API помилок, що виглядає так:

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-abc123...",
    "errors": {
        "Name": ["The Name field is required."],
        "Price": ["Price must be greater than 0."]
    }
}

Я хочу наголосити на кількох важливих деталях цього формату. Поле type — це URI, що ідентифікує тип помилки. Поле title — людиночитаний опис. Поле status — числовий HTTP статус код, що дублює статус відповіді. Поле traceId — унікальний ідентифікатор запиту для correlation logs. Поле errors — словник, де ключі — назви полів з помилками, а значення — масиви рядків з описами помилок.

Для тестування вам потрібно десеріалізувати цей формат:

// DTO для десеріалізації ProblemDetails
public class ValidationProblemDetailsResponse
{
    public string? Type { get; set; }
    public string? Title { get; set; }
    public int Status { get; set; }
    public string? TraceId { get; set; }
    public Dictionary<string, string[]>? Errors { get; set; }
}

Тестування FluentValidation через HTTP

Розглянемо ситуацію детально. У вас є такий Minimal API ендпоінт:

app.MapPost("/api/orders", async (
    CreateOrderDto dto,
    IValidator<CreateOrderDto> validator,
    IOrderService service) =>
{
    var validation = await validator.ValidateAsync(dto);
    if (!validation.IsValid)
    {
        return Results.ValidationProblem(validation.ToDictionary());
    }

    var order = await service.CreateAsync(dto);
    return Results.Created($"/api/orders/{order.Id}", order);
});

Тепер тести для різних сценаріїв невалідного введення:

public class OrderValidationTests : IClassFixture<TestWebApplicationFactory>
{
    private readonly HttpClient _client;

    public OrderValidationTests(TestWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task CreateOrder_WithEmptyBody_Returns400WithErrors()
    {
        // Відправляємо абсолютно порожній JSON об'єкт
        var response = await _client.PostAsJsonAsync("/api/orders", new { });

        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);

        var problem = await response.Content
            .ReadFromJsonAsync<ValidationProblemDetailsResponse>();

        problem.Should().NotBeNull();
        problem!.Status.Should().Be(400);
        // Перевіряємо що помилки є для обов'язкових полів
        problem.Errors.Should().ContainKey("CustomerName");
        problem.Errors.Should().ContainKey("Items");
    }

    [Fact]
    public async Task CreateOrder_WithNegativeAmount_Returns422WithSpecificError()
    {
        // Технічно валідна структура, але бізнес-правило порушено
        var dto = new CreateOrderDto
        {
            CustomerName = "John Doe",
            Items = new List<OrderItemDto>
            {
                new() { ProductId = Guid.NewGuid(), Quantity = 0, Price = -100m }
            }
        };

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

        // 422 Unprocessable Entity — для бізнес-правил (на відміну від 400 для формату)
        response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);

        var problem = await response.Content
            .ReadFromJsonAsync<ValidationProblemDetailsResponse>();

        problem!.Errors.Should().ContainKey("Items[0].Quantity");
        problem.Errors!["Items[0].Quantity"]
            .Should().Contain(msg => msg.Contains("greater than 0"));
    }

    [Theory]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData(null)]
    public async Task CreateOrder_WithInvalidCustomerName_Returns400(string? name)
    {
        // Параметризований тест: різні варіанти невалідної назви
        var dto = new { CustomerName = name, Items = new[] { new { ProductId = Guid.NewGuid(), Quantity = 1, Price = 100 } } };

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

        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);

        var problem = await response.Content
            .ReadFromJsonAsync<ValidationProblemDetailsResponse>();

        problem!.Errors.Should().ContainKey("CustomerName");
    }

    [Fact]
    public async Task CreateOrder_WithTooLongName_Returns400WithLengthError()
    {
        // Тест граничного значення: максимальна довжина поля
        var dto = new CreateOrderDto
        {
            CustomerName = new string('A', 256), // Перевищує max 255 символів
            Items = new List<OrderItemDto> { new() { ProductId = Guid.NewGuid(), Quantity = 1, Price = 100m } }
        };

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

        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
        var problem = await response.Content
            .ReadFromJsonAsync<ValidationProblemDetailsResponse>();

        problem!.Errors!["CustomerName"]
            .Should().Contain(msg => msg.Contains("256") || msg.Contains("maximum"));
    }
}

Зверніть увагу на деталі цих тестів. Ми перевіряємо не просто статус код — ми перевіряємо конкретні ключі у словнику errors і навіть вміст повідомлень про помилки. Це важливо: якщо розробник переіменує поле або змінить повідомлення — тест впаде і попередить про breaking change у API контракті.


Тестування глобального Error Handling

ASP.NET Core підтримує глобальну обробку винятків через app.UseExceptionHandler() або IExceptionHandler у .NET 8+. Integration тести дозволяють перевірити, що ця обробка працює коректно і не "протікає" деталі виключень клієнту.

Ось типова конфігурація global error handling у Minimal API:

// Program.cs
app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        var exceptionHandlerFeature = context.Features.Get<IExceptionHandlerFeature>();
        var exception = exceptionHandlerFeature?.Error;

        context.Response.StatusCode = exception switch
        {
            NotFoundException => 404,
            ForbiddenException => 403,
            ConflictException => 409,
            _ => 500
        };

        context.Response.ContentType = "application/problem+json";

        var problem = new ProblemDetails
        {
            Status = context.Response.StatusCode,
            Title = exception switch
            {
                NotFoundException e => e.Message,
                ForbiddenException => "Access denied",
                ConflictException e => e.Message,
                _ => "An unexpected error occurred"
            },
            // ВАЖЛИВО: у production НЕ показуємо деталі внутрішніх помилок
            Detail = app.Environment.IsDevelopment()
                ? exception?.StackTrace : null
        };

        await context.Response.WriteAsJsonAsync(problem);
    });
});

Тепер тести, що перевіряють цю логіку:

public class ErrorHandlingTests : IClassFixture<TestWebApplicationFactory>
{
    private readonly HttpClient _client;
    private readonly TestWebApplicationFactory _factory;

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

    [Fact]
    public async Task GetOrder_WhenNotFound_Returns404WithProblemDetails()
    {
        // IOrderService кидатиме NotFoundException для невідомого ID
        var nonExistentId = Guid.NewGuid();

        var response = await _client.GetAsync($"/api/orders/{nonExistentId}");

        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
        response.Content.Headers.ContentType?.MediaType
            .Should().Be("application/problem+json");

        var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
        problem!.Status.Should().Be(404);
        problem.Title.Should().Contain(nonExistentId.ToString());
    }

    [Fact]
    public async Task PerformAction_WhenUnexpectedExceptionOccurs_Returns500WithoutDetails()
    {
        // Спеціальний ендпоінт або мок сервісу, що кидає непередбачений Exception
        var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                var mock = new Mock<IOrderService>();
                mock.Setup(x => x.GetByIdAsync(It.IsAny<Guid>()))
                    .ThrowsAsync(new Exception("Database connection to 192.168.1.100 failed"));

                services.AddScoped<IOrderService>(_ => mock.Object);
            });
        }).CreateClient();

        var response = await client.GetAsync($"/api/orders/{Guid.NewGuid()}");

        response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);

        var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();

        // КРИТИЧНО: деталі внутрішньої помилки не повинні "протікати" клієнту
        problem!.Title.Should().Be("An unexpected error occurred");
        problem.Detail.Should().BeNullOrEmpty(); // Не Testing середовище — немає StackTrace
        // IP-адреса внутрішнього сервера не повинна бути у відповіді
        var responseBody = await response.Content.ReadAsStringAsync();
        responseBody.Should().NotContain("192.168.1.100");
        responseBody.Should().NotContain("Database connection");
    }

    [Fact]
    public async Task ConflictingOperation_Returns409WithExplanation()
    {
        // Тест для business-level конфлікту
        var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                var mock = new Mock<IOrderService>();
                mock.Setup(x => x.CancelAsync(It.IsAny<Guid>()))
                    .ThrowsAsync(new ConflictException("Order already shipped and cannot be cancelled"));

                services.AddScoped<IOrderService>(_ => mock.Object);
            });
        }).CreateClient();

        var response = await client.PostAsync($"/api/orders/{Guid.NewGuid()}/cancel", null);

        response.StatusCode.Should().Be(HttpStatusCode.Conflict);
        var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
        problem!.Title.Should().Contain("already shipped");
    }
}

Цікавий момент у тесті Returns500WithoutDetails — ми перевіряємо не лише статус код, а й що не повертається у відповіді. Це тест безпеки: внутрішні IP-адреси, stack traces, деталі підключення до бази — нічого з цього не повинно потрапити до клієнта у production. Такий тест є гарантією того, що ваш error handler коректно "sanitize-ить" помилки.


Тестування складних бізнес-сценаріїв через HTTP

Найбільша цінність integration тестів — перевірка повних бізнес-сценаріїв: створення замовлення → підтвердження → відправка → отримання. Кожен крок — окремий HTTP запит. Такі тести документують і верифікують бізнес-процеси на рівні API контракту.

Сценарій: повний lifecycle замовлення

public class OrderLifecycleIntegrationTests : IClassFixture<TestWebApplicationFactory>
{
    private readonly HttpClient _client;
    private readonly TestWebApplicationFactory _factory;

    public OrderLifecycleIntegrationTests(TestWebApplicationFactory factory)
    {
        _factory = factory;
        // Аутентифікований клієнт з роллю Manager
        _client = factory.CreateClient();
        _client.DefaultRequestHeaders.Add("X-Test-Auth", "manager-001");
        _client.DefaultRequestHeaders.Add("X-Test-Role", "Manager");
    }

    [Fact]
    public async Task Order_FullLifecycle_WorksCorrectly()
    {
        // Крок 1: Створення замовлення
        var createDto = new CreateOrderDto
        {
            CustomerName = "Іван Петренко",
            CustomerEmail = "ivan@example.com",
            Items = new List<OrderItemDto>
            {
                new() { ProductId = Guid.NewGuid(), Quantity = 2, Price = 1500m }
            }
        };

        var createResponse = await _client.PostAsJsonAsync("/api/orders", createDto);
        createResponse.StatusCode.Should().Be(HttpStatusCode.Created);

        var createdOrder = await createResponse.Content.ReadFromJsonAsync<OrderDto>();
        createdOrder.Should().NotBeNull();
        var orderId = createdOrder!.Id;

        // Перевіряємо початковий статус
        createdOrder.Status.Should().Be("Pending");
        createdOrder.TotalAmount.Should().Be(3000m); // 2 * 1500

        // Крок 2: Підтвердження замовлення
        var confirmResponse = await _client.PostAsync(
            $"/api/orders/{orderId}/confirm", null);
        confirmResponse.StatusCode.Should().Be(HttpStatusCode.OK);

        // Крок 3: Перевірка що статус змінився
        var getResponse = await _client.GetAsync($"/api/orders/{orderId}");
        var confirmedOrder = await getResponse.Content.ReadFromJsonAsync<OrderDto>();
        confirmedOrder!.Status.Should().Be("Confirmed");

        // Крок 4: Позначка як відправлений (з tracking номером)
        var shipResponse = await _client.PostAsJsonAsync(
            $"/api/orders/{orderId}/ship",
            new ShipOrderDto { TrackingNumber = "UA123456789" });
        shipResponse.StatusCode.Should().Be(HttpStatusCode.OK);

        // Крок 5: Перевірка що не можна скасувати після відправки
        var cancelResponse = await _client.PostAsync(
            $"/api/orders/{orderId}/cancel", null);
        cancelResponse.StatusCode.Should().Be(HttpStatusCode.Conflict);

        var problem = await cancelResponse.Content.ReadFromJsonAsync<ProblemDetails>();
        problem!.Title.Should().Contain("shipped");

        // Крок 6: Перевірка фінального стану
        var finalGetResponse = await _client.GetAsync($"/api/orders/{orderId}");
        var finalOrder = await finalGetResponse.Content.ReadFromJsonAsync<OrderDto>();
        finalOrder!.Status.Should().Be("Shipped");
        finalOrder.TrackingNumber.Should().Be("UA123456789");
    }
}

Цей тест охоплює весь бізнес-процес і фактично є живою документацією того, як повинен працювати API. Якщо бізнес-правила зміняться — тест одразу вкаже де саме.


WebApplicationFactory + Testcontainers: реальна БД у integration тестах

До цього ми замінювали PostgreSQL на SQLite in-memory у тестах. Але є сценарії, де SQLite недостатньо — наприклад, якщо ваш код використовує PostgreSQL-специфічні функції (JSONB пошук, масиви, повнотекстовий пошук).

Об'єднання WebApplicationFactory з Testcontainers дає найбільш реалістичний тип integration тестів: реальний ASP.NET pipeline + реальна PostgreSQL у Docker.

Архітектура рішення

Ключова ідея: WebApplicationFactory і PostgreSqlContainer ізольовані незалежно. Нам потрібно:

  1. Запустити PostgreSQL контейнер (через IAsyncLifetime)
  2. Передати рядок підключення у WebApplicationFactory при конфігурації
  3. Переконатись, що обидва ресурси правильно очищаються після тестів
// PostgreSqlApiFixture.cs — об'єднує Testcontainers і WebApplicationFactory
public class PostgreSqlApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
    // Testcontainers: реальний Docker контейнер
    private readonly PostgreSqlContainer _postgresContainer;

    public PostgreSqlApiFixture()
    {
        _postgresContainer = new PostgreSqlBuilder()
            .WithImage("postgres:16-alpine")
            .WithDatabase("integration_test_db")
            .WithUsername("integration_user")
            .WithPassword("integration_pass")
            .WithCleanUp(true) // Автоматично видаляти контейнер після тестів
            .Build();
    }

    // IAsyncLifetime: запуск контейнера ПЕРЕД першим тестом
    public async Task InitializeAsync()
    {
        await _postgresContainer.StartAsync();
    }

    // WebApplicationFactory: підміна реального рядка підключення
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");

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

            // Підключаємо до РЕАЛЬНОЇ PostgreSQL у Docker
            services.AddDbContext<AppDbContext>(opts =>
                opts.UseNpgsql(_postgresContainer.GetConnectionString()));

            // Замінюємо лише зовнішні HTTP сервіси
            services.AddScoped<IEmailService, FakeEmailService>();
            services.AddScoped<IPaymentGateway, FakePaymentGateway>();
        });
    }

    // Ініціалізація схеми після запуску контейнера
    public async Task ApplyMigrationsAsync()
    {
        using var scope = Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.MigrateAsync();
    }

    // Очистка даних між тестами (через SQL напряму — швидко)
    public async Task ResetDataAsync()
    {
        await using var connection = new NpgsqlConnection(
            _postgresContainer.GetConnectionString());
        await connection.OpenAsync();

        await using var cmd = connection.CreateCommand();
        cmd.CommandText = """
            TRUNCATE TABLE "OrderItems", "Orders", "Products", "Customers"
            RESTART IDENTITY CASCADE;
        """;
        await cmd.ExecuteNonQueryAsync();
    }

    // IAsyncLifetime: зупинка контейнера після останнього тесту
    public new async Task DisposeAsync()
    {
        await base.DisposeAsync(); // Зупинка WebApplicationFactory
        await _postgresContainer.DisposeAsync(); // Зупинка Docker контейнера
    }
}

Тести з реальною PostgreSQL

[Collection("PostgreSQL Integration")]
public class ProductApiWithRealDbTests : IAsyncLifetime
{
    private readonly PostgreSqlApiFixture _fixture;
    private readonly HttpClient _client;

    public ProductApiWithRealDbTests(PostgreSqlApiFixture fixture)
    {
        _fixture = fixture;
        _client = fixture.CreateClient();
    }

    // Очистка даних перед кожним тестом
    public async Task InitializeAsync() => await _fixture.ResetDataAsync();
    public Task DisposeAsync() => Task.CompletedTask;

    [Fact]
    public async Task SearchProducts_WithJsonbQuery_ReturnsCorrectResults()
    {
        // Arrange: seed даних напряму в PostgreSQL
        using var scope = _fixture.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        db.Products.AddRange(
            new Product
            {
                Id = Guid.NewGuid(),
                Name = "MacBook Pro",
                Metadata = """{"category": "laptop", "brand": "apple", "year": 2026}"""
            },
            new Product
            {
                Id = Guid.NewGuid(),
                Name = "Dell XPS",
                Metadata = """{"category": "laptop", "brand": "dell", "year": 2025}"""
            },
            new Product
            {
                Id = Guid.NewGuid(),
                Name = "iPhone 17",
                Metadata = """{"category": "phone", "brand": "apple", "year": 2026}"""
            }
        );
        await db.SaveChangesAsync();

        // Act: GET запит з JSONB фільтром через Query Parameter
        var response = await _client.GetAsync("/api/products?brand=apple");

        // Assert: тільки Apple продукти
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();

        products.Should().HaveCount(2);
        products!.All(p => p.Name == "MacBook Pro" || p.Name == "iPhone 17")
            .Should().BeTrue();
    }

    [Fact]
    public async Task CreateProduct_WithDuplicateSKU_Returns409Conflict()
    {
        // Перший продукт — успішно
        var dto = new CreateProductDto { Name = "Product", Sku = "SKU-001", Price = 100m };
        var first = await _client.PostAsJsonAsync("/api/products", dto);
        first.StatusCode.Should().Be(HttpStatusCode.Created);

        // Другий продукт з тим самим SKU — PostgreSQL UNIQUE constraint порушено
        var duplicate = new CreateProductDto { Name = "Another Product", Sku = "SKU-001", Price = 200m };
        var second = await _client.PostAsJsonAsync("/api/products", duplicate);

        // API має перехопити DbUpdateException і повернути 409
        second.StatusCode.Should().Be(HttpStatusCode.Conflict);

        var problem = await second.Content.ReadFromJsonAsync<ProblemDetails>();
        problem!.Title.Should().Contain("already exists");
    }
}

// Collection Definition
[CollectionDefinition("PostgreSQL Integration")]
public class PostgreSqlIntegrationCollection
    : ICollectionFixture<PostgreSqlApiFixture> { }

Тестування HTTP заголовків та серіалізації

Одна з найбільш важливих і часто ігнорованих частин API контракту — HTTP заголовки. Ваші клієнти (мобільний додаток, партнерська система) залежать від конкретних заголовків. Якщо ви їх зміните — клієнти зламаються. Integration тести є єдиним надійним способом зафіксувати цей контракт.

public class HeadersAndSerializationTests : IClassFixture<TestWebApplicationFactory>
{
    private readonly HttpClient _client;

    public HeadersAndSerializationTests(TestWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }

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

        response.Content.Headers.ContentType!.MediaType
            .Should().Be("application/json");
        response.Content.Headers.ContentType.CharSet
            .Should().BeNullOrEmpty(); // або Be("utf-8") залежно від конфігурації
    }

    [Fact]
    public async Task CreateProduct_ReturnsLocationHeader()
    {
        var dto = new CreateProductDto { Name = "Test", Price = 100m, Sku = "T-001" };
        var response = await _client.PostAsJsonAsync("/api/products", dto);

        response.Headers.Location.Should().NotBeNull();
        response.Headers.Location!.IsAbsoluteUri.Should().BeFalse();
        response.Headers.Location.ToString().Should().MatchRegex(
            @"^/api/products/[0-9a-f-]{36}$");
    }

    [Fact]
    public async Task GetProduct_JsonDateFormat_IsIso8601()
    {
        // Seed продукту з відомою датою
        var knownDate = new DateTime(2026, 3, 25, 12, 0, 0, DateTimeKind.Utc);
        // ... seed ...

        var response = await _client.GetAsync($"/api/products/{productId}");
        var json = await response.Content.ReadAsStringAsync();

        // ISO 8601 формат: "2026-03-25T12:00:00Z"
        json.Should().Contain("2026-03-25T12:00:00");
    }

    [Fact]
    public async Task GetProduct_EnumFields_SerializedAsStrings()
    {
        // seed продукту зі статусом...
        var response = await _client.GetAsync($"/api/products/{productId}");
        var json = await response.Content.ReadAsStringAsync();

        // "status": "Active" — не "status": 1
        json.Should().Contain("\"status\": \"Active\"");
        json.Should().NotMatchRegex("\"status\": \\d");
    }

    [Fact]
    public async Task GetProducts_SupportsPagination_ViaQueryParams()
    {
        // seed 25 продуктів...

        var response = await _client.GetAsync("/api/products?page=1&pageSize=10");

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

        // Перевіряємо pagination headers
        response.Headers.Should().ContainKey("X-Total-Count");
        response.Headers.GetValues("X-Total-Count").First().Should().Be("25");
        response.Headers.Should().ContainKey("X-Page-Count");

        var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
        products.Should().HaveCount(10); // pageSize
    }

    [Fact]
    public async Task Api_SupportsContentNegotiation()
    {
        _client.DefaultRequestHeaders.Accept.Clear();
        _client.DefaultRequestHeaders.Accept.Add(
            new MediaTypeWithQualityHeaderValue("application/json"));

        var response = await _client.GetAsync("/api/products");
        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}

Організація великої тест-сюїти

Коли кількість integration тестів зростає до сотень, організація стає критичною. Хаотичне розміщення тестів призводить до повільного виконання, важкого debugging і проблем з ізоляцією даних.

Рекомендована структура директорій

HttpClient Extension методи для чистішого коду

Повторювані паттерни PostAsJsonAsync, ReadFromJsonAsync, EnsureSuccessStatusCode засмічують тести. Виносьте їх у extension методи:

// HttpClientExtensions.cs
public static class HttpClientExtensions
{
    public static Task<HttpResponseMessage> GetPagedAsync(
        this HttpClient client, string url, int page = 1, int pageSize = 20)
        => client.GetAsync($"{url}?page={page}&pageSize={pageSize}");

    public static async Task<(HttpResponseMessage Response, T? Data)> PostAndGetAsync<T>(
        this HttpClient client, string url, object dto) where T : class
    {
        var response = await client.PostAsJsonAsync(url, dto);
        if (!response.IsSuccessStatusCode)
            return (response, null);

        var data = await response.Content.ReadFromJsonAsync<T>();
        return (response, data);
    }

    public static async Task<T> GetAndAssertOkAsync<T>(
        this HttpClient client, string url) where T : class
    {
        var response = await client.GetAsync(url);
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var data = await response.Content.ReadFromJsonAsync<T>();
        data.Should().NotBeNull();
        return data!;
    }

    public static HttpClient WithBearerToken(this HttpClient client, string token)
    {
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", token);
        return client;
    }

    public static HttpClient AsAdmin(this HttpClient client)
    {
        client.DefaultRequestHeaders.Add("X-Test-Auth", "admin-001");
        client.DefaultRequestHeaders.Add("X-Test-Role", "Admin");
        return client;
    }

    public static HttpClient AsUser(this HttpClient client, string userId = "user-001")
    {
        client.DefaultRequestHeaders.Add("X-Test-Auth", userId);
        client.DefaultRequestHeaders.Add("X-Test-Role", "User");
        return client;
    }
}

// Тепер тести виглядають чистіше
[Fact]
public async Task GetOrders_AsAuthenticatedUser_ReturnsOwnOrders()
{
    var orders = await _client.AsUser("user-001")
        .GetAndAssertOkAsync<List<OrderDto>>("/api/orders");

    orders.Should().NotBeEmpty();
    orders.All(o => o.UserId == "user-001").Should().BeTrue();
}

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

Рівень 1: Validation та Error Handling

Завдання 1.1 — Повне тестування Validation

Для вашого Minimal API ендпоінту POST /api/products реалізуйте FluentValidation та напишіть минімум 12 integration тестів, що покривають:

  • Обов'язкові поля (Name, Sku, Price)
  • Граничні значення (Name не більше 255 симв, Price від 0.01 до 1_000_000)
  • Формат Sku (лише великі літери, цифри, дефіс, максимум 50 симв)
  • Вкладені об'єкти (якщо є)
  • Комбіновані помилки (кілька полів одночасно невалідні)
  • Переконайтесь, що відповідь відповідає RFC 7807 ProblemDetails формату

Завдання 1.2 — Тестування Security

Напишіть тести що перевіряють:

  • XSS в рядкових полях (<script>alert('xss')</script> — або sanitize, або відхилити)
  • SQL injection в рядкових полях (EF Core захищає, але перевірте поведінку)
  • Занадто великі payload (10MB JSON — API не повинен впасти, а повернути 413)
  • Відсутній або неправильний Content-Type заголовок

Рівень 2: Складні сценарії

Завдання 2.1 — Full Lifecycle тест

Реалізуйте повний integration workflow тест для вашого домену (мінімум 6 кроків через HTTP):

Приклад для e-commerce: Реєстрація → Додавання продукту до корзини → Checkout → Підтвердження платежу → Відправка → Отримання трекінгу.

Кожен крок — окремий HTTP запит, що використовує дані з попереднього кроку. Тест повинен бути читабельним як бізнес-документ.

Завдання 2.2 — Pagination та Sorting

Напишіть integration тести для pagination та sorting:

  • Seed 50 продуктів з різними назвами та цінами
  • Перевірте що page=1&pageSize=10 повертає 10 елементів
  • Перевірте що page=5&pageSize=10 повертає останні 10
  • Перевірте що sort=name&order=asc повертає елементи у правильному алфавітному порядку
  • Перевірте headers: X-Total-Count: 50, X-Page-Count: 5
  • Перевірте edge cases: page=0, pageSize=0, pageSize=1001

Рівень 3: Реальна БД + CI

Завдання 3.1 — PostgreSqlApiFixture

Реалізуйте PostgreSqlApiFixture (WebApplicationFactory + Testcontainers) для вашого проєкту. Збіжіть на двох рівнях тестів:

  • Рівень 1: SQLite in-memory (швидкі, без Docker)
  • Рівень 2: Реальна PostgreSQL (повільніші, максимально реалістичні)

Запустіть одні і ті ж тести проти обох рівнів. Задокументуйте відмінності у поведінці (якщо є).

Завдання 3.2 — Contract Testing

Реалізуйте повний набір "contract tests" для вашого API:

  • Кожен ендпоінт має тест на Content-Type заголовок
  • Кожен ендпоінт що повертає ресурс — тест що всі очікувані поля присутні
  • Кожен POST що створює ресурс — тест на Location header
  • Кожен ендпоінт з помилкою — тест на ProblemDetails структуру
  • Swagger/OpenAPI відповідність: якщо є app.MapOpenApi() — порівняйте відповідь з OpenAPI схемою

Підсумок

Ключові думки цієї статті:
  • Validation testing: перевіряйте не лише статус код (400), а й конкретні ключі у errors словнику ProblemDetails. Тест на граничні значення (empty, null, max length) фіксує API контракт.
  • Error handling testing: перевіряйте що внутрішні деталі НЕ потрапляють у відповідь. Тест Does Not Contain("192.168.1.100") — це тест безпеки.
  • Full lifecycle tests: кілька HTTP запитів в одному тесті, де кожен наступний крок використовує дані попереднього. Читабельна документація бізнес-процесу.
  • WebApplicationFactory + Testcontainers: найреалістичніший тип integration тестів. Реальний HTTP pipeline + реальна PostgreSQL. Виправдано для PostgreSQL-specific коду (JSONB, массиви, full-text search).
  • Contract tests: фіксація API контракту — Content-Type, Location, ProblemDetails структура. Якщо клієнти залежать від ваших заголовків — тести захистять від breaking changes.
  • Extension методи: AsAdmin(), AsUser(), GetAndAssertOkAsync<T>() — зменшують дублювання та роблять тести читабельними.
  • Cleanup між тестами: TRUNCATE ... CASCADE або Respawn — набагато швидше ніж recreate schema. Для SQLite in-memory — унікальна назва бази на тест-клас. ::
Наступна стаття — HttpClient Mocking: WireMock.Net та MockHttpMessageHandler.