У цій практичній роботі ми застосуємо отримані знання про WebApplicationFactory для обкатання реального сценарію. Ми створимо невеликий застосунок з управління товарами (Products), розіб'ємо його архітектуру на логічні складові та напишемо інтеграційні тести, які перевірятимуть роботу API "ззовні" — посилаючи справжні HTTP запити до нашого сервера.
Щоб не ускладнювати практику налаштуванням бази даних чи Entity Framework, ми реалізуємо сервіс управління товарами "в пам'яті" (In-Memory). Основний фокус даного матеріалу — базовий сетап тестового середовища та перевірка HTTP-відповідей.
Спочатку нам потрібно створити рішення (solution), головний проєкт Minimal API та проєкт для зберігання тестів. Відкрийте термінал та виконайте наступні команди:
Щоб наш проєкт мав структуру, наближену до реальних застосунків, ми не будемо скидати всю логіку в Program.cs. Натомість, ми виділимо сутності, DTO та окремий сервіс обробки товарів.
В теці StoreApi створіть відповідну структуру папок та файлів:
Тепер налаштовуємо наш Program.cs. Окрім реєстрації сервісів та ендпоінтів, критично важливо не забути відкрити видимість класу Program для тестового проєкту.
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 { }
Щоб проєкт з тестами "бачив" наш API і міг піднімати його у фоновому режимі (in-process), нам потрібно дати посилання на цей проєкт та завантажити два важливі пакети.
Microsoft.AspNetCore.Mvc.Testing — містить саму WebApplicationFactory.FluentAssertions — популярна бібліотека для зручних та "людиночитабельних" Assert'ів.Перейдіть у каталог StoreApi.Tests та видаліть створений за замовчуванням UnitTest1.cs. Натомість, створіть файл ProductEndpointsTests.cs.
Ми використовуватимемо IClassFixture<WebApplicationFactory<Program>>, яка гарантує, що застосунок буде піднятий лише один раз для всіх тестів у цьому класі.
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, проте це виходить за межі даного базового прикладу.Настав час перевірити, чи все ми зробили правильно. Запустіть тести з кореневої папки (з рівня файлу .sln).
🎉 Вітаємо! Ви щойно з нуля підняли тестову інфраструктуру та написали справжні інтеграційні тести, які звертаються до "живого" HTTP pipeline, але не вимагають зовнішнього TCP сервера.
.csproj та файли проєкту для підтримки інтеграційного тестування.Program.cs (public partial class Program).WebApplicationFactory для ініціалізації тестового сервера клієнтом.HttpClient (PostAsJsonAsync).HTTP StatusCode і десеріалізувати відповідь (FluentAssertions).Integration Testing — Частина 1 [Теорія та WebApplicationFactory]
Що таке інтеграційне тестування і де його межі. Чому unit тестів недостатньо. Глибоке розуміння WebApplicationFactory — як вона працює всередині, як підмінити залежності, налаштувати середовище та організувати тести для ASP.NET Minimal API.
Integration Testing — Частина 2 [Просунуті Сценарії та Testcontainers]
Глибоке тестування validation, ProblemDetails та глобальної обробки помилок. Тестування складних бізнес-сценаріїв через HTTP. WebApplicationFactory разом із Testcontainers для реальної PostgreSQL. Організація великої тест-сюїти.