Тестування

Moq — Глибоке занурення в мокування

Повний практичний та теоретичний огляд бібліотеки Moq для .NET. Setup, Returns, ReturnsAsync, Verify, Callback, Sequences, ArgumentMatchers, MockBehavior, Partial Mocks та просунуті патерни.

Moq: Глибоке занурення в мокування

Чому мокування — це не обхідний шлях

Є поширена думка, що мокування — це "хак" або "брудний трюк", що дозволяє уникнути роботи з реальними залежностями. Насправді все навпаки: мокування є фундаментальним інструментом ізоляції, без якого unit-тестування більшості реального коду просто неможливе.

Розглянемо конкретну ситуацію. У вас є метод:

public async Task<PaymentResult> ProcessPaymentAsync(Order order)
{
    var customer = await _customerRepository.GetByIdAsync(order.CustomerId);
    var discount = _discountEngine.Calculate(customer, order.Total);
    var finalAmount = order.Total - discount;

    if (finalAmount > customer.CreditLimit)
        return PaymentResult.Declined("Credit limit exceeded");

    await _auditLogger.LogAsync(AuditEvent.PaymentAttempt(order.Id, finalAmount));
    var result = await _paymentGateway.ChargeAsync(customer.CardToken, finalAmount);

    if (result.IsSuccess)
        await _emailService.SendReceiptAsync(customer.Email, result.TransactionId);

    return result;
}

Як протестувати цей метод без мокування? Вам знадобиться:

  • Реальна база даних з клієнтом
  • Реальний payment gateway (Stripe, LiqPay) — що означає реальні транзакції
  • Реальний email-сервер — що буде відправляти листи
  • Реальна система аудиту

Очевидно, це неприйнятно для unit тестів. Мокування вирішує цю проблему: кожна залежність замінюється керованою заглушкою, що поводиться так, як нам потрібно для конкретного тесту.

Moq — найпопулярніша бібліотека мокування для .NET, що використовується у мільйонах проєктів по всьому світу. Завдяки fluent API вона є одночасно потужною і читабельною.


Встановлення та базова концепція

dotnet add package
$ dotnet add package Moq
Successfully added Moq (4.x) to MyProject.Tests.csproj

Moq генерує mock-об'єкти на льоту через DispatchProxy або Castle.DynamicProxy. Ці об'єкти реалізують ваш інтерфейс (або успадковують клас), але всі їхні методи за замовчуванням нічого не роблять і повертають дефолтні значення.

// Базовий синтаксис
var mock = new Mock<IPaymentGateway>();

// mock.Object — це об'єкт, що реалізує IPaymentGateway
IPaymentGateway paymentGateway = mock.Object;

При налагодженні ви можете переглянути стан mock об'єкта:

Debug: Mock Object
Filter
NameTypeValue
mockMock<IPaymentGateway>Mock<IPaymentGateway> instance
mock.ObjectIPaymentGatewayProxy instance implementing IPaymentGateway
mock.InvocationsIList<Invocation>List of called methods
mock.SetupsIList<Method>List of configured method setups
Running
Process: 12842

Що відбувається під капотом: Moq генерує новий клас на льоту (via IL emit), що реалізує IPaymentGateway. При виклику методу цей клас делегує виклик до Moq, що перевіряє чи є Setup для цього методу. Якщо є — повертає налаштоване значення. Якщо ні — поведінка залежить від MockBehavior.


MockBehavior: Loose vs Strict

MockBehavior визначає що відбувається при виклику методу, для якого немає Setup.

MockBehavior.Loose (за замовчуванням)

var mock = new Mock<IPaymentGateway>(); // Implicit: MockBehavior.Loose
// або явно:
var mock = new Mock<IPaymentGateway>(MockBehavior.Loose);

При Loose поведінці:

  • void методи: нічого не роблять
  • Методи, що повертають значення: повертають default(T) (null для reference types, 0 для int, false для bool)
  • Task методи: повертають Task.CompletedTask
  • Task<T> методи: повертають Task.FromResult(default(T))
var mock = new Mock<IOrderRepository>(MockBehavior.Loose);

// Без Setup — не кидає, повертає null
var order = mock.Object.GetByIdAsync(Guid.NewGuid()).Result; // null
var orders = mock.Object.GetAllAsync().Result; // null

// Void метод без Setup — нічого не відбувається, не кидає
mock.Object.Delete(Guid.NewGuid()); // нічого

