Протягом попередніх статей ми досліджували unit тести — ізольовані перевірки окремого класу чи методу, де всі зовнішні залежності замінені моками. Unit тести — швидкі, стабільні та дешеві. Але вони мають принципове обмеження: вони перевіряють компоненти ізольовано від одне одного.
Уявіть, що кожен музикант у оркестрі відмінно грає свою партію на репетиції соло. Але чи означає це, що оркестр звучатиме чудово разом? Не обов'язково — виникнуть питання темпу, взаємодії між інструментами, загальної динаміки, що неможливо перевірити окремо.
Саме тут з'являються інтеграційні тести: вони перевіряють як компоненти працюють разом. Вони відповідають на питання, на які unit тести відповісти не можуть:
/api/orders/{id} дійсно викликає правильний handler?createdAt відправляється у форматі ISO 8601?Жоден з цих аспектів неможливо перевірити, тестуючи сервіси та репозиторії в ізоляції.
Одна з найпоширеніших помилок у .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 тестів.
Коли ми кажемо "інтеграційний тест для ASP.NET Minimal API", ми маємо на увазі тест, що:
1. Піднімає весь ASP.NET додаток — зі всіма middleware, ендпоінтами, фільтрами, health checks.
2. Надсилає реальний HTTP запит — через HttpClient, який взаємодіє зі справжнім HTTP pipeline, а не напряму викликає метод.
3. Отримує HTTP відповідь і перевіряє:
4. Але при цьому може замінити зовнішні залежності (база даних, email сервіс, платіжний шлюз) на тестові дублі або in-memory реалізації.
Ось що відрізняє integration тест від end-to-end тест: e2e тест піднімає реальний сервер з реальною базою і реальним email — integration тест замінює те, що не потрібно перевіряти.
До появи WebApplicationFactory у ASP.NET Core 2.1 розробники мали кілька незручних варіантів для 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>HttpClient, який відправляє запити у TestServer, а не в реальний мережевий порт.TestServer, якщо потрібні низькорівневі перевірки.Коли ви пишете 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 та серіалізації, але залишається швидким і стабільним.
Найпоширеніша помилка при першому знайомстві з WebApplicationFactory — помилка компіляції: Program не доступний з тестового проєкту.
Це відбувається тому, що у .NET 6+ Program.cs використовує top-level statements, і клас Program генерується як internal за замовчуванням. Тестовий проєкт зовні не бачить internal типи.
Рішення: public partial class Program
Program у Minimal APIinternal типів, якщо треба відкрити кілька класів одразу.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.
Розглянемо повний приклад покроково. Є простий 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 { }
Тепер тестовий проєкт:
ProductEndpointsTests200 OK і порожній список продуктів.404 Not Found.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();
}
}
Що відбувається при запуску цього тесту:
WebApplicationFactory<Program> один раз (завдяки IClassFixture)Program.cs і піднімає весь додаток — DI, middleware, ендпоінтиfactory.CreateClient() повертає HttpClient підключений до TestServer_client.GetAsync("/api/products") проходить через весь реальний ASP.NET pipeline:
/api/products)IProductService і його залежності)HttpResponseMessage і перевіряємо їїТут важливо помітити логіку: тест не знає нічого про внутрішню реалізацію сервісу, але він бачить результат роботи всього ланцюжка. Саме тому один такий тест може одразу зловити проблему, яка в unit-тестах виглядала б нормально, бо там були замокані всі залежності.
Найважливіша можливість WebApplicationFactory — замінити production реєстрації на тестові. Це дозволяє уникнути реальної бази даних або зовнішніх сервісів у integration тестах.
Є кілька способів це зробити. Найчистіший — через WithWebHostBuilder:
WithWebHostBuilder зручний тоді, коли вам потрібно змінити тільки один або кілька сервісів для конкретного сценарію. Ідея проста: базовий хост лишається тим самим, але перед створенням клієнта ви вносите тестову правку в контейнер залежностей. Це краще, ніж намагатися змінювати production-код лише заради одного тесту.
ConfigureTestServicesProgram.cs, не торкаючись production-коду.DbContextOptions<T>.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.
Якщо підміни залежностей однакові для кількох тест-класів, варто винести їх у власний підклас WebApplicationFactory:
Це вже не просто зручність, а нормальна організація тестового коду. Коли ви бачите повторювані заміни сервісів у кількох тестах, значить ця логіка належить не в самі тести, а в окрему тестову фабрику. По суті це ваш “тестовий startup”, де зібрані всі правила для запуску app у тестовому режимі.
TestWebApplicationFactory::field{name="UseEnvironment("Testing")" type="method"} Перемикає додаток у тестове середовище, щоб production-branching міг поводитися інакше. ::
// 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 і ролями.
TestAuthHandlerClaimsPrincipal і повертає успішну або порожню аутентифікацію.AddAuthentication("Test").// 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));
}
}
LimitedTestAuthHandlerAdmin ролі, щоб протестувати 403 Forbidden.ClaimsPrincipal.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);
}
Оскільки 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 відповіді:
System.Text.Json)Рівень 2: Аутентифікація та авторизація
Завдання 2.1 — TestAuthHandler повна реалізація
Реалізуйте TestAuthHandler для вашого проєкту. Він повинен підтримувати:
X-Test-User-IdX-Test-Role (Admin, Manager, User)Напишіть по 3 тести для кожного рольового сценарію:
DELETE, PUTPOST, PUT, але не DELETEGETЗавдання 2.2 — Тест middleware
Для вашого API додайте rate limiting middleware (обмеження 5 запитів на хвилину). Напишіть integration тест, що:
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() — очистка між тест-класамиНапишіть 15+ тестів різних сценаріїв, що використовують цю інфраструктуру.
public partial class Program { } — один рядок у кінці Program.cs, що робить тип доступним для WebApplicationFactory з тестового проєкту.IClassFixture<WebApplicationFactory> — окрема ізольована фабрика. При ICollectionFixture потрібен явний reset даних.
::Тестування Баз Даних — EF Core, SQLite та Testcontainers
Повний огляд трьох стратегій тестування з базами даних у .NET. EF Core InMemory для unit тестів, SQLite як наближений замінник, і Testcontainers для тестування з реальною PostgreSQL у Docker-контейнері.
Інтеграційне тестування — Практика
Покроковий посібник зі створення Minimal API застосунку з нуля та написання для нього справжніх інтеграційних тестів за допомогою WebApplicationFactory.