Є поширена думка, що мокування — це "хак" або "брудний трюк", що дозволяє уникнути роботи з реальними залежностями. Насправді все навпаки: мокування є фундаментальним інструментом ізоляції, без якого 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;
}
Як протестувати цей метод без мокування? Вам знадобиться:
Очевидно, це неприйнятно для unit тестів. Мокування вирішує цю проблему: кожна залежність замінюється керованою заглушкою, що поводиться так, як нам потрібно для конкретного тесту.
Moq — найпопулярніша бібліотека мокування для .NET, що використовується у мільйонах проєктів по всьому світу. Завдяки fluent API вона є одночасно потужною і читабельною.
Moq генерує mock-об'єкти на льоту через DispatchProxy або Castle.DynamicProxy. Ці об'єкти реалізують ваш інтерфейс (або успадковують клас), але всі їхні методи за замовчуванням нічого не роблять і повертають дефолтні значення.
// Базовий синтаксис
var mock = new Mock<IPaymentGateway>();
// mock.Object — це об'єкт, що реалізує IPaymentGateway
IPaymentGateway paymentGateway = mock.Object;
При налагодженні ви можете переглянути стан mock об'єкта:
| Name | Type | Value |
|---|---|---|
| ◢mock | Mock<IPaymentGateway> | Mock<IPaymentGateway> instance |
| ◢mock.Object | IPaymentGateway | Proxy instance implementing IPaymentGateway |
| ◢mock.Invocations | IList<Invocation> | List of called methods |
| ◢mock.Setups | IList<Method> | List of configured method setups |
Що відбувається під капотом: Moq генерує новий клас на льоту (via IL emit), що реалізує IPaymentGateway. При виклику методу цей клас делегує виклик до Moq, що перевіряє чи є Setup для цього методу. Якщо є — повертає налаштоване значення. Якщо ні — поведінка залежить від MockBehavior.
MockBehavior визначає що відбувається при виклику методу, для якого немає Setup.
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.CompletedTaskTask<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, а не зрозумілий тест-провал.
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() — метод для налаштування того, що повертає або як поводиться мок при конкретному виклику.
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);
// Синхронний виняток
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 дозволяє виконати довільний код при виклику мок-методу — до того, як буде повернуто значення.
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"));
It.* — набір статичних методів для гнучкого порівняння аргументів у Setup та Verify.
// Відповідає будь-якому значенню типу 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);
// Відповідає тільки якщо значення задовольняє умову
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);
// Відповідає тільки якщо значення є в цьому наборі
mockService.Setup(x => x.GetStatusLabel(It.IsIn(1, 2, 3)))
.Returns("Active");
mockService.Setup(x => x.GetStatusLabel(It.IsNotIn(1, 2, 3)))
.Returns("Inactive");
// Відповідає для значень від 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);
// Відповідає тільки рядкам, що є email-адресами
mockEmail.Setup(x => x.Validate(It.IsRegex(@"^[\w.+-]+@[\w-]+\.[\w.]+$")))
.Returns(true);
mockRepo.Setup(x => x.Save(It.IsNull<Order>()))
.Throws<ArgumentNullException>();
mockRepo.Setup(x => x.Save(It.IsNotNull<Order>()))
.Returns(true);
// Відповідає тільки точному значенню specificId
mockRepo.Setup(x => x.GetByIdAsync(specificId)).ReturnsAsync(user1);
// Відповідає будь-якому Guid
mockRepo.Setup(x => x.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(user2);
// УВАГА: якщо обидва Setup визначені, більш специфічний (точне значення)
// виконується першим для specificId, матчер — для решти
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"
);
// Рівно 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: перевіряє що ВСІ 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 тощо
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);
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);
Іноді потрібно, щоб мок повертав різні значення при кожному наступному виклику.
// 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));
}
Коли багато залежностей — налаштування моків у конструкторі займає багато коду. 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())) { }
}
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");
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
"Don't Mock What You Don't Own" (і те, що не є інтерфейсом) — загальне правило.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();
// В основному .csproj:
// [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // для Moq
// [assembly: InternalsVisibleTo("MyProject.Tests")]
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();
}
}
Іноді потрібно перевірити складний об'єкт, що передається як аргумент:
// Підхід 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
);
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*");
}
}
// ❌ Мокування конкретного класу — запах поганого дизайну
var mockService = new Mock<OrderService>();
// ✅ Витягніть інтерфейс і мокуйте його
var mockService = new Mock<IOrderService>();
// ❌ Перевіряємо занадто багато деталей реалізації
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);
// ❌ Мокується навіть те, що можна використати реально
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);
// ❌ 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);
// Все інше — у конкретних тестах
}
mock.Setup(x => x.Method(args)).Returns(value)Times.Once, Never, Exactly(N), AtLeast(N), AtMost(N)Наступна стаття — Integration Testing з WebApplicationFactory.
xUnit Advanced — Fixtures, Кастомізація та Розширення
Просунуті можливості xUnit.net — TestServer з WebApplicationFactory, складні Fixture сценарії, кастомні Theory data providers, xUnit extensibility points, інтеграція з Microsoft DI, конфігурація для CI/CD та діагностика проблем.
Тестування Баз Даних — EF Core, SQLite та Testcontainers
Повний огляд трьох стратегій тестування з базами даних у .NET. EF Core InMemory для unit тестів, SQLite як наближений замінник, і Testcontainers для тестування з реальною PostgreSQL у Docker-контейнері.