Перейти к основному содержимому

Тесты 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 serverHTTP обрабатывается в памяти, без реального сетевого сокета.
FixtureОбщая настройка на класс тестов (IClassFixture<...>).
InMemory DBEF Core "база" в RAM для быстрых тестов (с ограничениями).
MoqБиблиотека заглушек: подмена IProductRepository и других интерфейсов.
ControllerContextОкружение MVC для вызова action без HTTP-сервера.

Что получится

ТестПроверка
Health_ReturnsOkGET /health → 200
PostNote_ThenGetAllPOST + 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(...) возвращает HTTP 200 и 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-вызовы, но в безопасном тестовом окружении.
InMemory и транзакции

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/RedirectPipeline, 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 redirectHTTPS redirect: AllowAutoRedirect = false или тестируйте HTTPS
Падает только на CIОбщая InMemory БД — уникальное имя на фабрику

Что попробовать

  1. Ожидайте 400 на POST с пустым телом (Minimal API и OpenAPIValidationProblem).
  2. Testcontainers + PostgreSQL на GitHub Actions.
  3. Отдельный сьют только для AuthController с тестовым Jwt:Key в appsettings.Testing.json.

Дальше


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.