Коли Loose добре: при написанні тестів, де вам важливо лише кілька взаємодій, а решта — неважливі. Незалежно від того, які ще методи викликаються — тест не падає через "unexpected call".

Коли Loose небезпечно: ви можете пропустити важливі виклики. Якщо код викликає залежність, що повертає null (Loose default), і ваш код не обробляє null — ви отримаєте NullReferenceException, а не зрозумілий тест-провал.

MockBehavior.Strict

var mock = new Mock<IPaymentGateway>(MockBehavior.Strict);

При Strict поведінці: будь-який виклик методу без явного Setup кидає MockException.

var mock = new Mock<IOrderRepository>(MockBehavior.Strict);

// КИДАЄ MockException: "IOrderRepository.GetByIdAsync() invocation failed with mock behavior Strict"
var order = await mock.Object.GetByIdAsync(Guid.NewGuid());

Коли Strict дуже корисний: він змушує вас явно задекларувати кожну залежність, що використовується у тесті. Це — живий документ того, які взаємодії відбуваються. Якщо код під тестом змінюється і починає використовувати нову залежність — тест одразу падає, сигналізуючи: "ця залежність не задекларована".

// Strict: кожна взаємодія задекларована явно
var mock = new Mock<IOrderRepository>(MockBehavior.Strict);

mock.Setup(x => x.GetByIdAsync(specificOrderId))
    .ReturnsAsync(testOrder);

mock.Setup(x => x.UpdateAsync(It.IsAny<Order>()))
    .Returns(Task.CompletedTask);

// Якщо ProcessOrder раптом викличе ще й DeleteAsync — тест впаде
// із зрозумілим повідомленням, а не прихованим багом
var service = new OrderProcessingService(mock.Object);
await service.ProcessOrder(specificOrderId);

Setup: налаштування поведінки

Setup() — метод для налаштування того, що повертає або як поводиться мок при конкретному виклику.

Returns та ReturnsAsync

var mockRepo = new Mock<IUserRepository>();

// Синхронне значення
mockRepo.Setup(x => x.GetById(42)).Returns(new User { Id = 42, Name = "John" });

// Lazy Returns: обчислюється при кожному виклику (не один раз)
mockRepo.Setup(x => x.GetById(42)).Returns(() => new User { Id = 42, CreatedAt = DateTime.Now });

// Async: повертає Task<T>
mockRepo.Setup(x => x.GetByIdAsync(Guid.Empty)).ReturnsAsync((User?)null);
mockRepo.Setup(x => x.GetByIdAsync(specificId)).ReturnsAsync(new User { Id = specificId });

// ReturnsAsync з функцією (обчислюється при виклику)
mockRepo.Setup(x => x.GetByIdAsync(It.IsAny<Guid>()))
    .ReturnsAsync((Guid id) => _testUsers.FirstOrDefault(u => u.Id == id));

// Task без результату (void async)
mockRepo.Setup(x => x.DeleteAsync(It.IsAny<Guid>()))
    .Returns(Task.CompletedTask);
// або коротше:
mockRepo.Setup(x => x.DeleteAsync(It.IsAny<Guid>()))
    .Returns(Task.CompletedTask);

Throws та ThrowsAsync

// Синхронний виняток
mockRepo.Setup(x => x.GetById(-1))
    .Throws<ArgumentException>();

// З повідомленням
mockRepo.Setup(x => x.GetById(-1))
    .Throws(new ArgumentException("Id cannot be negative", "id"));

// Async виняток
mockPayment.Setup(x => x.ChargeAsync(It.IsAny<string>(), It.IsAny<decimal>()))
    .ThrowsAsync(new PaymentGatewayException("Connection timeout"));

// Виняток за умовою (через функцію)
mockRepo.Setup(x => x.GetByIdAsync(It.IsAny<Guid>()))
    .ReturnsAsync((Guid id) =>
    {
        if (id == Guid.Empty)
            throw new ArgumentException("Id cannot be empty");
        return _users.FirstOrDefault(u => u.Id == id);
    });

Callback: виконати код при виклику

Callback дозволяє виконати довільний код при виклику мок-методу — до того, як буде повернуто значення.

var capturedOrders = new List<Order>();

mockRepo.Setup(x => x.SaveAsync(It.IsAny<Order>()))
    .Callback<Order>(order =>
    {
        // Зберігаємо копію для пізнішої перевірки
        capturedOrders.Add(order);
        Console.WriteLine($"SaveAsync called with Order ID: {order.Id}");
    })
    .Returns(Task.CompletedTask);

