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

Тестирование — Практическое Руководство

Назначение

Документ описывает стандарты, паттерны и примеры написания тестов для backend сервисов Pin. Является практическим дополнением к Backend Testing Rules с конкретными примерами кода, организацией test projects и правилами использования xUnit, FluentAssertions, Moq, AutoFixture, Testcontainers и WebApplicationFactory.


Стек Тестирования

ИнструментВерсияНазначение
xUnit2.xОсновной test framework
FluentAssertions6.xЧитаемые assertions
Moq4.xMocks для ports и external dependencies
AutoFixture4.xTest data builders
AutoFixture.AutoMoq4.xИнтеграция AutoFixture + Moq
Testcontainers3.xPostgreSQL, Kafka, Redis, ClickHouse, Elasticsearch
Testcontainers.PostgreSql3.xРеальный PostgreSQL в Docker
Testcontainers.Kafka3.xРеальный Kafka в Docker
MassTransit.Testing8.xConsumer, saga, outbox tests
Respawn6.xСброс БД между тестами
Microsoft.AspNetCore.Mvc.Testing.NET 10WebApplicationFactory/TestServer
Bogus34.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 — имена фиксированы xUnit IAsyncLifetime; Async суффикс в этом контексте обязателен.
  • Все остальные собственные методы без суффикса Async.
  • Добавляй человекочитаемую because-строку в FluentAssertions: .Should().Be(x, "потому что ...").

Ссылки

Обратные ссылки

Автоссылки: где используется этот документ.