.NET Tutorial
Junior5 мин чтенияВопрос 6 из 10

AAA-паттерн (Arrange-Act-Assert)?

Структура

C#
[Fact]
public void Apply_discount_reduces_total_by_percentage()
{
    // Arrange — подготовка
    var order = new OrderBuilder()
        .WithItem(price: 100_000, qty: 2)
        .Build();
    var discount = new PercentageDiscount(10);

    // Act — одно действие
    order.ApplyDiscount(discount);

    // Assert — проверка результата
    order.Total.Should().Be(180_000);
}

Arrange
объект, зависимости,
входные данные

Act
ОДИН вызов
тестируемого поведения

Assert
результат, состояние,
побочный эффект

Arrange
объект, зависимости,
входные данные

Act
ОДИН вызов
тестируемого поведения

Assert
результат, состояние,
побочный эффект

Три фазы теста

Given / When / Then

То же самое в терминах BDD — удобно, когда тест читают не только разработчики:

C#
[Fact]
public void Given_paid_order_when_cancel_then_refund_is_requested()
{
    // Given
    var order = OrderBuilder.Paid(total: 500_000);
    var payments = new FakePaymentGateway();
    var sut = new CancelOrderHandler(payments);

    // When
    sut.Handle(order);

    // Then
    order.Status.Should().Be(OrderStatus.Cancelled);
    payments.Refunds.Should().ContainSingle(r => r.Amount == 500_000);
}

Правила хорошего AAA

Правило Почему
Одно действие в Act Два действия = два теста; при падении непонятно, что сломалось
Нет логики в тесте (if, for, try) Тест с логикой сам требует тестов; используйте параметризацию ([Theory])
Assert проверяет одно поведение Несколько утверждений об одном результате — нормально; про разные поведения — отдельные тесты
Секции видны (пустые строки или комментарии) Быстро понять структуру
Arrange — только необходимое Лишние детали скрывают суть; значения по умолчанию — в билдерах
Нет зависимостей между тестами Каждый тест сам готовит своё состояние
Детерминированность Время через FakeTimeProvider, случайности с фиксированным seed

Именование тестов

Стиль Пример
Method_Scenario_Expected Withdraw_AmountGreaterThanBalance_ThrowsInsufficientFunds
Предложение Withdrawing_more_than_balance_is_rejected
Given/When/Then Given_empty_cart_When_checkout_Then_error
Should Checkout_should_fail_for_empty_cart

Главное — имя описывает поведение в терминах домена, а не реализацию («CallsRepositoryTwice» — плохо). Хорошее имя вместе с сообщением об ошибке позволяет понять проблему, не открывая код.

Как сократить Arrange

Test Data Builder

C#
public sealed class OrderBuilder
{
    private CustomerId _customer = CustomerId.New();
    private readonly List<(decimal price, int qty)> _items = [];
    private OrderStatus _status = OrderStatus.Draft;

    public OrderBuilder ForCustomer(CustomerId id) { _customer = id; return this; }
    public OrderBuilder WithItem(decimal price = 10_000, int qty = 1) { _items.Add((price, qty)); return this; }
    public OrderBuilder Paid() { _status = OrderStatus.Paid; return this; }

    public Order Build()
    {
        var order = Order.Create(_customer);
        foreach (var (price, qty) in _items.DefaultIfEmpty((10_000, 1)))
            order.AddItem(ProductId.New(), qty, new Money(price, "UZS"));
        if (_status == OrderStatus.Paid) order.MarkPaid();
        return order;
    }

    public static Order Paid(decimal total) => new OrderBuilder().WithItem(total).Paid().Build();
}

Тест указывает только то, что важно для проверяемого поведения («заказ оплачен на 500 000»), остальное — разумные значения по умолчанию. Для генерации «любых» данных — AutoFixture или Bogus.

SUT-фабрика

C#
private static (CancelOrderHandler sut, FakePaymentGateway payments) CreateSut()
{
    var payments = new FakePaymentGateway();
    return (new CancelOrderHandler(payments), payments);
}

Параметризация вместо циклов

C#
[Theory]
[InlineData(0, false)]
[InlineData(17, false)]
[InlineData(18, true)]
[InlineData(65, true)]
public void Can_buy_alcohol_depends_on_age(int age, bool expected)
{
    var customer = new CustomerBuilder().WithAge(age).Build();
    var result = AgePolicy.CanBuyAlcohol(customer);
    result.Should().Be(expected);
}

Каждый случай — отдельный тест в отчёте; падение одного не скрывает остальные.

Вопросы на засыпку

Можно ли иметь несколько Assert в тесте?

Да, если они проверяют одно поведение (например, свойства одного результата). Проблема — когда тест проверяет несколько разных поведений: первое падение скрывает остальные. Для группы проверок удобно using (new AssertionScope()) в FluentAssertions — отчёт покажет все несоответствия сразу.

Что делать, если Arrange занимает 30 строк?

Это сигнал: либо тестируемый класс имеет слишком много зависимостей (нарушение SRP), либо нужны билдеры и фабрики тестовых данных. Иногда — что тест проверяет слишком большой сценарий и его стоит разделить.

Где в AAA проверка вызова мока?

В секции Assert: email.Verify(...) или email.Received().Send(...) — это проверка побочного эффекта. Настройка мока (Setup/Returns) — в Arrange.

Чем AAA отличается от Four-Phase Test?

Four-Phase (Месарош): Setup, Exercise, Verify, Teardown. Последняя фаза — освобождение ресурсов; в xUnit она выполняется через Dispose/IAsyncLifetime, поэтому в теле теста обычно не видна.

Итог

AAA делает тест историей из трёх частей: условия, действие, ожидаемый результат. Одно действие, никакой логики, проверка одного поведения, говорящее имя и билдеры тестовых данных — и тест становится документацией, которую легко читать и поддерживать.