// ...після виклику сервісу...
Assert.Single(capturedOrders);
Assert.Equal(OrderStatus.Pending, capturedOrders[0].Status);

CallbackAsync для async методів:

mockRepo.Setup(x => x.SaveAsync(It.IsAny<Order>()))
    .Callback<Order>(order =>
    {
        // Callback завжди синхронний, навіть для async методів
        _capturedOrder = order;
    })
    .ReturnsAsync(true);

Callback з кількома параметрами:

mockPayment.Setup(x => x.ChargeAsync(It.IsAny<string>(), It.IsAny<decimal>()))
    .Callback<string, decimal>((cardToken, amount) =>
    {
        _capturedCardToken = cardToken;
        _capturedAmount = amount;
    })
    .ReturnsAsync(PaymentResult.Success("txn_123"));

Argument Matchers (It.*): гнучке порівняння аргументів

It.* — набір статичних методів для гнучкого порівняння аргументів у Setup та Verify.

It.IsAny: будь-яке значення

// Відповідає будь-якому значенню типу Guid
mockRepo.Setup(x => x.GetByIdAsync(It.IsAny<Guid>()))
    .ReturnsAsync(testUser);

// Відповідає будь-якому рядку, включаючи null
mockEmail.Setup(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>()))
    .Returns(Task.CompletedTask);

It.Is: умова через предикат

// Відповідає тільки якщо значення задовольняє умову
mockRepo.Setup(x => x.GetByIdAsync(It.Is<Guid>(id => id != Guid.Empty)))
    .ReturnsAsync(testUser);

// Відповідає тільки для замовлень з позитивною сумою
mockPayment.Setup(x => x.ChargeAsync(
        It.IsAny<string>(),
        It.Is<decimal>(amount => amount > 0)))
    .ReturnsAsync(PaymentResult.Success("txn_123"));

// Відповідає тільки якщо email містить @
mockEmail.Setup(x => x.SendAsync(
        It.Is<string>(email => email.Contains("@")),
        It.IsAny<string>()))
    .Returns(Task.CompletedTask);

It.IsIn / It.IsNotIn: набір значень

// Відповідає тільки якщо значення є в цьому наборі
mockService.Setup(x => x.GetStatusLabel(It.IsIn(1, 2, 3)))
    .Returns("Active");

mockService.Setup(x => x.GetStatusLabel(It.IsNotIn(1, 2, 3)))
    .Returns("Inactive");

It.IsInRange: діапазон

// Відповідає для значень від 1 до 100 включно
mockService.Setup(x => x.Calculate(It.IsInRange(1, 100, Range.Inclusive)))
    .Returns(42m);

// Виключні межі (1 < value < 100)
mockService.Setup(x => x.Calculate(It.IsInRange(1, 100, Range.Exclusive)))
    .Returns(42m);

It.IsRegex: регулярний вираз для рядків

// Відповідає тільки рядкам, що є email-адресами
mockEmail.Setup(x => x.Validate(It.IsRegex(@"^[\w.+-]+@[\w-]+\.[\w.]+$")))
    .Returns(true);

It.IsNull / It.IsNotNull

mockRepo.Setup(x => x.Save(It.IsNull<Order>()))
    .Throws<ArgumentNullException>();

mockRepo.Setup(x => x.Save(It.IsNotNull<Order>()))
    .Returns(true);

Конкретне значення vs matcher: важлива відмінність

// Відповідає тільки точному значенню specificId
mockRepo.Setup(x => x.GetByIdAsync(specificId)).ReturnsAsync(user1);

// Відповідає будь-якому Guid
mockRepo.Setup(x => x.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(user2);

// УВАГА: якщо обидва Setup визначені, більш специфічний (точне значення)
// виконується першим для specificId, матчер — для решти

Verify: перевірка взаємодій

Verify — метод для перевірки того, чи відбулася очікувана взаємодія з моком.

Базова верифікація

var mockEmail = new Mock<IEmailService>();
var service = new OrderService(mockEmail.Object);

await service.CreateOrderAsync(new CreateOrderDto { CustomerEmail = "user@test.com" });

// Перевіряємо що SendAsync був викликаний
mockEmail.Verify(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>()));

// Більш точно — з конкретними аргументами
mockEmail.Verify(x => x.SendAsync("user@test.com", "Order Confirmation"));

// З кастомним повідомленням при провалі
mockEmail.Verify(
    x => x.SendAsync("user@test.com", "Order Confirmation"),
    "Email confirmation must be sent to the customer with correct subject"
);

Times: кількість викликів

