Тесты ASP.NET Core — юнит и интеграция
Тесты ASP.NET Core — юнит и интеграция
Юнит и интеграция в одном проекте
| Уровень | Проверяет | С чего начать |
|---|---|---|
| Юнит | action, ModelState, логика контроллера | Moq + HomeController |
| Интеграция | Program.cs, middleware, маршруты | WebApplicationFactory, GET /health |
Сначала юнит на изолированный action, затем один smoke-тест всего pipeline.
Где применяют интеграционные тесты
Юнит-тест проверяет один класс: вы вызываете метод напрямую, зависимости подменяете моками. Интеграционный тест поднимает почти настоящее приложение — с Program.cs, middleware, маршрутами, JSON-сериализацией — и шлёт запросы как внешний клиент через HttpClient.
Так ловят ошибки, которые юнит не увидит:
- забыли
app.UseAuthentication(); - опечатка в
[Route]; - неверный порядок middleware;
- POST без
Content-Type: application/json.
В ASP.NET Core для этого есть WebApplicationFactory<Program>: тестовый хост в памяти, без ручного выбора порта в браузере.
Опираемся на API из Первая программа на ASP.NET Core. С JWT — Identity — JWT, cookie и MVC. Solution в стиле Clean Architecture — Clean Architecture на ASP.NET Core, MediatR — MediatR и pipeline в слое Application. Контекст отладки: /encyclopedia/4-code-dev/4-14-razrabotka-i-otladka/intro.
Словарь
| Термин | Простыми словами |
|---|---|
| xUnit | Популярный фреймворк тестов в .NET ([Fact], dotnet test). |
| WebApplicationFactory | Создаёт тестовый экземпляр вашего веб-приложения. |
| Test server | HTTP обрабатывается в памяти, без реального сетевого сокета. |
| Fixture | Общая настройка на класс тестов (IClassFixture<...>). |
| InMemory DB | EF Core "база" в RAM для быстрых тестов (с ограничениями). |
| Moq | Библиотека заглушек: подмена IProductRepository и других интерфейсов. |
| ControllerContext | Окружение MVC для вызова action без HTTP-сервера. |
Что получится
| Тест | Проверка |
|---|---|
Health_ReturnsOk | GET /health → 200 |
PostNote_ThenGetAll | POST + GET, тело JSON |
| (опционально) | Защищённый endpoint с Bearer после login |
Подготовка решения
Если API ещё нет — создайте по Первая программа на ASP.NET Core:
dotnet new webapi -n HelloApi -o HelloApi --use-controllers
cd HelloApi
Разбор:
dotnet new webapiсоздаёт стартовый API-проект с готовым HTTP pipeline.- Флаг
--use-controllersвыбирает controller-based стиль вместо чистого Minimal API. -nзадаёт имя проекта иcsproj, а-oсоздаёт отдельную папку для исходников.cd HelloApiпереводит терминал в каталог проекта для следующих команд сборки и тестов.
Добавьте endpoint (если нет):
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
Разбор:
MapGet("/health", ...)добавляет простой endpoint для проверки живости сервиса.Results.Ok(...)возвращает HTTP200и JSON-тело.- Анонимный объект
{ status = "ok" }сериализуется стандартнымSystem.Text.Json. - Такой endpoint используют как smoke-check в CI и в первых интеграционных тестах.
public partial class Program
В .NET 6+ Program.cs часто без явного класса. Тестам нужен тип Program:
app.Run();
public partial class Program { }
Разбор:
app.Run()запускает web-host и начинает обработку HTTP-запросов.public partial class Program { }создаёт тип, который нуженWebApplicationFactory<Program>.- Без этого класса тестовый проект часто не может сослаться на entry point приложения.
- Приём безопасен и стандартен для .NET 6+ minimal-hosting модели.
В конце Program.cs — стандартный приём для WebApplicationFactory<Program>.
Проект тестов
Из корня решения:
dotnet new xunit -n HelloApi.Tests -o HelloApi.Tests
dotnet add HelloApi.Tests reference HelloApi
dotnet add HelloApi.Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet sln add HelloApi.Tests/HelloApi.Tests.csproj
Разбор:
dotnet new xunitсоздаёт отдельный проект тестов на xUnit.dotnet add ... reference HelloApiдаёт тестам доступ к типуProgramи общим контрактам.- Пакет
Microsoft.AspNetCore.Mvc.TestingдобавляетWebApplicationFactoryи test host. dotnet sln addвключает тестовый проект в solution для запуска всей командойdotnet test.
Microsoft.AspNetCore.Mvc.Testing подтягивает test host и фабрику приложения.
Базовый тест
HelloApi.Tests/HealthEndpointTests.cs:
Код ITЗагрузка примера кода…
Разбор:
IClassFixture<WebApplicationFactory<Program>>поднимает приложение один раз на класс тестов.factory.CreateClient()выдаётHttpClient, направленный на in-memory test server.- В тесте выполняется реальный HTTP-запрос
GET /healthчерез весь pipeline приложения. Assert.Equal(HttpStatusCode.OK, ...)проверяет статус, аAssert.Containsвалидирует JSON-ответ.- Такой кейс подтверждает корректность маршрутизации, сериализации и старта приложения.
dotnet test
Разбор:
dotnet testсобирает решение, запускает все тесты и выводит итог по passed/failed.- Команда удобна для локального smoke-прогона и подключения в CI pipeline.
- При падении здесь сразу видно, что инфраструктурная проверка API не проходит.
Чек-лист стабильности тестов
| Проверка | Почему важно |
|---|---|
| Тесты независимы друг от друга | Избегаете "плавающих" падений |
| Данные теста уникальны | Нет конфликтов между кейсами |
Явный Testing environment | Не подключаются внешние прод-сервисы |
| Детальные assert'ы | Быстрее находите причину падения |
Что происходит внутри
| Шаг | Смысл |
|---|---|
IClassFixture<WebApplicationFactory<Program>> | Одна фабрика на класс — приложение поднимается один раз. |
factory.CreateClient() | HttpClient с BaseAddress на test server. |
GetAsync("/health") | Путь от корня; ведущий / важен. |
Assert.Equal | Проверка статуса и тела. |
Тест CRUD
При наличии NotesController из Первая программа на ASP.NET Core:
Код ITЗагрузка примера кода…
Разбор:
PostAsJsonAsyncсериализует объект в JSON и автоматически задаётContent-Type.- После POST вызывается
EnsureSuccessStatusCode(), чтобы тест падал на любом неуспешном коде. GetFromJsonAsync<List<NoteDto>>читает ответ и десериализует его в типизированную коллекцию.Assert.Containsпроверяет бизнес-результат: созданная заметка реально появилась в выдаче.- Этот тест покрывает связку create+read как единый пользовательский сценарий.
PostAsJsonAsync сериализует объект в JSON и ставит заголовок Content-Type: application/json — как настоящий клиент.
Подмена базы данных
Интеграционные тесты не должны писать в продакшен-PostgreSQL.
| Вариант | Когда |
|---|---|
| EF InMemory | Быстро; нет настоящего SQL |
SQLite :memory: | Ближе к SQL |
| Testcontainers | Реальный PostgreSQL в Docker на CI |
HelloApi.Tests/CustomWebApplicationFactory.cs:
Код ITЗагрузка примера кода…
Разбор:
- Класс наследует
WebApplicationFactory<Program>и кастомизирует DI только для тестов. SingleOrDefaultнаходит регистрациюDbContextOptions<AppDbContext>из production-настройки.services.Remove(descriptor)удаляет исходную конфигурацию БД, чтобы избежать записи в внешнюю среду.UseInMemoryDatabase("TestsDb")подставляет быструю in-memory БД для изолированных прогонов.UseEnvironment("Testing")включает специальную ветку конфигурации приложения под тестовый контур.
Тест:
public class NotesDbTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public NotesDbTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
// те же HTTP-вызовы
}
Разбор:
- В тестах используется уже
CustomWebApplicationFactory, а не базовая фабрика. - Клиент создаётся из этой фабрики и автоматически работает с подменённой БД.
- Шаблон позволяет переиспользовать одну инфраструктурную настройку на весь набор тестов.
- Дальше в этом классе пишутся обычные HTTP-вызовы, но в безопасном тестовом окружении.
UseInMemoryDatabase не эмулирует все ограничения SQL Server. Для raw SQL и миграций — SQLite или Testcontainers.
В Program.cs для внешних сервисов:
if (!app.Environment.IsEnvironment("Testing"))
{
// подключение к реальной почте, платежам и т.д.
}
Разбор:
- Проверка
IsEnvironment("Testing")защищает интеграционные тесты от внешних side effects. - В условие помещают реальные интеграции — SMTP, платежи, очереди, вебхуки.
- В тестовом окружении блок пропускается, и тесты остаются детерминированными.
- Такой guard делает CI безопаснее и быстрее при большом количестве прогонов.
Подход к наборам тестов
На практике удобна структура из трёх уровней:
- smoke-тесты (проверка запуска и базовых endpoint'ов);
- сценарные тесты (регистрация, CRUD, ошибки валидации);
- тесты безопасности (JWT, роли, запреты доступа).
Такой набор покрывает и стабильность инфраструктуры, и бизнес-потоки.
Тест API с JWT
Для Identity — JWT, cookie и MVC:
var login = await _client.PostAsJsonAsync("/login",
new { email = "test@local", password = "Passw0rd!" });
login.EnsureSuccessStatusCode();
var auth = await login.Content.ReadFromJsonAsync<AuthDto>();
_client.DefaultRequestHeaders.Authorization =
new("Bearer", auth!.Token);
var secured = await _client.GetAsync("/api/notes");
secured.EnsureSuccessStatusCode();
private record AuthDto(string Token, DateTime ExpiresAt);
Разбор:
- Сначала тест получает JWT через
POST /loginи проверяет успешный ответ. ReadFromJsonAsync<AuthDto>()извлекает токен из тела ответа в типизированную запись.- Заголовок
DefaultRequestHeaders.Authorization = new("Bearer", token)настраивает клиент для следующих вызовов. - Далее
GET /api/notesвыполняется уже как авторизованный запрос и должен вернуть успех. - Кейс подтверждает, что связка login+bearer-защита действительно работает end-to-end.
Альтернатива для большого сьюта: в ConfigureTestServices подставить тестового ClaimsPrincipal без реального JWT — быстрее, но не проверяет подпись токена.
Юнит-тесты MVC-контроллера
Интеграционный тест ловит опечатку в [Route]. Юнит-тест отвечает на вопрос — при заданных данных из репозитория action возвращает ожидаемый ViewResult? Контроллер создаётся вручную, зависимости подменяются Moq, HTTP-сервер не поднимается.
Пример из учебного MVC
Контроллер каталога зависит от репозитория, а не от EF напрямую — так проще тестировать:
StoreMvc/Services/IProductRepository.cs:
namespace StoreMvc.Services;
public interface IProductRepository
{
IReadOnlyList<Product> GetAll();
}
public record Product(int Id, string Name, decimal Price);
StoreMvc/Controllers/HomeController.cs:
Код ITЗагрузка примера кода…
Проект тестов и Moq
dotnet new xunit -n StoreMvc.Tests -o StoreMvc.Tests
dotnet add StoreMvc.Tests reference StoreMvc
dotnet add StoreMvc.Tests package Moq
dotnet add StoreMvc.Tests package Microsoft.AspNetCore.Mvc
StoreMvc.Tests/HomeControllerTests.cs:
Код ITЗагрузка примера кода…
Разбор:
Mock<IProductRepository>задаёт поведениеGetAll()без базы данных.controller.Index()вызывает action напрямую — маршрутизация здесь не участвует.Assert.IsType<ViewResult>проверяет тип результата, а не HTML-разметку.Verify(..., Times.Once)убеждается, что контроллер обратился к репозиторию ровно один раз.
POST и ModelState
В админке POST обычно проверяют через ModelState. Упрощённый action:
public IActionResult Create(Product product)
{
if (!ModelState.IsValid)
return View(product);
// сохранение в репозиторий
return RedirectToAction(nameof(Index));
}
Тест:
[Fact]
public void Create_InvalidModel_ReturnsView()
{
var repo = new Mock<IProductRepository>();
var controller = new ProductsController(repo.Object); // ваш контроллер с Create
controller.ModelState.AddModelError("Name", "Обязательное поле");
var result = controller.Create(new Product(0, "", 0));
Assert.IsType<ViewResult>(result);
}
Antiforgery в юнит-тестах обычно не поднимают; проверку CSRF оставляют интеграционным сценариям с cookie (Identity — JWT, cookie и MVC).
Когда юнит-теста недостаточно
| Симптом | Добавьте интеграционный тест |
|---|---|
| 404 при правильном action | Маршруты, MapControllerRoute, Areas |
401 при [Authorize] | UseAuthentication, cookie или JWT |
| JSON не тот регистр | System.Text.Json options в Program.cs |
| Ошибка только с реальной БД | CustomWebApplicationFactory ниже |
Сравнение уровней
| Юнит (Moq) | WebApplicationFactory | |
|---|---|---|
| Проверяет | Логику action, View/Redirect | Pipeline, routing, JSON, auth |
| Скорость | Миллисекунды | Сотни мс на подъём хоста |
Program.cs | Не читает | Читает целиком |
| Репозиторий | Mock | Реальный или InMemory DB |
Пирамида для типичного MVC — много юнит-тестов на контроллеры и сервисы, меньше интеграционных на критичные сценарии (оформление заказа, login, админка). Подробнее про тестирование в целом — /encyclopedia/7-project/7-05-testirovanie/intro.
Частые ошибки
| Симптом | Решение |
|---|---|
Program не найден | public partial class Program { } |
| 404 в тесте | Путь /health, не health |
| Пустая БД после POST | Подмена DbContext не сработала — проверьте тип в Remove |
| 307 redirect | HTTPS redirect: AllowAutoRedirect = false или тестируйте HTTPS |
| Падает только на CI | Общая InMemory БД — уникальное имя на фабрику |
Что попробовать
- Ожидайте
400на POST с пустым телом (Minimal API и OpenAPI —ValidationProblem). - Testcontainers + PostgreSQL на GitHub Actions.
- Отдельный сьют только для
AuthControllerс тестовымJwt:Keyвappsettings.Testing.json.
Дальше
- Web API — первая программа · Identity — JWT и cookie
- Minimal API и OpenAPI · ASP.NET — обзор
- Справочник ASP.NET
Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.