Тестування

Патерни та Анти-патерни Тестування: Test Smells

Тести, які важче підтримувати ніж production-код — це Test Smells. Розбираємо каталог поганих практик і вивчаємо патерни Object Mother, Test Data Builder та бібліотеку Bogus для чистих і виразних тестів.

Патерни та Анти-патерни Тестування: Test Smells

Є два типи поганого коду: код, який одразу видно, що поганий, і код, який виглядає нормально, але з часом перетворюється на тягар. У тестуванні другий тип зустрічається значно частіше, і він має назву — Test Smells (тестові запахи).

Ви написали 200 тестів. Проєкт зростає. Одного дня ви змінили ім'я поля в DTO — і 47 тестів упали. Не тому що логіка зламалась, а тому що вони жорстко залежали від назви поля. Або ви додали новий обов'язковий параметр до конструктора Product — і тепер треба виправити 60 рядків у 15 різних тестових файлах. Або ви дивитесь на тест і не можете зрозуміти, що він тестує, бо він заповнений незрозумілими магічними числами і складною логікою підготовки.

Це і є test smells — ознаки того, що тестовий код наростив проблеми, які ускладнюють його підтримку. На відміну від production-коду, де запахи ведуть до рефакторингу функціональності, у тестах смоли ведуть до того, що команда починає ненавидіти тести і перестає їх писати.

У цій статті ми розберемо найпоширеніші запахи, зрозуміємо їх причини і навчимось усувати їх за допомогою перевірених патернів.

Каталог Test Smells

Obscure Test (Непрозорий Тест)

Тест, прочитавши який, неможливо зрозуміти, що він перевіряє. Причина — надмірна кількість деталей підготовки, що ховають суть тесту.

// ❌ 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 (Таємний Гість)

Тест залежить від зовнішнього ресурсу (файлу, бази даних, сервісу), який не видно з самого тесту. Читач не може зрозуміти, звідки беруться тестові дані.

// ❌ 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));
}

Fragile Test (Крихкий Тест)

Тест, який ламається при зміні коду, що не стосується перевірюваної функціональності. Зазвичай виникає через занадто конкретні 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);
}

Test Logic in Production (Логіка тестів у Production)

Виробничий код містить умови 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 та мокування.

Assertion Roulette (Рулетка Тверджень)

Безліч 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 — об'єкт (зазвичай статичний клас), що є «фабрикою» стандартних тестових об'єктів. Назва відсилає до концепції «матері», що народжує готові до використання об'єкти.

Проблема, яку він вирішує

Без 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 отримає новий обов'язковий параметр у конструкторі, вам доведеться правити десятки рядків.

Реалізація Object Mother

// 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

Object Mother чудово підходить для стабільних, добре відомих конфігурацій. Але якщо вам потрібна велика кількість варіацій — клас розбухає. Для складніших сценаріїв краще підходить наступний патерн.

Патерн Test Data Builder

Test Data Builder — більш гнучка еволюція Object Mother. Замість фіксованих фабричних методів Builder надає fluent API для покрокового конструювання тестових об'єктів.

Loading diagram...
graph LR
    A["new ProductBuilder()"] --> B[".WithName('Widget')"]
    B --> C[".WithPrice(99m)"]
    C --> D[".OutOfStock()"]
    D --> E[".Build()"]
    E --> F["Product (готовий об'єкт)"]

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style F fill:#10b981,stroke:#047857,color:#ffffff
    style B fill:#64748b,stroke:#334155,color:#ffffff
    style C fill:#64748b,stroke:#334155,color:#ffffff
    style D fill:#64748b,stroke:#334155,color:#ffffff
    style E fill:#64748b,stroke:#334155,color:#ffffff

Реалізація Test Data Builder

// 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 з колекціями

Для складних об'єктів 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();

Бібліотека Bogus: Реалістичні Фейкові Дані

До цього моменту ми використовували фіксовані тестові дані: "Test Widget", User 1, test@example.com. Це добре для більшості юніт-тестів, але є клас тестів, де реалістичні і різноманітні дані важливі — наприклад, тестування валідації, пошуку або звітності.

Bogus — бібліотека для генерації фейкових, але реалістично виглядаючих даних. Імена людей, адреси, email-адреси, телефони, компанії — все з правильним форматом і локалізацією.

dotnet add package
$ dotnet add package Bogus
Successfully added Bogus to MyApp.Tests.csproj

Базовий синтаксис Bogus

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());

Детермінований генератор (Seed)

Проблема рандомних генераторів — тести стають нестабільними (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

Інтеграція Bogus з Test Data Builder

Найпотужніший підхід — поєднати 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();      // Решта полів реалістична (рандомна назва, ціна, дата)

DRY vs DAMP у Тестах

У 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
}
Правило балансу: Виносьте в допоміжні методи лише деталі, нерелевантні для тесту. Те, що важливо для розуміння тесту — залишайте видимим. Builder/Mother чудово вирішують цю проблему: 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));
}

Що покращилось:

  • Назва тесту описує поведінку, а не є індексом (T47GetActiveProducts_ReturnsOnlyActiveProducts)
  • Немає дублювання створення DbContext та сервісів — це у SetUp/IAsyncLifetime
  • Немає зайвих деталей (Price, SupplierId, CreatedAt — не важливі, схові у Builder)
  • Намір очевидний: 2 активних + 1 неактивний → очікуємо 2

Практика


У наступній статті ми перейдемо до просунутих інструментів: TimeProvider для тестування часозалежного коду, Snapshot Testing через Verify та Property-Based Testing з FsCheck.