// Рівно 1 раз (найчастіший варіант)
mockEmail.Verify(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Once);

// Рівно 0 разів (НЕ повинен бути викликаний)
mockEmail.Verify(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);

// Мінімум N разів
mockEmail.Verify(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>()), Times.AtLeast(2));

// Максимум N разів
mockEmail.Verify(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>()), Times.AtMost(3));

// Рівно N разів
mockEmail.Verify(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(2));

// Від N до M разів
mockEmail.Verify(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Between(1, 3, Range.Inclusive));

VerifyAll та VerifyNoOtherCalls

// VerifyAll: перевіряє що ВСІ Setup були викликані хоча б раз
mockRepo.Setup(x => x.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(user);
mockRepo.Setup(x => x.UpdateAsync(It.IsAny<User>())).Returns(Task.CompletedTask);

// ... виконання коду ...

mockRepo.VerifyAll(); // Перевіряє що обидва Setup були викликані

// VerifyNoOtherCalls: перевіряє що після явних Verify не було інших викликів
// Корисно при MockBehavior.Strict альтернативі
mockRepo.Verify(x => x.GetByIdAsync(It.IsAny<Guid>()), Times.Once);
mockRepo.VerifyNoOtherCalls(); // Провалиться якщо викликались DeleteAsync, UpdateAsync тощо

Verify для властивостей (Properties)

var mockConfig = new Mock<IApplicationConfig>();
mockConfig.SetupGet(x => x.MaxRetryCount).Returns(3);

// Перевірка зчитування властивості
mockConfig.VerifyGet(x => x.MaxRetryCount, Times.Once);

// Для set-властивостей
mockConfig.SetupSet(x => x.MaxRetryCount = It.IsAny<int>());
mockConfig.VerifySet(x => x.MaxRetryCount = 5, Times.Once);

Setup для властивостей (Properties)

public interface IConfiguration
{
    string ConnectionString { get; set; }
    int Timeout { get; }
    bool IsDebugMode { get; set; }
}

var mockConfig = new Mock<IConfiguration>();

// Get-only властивість
mockConfig.SetupGet(x => x.Timeout).Returns(30);

// Get/Set властивість — налаштовуємо get
mockConfig.SetupGet(x => x.ConnectionString).Returns("Server=localhost;...");

// Налаштовуємо set (щоб відслідковувати встановлення значення)
mockConfig.SetupSet(x => x.IsDebugMode = true);

// SetupProperty: дозволяє і get і set без SetupGet/SetupSet
mockConfig.SetupProperty(x => x.ConnectionString, "default-value");
// Після SetupProperty: get повертає встановлене значення; set зберігає нове
mockConfig.Object.ConnectionString = "new-value";
Assert.Equal("new-value", mockConfig.Object.ConnectionString);

Sequences: різні значення при послідовних викликах

Іноді потрібно, щоб мок повертав різні значення при кожному наступному виклику.

// SetupSequence: різні значення при 1-му, 2-му, 3-му виклику
var mockQueue = new Mock<IMessageQueue>();
mockQueue.SetupSequence(x => x.DequeueAsync())
    .ReturnsAsync(new Message { Content = "First" })
    .ReturnsAsync(new Message { Content = "Second" })
    .ReturnsAsync(new Message { Content = "Third" })
    .ThrowsAsync(new QueueEmptyException()); // 4-й та подальші виклики

// Використання
var msg1 = await mockQueue.Object.DequeueAsync(); // "First"
var msg2 = await mockQueue.Object.DequeueAsync(); // "Second"
var msg3 = await mockQueue.Object.DequeueAsync(); // "Third"
// await mockQueue.Object.DequeueAsync(); // throws QueueEmptyException

Практичний приклад — retry logic:

[Fact]
public async Task RetryService_RetriesOnFailure_SucceedsOnThirdAttempt()
{
    var mockHttp = new Mock<IHttpClient>();

    // Перші два рази — failure, третій — success
    mockHttp.SetupSequence(x => x.GetAsync(It.IsAny<string>()))
        .ThrowsAsync(new HttpRequestException("Connection refused"))
        .ThrowsAsync(new HttpRequestException("Timeout"))
        .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("{\"status\": \"ok\"}")
        });

    var retryService = new RetryHttpService(mockHttp.Object, maxRetries: 3);
    var result = await retryService.GetWithRetryAsync("/api/data");

    result.StatusCode.Should().Be(HttpStatusCode.OK);
    mockHttp.Verify(x => x.GetAsync(It.IsAny<string>()), Times.Exactly(3));
}

