Є два типи поганого коду: код, який одразу видно, що поганий, і код, який виглядає нормально, але з часом перетворюється на тягар. У тестуванні другий тип зустрічається значно частіше, і він має назву — Test Smells (тестові запахи).
Ви написали 200 тестів. Проєкт зростає. Одного дня ви змінили ім'я поля в DTO — і 47 тестів упали. Не тому що логіка зламалась, а тому що вони жорстко залежали від назви поля. Або ви додали новий обов'язковий параметр до конструктора Product — і тепер треба виправити 60 рядків у 15 різних тестових файлах. Або ви дивитесь на тест і не можете зрозуміти, що він тестує, бо він заповнений незрозумілими магічними числами і складною логікою підготовки.
Це і є test smells — ознаки того, що тестовий код наростив проблеми, які ускладнюють його підтримку. На відміну від production-коду, де запахи ведуть до рефакторингу функціональності, у тестах смоли ведуть до того, що команда починає ненавидіти тести і перестає їх писати.
У цій статті ми розберемо найпоширеніші запахи, зрозуміємо їх причини і навчимось усувати їх за допомогою перевірених патернів.
Тест, прочитавши який, неможливо зрозуміти, що він перевіряє. Причина — надмірна кількість деталей підготовки, що ховають суть тесту.
// ❌ Obscure Test — що тут тестується?
[Fact]
public async Task Test1()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("TestDb_" + Guid.NewGuid())
.Options;
using var context = new AppDbContext(options);
var product = new Product
{
Id = 0,
Name = "Widget",
Price = 10.99m,
CategoryId = 1,
Stock = 50,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
SupplierId = 3
};
context.Products.Add(product);
await context.SaveChangesAsync();
var repository = new ProductRepository(context);
var service = new ProductService(repository, new NullLogger<ProductService>());
var result = await service.GetProductAsync(product.Id);
Assert.NotNull(result);
Assert.Equal("Widget", result.Name);
}
Тест займає 25+ рядків, але перевіряє одну просту річ: чи GetProductAsync повертає продукт за ID. Більшість рядків — це шум.
// ✅ Після рефакторингу — суть очевидна
[Fact]
public async Task GetProduct_WhenExists_ReturnsCorrectProduct()
{
// Arrange
var product = ProductMother.SimpleProduct(); // Вся складність схована тут
await _repository.AddAsync(product);
// Act
var result = await _service.GetProductAsync(product.Id);
// Assert
Assert.Equal(product.Name, result!.Name);
}
Тест залежить від зовнішнього ресурсу (файлу, бази даних, сервісу), який не видно з самого тесту. Читач не може зрозуміти, звідки беруться тестові дані.
// ❌ Mystery Guest — звідки взялись ці продукти?
[Fact]
public async Task GetActiveProducts_ReturnsThreeProducts()
{
// Тест просто викликає метод і перевіряє число
var result = await _service.GetActiveProductsAsync();
Assert.Equal(3, result.Count()); // Звідки 3? Хтось їх додав у базу? Де?
}
Тест не показує, чому очікується 3 продукти. Дані прийшли з seed-файлу? З IClassFixture? Від попереднього тесту? Неясно.
// ✅ Все необхідне видно у тесті
[Fact]
public async Task GetActiveProducts_ReturnsOnlyActiveOnes()
{
// Arrange: явно і навмисно додаємо 3 активних і 2 неактивних
var activeProducts = ProductMother.ActiveProducts(count: 3);
var inactiveProducts = ProductMother.InactiveProducts(count: 2);
await _repository.AddRangeAsync(activeProducts.Concat(inactiveProducts));
// Act
var result = await _service.GetActiveProductsAsync();
// Assert: тепер 3 — не магія, а логічний висновок
Assert.Equal(3, result.Count());
Assert.All(result, p => Assert.True(p.IsActive));
}
Тест, який ламається при зміні коду, що не стосується перевірюваної функціональності. Зазвичай виникає через занадто конкретні assertions або жорстку залежність від деталей реалізації.
// ❌ Fragile — ламається при будь-якій зміні структури відповіді
[Fact]
public async Task CreateOrder_ReturnsCorrectJson()
{
var result = await _client.PostAsJsonAsync("/api/orders", orderRequest);
var json = await result.Content.ReadAsStringAsync();
// Перевіряємо точний рядок JSON — будь-яке форматування/порядок ламає тест
Assert.Equal("""{"id":1,"customerId":42,"status":"pending","items":[{"productId":1,"qty":2}]}""", json);
}
// ✅ Стійкий — перевіряємо сутність, не формат
[Fact]
public async Task CreateOrder_ReturnsCreatedOrder()
{
var result = await _client.PostAsJsonAsync("/api/orders", orderRequest);
var order = await result.Content.ReadFromJsonAsync<OrderDto>();
Assert.Equal(HttpStatusCode.Created, result.StatusCode);
Assert.Equal(42, order!.CustomerId);
Assert.Equal("pending", order.Status);
Assert.Single(order.Items);
}
Виробничий код містить умови if (isDevelopment), спеціальні гілки для тестування, або тести залежать від Environment.GetEnvironmentVariable("RUNNING_TESTS").
// ❌ Production-код знає про тести — АРХІТЕКТУРНА ПОМИЛКА
public async Task<ChargeResult> ChargeAsync(decimal amount)
{
if (Environment.GetEnvironmentVariable("RUNNING_TESTS") == "true")
{
return new ChargeResult { Status = "succeeded" }; // Тестова гілка!
}
return await _stripeClient.CreateChargeAsync(amount);
}
Правильне рішення — залежності через DI та мокування.
Безліч assertions без повідомлень, через що при провалі незрозуміло, яке саме твердження не спрацювало.
// ❌ Яке із 5 тверджень провалилось?
Assert.NotNull(result);
Assert.Equal(42, result.Id);
Assert.Equal("Widget", result.Name);
Assert.True(result.IsActive);
Assert.Equal(99.99m, result.Price);
// ✅ Або fluent assertions з повідомленнями, або розбити на окремі тести
Assert.True(result.IsActive, $"Expected product {result.Id} to be active, but IsActive was false");
Ще краще — використовувати бібліотеку FluentAssertions:
result.Should().NotBeNull();
result.Id.Should().Be(42);
result.Name.Should().Be("Widget");
result.IsActive.Should().BeTrue("because newly created products are active by default");
result.Price.Should().Be(99.99m);
При провалі FluentAssertions видасть зрозуміле повідомлення: Expected result.Name to be "Widget", but found "Gadget".
Object Mother — об'єкт (зазвичай статичний клас), що є «фабрикою» стандартних тестових об'єктів. Назва відсилає до концепції «матері», що народжує готові до використання об'єкти.
Без Object Mother кожен тест або тримає всі деталі у собі (Obscure Test), або дублює код створення об'єктів:
// Дублювання у TEST_A:
var product = new Product
{
Id = 1, Name = "Widget", Price = 10m, CategoryId = 1,
IsActive = true, Stock = 100, SupplierId = 1,
CreatedAt = DateTime.UtcNow
};
// Точно такий же код у TEST_B, TEST_C, TEST_D...
Якщо Product отримає новий обов'язковий параметр у конструкторі, вам доведеться правити десятки рядків.
// Tests/Mothers/ProductMother.cs
public static class ProductMother
{
// Базовий "здоровий" продукт — готовий до більшості тестів
public static Product SimpleProduct(
int id = 1,
string name = "Test Widget",
decimal price = 99.99m,
bool isActive = true) => new Product
{
Id = id,
Name = name,
Price = price,
CategoryId = 1,
IsActive = isActive,
Stock = 100,
SupplierId = 1,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
// Продукт без запасів
public static Product OutOfStockProduct() =>
SimpleProduct() with { Stock = 0 };
// Неактивний продукт
public static Product InactiveProduct() =>
SimpleProduct() with { IsActive = false };
// Список активних продуктів
public static IEnumerable<Product> ActiveProducts(int count = 3) =>
Enumerable.Range(1, count)
.Select(i => SimpleProduct(id: i, name: $"Product {i}"));
// Список неактивних продуктів
public static IEnumerable<Product> InactiveProducts(int count = 2) =>
Enumerable.Range(100, count)
.Select(i => InactiveProduct() with { Id = i, Name = $"Inactive {i}" });
// Продукт з максимально можливою ціною (для граничних тестів)
public static Product ExpensiveProduct() =>
SimpleProduct(price: decimal.MaxValue / 2);
// Продукт зі спеціальними символами у назві (для тестів валідації)
public static Product ProductWithSpecialCharacters() =>
SimpleProduct(name: "Widget <script>alert('xss')</script>");
}
Тепер тести стають короткими і виразними:
[Fact]
public async Task ApplyDiscount_WhenProductOutOfStock_ThrowsException()
{
var product = ProductMother.OutOfStockProduct();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.ApplyDiscountAsync(product.Id, 10));
}
[Fact]
public async Task GetActiveProducts_ReturnsOnlyActive()
{
await _repository.AddRangeAsync(
ProductMother.ActiveProducts(count: 3)
.Concat(ProductMother.InactiveProducts(count: 2)));
var result = await _service.GetActiveProductsAsync();
Assert.Equal(3, result.Count());
}
Object Mother чудово підходить для стабільних, добре відомих конфігурацій. Але якщо вам потрібна велика кількість варіацій — клас розбухає. Для складніших сценаріїв краще підходить наступний патерн.
Test Data Builder — більш гнучка еволюція Object Mother. Замість фіксованих фабричних методів Builder надає fluent API для покрокового конструювання тестових об'єктів.
// Tests/Builders/ProductBuilder.cs
public class ProductBuilder
{
private int _id = 1;
private string _name = "Default Test Product";
private decimal _price = 99.99m;
private int _categoryId = 1;
private bool _isActive = true;
private int _stock = 100;
private int _supplierId = 1;
private DateTime _createdAt = DateTime.UtcNow;
// Fluent методи — кожен повертає this для ланцюжка
public ProductBuilder WithId(int id)
{
_id = id;
return this;
}
public ProductBuilder WithName(string name)
{
_name = name;
return this;
}
public ProductBuilder WithPrice(decimal price)
{
_price = price;
return this;
}
public ProductBuilder InCategory(int categoryId)
{
_categoryId = categoryId;
return this;
}
public ProductBuilder ThatIsActive()
{
_isActive = true;
return this;
}
public ProductBuilder ThatIsInactive()
{
_isActive = false;
return this;
}
public ProductBuilder WithStock(int stock)
{
_stock = stock;
return this;
}
public ProductBuilder OutOfStock()
{
_stock = 0;
return this;
}
public ProductBuilder CreatedAt(DateTime date)
{
_createdAt = date;
return this;
}
// Термінальний метод — будує реальний об'єкт
public Product Build() => new Product
{
Id = _id,
Name = _name,
Price = _price,
CategoryId = _categoryId,
IsActive = _isActive,
Stock = _stock,
SupplierId = _supplierId,
CreatedAt = _createdAt,
UpdatedAt = _createdAt
};
// Зручний неявний оператор конвертації
public static implicit operator Product(ProductBuilder builder) => builder.Build();
}
Використання у тестах:
[Fact]
public async Task Checkout_WithOutOfStockProduct_ThrowsException()
{
var product = new ProductBuilder()
.WithId(42)
.WithName("Rare Item")
.WithPrice(499.99m)
.OutOfStock() // ❗ Ось що тест перевіряє — це відразу видно
.Build();
await Assert.ThrowsAsync<OutOfStockException>(() =>
_checkout.ProcessAsync(product.Id, quantity: 1));
}
[Fact]
public async Task GetProducts_ByCategory_ReturnsOnlyThatCategory()
{
// Неявна конвертація через implicit operator
Product electronics1 = new ProductBuilder().WithId(1).InCategory(categoryId: 5);
Product electronics2 = new ProductBuilder().WithId(2).InCategory(categoryId: 5);
Product furniture = new ProductBuilder().WithId(3).InCategory(categoryId: 8);
await _repository.AddRangeAsync([electronics1, electronics2, furniture]);
var result = await _service.GetByCategoryAsync(categoryId: 5);
Assert.Equal(2, result.Count());
Assert.All(result, p => Assert.Equal(5, p.CategoryId));
}
Для складних об'єктів Builder може генерувати колекції:
public class OrderBuilder
{
private int _customerId = 1;
private List<OrderItemBuilder> _items = new();
private OrderStatus _status = OrderStatus.Pending;
public OrderBuilder ForCustomer(int customerId)
{
_customerId = customerId;
return this;
}
public OrderBuilder WithItem(Action<OrderItemBuilder> configure)
{
var itemBuilder = new OrderItemBuilder();
configure(itemBuilder);
_items.Add(itemBuilder);
return this;
}
public OrderBuilder WithStatus(OrderStatus status)
{
_status = status;
return this;
}
public Order Build() => new Order
{
CustomerId = _customerId,
Status = _status,
Items = _items.Select(b => b.Build()).ToList(),
CreatedAt = DateTime.UtcNow
};
}
// Використання:
var order = new OrderBuilder()
.ForCustomer(customerId: 42)
.WithItem(item => item.WithProduct(1).WithQuantity(2))
.WithItem(item => item.WithProduct(5).WithQuantity(1).WithDiscount(10))
.WithStatus(OrderStatus.Processing)
.Build();
До цього моменту ми використовували фіксовані тестові дані: "Test Widget", User 1, test@example.com. Це добре для більшості юніт-тестів, але є клас тестів, де реалістичні і різноманітні дані важливі — наприклад, тестування валідації, пошуку або звітності.
Bogus — бібліотека для генерації фейкових, але реалістично виглядаючих даних. Імена людей, адреси, email-адреси, телефони, компанії — все з правильним форматом і локалізацією.
using Bogus;
// Faker<T> — генератор для конкретного типу
var productFaker = new Faker<Product>()
.RuleFor(p => p.Id, f => f.IndexFaker + 1) // auto-increment
.RuleFor(p => p.Name, f => f.Commerce.ProductName())
.RuleFor(p => p.Price, f => f.Finance.Amount(min: 1, max: 10000))
.RuleFor(p => p.CategoryId, f => f.Random.Int(1, 5))
.RuleFor(p => p.IsActive, f => f.Random.Bool(weight: 0.8f)) // 80% активних
.RuleFor(p => p.Stock, f => f.Random.Int(0, 500))
.RuleFor(p => p.CreatedAt, f => f.Date.Past(1)); // за останній рік
// Генерація одного об'єкта
var product = productFaker.Generate();
// Генерація списку
var products = productFaker.Generate(50);
// Faker з локалізацією — українські/польські/etc. дані
var userFaker = new Faker<User>("uk")
.RuleFor(u => u.FirstName, f => f.Name.FirstName())
.RuleFor(u => u.LastName, f => f.Name.LastName())
.RuleFor(u => u.Email, (f, u) => f.Internet.Email(u.FirstName, u.LastName))
.RuleFor(u => u.Phone, f => f.Phone.PhoneNumber("+38 (0##) ###-##-##"))
.RuleFor(u => u.City, f => f.Address.City());
Проблема рандомних генераторів — тести стають нестабільними (flaky): сьогодні проходять, завтра провалюються через "невдалий" набір даних.
Рішення — фіксований seed:
// Богус з фіксованим seed — завжди виробляє ті самі дані
Randomizer.Seed = new Random(42);
var productFaker = new Faker<Product>()
.RuleFor(p => p.Name, f => f.Commerce.ProductName())
.RuleFor(p => p.Price, f => f.Finance.Amount(1, 1000));
var product1 = productFaker.Generate();
// Завжди повертає однакове значення при seed = 42
Найпотужніший підхід — поєднати Builder із Bogus:
public class ProductBuilder
{
private static readonly Faker<Product> _faker = new Faker<Product>()
.RuleFor(p => p.Name, f => f.Commerce.ProductName())
.RuleFor(p => p.Price, f => f.Finance.Amount(1, 9999))
.RuleFor(p => p.CategoryId, f => f.Random.Int(1, 10))
.RuleFor(p => p.Stock, f => f.Random.Int(0, 1000))
.RuleFor(p => p.IsActive, _ => true)
.RuleFor(p => p.CreatedAt, f => f.Date.Past());
private Product _product = _faker.Generate(); // Починаємо з реалістичного об'єкта
// Якщо тест не вказав конкретне ім'я — буде реалістичне рандомне
public ProductBuilder WithName(string name)
{
_product = _product with { Name = name };
return this;
}
public ProductBuilder OutOfStock()
{
_product = _product with { Stock = 0 };
return this;
}
// Тест зазначає лише те, що важливо для нього,
// решта полів заповнена реалістичними даними
public Product Build() => _product;
}
// Використання:
var product = new ProductBuilder()
.OutOfStock() // Явно — тільки критична деталь
.Build(); // Решта полів реалістична (рандомна назва, ціна, дата)
У production-коді принцип DRY (Don't Repeat Yourself) — священний: дублювання коду — це зло. Але у тестах є нюанс.
DRY у тестах може зробити їх менш зрозумілими: коли логіка підготовки сховата у 3-х рівнях допоміжних методів, розуміння тесту вимагає стрибків між файлами.
DAMP (Descriptive And Meaningful Phrases) — альтернативний принцип для тестів. Він допускає певне дублювання даних заради читабельності. Тест повинен бути зрозумілим при читанні сам по собі, навіть якщо це означає невелике дублювання.
// DRY — але важко читати без контексту
[Fact]
public void Discount_ShouldApply()
{
var order = CreateOrderWithPremiumCustomer(); // ← Де це? Що всередині?
Apply10PercentDiscount(order); // ← Що це робить?
AssertDiscountApplied(order, 0.1m); // ← Перевіряє що саме?
}
// DAMP — трохи довше, але самодостатнє
[Fact]
public void PremiumCustomer_WithOrderOver100_Gets10PercentDiscount()
{
// Arrange: все явно описано в тесті
var customer = new CustomerBuilder().AsPremium().Build();
var order = new OrderBuilder()
.ForCustomer(customer.Id)
.WithItem(item => item.WithProduct(50).WithQuantity(3)) // order = $150
.Build();
// Act
_discountService.Apply(order, customer);
// Assert
Assert.Equal(135m, order.TotalAmount); // $150 - 10% = $135
}
new ProductBuilder().OutOfStock().Build() одразу показує критичну деталь (OutOfStock), не відволікаючи на решту полів.Розглянемо повний приклад рефакторингу «смердючого» тесту:
// ❌ BEFORE — повний набір Test Smells
[Fact]
public async Task T47()
{
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
var p1 = new Product { Id = 1, Name = "A", Price = 10, CategoryId = 1,
IsActive = true, Stock = 5, SupplierId = 1, CreatedAt = DateTime.Now };
var p2 = new Product { Id = 2, Name = "B", Price = 20, CategoryId = 1,
IsActive = false, Stock = 0, SupplierId = 1, CreatedAt = DateTime.Now };
var p3 = new Product { Id = 3, Name = "C", Price = 30, CategoryId = 2,
IsActive = true, Stock = 10, SupplierId = 1, CreatedAt = DateTime.Now };
db.Products.AddRange(p1, p2, p3);
await db.SaveChangesAsync();
var repo = new ProductRepository(db);
var svc = new ProductService(repo, new NullLogger<ProductService>());
var r = await svc.GetActiveProductsAsync();
Assert.Equal(2, r.Count());
}
// ✅ AFTER — чистий DAMP тест
[Fact]
public async Task GetActiveProducts_ReturnsOnlyActiveProducts()
{
// Arrange: явно і навмисно — 2 активних, 1 неактивний
await _repository.AddRangeAsync([
new ProductBuilder().ThatIsActive().Build(),
new ProductBuilder().ThatIsActive().Build(),
new ProductBuilder().ThatIsInactive().Build()
]);
// Act
var result = await _productService.GetActiveProductsAsync();
// Assert
Assert.Equal(2, result.Count());
Assert.All(result, p => Assert.True(p.IsActive));
}
Що покращилось:
T47 → GetActiveProducts_ReturnsOnlyActiveProducts)DbContext та сервісів — це у SetUp/IAsyncLifetimeЗнайдіть у поточному тестовому проєкті мінімум по 2 приклади кожного smell:
Для кожного запишіть: що саме є проблемою і як би ви це виправили.
ProductMother зі статичними методами для вашої моделі Product:SimpleProduct(), OutOfStockProduct(), InactiveProduct(), ActiveProducts(count), InactiveProducts(count)ProductBuilder з fluent API. Мінімальний набір методів:WithName(), WithPrice(), OutOfStock(), ThatIsActive(), ThatIsInactive(), InCategory(), Build()Bogus у ProductBuilder: за замовчуванням всі поля мають реалістичні рандомні значення, і лише вказані явно через fluent API — фіксовані.Randomizer.Seed = new Random(1337) у тестовому AssemblyInitialize, щоб тести були відтворжуваними.[MemberData] для сценаріїв валідації:public static IEnumerable<object[]> InvalidProducts() =>
new ProductBuilder().AsInvalid().Generate(10)
.Select(p => new object[] { p });
У наступній статті ми перейдемо до просунутих інструментів: TimeProvider для тестування часозалежного коду, Snapshot Testing через Verify та Property-Based Testing з FsCheck.
HttpClient у Тестах Частина 2: WireMock.Net та Resilience
MockHttpMessageHandler не ловить помилки формування URL чи заголовків на рівні мережі. WireMock.Net — справжній HTTP-сервер у тестах. Навчимось перевіряти стійкість Polly retry та Circuit Breaker до збоїв.
Просунуті інструменти: Time, Snapshots та Властивості
Як протестувати код, що залежить від часу? Як замінити 50 Assert-ів одним рядком? Як знайти баги, про які ви не думали? Вивчаємо TimeProvider (.NET 8), Snapshot Testing з Verify та Property-Based Testing з FsCheck.