
Автоматизированное тестирование .NET и Angular-приложения: от Unit-тестов до E2E
Современную разработку программного обеспечения трудно представить без надёжного автоматизированного тестирования.
Существует множество видов автоматизированных тестов, а выбор конкретного набора зависит от целей, архитектуры и масштаба проекта. Чем лучше протестировано приложение, тем ниже вероятность того, что изменения приведут к неожиданным ошибкам. Однако тесты тоже требуют ресурсов: их необходимо проектировать, писать, запускать и поддерживать.
Поэтому вопрос баланса между разработкой новой функциональности и расширением тестового покрытия остаётся актуальным практически для любого проекта.
В этой статье рассмотрим подход к тестированию небольших и средних приложений. Представленный набор тестов и способ их реализации основаны на моём практическом опыте и не претендуют на роль единственно правильного решения.
Для небольших проектов я обычно использую три уровня автоматизированного тестирования:
- unit-тесты;
- интеграционные API-тесты;
- E2E-тесты.
В качестве тестируемого приложения возьмём текущую версию сайта serdg.net.
Технологический стек
Технологический стек приложения
Angular 21 → .NET 10 Web API → Entity Framework Core → Microsoft SQL Server
Технологический стек тестирования
NUnit + WebApplicationFactory + Playwright + Moq + SQLite
В идеальном случае эти три уровня образуют своеобразную пирамиду: unit-тестов должно быть много, интеграционных тестов — меньше, а E2E-тесты должны покрывать только наиболее важные пользовательские сценарии.
Unit-тесты
В рамках этой статьи подробно останавливаться на unit-тестах не будем: об их назначении, структуре и подходах к написанию уже сказано достаточно.
Отметим только их основную зону ответственности. Unit-тесты проверяют отдельные части приложения изолированно от базы данных, файловой системы, сети и других внешних зависимостей.
Особенно полезны они для кода, который:
- содержит сложную бизнес-логику;
- выполняет критически важные вычисления;
- имеет большое количество граничных случаев;
- должен быстро проверяться при каждом изменении.
Однако одних unit-тестов недостаточно. Даже если каждый компонент приложения корректно работает в изоляции, ошибки могут возникать при взаимодействии компонентов друг с другом.
Именно здесь становятся полезны интеграционные API-тесты.
Интеграционные API-тесты
В интеграционных API-тестах серверная часть приложения рассматривается как единое целое.
Тест отправляет HTTP-запрос и анализирует HTTP-ответ, не вызывая напрямую контроллеры, сервисы или репозитории. С этой точки зрения приложение действительно напоминает чёрный ящик: мы управляем входными данными и проверяем результат.
Такой подход позволяет протестировать сразу несколько уровней приложения:
- маршрутизацию;
- middleware;
- авторизацию;
- валидацию входных данных;
- контроллеры;
- бизнес-логику;
- сериализацию;
- работу с базой данных.
При этом тесты должны оставаться запускаемыми, предсказуемыми и повторяемыми не только локально, но и в CI/CD.
Для решения этой задачи в ASP.NET Core используется WebApplicationFactory.
Настройка WebApplicationFactory
WebApplicationFactory<TEntryPoint> позволяет создать тестовый хост на основе
конфигурации основного приложения.
По умолчанию фабрика использует TestServer, благодаря чему HTTP-запросы можно выполнять
без запуска отдельного сетевого процесса. При этом тестовое приложение проходит практически тот же
процесс конфигурации, что и рабочая версия.
Основной интерес представляет метод:
void ConfigureWebHost(IWebHostBuilder builder)
В нём можно изменить окружение, конфигурацию, логирование и набор зарегистрированных зависимостей.
Пример настройки фабрики:
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Определение окружения
builder.UseEnvironment("Testing");
// Загрузка специфичных для окружения настроек из application settings
builder.ConfigureAppConfiguration((context, configBuilder) =>
{
configBuilder.AddJsonFile(
"appsettings.Test.json",
optional: false,
reloadOnChange: false);
});
// Настройка логирования
builder.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.AddConsole();
logging.SetMinimumLevel(LogLevel.Warning);
});
// Передача параметров JWT
builder.ConfigureServices((context, services) =>
{
JwtSettings = context.Configuration
.GetSection("JWTSettings")
.Get<JwtSettings>()!;
});
builder.ConfigureTestServices(services =>
{
_configureTestServices?.Invoke(services);
// Замена зависимостей для базы данных
services.RemoveAll<SdContext>();
services.RemoveAll<DbContextOptions<SdContext>>();
services.RemoveAll<DbConnection>();
services.RemoveAll<IDbContextFactory<SdContext>>();
// Создание и открытие соединения с БД
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
services.AddSingleton<DbConnection>(_connection);
services.AddDbContextFactory<SdContext>((serviceProvider, options) =>
{
var connection = serviceProvider.GetRequiredService<DbConnection>();
options.UseSqlite(connection);
});
// ...
});
}
Здесь важно обратить внимание на несколько моментов.
Отдельное тестовое окружение
Вызов:
builder.UseEnvironment("Testing");
позволяет отличать тестовый запуск приложения от Development и
Production.
Это удобно, если отдельные части конфигурации должны работать только во время тестирования.
Отдельный файл настроек
Файл appsettings.Test.json содержит параметры, предназначенные исключительно
для тестов.
Например, в нём можно определить:
- тестовые JWT-настройки;
- настройки логирования;
- значения feature flags;
- другие параметры тестового окружения.
Секреты рабочего приложения не должны использоваться в тестах.
Замена базы данных
Рабочая конфигурация Microsoft SQL Server заменяется на SQLite, работающую в памяти.
Это не то же самое, что EF Core InMemory Provider. SQLite остаётся реляционной базой данных и поддерживает многие характерные для SQL ограничения:
- внешние ключи;
- уникальные индексы;
- транзакции;
- SQL-запросы;
- реляционную модель данных.
Благодаря этому поведение тестов оказывается ближе к поведению реальной базы данных.
Важная особенность SQLite in-memory заключается в том, что база существует до тех пор, пока открыто соединение. Поэтому соединение создаётся один раз, регистрируется как singleton и остаётся открытым до уничтожения фабрики.
Базовый класс API-тестов
Следующим шагом создадим базовый класс, от которого будут наследоваться тесты контроллеров.
В нём находятся:
- экземпляр
WebApplicationFactory; HttpClient;- общая подготовка тестовых данных;
- настройка авторизации;
- замена внешних зависимостей;
- очистка ресурсов после выполнения теста.
public abstract class BaseController
{
protected HttpClient Client { get; private set; } = null!;
protected CustomWebApplicationFactory<Program> Factory { get; private set; } = null!;
protected Mock<ISmtpClientWrapper> SmtpClientWrapper { get; private set; } = null!;
[SetUp]
public async Task SetUp()
{
SmtpClientWrapper = new Mock<ISmtpClientWrapper>(MockBehavior.Strict);
Factory = new CustomWebApplicationFactory<Program>(services =>
{
services.RemoveAll<ISmtpClientWrapper>();
services.AddSingleton<ISmtpClientWrapper>(SmtpClientWrapper.Object);
});
Client = Factory.CreateClient(new WebApplicationFactoryClientOptions());
// Сброс БД до начального состояния перед каждым тестом
await Factory.ResetDatabaseAsync();
AuthorizeClient();
}
[TearDown]
public void TearDown()
{
Client.Dispose();
Factory.Dispose();
}
// ...
}
Методы [SetUp] и [TearDown] являются стандартными механизмами NUnit.
Метод SetUp() выполняется перед каждым тестом, а TearDown() — после него.
Во время подготовки теста мы заменяем реальную реализацию
ISmtpClientWrapper объектом Moq. Это необходимо, чтобы тесты не отправляли настоящие
электронные письма.
По такому же принципу можно заменить:
- SMTP-клиенты;
- CAPTCHA-сервисы;
- платёжные системы;
- облачные хранилища;
- внешние HTTP API;
- другие интеграции, выходящие за границы тестируемого приложения.
Сброс базы данных
Соединение с SQLite остаётся открытым в течение времени жизни фабрики, однако перед каждым тестом база данных возвращается в исходное состояние:
await Factory.ResetDatabaseAsync();
Метод ResetDatabaseAsync() может:
- удалить существующую схему;
- создать её заново;
- применить миграции;
- добавить начальный набор тестовых данных.
Конкретная реализация зависит от архитектуры проекта.
Главное требование заключается в том, чтобы каждый тест начинал работу с известным и предсказуемым состоянием базы данных.
Тесты не должны зависеть от порядка запуска и от данных, оставшихся после предыдущих сценариев.
Пример API-теста
После такой настройки интеграционный тест выглядит достаточно просто:
[TestFixture]
[NonParallelizable]
public class TopicControllerTests : BaseController
{
private const string TopicBasePath = "/api/Topic";
[Test]
public async Task GetAll_WithValidData_ReturnsValidResponse()
{
var expectedCount = TestData.GetTopics().Count();
using var response = await Client.GetAsync(TopicBasePath);
response.Should().NotBeNull();
response.StatusCode.Should().Be(HttpStatusCode.OK);
var responseContent = await response.Content.ReadAsStringAsync();
var topics = JsonConvert.DeserializeObject<TopicResponse>(responseContent);
topics.Should().NotBeNull();
topics!.Items.Should().NotBeNull();
topics.Items.Count.Should().Be(expectedCount);
}
}
Тест выполняет настоящий HTTP-запрос к тестовому приложению и проверяет:
- статус ответа;
- возможность десериализации;
- наличие данных;
- количество полученных элементов.
В отличие от unit-теста здесь не создаётся экземпляр контроллера вручную и не вызывается его метод напрямую. Запрос проходит через HTTP-конвейер приложения.
Параллельный запуск тестов
В приведённом примере тестовый класс отмечен атрибутом:
[NonParallelizable]
Это означает, что NUnit не будет запускать тесты данного fixture параллельно с другими тестами.
Такой подход полезен, если тесты используют общие ресурсы:
- одно соединение с базой данных;
- один набор начальных данных;
- общие статические настройки;
- один экземпляр тестового сервера;
- фиксированные сетевые порты.
При большом количестве тестов последовательное выполнение может заметно увеличить общее время
запуска. Поэтому NonParallelizable стоит рассматривать как практичный начальный
вариант, а не как обязательное правило.
Для поддержки параллельного запуска каждому тесту или fixture потребуется предоставить изолированные ресурсы: отдельную базу данных, отдельную фабрику и, при необходимости, отдельный сетевой порт.
Вложенные тестовые классы также лучше использовать только тогда, когда они действительно улучшают
структуру тестов. Они поддерживаются NUnit, но могут усложнить понимание жизненного цикла fixture
и методов SetUp/TearDown.
После завершения этого этапа мы получаем API, поведение которого проверяется через HTTP и воспроизводится как локально, так и в CI/CD.
Переход к E2E-тестам
Теперь можно перейти к E2E-тестированию.
Значительная часть необходимой инфраструктуры уже создана:
- тестовое серверное приложение запускается;
- база данных создаётся и заполняется;
- внешние зависимости можно заменить mock-объектами;
- сервер возвращает предсказуемые результаты.
Остаётся добавить пользовательский интерфейс и выполнять проверки через браузер, а не обращаться к API напрямую.
Поскольку Angular-приложение поддерживает Server-Side Rendering, для E2E-тестов необходимо запустить Angular SSR-сервер.
Запуск Angular SSR-сервера
Для управления Node.js-процессом используется класс AngularSsrServer.
public sealed class AngularSsrServer : IAsyncDisposable
{
private const string AngularSsrServerPathKey = "AngularSsr:ServerPath";
private const string E2eSettingsFileName = "appsettings.E2E.json";
private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);
private readonly HttpClient _httpClient;
private readonly Process _process;
private readonly StringBuilder _stderr = new();
private readonly StringBuilder _stdout = new();
private AngularSsrServer(Process process, Uri baseAddress)
{
_process = process;
BaseAddress = baseAddress;
_httpClient = new HttpClient
{
BaseAddress = baseAddress,
Timeout = TimeSpan.FromSeconds(2)
};
}
public Uri BaseAddress { get; }
public string ProcessOutput => GetProcessOutput();
public static async Task<AngularSsrServer> StartAsync(Uri apiBaseAddress)
{
var serverPath = GetAngularSsrServerPath();
var port = GetFreeTcpPort();
var baseAddress = new Uri($"http://127.0.0.1:{port}");
var startInfo = new ProcessStartInfo
{
FileName = "node",
WorkingDirectory = Path.GetDirectoryName(serverPath)!,
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true
};
startInfo.ArgumentList.Add(serverPath);
startInfo.Environment["PORT"] = port.ToString();
startInfo.Environment["SERDG_API_BASE_URL"] = apiBaseAddress.ToString().TrimEnd('/');
startInfo.Environment["NG_ALLOWED_HOSTS"] = "127.0.0.1,localhost";
startInfo.Environment["NODE_ENV"] = "production";
startInfo.Environment["NO_COLOR"] = "1";
var process = Process.Start(startInfo)
?? throw new InvalidOperationException(
"Failed to start Angular SSR server process.");
var server = new AngularSsrServer(process, baseAddress);
process.OutputDataReceived += (_, args) => server.AppendOutput(args.Data);
process.ErrorDataReceived += (_, args) => server.AppendError(args.Data);
process.BeginOutputReadLine();
process.BeginErrorReadLine();
try
{
await server.WaitUntilReadyAsync();
return server;
}
catch
{
await server.DisposeAsync();
throw;
}
}
// Other implementation details are omitted.
}
Класс выполняет несколько задач:
- Находит собранный файл Angular SSR-сервера.
- Выбирает свободный TCP-порт.
- Запускает Node.js-процесс.
- Передаёт URL тестового API через переменную окружения.
- Перенаправляет стандартный вывод и поток ошибок.
- Ожидает готовности сервера принимать запросы.
- Завершает процесс после выполнения тестов.
Использование случайного свободного порта позволяет избежать конфликтов с локально запущенными приложениями и другими тестовыми процессами.
Важное ограничение TestServer
HttpClient, возвращаемый методом CreateClient(), взаимодействует с ним
внутри процесса.
Отдельный Node.js-процесс не может обратиться к такому серверу по адресу вида:
http://127.0.0.1:5000
Поэтому для SSR- и E2E-тестов CustomWebApplicationFactory должна запускать приложение
через Kestrel или использовать другой механизм, предоставляющий реальный HTTP-адрес.
В рассматриваемом проекте этот адрес доступен через:
Factory.ServerAddress
Именно он передаётся Angular SSR-серверу.
Если фабрика использует только стандартный TestServer, передача
ServerAddress во внешний Node.js-процесс работать не будет.
Запуск SSR-сервера в SetUp
SSR-сервер запускается в конце метода SetUp():
[SetUp]
public async Task SetUp()
{
// Configure the test API and reset the database.
ServerAddress = Factory.ServerAddress;
SsrServer = await AngularSsrServer.StartAsync(ServerAddress);
}
Таким образом, Angular SSR-сервер получает адрес тестового API и может обращаться к нему во время серверного рендеринга страниц.
Интеграция с Playwright
Поскольку теперь проверки выполняются через браузер, базовый класс E2E-тестов наследуется от
PageTest, предоставляемого пакетом Microsoft.Playwright.NUnit.
public abstract class BaseTests : PageTest
{
protected AngularSsrServer SsrServer { get; private set; } = null!;
protected Uri ServerAddress { get; private set; } = null!;
// Test initialization and cleanup.
}
PageTest предоставляет доступ к основным объектам Playwright, включая:
Browser;Context;Page;Playwright.
При этом следует учитывать жизненный цикл NUnit. Если базовые и производные классы объявляют
собственные методы [SetUp] и [TearDown], нужно убедиться, что они не
скрывают методы друг друга и выполняются в ожидаемом порядке.
Метод очистки расширяется завершением SSR-процесса:
[TearDown]
public async Task TearDown()
{
if (SsrServer is not null)
{
await SsrServer.DisposeAsync();
}
// Dispose other test resources.
}
Процесс Node.js необходимо завершать даже в случае падения теста. В противном случае после нескольких запусков в системе могут остаться фоновые процессы и занятые порты.
Проверка Server-Side Rendering
В текущей конфигурации тест может выглядеть следующим образом:
[TestFixture]
[NonParallelizable]
public class TopicTests : BaseTests
{
[Test]
public async Task Home_WhenServerSideRendered_ContainsArticlesInInitialHtml()
{
await using var context = await Browser.NewContextAsync(new BrowserNewContextOptions
{
// Отключим JavaScript для проверки SSR
JavaScriptEnabled = false
});
var page = await context.NewPageAsync();
var response = await page.GotoAsync(
SsrServer.Url(TopicTestUrls.Home),
new PageGotoOptions
{
WaitUntil = WaitUntilState.DOMContentLoaded
});
response.Should().NotBeNull();
response!.Status.Should().Be((int)HttpStatusCode.OK);
await RunWithSsrDiagnosticsAsync(async () =>
{
await ExpectHomeArticlesAsync(page);
var html = await page.ContentAsync();
html.Should().Contain(
TopicTestLocators.Topic10Title);
});
}
}
В этом тесте JavaScript отключён намеренно:
JavaScriptEnabled = false
Это позволяет убедиться, что заголовки статей присутствуют уже в HTML, сформированном SSR-сервером, а не добавляются Angular после загрузки страницы в браузере.
Такой сценарий особенно важен для проверки:
- индексации страниц поисковыми системами;
- Open Graph metadata;
- предварительного просмотра ссылок;
- доступности основного содержимого без выполнения JavaScript;
- корректности серверного рендеринга.
К таким сценариям относятся:
- переход между страницами;
- заполнение форм;
- авторизация;
- отправка данных;
- обработка ошибок;
- загрузка изображений;
- отображение полученных от API данных.
После добавления необходимых пользовательских сценариев мы получаем тестирование всей цепочки:
Browser → Angular → SSR → .NET API → Entity Framework Core → SQLite
Запуск E2E-тестов в GitHub Actions
Остаётся добавить запуск тестов в CI/CD.
После сборки проекта необходимо установить браузер Playwright и системные зависимости:
- name: Install Playwright browsers
working-directory: ${{ env.API_DIR }}
run: >
pwsh
./E2ETests/bin/${{ env.CONFIGURATION }}/net10.0/playwright.ps1
install
--with-deps
chromium
Затем запускаются сами E2E-тесты:
- name: Run E2E tests
working-directory: ${{ env.API_DIR }}
run: >
dotnet test
./E2ETests/E2ETests.csproj
--configuration "${{ env.CONFIGURATION }}"
--no-build
--settings ./E2ETests/playwright.runsettings
--logger "trx;LogFileName=e2e-test-results.trx"
--results-directory ./TestResults/e2e
Параметр:
--no-build
означает, что проект уже должен быть собран на одном из предыдущих шагов workflow. Это также необходимо для появления файла:
playwright.ps1
В рассматриваемой конфигурации устанавливается только Chromium. Это сокращает время выполнения workflow.
Если необходимо проверить кросс-браузерную совместимость, дополнительно можно установить Firefox и WebKit. Однако для небольшого проекта запуск тестов только в Chromium часто является разумным компромиссом между покрытием и временем выполнения.
Теперь E2E-тесты запускаются при каждом выполнении соответствующего GitHub Actions workflow. Если один из критических сценариев завершается ошибкой, сборка не переходит к следующим этапам, например к созданию и публикации Docker-образа.
Итог
В результате мы получили три уровня автоматизированного тестирования.
Unit-тесты проверяют отдельные части бизнес-логики быстро и изолированно.
Интеграционные API-тесты запускают серверное приложение и проверяют всю цепочку обработки HTTP-запроса, включая маршрутизацию, авторизацию, контроллеры, сервисы и работу с базой данных.
E2E-тесты добавляют Angular, SSR и реальный браузер, позволяя проверить приложение с точки зрения пользователя.
Для небольшого или среднего проекта такой набор обеспечивает хороший баланс между качеством, скоростью выполнения и стоимостью поддержки тестов.
При этом не следует стремиться проверить каждый возможный сценарий на всех трёх уровнях. Важно выбирать наиболее подходящий уровень:
- отдельную бизнес-логику проверять unit-тестами;
- работу HTTP API и взаимодействие серверных компонентов — интеграционными тестами;
- критически важные пользовательские сценарии — E2E-тестами.
Так тесты не превращаются в самоцель, а остаются инструментом, который помогает безопасно развивать приложение и быстрее обнаруживать ошибки.