Frozen та AutoMock з AutoFixture

Коли багато залежностей — налаштування моків у конструкторі займає багато коду. AutoFixture + AutoMock вирішують це елегантно.

// Без AutoMock: ручне створення кожного мок
public class OrderServiceTests
{
    private readonly Mock<IOrderRepository> _mockRepo;
    private readonly Mock<IPaymentGateway> _mockPayment;
    private readonly Mock<IEmailService> _mockEmail;
    private readonly Mock<IAuditLog> _mockAuditLog;
    private readonly Mock<IDiscountEngine> _mockDiscount;
    private readonly OrderService _sut;

    public OrderServiceTests()
    {
        _mockRepo = new Mock<IOrderRepository>();
        _mockPayment = new Mock<IPaymentGateway>();
        _mockEmail = new Mock<IEmailService>();
        _mockAuditLog = new Mock<IAuditLog>();
        _mockDiscount = new Mock<IDiscountEngine>();
        _sut = new OrderService(
            _mockRepo.Object, _mockPayment.Object,
            _mockEmail.Object, _mockAuditLog.Object, _mockDiscount.Object);
    }
}

З AutoFixture + AutoMoq:

// AutoFixture.AutoMoq
using AutoFixture;
using AutoFixture.AutoMoq;

// Кастомізація AutoFixture для автоматичного створення моків
var fixture = new Fixture().Customize(new AutoMoqCustomization());

// AutoFixture автоматично будує граф залежностей
var sut = fixture.Create<OrderService>(); // Всі залежності — авто-моки!

// Отримати мок конкретної залежності
var mockRepo = fixture.Freeze<Mock<IOrderRepository>>();
// Freeze означає: один і той самий екземпляр в усьому fixture
mockRepo.Setup(x => x.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(testOrder);

AutoData атрибут:

public class OrderServiceAutoTests
{
    [Theory, AutoMoqData]
    public async Task ProcessOrder_WithValidOrder_SendsEmail(
        [Frozen] Mock<IEmailService> mockEmail,
        [Frozen] Mock<IOrderRepository> mockRepo,
        Order testOrder,           // AutoFixture генерує Order з реалістичними даними
        OrderService sut)          // AutoFixture будує OrderService з frozen моками
    {
        mockRepo.Setup(x => x.GetByIdAsync(testOrder.Id)).ReturnsAsync(testOrder);

        await sut.ProcessOrder(testOrder.Id);

        mockEmail.Verify(x => x.SendReceiptAsync(testOrder.CustomerEmail), Times.Once);
    }
}

// Кастомний атрибут
public class AutoMoqDataAttribute : AutoDataAttribute
{
    public AutoMoqDataAttribute()
        : base(() => new Fixture().Customize(new AutoMoqCustomization())) { }
}

Partial Mocks та Mock з класами

Moq може мокувати не лише інтерфейси, але й абстрактні класи та конкретні класиvirtual методами).

Абстрактний клас

public abstract class BaseProcessor
{
    public abstract Task<ProcessResult> ProcessAsync(Order order);

    // Конкретний метод — не можна замокати без CallBase
    public virtual string GetProcessorName() => "BaseProcessor";

    protected virtual bool ValidateOrder(Order order) => order.Amount > 0;
}

// Мок абстрактного класу
var mockProcessor = new Mock<BaseProcessor>();
mockProcessor.Setup(x => x.ProcessAsync(It.IsAny<Order>()))
    .ReturnsAsync(ProcessResult.Success());

// GetProcessorName — має конкретну реалізацію
// Без Setup поверне default(string) = null (Loose) або кине (Strict)
mockProcessor.Setup(x => x.GetProcessorName()).Returns("TestProcessor");

Конкретний клас з virtual методами

public class TaxCalculator
{
    public virtual decimal GetTaxRate(string country) // virtual!
    {
        // Реальна логіка з зовнішнім API
        return _taxApiClient.GetRate(country);
    }

    public decimal CalculateTax(decimal amount, string country) // NOT virtual
    {
        return amount * GetTaxRate(country);
    }
}

// Partial mock: реальна реалізація + замоканий virtual метод
var mockCalc = new Mock<TaxCalculator> { CallBase = true };
// CallBase = true: невіртуальні та неперевизначені методи делегуються реальному коду

mockCalc.Setup(x => x.GetTaxRate("UA")).Returns(0.20m);

