Тестирование — Практическое Руководство
Назначение
Документ описывает стандарты, паттерны и примеры написания тестов для backend сервисов Pin. Является практическим дополнением к Backend Testing Rules с конкретными примерами кода, организацией test projects и правилами использования xUnit, FluentAssertions, Moq, AutoFixture, Testcontainers и WebApplicationFactory.
Стек Тестирования
| Инструмент | Версия | Назначение |
|---|---|---|
xUnit | 2.x | Основной test framework |
FluentAssertions | 6.x | Читаемые assertions |
Moq | 4.x | Mocks для ports и external dependencies |
AutoFixture | 4.x | Test data builders |
AutoFixture.AutoMoq | 4.x | Интеграция AutoFixture + Moq |
Testcontainers | 3.x | PostgreSQL, Kafka, Redis, ClickHouse, Elasticsearch |
Testcontainers.PostgreSql | 3.x | Реальный PostgreSQL в Docker |
Testcontainers.Kafka | 3.x | Реальный Kafka в Docker |
MassTransit.Testing | 8.x | Consumer, saga, outbox tests |
Respawn | 6.x | Сброс БД между тестами |
Microsoft.AspNetCore.Mvc.Testing | .NET 10 | WebApplicationFactory/TestServer |
Bogus | 34.x | Генерация реалистичных fake данных |
Структура Test Projects
tests/services/<service>/
Pin.<Service>.Domain.Tests/ ← unit tests domain layer
Pin.<Service>.Application.Tests/ ← unit + integration tests handlers
Pin.<Service>.Infrastructure.Tests/ ← integration tests repositories, adapters
Pin.<Service>.Api.Tests/ ← API component/integration tests
Pin.<Service>.Contract.Tests/ ← schema/contract compatibility tests
Pin.<Service>.TestKit/ ← shared test helpers (NO production deps)
TestKit Содержимое
Pin.Accounting.TestKit/
Fixtures/
AccountingWebApplicationFixture.cs — WebApplicationFactory setup
AccountingDatabaseFixture.cs — PostgreSQL container + Respawn
AccountingKafkaFixture.cs — Kafka container
Builders/
AccountingAccountBuilder.cs — fluent builder для AccountingAccount
WithdrawalRequestBuilder.cs — fluent builder для WithdrawalRequest
UserActorBuilder.cs
AdminActorBuilder.cs
ObjectMothers/
AccountingMother.cs — готовые preset-объекты для типичных сценариев
WithdrawalMother.cs
TestData/
AccountingTestConstants.cs — фиксированные test GUIDs, amounts
AccountingSeedData.cs — seed для integration tests
Fakes/
FakePaymentProviderAdapter.cs — test double для payment provider
FakeDecision CheckPort.cs
Extensions/
HttpClientExtensions.cs — ReadAs<T>(), PostJson(), etc.
ResultExtensions.cs — AssertSuccess(), AssertFailWith()
AAA Pattern — Стандарт Разметки
Каждый тест явно размечается тремя секциями. Комментарии обязательны; пустые строки разделяют секции:
[Fact]
public void ИмяМетода_Условие_ОжидаемыйРезультат()
{
// --- Arrange ---
// Настройка входных данных, состояния, mock-ов.
// Правило: всё необходимое для Act должно быть видно здесь.
// --- Act ---
// Ровно одно действие — вызов тестируемого кода.
// Не смешивать с Arrange или Assert.
// --- Assert ---
// Проверка результата и observable side effects.
// Не вызывать снова тестируемый код.
}
Исключение для Act & Assert вместе допустимо только при проверке исключений:
// --- Act & Assert ---
var act = () => SomeMethod(invalidInput);
act.Should().Throw<DomainException>().WithMessage("*reason*");
Именование Тестов
Формат: {Метод}_{Условие}_{ОжидаемыйРезультат}
Approve_WhenStatusIsRequested_ShouldSucceedAndRaiseEvent
Approve_WhenStatusIsAlreadyApproved_ShouldFailWithInvalidTransitionError
Create_WithNegativeAmount_ShouldThrowDomainException
Handle_WhenUserNotEligible_ShouldFailAndNotPersist
ListByUser_WhenScopeIsUser_ShouldReturnOnlyOwnAccountings
GetAsync_WithMissingTenantScope_ShouldFailClosed
Unit Tests — Domain Layer
Domain unit tests не используют mocks агрегатов и не обращаются в БД. Тест проверяет поведение aggregate через его публичный API.
// Pin.Accounting.Domain.Tests/Withdrawals/WithdrawalRequestTests.cs
public sealed class WithdrawalRequestTests
{
private readonly IFixture _fixture = new Fixture()
.Customize(new AccountingDomainCustomization());
[Fact]
public void Approve_WhenStatusIsRequested_ShouldSucceedAndRaiseEvent()
{
// --- Arrange ---
var withdrawal = _fixture
.Build<WithdrawalRequest>()
.WithStatus(WithdrawalStatus.Requested)
.Create();
var actor = _fixture.Create<ActorSnapshot>();
// --- Act ---
var result = withdrawal.Approve(actor);
// --- Assert ---
result.IsSuccess.Should().BeTrue();
withdrawal.Status.Should().Be(WithdrawalStatus.Approved);
withdrawal.DomainEvents.Should().ContainSingle()
.Which.Should().BeOfType<WithdrawalApprovedEvent>();
}
[Theory]
[InlineData(WithdrawalStatus.Approved)]
[InlineData(WithdrawalStatus.Completed)]
[InlineData(WithdrawalStatus.Cancelled)]
public void Approve_WhenStatusIsNotRequested_ShouldFailWithInvalidTransitionError(
WithdrawalStatus initialStatus)
{
// --- Arrange ---
var withdrawal = _fixture
.Build<WithdrawalRequest>()
.WithStatus(initialStatus)
.Create();
var actor = _fixture.Create<ActorSnapshot>();
// --- Act ---
var result = withdrawal.Approve(actor);
// --- Assert ---
result.IsFailed.Should().BeTrue();
result.Errors.Should().ContainSingle(e =>
e.Message.Contains("invalid-transition") || e is InvalidStatusTransitionError);
withdrawal.DomainEvents.Should().BeEmpty("нет события при failure");
}
[Fact]
public void Create_WithNegativeAmount_ShouldThrowDomainException()
{
// --- Arrange ---
var negativeAmount = new MoneyAmount(-1m, _fixture.Create<CurrencyId>());
// --- Act & Assert ---
var act = () => WithdrawalRequest.Create(
userId: _fixture.Create<UserId>(),
tenantId: _fixture.Create<TenantId>(),
brandId: _fixture.Create<BrandId>(),
amount: negativeAmount,
actor: _fixture.Create<ActorSnapshot>());
act.Should().Throw<DomainException>()
.WithMessage("*amount-positive*");
}
[Fact]
public void Cancel_WhenStatusIsRequested_ShouldSucceedAndSetCancelledAt()
{
// --- Arrange ---
var withdrawal = _fixture
.Build<WithdrawalRequest>()
.WithStatus(WithdrawalStatus.Requested)
.Create();
var actor = _fixture.Create<ActorSnapshot>();
var before = DateTimeOffset.UtcNow;
// --- Act ---
var result = withdrawal.Cancel(actor, reason: "user-request");
// --- Assert ---
result.IsSuccess.Should().BeTrue();
withdrawal.Status.Should().Be(WithdrawalStatus.Cancelled);
withdrawal.CancelledAt.Should().NotBeNull()
.And.BeOnOrAfter(before);
withdrawal.DomainEvents.Should().ContainSingle()
.Which.Should().BeOfType<WithdrawalCancelledEvent>();
}
}
AutoFixture Customization и Object Mothers
Customization (генерация корректных domain объектов)
// Pin.Accounting.TestKit/Builders/AccountingDomainCustomization.cs
public sealed class AccountingDomainCustomization : ICustomization
{
public void Customize(IFixture fixture)
{
// Strongly typed ids — всегда Version7 Guid
fixture.Customize<TenantId>(c => c.FromFactory(
() => new TenantId(Guid.CreateVersion7())));
fixture.Customize<BrandId>(c => c.FromFactory(
() => new BrandId(Guid.CreateVersion7())));
fixture.Customize<UserId>(c => c.FromFactory(
() => UserId.NewId()));
fixture.Customize<AccountingAccountId>(c => c.FromFactory(
() => new AccountingAccountId(Guid.CreateVersion7())));
// MoneyAmount с разумными значениями (не отрицательными)
fixture.Customize<MoneyAmount>(c => c.FromFactory<IFixture>(f =>
new MoneyAmount(
amount: Math.Round(Math.Abs(f.Create<decimal>()) % 9999 + 1, 2),
currencyId: f.Create<CurrencyId>())));
// ActorSnapshot по умолчанию — Admin
fixture.Customize<ActorSnapshot>(c => c.FromFactory(() => new ActorSnapshot
{
ActorId = Guid.NewGuid().ToString(),
ActorType = "Admin",
TenantId = Guid.NewGuid().ToString(),
}));
// IdentityContext — Admin с базовыми permissions
fixture.Customize<IdentityContext>(c => c.FromFactory<IFixture>(f =>
new IdentityContext
{
ActorType = ActorType.Admin,
ActorId = Guid.NewGuid().ToString(),
TenantId = f.Create<TenantId>(),
BrandId = f.Create<BrandId>(),
Permissions = ["accounting.withdrawal.approve"]
}));
}
}
Fluent Builder Extensions
// Pin.Accounting.TestKit/Builders/WithdrawalRequestBuilderExtensions.cs
public static class WithdrawalRequestBuilderExtensions
{
public static IPostprocessComposer<WithdrawalRequest> WithStatus(
this IPostprocessComposer<WithdrawalRequest> composer,
WithdrawalStatus status)
=> composer.Do(w => SetPrivateField(w, "_status", status));
public static IPostprocessComposer<WithdrawalRequest> WithAmount(
this IPostprocessComposer<WithdrawalRequest> composer,
decimal amount, string currencyId = "USD")
=> composer.Do(w => SetPrivateField(w, "_amount",
new MoneyAmount(amount, new CurrencyId(currencyId))));
}
Object Mother (готовые preset-объекты)
Object Mother даёт семантически понятные предустановленные объекты для типовых сценариев — без необходимости вникать в детали построения в каждом тесте:
// Pin.Accounting.TestKit/ObjectMothers/WithdrawalMother.cs
public static class WithdrawalMother
{
/// <summary>Новая заявка в статусе Requested, готова к Approve.</summary>
public static WithdrawalRequest PendingApproval(
UserId? userId = null,
TenantId? tenantId = null,
decimal amount = 100m) =>
WithdrawalRequest.Create(
userId: userId ?? UserId.NewId(),
tenantId: tenantId ?? TestConstants.DefaultTenant,
brandId: TestConstants.DefaultBrand,
amount: new MoneyAmount(amount, new CurrencyId("USD")),
actor: ActorMother.Admin());
/// <summary>Заявка уже одобрена — нельзя одобрить повторно.</summary>
public static WithdrawalRequest AlreadyApproved(UserId? userId = null)
{
var w = PendingApproval(userId);
w.Approve(ActorMother.Admin());
return w;
}
/// <summary>Заявка на крупную сумму — для AML/limit тестов.</summary>
public static WithdrawalRequest HighValue(decimal amount = 50_000m) =>
PendingApproval(amount: amount);
}
Пример теста с Object Mother:
[Fact]
public void Approve_WhenAlreadyApproved_ShouldFailWithInvalidTransitionError()
{
// --- Arrange ---
var withdrawal = WithdrawalMother.AlreadyApproved();
var actor = ActorMother.Admin();
// --- Act ---
var result = withdrawal.Approve(actor);
// --- Assert ---
result.IsFailed.Should().BeTrue();
result.Errors.Should().ContainSingle(e => e is InvalidStatusTransitionError);
}
Application Tests — Command Handlers
Handler tests используют mocks для repository и ports, но не mock aggregate:
// Pin.Accounting.Application.Tests/Withdrawals/ApproveWithdrawal/ApproveWithdrawalCommandHandlerTests.cs
public sealed class ApproveWithdrawalCommandHandlerTests
{
// --- Test infrastructure ---
private readonly Mock<IWithdrawalRepository> _repositoryMock = new();
private readonly Mock<IDecision CheckPort> _decision checkPortMock = new();
private readonly Mock<IIdentityContextProvider> _identityMock = new();
private readonly IFixture _fixture = new Fixture()
.Customize(new AutoMoqCustomization())
.Customize(new AccountingDomainCustomization());
private readonly ApproveWithdrawalCommandHandler _handler;
public ApproveWithdrawalCommandHandlerTests()
{
_handler = new ApproveWithdrawalCommandHandler(
_repositoryMock.Object,
_identityMock.Object,
_decision checkPortMock.Object);
}
[Fact]
public async Task Handle_WhenEligibleAndStatusRequested_ShouldApproveAndPersist()
{
// --- Arrange ---
var command = _fixture.Create<ApproveWithdrawalCommand>();
var withdrawal = WithdrawalMother.PendingApproval();
var adminActor = _fixture.Create<IdentityContext>();
_repositoryMock
.Setup(r => r.Get(command.WithdrawalId, It.IsAny<CancellationToken>()))
.ReturnsAsync(withdrawal);
_identityMock
.Setup(p => p.GetCurrent())
.Returns(adminActor);
_decision checkPortMock
.Setup(p => p.Check(withdrawal.UserId, It.IsAny<CancellationToken>()))
.ReturnsAsync(Decision CheckResult.Eligible());
// --- Act ---
var result = await _handler.Handle(command, CancellationToken.None);
// --- Assert ---
result.IsSuccess.Should().BeTrue();
result.Value.NewStatus.Should().Be(WithdrawalStatus.Approved);
_repositoryMock.Verify(
r => r.Update(withdrawal, It.IsAny<CancellationToken>()),
Times.Once,
"aggregate должен быть сохранён ровно один раз");
}
[Fact]
public async Task Handle_WhenUserNotEligible_ShouldFailAndNotPersist()
{
// --- Arrange ---
var command = _fixture.Create<ApproveWithdrawalCommand>();
var withdrawal = WithdrawalMother.PendingApproval();
_repositoryMock
.Setup(r => r.Get(command.WithdrawalId, It.IsAny<CancellationToken>()))
.ReturnsAsync(withdrawal);
_identityMock
.Setup(p => p.GetCurrent())
.Returns(_fixture.Create<IdentityContext>());
_decision checkPortMock
.Setup(p => p.Check(It.IsAny<UserId>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Decision CheckResult.NotEligible("user-excluded"));
// --- Act ---
var result = await _handler.Handle(command, CancellationToken.None);
// --- Assert ---
result.IsFailed.Should().BeTrue();
result.Errors.Should().ContainSingle(e => e.Message.Contains("not-eligible"));
_repositoryMock.Verify(
r => r.Update(It.IsAny<WithdrawalRequest>(), It.IsAny<CancellationToken>()),
Times.Never,
"не должен сохранять при failure");
}
[Fact]
public async Task Handle_WhenWithdrawalNotFound_ShouldReturnNotFoundError()
{
// --- Arrange ---
var command = _fixture.Create<ApproveWithdrawalCommand>();
_repositoryMock
.Setup(r => r.Get(command.WithdrawalId, It.IsAny<CancellationToken>()))
.ThrowsAsync(new EntityNotFoundException(typeof(WithdrawalRequest), command.WithdrawalId));
_identityMock
.Setup(p => p.GetCurrent())
.Returns(_fixture.Create<IdentityContext>());
// --- Act ---
var act = async () => await _handler.Handle(command, CancellationToken.None);
// --- Assert ---
await act.Should().ThrowAsync<EntityNotFoundException>()
.WithMessage($"*{command.WithdrawalId}*");
}
}
Integration Tests — Repositories
Реальный PostgreSQL через Testcontainers, Respawn между тестами:
// Pin.Accounting.Infrastructure.Tests/Repositories/AccountingRepositoryScopeTests.cs
[Collection("AccountingDatabase")]
public sealed class AccountingRepositoryScopeTests : IAsyncLifetime
{
private readonly AccountingDatabaseFixture _fixture;
public AccountingRepositoryScopeTests(AccountingDatabaseFixture fixture)
=> _fixture = fixture;
// IAsyncLifetime — xUnit framework interface; имена методов фиксированы фреймворком
public Task InitializeAsync() => _fixture.ResetDatabase();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task List_WhenScopeIsUser_ShouldReturnOnlyCurrentUserAccountings()
{
// --- Arrange ---
var tenant = new TenantId(TestConstants.TenantId);
var brand = new BrandId(TestConstants.BrandId);
var user1 = UserId.NewId();
var user2 = UserId.NewId();
await _fixture.SeedAccounting(user1, tenant, brand);
await _fixture.SeedAccounting(user2, tenant, brand);
// --- Act ---
using var scope = _fixture.CreateUserScope(user1, tenant, brand);
var result = await _fixture.Repository.List(
filter: AccountingFilter.Empty,
paging: new PagedQuery { Take = 10 },
ct: CancellationToken.None);
// --- Assert ---
result.Items.Should().NotBeEmpty();
result.Items.Should().AllSatisfy(w => w.UserId.Should().Be(user1));
result.Items.Should().NotContain(w => w.UserId == user2,
"данные user2 не должны быть видны в контексте user1");
}
[Fact]
public async Task Get_WithMissingTenantScope_ShouldFailClosed()
{
// --- Arrange ---
var accountingId = await _fixture.SeedAccounting(
UserId.NewId(), TestConstants.Tenant, TestConstants.Brand);
// --- Act ---
using var scope = _fixture.CreateSystemScope(); // нет tenant
var act = async () => await _fixture.Repository.Get(accountingId, CancellationToken.None);
// --- Assert ---
await act.Should().ThrowAsync<MissingScopeException>()
.WithMessage("*TenantId*",
"TenantScope обязателен — запрос в БД не должен выполняться без него");
}
[Fact]
public async Task List_WhenAdminScopeForTenant1_ShouldNotReturnTenant2Data()
{
// --- Arrange ---
var tenant1 = new TenantId(Guid.NewGuid());
var tenant2 = new TenantId(Guid.NewGuid());
await _fixture.SeedAccountingForTenant(tenant1);
await _fixture.SeedAccountingForTenant(tenant2);
// --- Act ---
using var scope = _fixture.CreateAdminScope(tenant: tenant1);
var result = await _fixture.Repository.List(
filter: AccountingFilter.Empty,
paging: PagedQuery.Default,
ct: CancellationToken.None);
// --- Assert ---
result.Items.Should().AllSatisfy(w => w.TenantId.Should().Be(tenant1));
result.Items.Should().NotContain(w => w.TenantId == tenant2);
}
}
// Database fixture — реальный PostgreSQL через Testcontainers
public sealed class AccountingDatabaseFixture : IAsyncLifetime
{
private PostgreSqlContainer _container = null!;
private string _connectionString = null!;
public IAccountingRepository Repository { get; private set; } = null!;
public async Task InitializeAsync()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("Pin_accounting_test")
.Build();
await _container.StartAsync();
_connectionString = _container.GetConnectionString();
var context = CreateDbContext(_connectionString);
await context.Database.MigrateAsync();
Repository = CreateRepository(context);
}
// Вызывается перед каждым тестом через InitializeAsync в тестовом классе
public async Task ResetDatabase()
{
var respawner = await Respawner.CreateAsync(_connectionString, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
SchemasToInclude = ["accounting"],
});
await respawner.ResetAsync(_connectionString);
}
public async Task DisposeAsync() => await _container.DisposeAsync();
}
API Integration Tests
// Pin.Accounting.Api.Tests/Withdrawals/ApproveWithdrawalApiTests.cs
public sealed class ApproveWithdrawalApiTests
: IClassFixture<AccountingWebApplicationFixture>
{
private readonly AccountingWebApplicationFixture _fixture;
private readonly HttpClient _adminClient;
public ApproveWithdrawalApiTests(AccountingWebApplicationFixture fixture)
{
_fixture = fixture;
_adminClient = fixture.CreateAdminClient();
}
[Fact]
public async Task ApproveWithdrawal_ValidRequest_Returns200WithApprovedStatus()
{
// --- Arrange ---
var withdrawalId = await CreatePendingWithdrawal();
var request = new ApproveWithdrawalRequest { ReasonCode = "verified-identity" };
// --- Act ---
var response = await _adminClient.PostJson(
$"/api/admin/withdrawals/{withdrawalId}/approve", request);
// --- Assert ---
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.ReadAs<ApproveWithdrawalResponse>();
body.Status.Should().Be("Approved");
body.WithdrawalId.Should().Be(withdrawalId.ToString());
response.Headers.Should().ContainKey("X-Correlation-Id",
"каждый response должен содержать correlation id");
}
[Fact]
public async Task ApproveWithdrawal_WithoutPermission_Returns403()
{
// --- Arrange ---
var withdrawalId = await CreatePendingWithdrawal();
var noPermClient = _fixture.CreateAdminClient(permissions: []);
// --- Act ---
var response = await noPermClient.PostJson(
$"/api/admin/withdrawals/{withdrawalId}/approve", new { });
// --- Assert ---
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
var problem = await response.ReadAs<ProblemDetails>();
problem.Error.Code.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task ApproveWithdrawal_DuplicateIdempotencyKey_ReturnsIdenticalResult()
{
// --- Arrange ---
var withdrawalId = await CreatePendingWithdrawal();
var idempotencyKey = Guid.NewGuid().ToString();
var request = new ApproveWithdrawalRequest { ReasonCode = "verified" };
// --- Act ---
var response1 = await _adminClient.PostJson(
$"/api/admin/withdrawals/{withdrawalId}/approve", request,
headers: new() { ["Idempotency-Key"] = idempotencyKey });
var response2 = await _adminClient.PostJson(
$"/api/admin/withdrawals/{withdrawalId}/approve", request,
headers: new() { ["Idempotency-Key"] = idempotencyKey });
// --- Assert ---
response1.StatusCode.Should().Be(HttpStatusCode.OK);
response2.StatusCode.Should().Be(HttpStatusCode.OK,
"повторный запрос с тем же ключом должен вернуть кешированный результат");
var result1 = await response1.ReadAs<ApproveWithdrawalResponse>();
var result2 = await response2.ReadAs<ApproveWithdrawalResponse>();
result1.WithdrawalId.Should().Be(result2.WithdrawalId,
"idempotency replay должен возвращать тот же ответ");
}
[Fact]
public async Task ApproveWithdrawal_Unauthenticated_Returns401()
{
// --- Arrange ---
var anonClient = _fixture.CreateAnonymousClient();
// --- Act ---
var response = await anonClient.PostJson(
"/api/admin/withdrawals/any-id/approve", new { });
// --- Assert ---
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
private async Task<WithdrawalRequestId> CreatePendingWithdrawal()
{
// Создаём тестовую заявку через TestKit seed helper
return await _fixture.SeedWithdrawal(
userId: UserId.NewId(),
amount: new MoneyAmount(100m, new CurrencyId("USD")),
status: WithdrawalStatus.Requested);
}
}
// WebApplicationFactory setup
public sealed class AccountingWebApplicationFixture
: PinWebApplicationFactory<AccountingWebStartup>
{
private static readonly string[] DefaultAdminPermissions =
["accounting.withdrawal.approve", "accounting.account.read"];
public HttpClient CreateAdminClient(string[]? permissions = null)
=> CreateClientWithActorContext(ctx => ctx.AsAdmin(
adminId: AdminUserId.NewId(),
tenantId: new TenantId(TestConstants.TenantId),
permissions: permissions ?? DefaultAdminPermissions));
public HttpClient CreateAnonymousClient()
=> CreateClient(); // без authentication
protected override void ConfigureTestServices(IServiceCollection services)
{
// Заменяем реальные adapters на fake
services.RemoveAll<IPaymentProviderAdapter>();
services.AddSingleton<IPaymentProviderAdapter, FakePaymentProviderAdapter>();
// Подключаем реальный PostgreSQL container
services.AddPostgreSqlTestContainer(_postgresContainer);
}
}
Consumer и Saga Tests
// Pin.Accounting.Application.Tests/Consumers/DepositCallbackConsumerTests.cs
public sealed class DepositCallbackConsumerTests
{
[Fact]
public async Task Consumer_DuplicateEvent_ShouldProcessOnceAndBeIdempotent()
{
// --- Arrange ---
await using var harness = new InMemoryTestHarness();
var consumerHarness = harness.Consumer<DepositCallbackConsumer>();
await harness.Start();
var message = new DepositCallbackMessage
{
DepositId = Guid.NewGuid(),
ProviderReference = "stripe:pi_test_123",
Status = "succeeded",
};
// --- Act ---
// Отправляем то же сообщение дважды
await harness.InputQueueSendEndpoint.Send(message);
await harness.InputQueueSendEndpoint.Send(message);
await harness.WaitForMessageConsumed<DepositCallbackMessage>(TimeSpan.FromSeconds(5));
// --- Assert ---
// Consumer получил 2 сообщения
consumerHarness.Consumed.Select<DepositCallbackMessage>()
.Count().Should().Be(2);
// Но бизнес-эффект (deposit created) — ровно один раз
var deposits = await _testRepo.GetByProviderReference("stripe:pi_test_123");
deposits.Count.Should().Be(1, "idempotency должна предотвратить дублирование");
}
}
Repository Scope Tests (Обязательные для Каждого Сервиса)
Четыре обязательных scope-теста должны присутствовать в каждом сервисе:
// Шаблон: tests/services/<service>/Pin.<Service>.Infrastructure.Tests/Repositories/
[Fact]
public async Task List_WhenActorIsUser_ShouldNotReturnOtherUsersData()
{
// --- Arrange ---
var user1 = UserId.NewId();
var user2 = UserId.NewId();
var tenant = TestConstants.Tenant;
var brand = TestConstants.Brand;
await _fixture.Seed(user1, tenant, brand);
await _fixture.Seed(user2, tenant, brand);
// --- Act ---
using var scope = _fixture.AsUser(user1, tenant, brand);
var result = await _fixture.Repository.List(
filter: FilterBase.Empty,
paging: PagedQuery.Default,
ct: CancellationToken.None);
// --- Assert ---
result.Items.Should().NotBeEmpty();
result.Items.Should().AllSatisfy(item =>
item.UserId.Should().Be(user1));
result.Items.Should().NotContain(item =>
item.UserId == user2,
"user1 не должен видеть данные user2");
}
[Fact]
public async Task List_WhenAdminScopeForTenant1_ShouldNotReturnTenant2Data()
{
// --- Arrange ---
var tenant1 = new TenantId(Guid.NewGuid());
var tenant2 = new TenantId(Guid.NewGuid());
await _fixture.SeedForTenant(tenant1);
await _fixture.SeedForTenant(tenant2);
// --- Act ---
using var scope = _fixture.AsAdmin(tenant: tenant1);
var result = await _fixture.Repository.List(
filter: FilterBase.Empty,
paging: PagedQuery.Default,
ct: CancellationToken.None);
// --- Assert ---
result.Items.Should().AllSatisfy(item =>
item.TenantId.Should().Be(tenant1));
result.Items.Should().NotContain(item =>
item.TenantId == tenant2);
}
[Fact]
public async Task List_WithMissingTenantScope_ShouldFailClosed()
{
// --- Arrange ---
using var scope = _fixture.AsSystemAccount(); // нет tenant scope
// --- Act ---
var act = async () => await _fixture.Repository.List(
filter: FilterBase.Empty,
paging: PagedQuery.Default,
ct: CancellationToken.None);
// --- Assert ---
await act.Should().ThrowAsync<MissingScopeException>()
.WithMessage("*TenantId*",
"fail closed: БД запрос не должен выполняться без обязательного scope");
}
[Fact]
public async Task List_WhenGlobalAdminBypassScope_ShouldReturnCrossTenantDataAndWriteAudit()
{
// --- Arrange ---
var tenant1 = new TenantId(Guid.NewGuid());
var tenant2 = new TenantId(Guid.NewGuid());
await _fixture.SeedForTenant(tenant1);
await _fixture.SeedForTenant(tenant2);
using var scope = _fixture.AsGlobalAdmin(permissions: ["admin.cross-tenant.access"]);
// --- Act ---
var result = await _fixture.Repository.List(
filter: FilterBase.Empty,
paging: PagedQuery.Default,
options: RepositoryScopeOptions.CrossTenantBypass("cross-tenant-audit"),
ct: CancellationToken.None);
// --- Assert ---
result.Items.Should().Contain(i => i.TenantId == tenant1);
result.Items.Should().Contain(i => i.TenantId == tenant2);
var auditRecord = await _fixture.GetLastAuditRecord();
auditRecord.Should().NotBeNull("bypass должен оставлять audit след");
auditRecord!.Reason.Should().Be("cross-tenant-audit");
}
Export Tests
[Fact]
public async Task ExportWithdrawals_WhenFormatIsXlsx_ShouldProduceValidWorkbook()
{
// --- Arrange ---
await _fixture.SeedWithdrawals(count: 5);
var command = new CreateWithdrawalReportExportCommand
{
Format = ExportFormat.Xlsx,
DateFrom = DateTimeOffset.UtcNow.AddDays(-7),
DateTo = DateTimeOffset.UtcNow,
};
// --- Act ---
var result = await _handler.Handle(command, CancellationToken.None);
// --- Assert ---
result.IsSuccess.Should().BeTrue();
var fileBytes = await _fileStorage.Read(result.Value.FileKey);
using var workbook = new XLWorkbook(new MemoryStream(fileBytes));
workbook.Worksheets.Should().NotBeEmpty();
workbook.Worksheets.First().Row(1).IsEmpty()
.Should().BeFalse("первая строка должна содержать заголовки колонок");
}
[Fact]
public async Task ExportCommissions_WhenActorIsPartner_ShouldNotContainOtherPartnerData()
{
// --- Arrange ---
var partner1 = PartnerId.NewId();
var partner2 = PartnerId.NewId();
await _fixture.SeedPartnerData(partner1);
await _fixture.SeedPartnerData(partner2);
using var scope = _fixture.AsPartner(partner1);
// --- Act ---
var response = await _client.PostJson("/api/partner/reports/exports", new
{
Format = "csv",
ReportType = "commissions",
DateFrom = "2026-01-01",
DateTo = "2026-05-01",
});
// --- Assert ---
response.StatusCode.Should().Be(HttpStatusCode.OK);
var jobView = await response.ReadAs<ReportExportJobView>();
var fileContent = await DownloadExportFile(jobView.JobId);
fileContent.Should().NotContain(
partner2.Value.ToString(),
"экспорт partner1 не должен содержать данные partner2");
}
Правила Тестирования
- Явно размечай секции
// --- Arrange ---,// --- Act ---,// --- Assert ---в каждом тесте. - Тестируй поведение, не private implementation.
- Domain aggregates не мокаются; mocks — только для ports и external dependencies.
- Каждый high-impact endpoint тестируется: 401, 403, validation error, success, idempotent replay, audit.
- Financial и callback code — обязательно integration tests с Testcontainers.
- Тест называется по паттерну:
{Метод}_{Условие}_{ОжидаемыйРезультат}. - Не дублируй setup в каждом тесте — используй Fixtures, Builders, Object Mothers.
- Не assert'ируй implementation details; assert'ируй observable outcomes.
- Consumer tests всегда проверяют идемпотентность.
- Respawn сбрасывает только данные, не схему — вызывай
ResetDatabase()перед каждым тестом. InitializeAsync/DisposeAsync— имена фиксированы xUnitIAsyncLifetime; Async суффикс в этом контексте обязателен.- Все остальные собственные методы без суффикса
Async. - Добавляй человекочитаемую
because-строку в FluentAssertions:.Should().Be(x, "потому что ...").
Ссылки
- Backend Testing Rules
- Bootstrap Kernel Runtime And Testing
- 05 — Repository Pipeline
- 06 — MediatR CQRS Pipeline
Обратные ссылки
Автоссылки: где используется этот документ.
- adr (1): ADR-0040: Testing Strategy — Стандарт Тестирования Backend
- index.md (1): Backend SDK — Руководство Разработчика
- 05-repository-pipeline.md (1): Repository Pipeline и Scope Filters — Практическое Руководство
- backend-shared-standards-index.md (1): Backend Shared Standards Index