Введение: Зачем писать тесты на продвинутом уровне?
Начальный уровень тестирования в C# обычно ограничивается простыми Assert.Equal и изолированными юнит-тестами. Однако в реальных enterprise-проектах этого недостаточно. Баги часто возникают на стыке компонентов — при взаимодействии с базой данных, внешними API или в сложной бизнес-логике с побочными эффектами.
Продвинутое тестирование в C# (.NET) — это умение строить пирамиду тестов, где юнит-тесты быстрые и изолированные, интеграционные проверяют реальные взаимодействия, а E2E тесты имитируют поведение пользователя. В этой статье мы рассмотрим современные инструменты: xUnit, Moq, FluentAssertions, Testcontainers и WebApplicationFactory. Вы узнаете, как писать надежные тесты, которые не ломаются при рефакторинге и дают уверенность в коде.
1. Продвинутые Unit-тесты: Моки и Stub-объекты
Unit-тесты должны быть изолированы от внешних зависимостей. На продвинутом уровне мы используем библиотеки для создания mock-объектов, такие как Moq или NSubstitute.
Правильная настройка Mock-объектов
Вместо того чтобы создавать заглушки вручную, используйте Mock.Of<T>() для быстрой настройки. Пример с Moq и FluentAssertions:
public interface IEmailService
{
Task SendAsync(string to, string subject);
}
public class NotificationService
{
private readonly IEmailService _emailService;
public NotificationService(IEmailService emailService)
{
_emailService = emailService;
}
public async Task NotifyUserAsync(string email)
{
await _emailService.SendAsync(email, "Welcome!");
}
}
// Тест
[Fact]
public async Task NotifyUserAsync_ShouldSendEmail()
{
// Arrange
var mockEmailService = new Mock<IEmailService>();
var service = new NotificationService(mockEmailService.Object);
// Act
await service.NotifyUserAsync("test@example.com");
// Assert
mockEmailService.Verify(
x => x.SendAsync("test@example.com", "Welcome!"),
Times.Once);
}
Избегайте тестирования деталей реализации
Продвинутый подход — тестировать поведение, а не внутренние вызовы. Используйте Verify только для критически важных зависимостей. Для остальных — проверяйте результат.
2. Интеграционное тестирование: База данных и внешние сервисы
Интеграционные тесты проверяют, что несколько модулей работают вместе. Для .NET Core/5+ идеальным инструментом является WebApplicationFactory (для ASP.NET Core) и Testcontainers (для контейнеризации зависимостей).
Настройка WebApplicationFactory с реальной БД
Вместо использования InMemory-провайдера (который ведет себя иначе, чем реальная БД), используйте Testcontainers для запуска PostgreSQL или SQL Server в Docker:
public class IntegrationTestFixture : IAsyncLifetime
{
private readonly TestcontainersContainer _dbContainer;
public string ConnectionString { get; private set; }
public IntegrationTestFixture()
{
_dbContainer = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("postgres:15")
.WithPortBinding(5432, true)
.WithEnvironment("POSTGRES_USER", "test")
.WithEnvironment("POSTGRES_PASSWORD", "test")
.WithEnvironment("POSTGRES_DB", "testdb")
.Build();
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
ConnectionString = $"Host=localhost;Port={_dbContainer.GetMappedPublicPort(5432)};" +
$"Database=testdb;Username=test;Password=test";
}
public async Task DisposeAsync()
{
await _dbContainer.StopAsync();
}
}
[CollectionDefinition("Integration Tests")]
public class IntegrationTestCollection : ICollectionFixture<IntegrationTestFixture> { }
Тестирование репозитория
[Collection("Integration Tests")]
public class UserRepositoryTests
{
private readonly IntegrationTestFixture _fixture;
public UserRepositoryTests(IntegrationTestFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task CreateUser_ShouldPersistToDatabase()
{
// Arrange
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_fixture.ConnectionString)
.Options;
var context = new AppDbContext(options);
await context.Database.EnsureCreatedAsync();
var repo = new UserRepository(context);
// Act
var user = new User { Name = "Test", Email = "test@test.com" };
await repo.AddAsync(user);
var result = await repo.GetByIdAsync(user.Id);
// Assert
result.Should().NotBeNull();
result.Email.Should().Be("test@test.com");
}
}
3. E2E тестирование: Имитация пользовательского сценария
End-to-End тесты проверяют всю систему целиком. Для веб-приложений на ASP.NET Core используйте Playwright или Selenium, но в контексте .NET удобнее Playwright с его асинхронным C# API.
Настройка Playwright в .NET
public class E2ETests : IAsyncLifetime
{
private IPlaywright _playwright;
private IBrowser _browser;
private IPage _page;
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
_page = await _browser.NewPageAsync();
}
[Fact]
public async Task UserCanRegisterAndLogin()
{
// Arrange
await _page.GotoAsync("https://localhost:5001/register");
// Act - заполняем форму
await _page.FillAsync("input[name='Email']", "user@example.com");
await _page.FillAsync("input[name='Password']", "Str0ng!Pass");
await _page.ClickAsync("button[type='submit']");
// Assert - проверяем редирект
await _page.WaitForURLAsync("**/login");
var title = await _page.TitleAsync();
title.Should().Contain("Login");
// Логинимся
await _page.FillAsync("input[name='Email']", "user@example.com");
await _page.FillAsync("input[name='Password']", "Str0ng!Pass");
await _page.ClickAsync("button[type='submit']");
await _page.WaitForURLAsync("**/dashboard");
var dashboardText = await _page.TextContentAsync("h1");
dashboardText.Should().Be("Dashboard");
}
public async Task DisposeAsync()
{
await _browser.CloseAsync();
_playwright.Dispose();
}
}