// CalculateTax НЕ virtual — делегується реальному коду
// але GetTaxRate — virtual і замокано, повертає 0.20
var tax = mockCalc.Object.CalculateTax(1000m, "UA"); // 200m
Моковання конкретних класів — запах поганого дизайну. Якщо ви часто мокуєте конкретні класи замість інтерфейсів — це сигнал: витягніть інтерфейс та використовуйте Dependency Injection. "Don't Mock What You Don't Own" (і те, що не є інтерфейсом) — загальне правило.

Protected членів та Internal методів

Protected методи

using Moq.Protected;

public class PaymentProcessor
{
    protected virtual async Task<bool> ValidateCardAsync(string token) { ... }

    public async Task<PaymentResult> ChargeAsync(string token, decimal amount)
    {
        if (!await ValidateCardAsync(token))
            return PaymentResult.Declined("Invalid card");
        // ...
    }
}

var mockProcessor = new Mock<PaymentProcessor>();
mockProcessor.Protected()
    .Setup<Task<bool>>("ValidateCardAsync", ItExpr.IsAny<string>())
    .ReturnsAsync(false); // Симулюємо невалідну картку

var result = await mockProcessor.Object.ChargeAsync("invalid-token", 100m);
result.IsDeclined.Should().BeTrue();

Internal методи через InternalsVisibleTo

// В основному .csproj:
// [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // для Moq
// [assembly: InternalsVisibleTo("MyProject.Tests")]

MockRepository: централізоване управління моками

MockRepository дозволяє створювати та верифікувати всі моки централізовано:

public class OrderServiceTests
{
    private readonly MockRepository _mockRepository;
    private readonly Mock<IOrderRepository> _mockRepo;
    private readonly Mock<IEmailService> _mockEmail;
    private readonly OrderService _sut;

    public OrderServiceTests()
    {
        // Всі моки матимуть однакову поведінку за замовчуванням
        _mockRepository = new MockRepository(MockBehavior.Loose);

        _mockRepo = _mockRepository.Create<IOrderRepository>();
        _mockEmail = _mockRepository.Create<IEmailService>();

        _sut = new OrderService(_mockRepo.Object, _mockEmail.Object);
    }

    [Fact]
    public async Task CreateOrder_VerifiesAllInteractions()
    {
        _mockRepo.Setup(x => x.SaveAsync(It.IsAny<Order>())).Returns(Task.CompletedTask);

        await _sut.CreateOrderAsync(new CreateOrderDto());

        // VerifyAll для ВСІХ моків, створених через _mockRepository
        _mockRepository.VerifyAll();
    }
}

Capture Arguments: перехоплення аргументів

Іноді потрібно перевірити складний об'єкт, що передається як аргумент:

// Підхід 1: Callback + capture
Order? capturedOrder = null;
mockRepo.Setup(x => x.SaveAsync(It.IsAny<Order>()))
    .Callback<Order>(order => capturedOrder = order)
    .Returns(Task.CompletedTask);

await service.CreateOrderAsync(dto);

capturedOrder.Should().NotBeNull();
capturedOrder!.Status.Should().Be(OrderStatus.Pending);
capturedOrder.Items.Should().HaveCount(dto.Items.Count);
capturedOrder.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));

// Підхід 2: Verify з It.Is для складних умов
mockRepo.Verify(x => x.SaveAsync(
    It.Is<Order>(o =>
        o.Status == OrderStatus.Pending &&
        o.CustomerId == dto.CustomerId &&
        o.Items.Count == dto.Items.Count
    )),
    Times.Once
);

Events на моках

Moq дозволяє симулювати події:

public interface IStockService
{
    event EventHandler<StockChangeEventArgs> StockChanged;
    decimal GetCurrentPrice(string ticker);
}

var mockStock = new Mock<IStockService>();

// Підписка на подію у тестованому коді
var portfolioService = new PortfolioService(mockStock.Object);

// Симуляція події (raise)
mockStock.Raise(
    x => x.StockChanged += null,
    new StockChangeEventArgs { Ticker = "AAPL", NewPrice = 195.5m }
);

// Перевіряємо, що portfolioService відреагував на подію
portfolioService.LastNotifiedPrice.Should().Be(195.5m);

Практичний повний приклад

Розглянемо повний тест для OrderService.ProcessPaymentAsync, описаного на початку статті:

public class OrderServicePaymentTests
{
    private readonly Mock<ICustomerRepository> _mockCustomerRepo;
    private readonly Mock<IDiscountEngine> _mockDiscount;
    private readonly Mock<IAuditLogger> _mockAudit;
    private readonly Mock<IPaymentGateway> _mockPayment;
    private readonly Mock<IEmailService> _mockEmail;
    private readonly OrderService _sut;

