Одна з найбільш занедбаних областей integration тестування — перевірка того, як API реагує на невалідні дані. Розробники часто пишуть тести на happy path (валідні дані → 200 OK), але залишають без уваги сотні можливих сценаріїв невалідного введення.
Чому це важливо? Уявіть, що ваш POST ендпоінт приймає CreateOrderDto. Ви визначили FluentValidation правила: ім'я обов'язкове (Required), сума має бути більше нуля (GreaterThan(0)), email має бути у правильному форматі. Але хто перевіряє, що ці правила справді застосовуються до HTTP запиту? Unit тест валідатора перевіряє логіку валідатора. Але чи правильно ваш middleware підключений? Чи повертається 400 Bad Request а не 500? Чи правильна структура тіла помилки?
Саме на ці питання відповідають integration тести для validation.
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; }
}
Розглянемо ситуацію детально. У вас є такий 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 контракті.
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-ить" помилки.
Найбільша цінність integration тестів — перевірка повних бізнес-сценаріїв: створення замовлення → підтвердження → відправка → отримання. Кожен крок — окремий HTTP запит. Такі тести документують і верифікують бізнес-процеси на рівні API контракту.
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. Якщо бізнес-правила зміняться — тест одразу вкаже де саме.
До цього ми замінювали PostgreSQL на SQLite in-memory у тестах. Але є сценарії, де SQLite недостатньо — наприклад, якщо ваш код використовує PostgreSQL-специфічні функції (JSONB пошук, масиви, повнотекстовий пошук).
Об'єднання WebApplicationFactory з Testcontainers дає найбільш реалістичний тип integration тестів: реальний ASP.NET pipeline + реальна PostgreSQL у Docker.
Ключова ідея: WebApplicationFactory і PostgreSqlContainer ізольовані незалежно. Нам потрібно:
IAsyncLifetime)WebApplicationFactory при конфігурації// 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 контейнера
}
}
[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> { }
Одна з найбільш важливих і часто ігнорованих частин 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 і проблем з ізоляцією даних.
Повторювані паттерни 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 тестів, що покривають:
Завдання 1.2 — Тестування Security
Напишіть тести що перевіряють:
<script>alert('xss')</script> — або sanitize, або відхилити)Рівень 2: Складні сценарії
Завдання 2.1 — Full Lifecycle тест
Реалізуйте повний integration workflow тест для вашого домену (мінімум 6 кроків через HTTP):
Приклад для e-commerce: Реєстрація → Додавання продукту до корзини → Checkout → Підтвердження платежу → Відправка → Отримання трекінгу.
Кожен крок — окремий HTTP запит, що використовує дані з попереднього кроку. Тест повинен бути читабельним як бізнес-документ.
Завдання 2.2 — Pagination та Sorting
Напишіть integration тести для pagination та sorting:
page=1&pageSize=10 повертає 10 елементівpage=5&pageSize=10 повертає останні 10sort=name&order=asc повертає елементи у правильному алфавітному порядкуX-Total-Count: 50, X-Page-Count: 5Рівень 3: Реальна БД + CI
Завдання 3.1 — PostgreSqlApiFixture
Реалізуйте PostgreSqlApiFixture (WebApplicationFactory + Testcontainers) для вашого проєкту. Збіжіть на двох рівнях тестів:
Запустіть одні і ті ж тести проти обох рівнів. Задокументуйте відмінності у поведінці (якщо є).
Завдання 3.2 — Contract Testing
Реалізуйте повний набір "contract tests" для вашого API:
app.MapOpenApi() — порівняйте відповідь з OpenAPI схемоюerrors словнику ProblemDetails. Тест на граничні значення (empty, null, max length) фіксує API контракт.Does Not Contain("192.168.1.100") — це тест безпеки.AsAdmin(), AsUser(), GetAndAssertOkAsync<T>() — зменшують дублювання та роблять тести читабельними.TRUNCATE ... CASCADE або Respawn — набагато швидше ніж recreate schema. Для SQLite in-memory — унікальна назва бази на тест-клас.
::Інтеграційне тестування — Практика
Покроковий посібник зі створення Minimal API застосунку з нуля та написання для нього справжніх інтеграційних тестів за допомогою WebApplicationFactory.
Професійний Postman: Колекції, Змінні та GitHub Інтеграція
Від разових запитів до автоматизованих тестових сюїт — опановуємо Postman як інженерний інструмент. Колекції, змінні, Pre-request Scripts, тестові assertions та запуск у CI/CD через Newman.