    // Тестові дані
    private readonly Customer _testCustomer = new()
    {
        Id = Guid.NewGuid(),
        Email = "customer@test.com",
        CardToken = "tok_test_abc123",
        CreditLimit = 5000m
    };

    private readonly Order _testOrder;

    public OrderServicePaymentTests()
    {
        _mockCustomerRepo = new Mock<ICustomerRepository>();
        _mockDiscount = new Mock<IDiscountEngine>();
        _mockAudit = new Mock<IAuditLogger>();
        _mockPayment = new Mock<IPaymentGateway>();
        _mockEmail = new Mock<IEmailService>();

        _testOrder = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = _testCustomer.Id,
            Total = 1000m
        };

        _sut = new OrderService(
            _mockCustomerRepo.Object,
            _mockDiscount.Object,
            _mockAudit.Object,
            _mockPayment.Object,
            _mockEmail.Object);

        // Загальні налаштування
        _mockCustomerRepo
            .Setup(x => x.GetByIdAsync(_testCustomer.Id))
            .ReturnsAsync(_testCustomer);
    }

    [Fact]
    public async Task ProcessPayment_WithEligibleCustomer_ChargesCorrectAmount()
    {
        // Arrange: знижка 10%
        _mockDiscount
            .Setup(x => x.Calculate(_testCustomer, _testOrder.Total))
            .Returns(100m);

        _mockPayment
            .Setup(x => x.ChargeAsync(_testCustomer.CardToken, 900m))
            .ReturnsAsync(PaymentResult.Success("txn_001"));

        _mockAudit
            .Setup(x => x.LogAsync(It.IsAny<AuditEvent>()))
            .Returns(Task.CompletedTask);

        _mockEmail
            .Setup(x => x.SendReceiptAsync(_testCustomer.Email, "txn_001"))
            .Returns(Task.CompletedTask);

        // Act
        var result = await _sut.ProcessPaymentAsync(_testOrder);

        // Assert: результат
        result.IsSuccess.Should().BeTrue();

        // Assert: правильна сума заряджена (після знижки)
        _mockPayment.Verify(
            x => x.ChargeAsync(_testCustomer.CardToken, 900m),
            Times.Once
        );

        // Assert: лист відправлено тільки після успішного платежу
        _mockEmail.Verify(
            x => x.SendReceiptAsync(_testCustomer.Email, "txn_001"),
            Times.Once
        );

        // Assert: аудит зафіксовано
        _mockAudit.Verify(
            x => x.LogAsync(It.Is<AuditEvent>(e => e.OrderId == _testOrder.Id)),
            Times.Once
        );
    }

    [Fact]
    public async Task ProcessPayment_WhenExceedsCreditLimit_DeclinedWithoutCharge()
    {
        // Arrange: замовлення перевищує кредитний ліміт
        var bigOrder = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = _testCustomer.Id,
            Total = 10_000m  // Більше ніж CreditLimit = 5000
        };

        _mockDiscount
            .Setup(x => x.Calculate(_testCustomer, bigOrder.Total))
            .Returns(0m); // Без знижки

        // Act
        var result = await _sut.ProcessPaymentAsync(bigOrder);

        // Assert
        result.IsDeclined.Should().BeTrue();
        result.DeclineReason.Should().Contain("Credit limit");

        // Платіж НЕ повинен відбутись
        _mockPayment.Verify(
            x => x.ChargeAsync(It.IsAny<string>(), It.IsAny<decimal>()),
            Times.Never
        );

        // Email також НЕ відправляється
        _mockEmail.Verify(
            x => x.SendReceiptAsync(It.IsAny<string>(), It.IsAny<string>()),
            Times.Never
        );
    }

    [Fact]
    public async Task ProcessPayment_WhenPaymentGatewayFails_DoesNotSendEmail()
    {
        // Arrange
        _mockDiscount.Setup(x => x.Calculate(It.IsAny<Customer>(), It.IsAny<decimal>()))
            .Returns(0m);

        _mockPayment
            .Setup(x => x.ChargeAsync(It.IsAny<string>(), It.IsAny<decimal>()))
            .ReturnsAsync(PaymentResult.Failed("Insufficient funds"));

        _mockAudit.Setup(x => x.LogAsync(It.IsAny<AuditEvent>())).Returns(Task.CompletedTask);

        // Act
        var result = await _sut.ProcessPaymentAsync(_testOrder);

        // Assert
        result.IsSuccess.Should().BeFalse();

        // Email НЕ відправляється при невдалому платежі
        _mockEmail.Verify(
            x => x.SendReceiptAsync(It.IsAny<string>(), It.IsAny<string>()),
            Times.Never
        );
    }

    [Fact]
    public async Task ProcessPayment_WhenPaymentGatewayThrows_PropagatesException()
    {
        _mockDiscount.Setup(x => x.Calculate(It.IsAny<Customer>(), It.IsAny<decimal>())).Returns(0m);
        _mockPayment
            .Setup(x => x.ChargeAsync(It.IsAny<string>(), It.IsAny<decimal>()))
            .ThrowsAsync(new PaymentGatewayException("Gateway timeout"));

        _mockAudit.Setup(x => x.LogAsync(It.IsAny<AuditEvent>())).Returns(Task.CompletedTask);

        Func<Task> act = () => _sut.ProcessPaymentAsync(_testOrder);

        await act.Should()
            .ThrowAsync<PaymentGatewayException>()
            .WithMessage("*Gateway timeout*");
    }
}

Антипатерни мокування

1. Mocking Concrete Classes Without Reason

// ❌ Мокування конкретного класу — запах поганого дизайну
var mockService = new Mock<OrderService>();

// ✅ Витягніть інтерфейс і мокуйте його
var mockService = new Mock<IOrderService>();

2. Over-specification (Надмірна специфікація)

// ❌ Перевіряємо занадто багато деталей реалізації
mockRepo.Verify(x => x.BeginTransactionAsync(), Times.Once);
mockRepo.Verify(x => x.GetByIdAsync(orderId), Times.Once);
mockRepo.Verify(x => x.UpdateAsync(It.IsAny<Order>()), Times.Once);
mockRepo.Verify(x => x.CommitTransactionAsync(), Times.Once);
// Цей тест провалиться при будь-якому рефакторингу реалізації

// ✅ Перевіряйте лише важливі side effects
mockRepo.Verify(x => x.UpdateAsync(
    It.Is<Order>(o => o.Status == OrderStatus.Processed)),
    Times.Once);

3. Mock все підряд (Mock-everything anti-pattern)

// ❌ Мокується навіть те, що можна використати реально
var mockMoney = new Mock<Money>();  // Money — value object без залежностей!
mockMoney.Setup(x => x.Amount).Returns(100m);

// ✅ Використовуйте реальні value objects та domain entities
var money = new Money(100m, Currency.UAH);

4. Setup без Arrange = Implicit Behaviour

// ❌ Setup в конструкторі для всіх тестів — незрозуміло для якого тесту
public TestClass() {
    _mockRepo.Setup(x => x.GetAllAsync()).ReturnsAsync(new List<Order>());
    _mockRepo.Setup(x => x.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(someOrder);
    _mockRepo.Setup(x => x.CountAsync()).ReturnsAsync(5);
    // Після 10 тестів незрозуміло які Setup потрібні якому тесту
}

// ✅ Тільки необхідний мінімум у конструкторі, деталі — в кожному тесті
public TestClass() {
    _sut = new OrderService(_mockRepo.Object);
    // Все інше — у конкретних тестах
}

Підсумок та шпаргалка

Ключові думки цієї статті:
  • MockBehavior.Loose (default): незаданий Setup повертає default. Гнучко, але може маскувати проблеми.
  • MockBehavior.Strict: незаданий Setup кидає. Примушує до явної декларації. Кращий feedback про зміни.
  • Setup + Returns: mock.Setup(x => x.Method(args)).Returns(value)
  • ReturnsAsync: для Task‹T› методів
  • Throws/ThrowsAsync: симуляція винятків
  • Callback: виконати код при виклику; захопити аргументи
  • It.IsAny‹T›: будь-яке значення
  • It.Is‹T›(predicate): умова через лямбду
  • It.IsIn/IsNotIn: набір значень
  • It.IsInRange: діапазон
  • Verify + Times: Times.Once, Never, Exactly(N), AtLeast(N), AtMost(N)
  • VerifyAll: перевіряє всі Setup викликані
  • VerifyNoOtherCalls: не було незадекларованих викликів
  • SetupSequence: різні значення при послідовних викликах (retry logic)
  • SetupProperty: дозволяє get + set для властивостей
  • Антипатерни: mock concrete classes, over-specification, mock value objects.

Наступна стаття — Integration Testing з WebApplicationFactory.