Тестування

Просунуті інструменти: Time, Snapshots та Властивості

Як протестувати код, що залежить від часу? Як замінити 50 Assert-ів одним рядком? Як знайти баги, про які ви не думали? Вивчаємо TimeProvider (.NET 8), Snapshot Testing з Verify та Property-Based Testing з FsCheck.

Просунуті інструменти: Time, Snapshots та Властивості

Є три класи задач у тестуванні, для яких стандартні підходи або незручні, або недостатні.

Перша — код, що залежить від поточного часу. DateTime.Now, DateTimeOffset.UtcNow, таймери, розклади — все це є джерелом нестабільних тестів. Як перевірити, що щойно виданий JWT токен містить правильний exp-клейм, якщо час кожен раз різний?

Друга — складні об'єкти чи документи, де потрібно перевірити десятки полів. 50 Assert.Equal(...) — це not only багатослівно, але й крихко: при зміні структури об'єкта вам треба переписувати десятки тверджень.

Третя — пошук граничних випадків, про які ви не подумали. Ваша функція сортування правильна? Ви написали 5 [InlineData]-тест. Але чи гарантує це, що вона правильна для всіх можливих вхідних даних?

У цій статті — по одному потужному інструменту для кожної з цих задач.

Частина 1: Тестування Часу з TimeProvider

Проблема неявної залежності від часу

Розглянемо типовий код генерації токену:

public class JwtTokenService
{
    private readonly IConfiguration _config;
    
    public string GenerateAccessToken(User user)
    {
        var expiry = DateTime.UtcNow.AddMinutes(15); // ← Неявна залежність!
        
        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim(JwtRegisteredClaimNames.Exp, 
                new DateTimeOffset(expiry).ToUnixTimeSeconds().ToString())
        };
        
        // ... підпис токену ...
        return tokenString;
    }
}

Як написати тест, що перевіряє, чи токен дійсно закінчується рівно через 15 хвилин? Якщо використати Assert.Equal(DateTime.UtcNow.AddMinutes(15), expiry) — тест буде нестабільним, тому що між рядком генерації токену і рядком assertion проходить певний час (мікросекунди або мілісекунди), і рівності не буде ніколи.

Класичне рішення — ввести абстракцію IClock або ITimeProvider і замінити реальний час фейковим. У .NET 8 Microsoft нарешті стандартизувала цю абстракцію у вигляді TimeProvider.

TimeProvider у .NET 8

TimeProvider — абстрактний клас у System. Він замінює пряме використання DateTime.UtcNow і DateTimeOffset.UtcNow:

// ✅ Завжди використовуйте TimeProvider замість DateTime.UtcNow
public class JwtTokenService
{
    private readonly IConfiguration _config;
    private readonly TimeProvider _timeProvider; // ← Залежність через DI

    public JwtTokenService(IConfiguration config, TimeProvider timeProvider)
    {
        _config = config;
        _timeProvider = timeProvider;
    }
    
    public string GenerateAccessToken(User user)
    {
        // Використовуємо TimeProvider замість DateTime.UtcNow
        var now = _timeProvider.GetUtcNow();
        var expiry = now.AddMinutes(15);
        
        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new Claim(JwtRegisteredClaimNames.Exp, 
                expiry.ToUnixTimeSeconds().ToString())
        };
        
        return BuildToken(claims);
    }
}

Реєстрація в DI:

// Program.cs — реальний TimeProvider (системний час)
builder.Services.AddSingleton(TimeProvider.System);

FakeTimeProvider у Тестах

Пакет Microsoft.Extensions.TimeProvider.Testing надає FakeTimeProvider — реалізацію TimeProvider, яку можна повністю контролювати:

dotnet add package
$ dotnet add package Microsoft.Extensions.TimeProvider.Testing
Successfully added Microsoft.Extensions.TimeProvider.Testing to MyApp.Tests.csproj
using Microsoft.Extensions.Time.Testing;

[Fact]
public void GenerateAccessToken_ContainsCorrectExpiry()
{
    // Arrange: замороджуємо час
    var frozenTime = new DateTimeOffset(2024, 6, 15, 10, 0, 0, TimeSpan.Zero);
    var fakeTime = new FakeTimeProvider(frozenTime);
    
    var service = new JwtTokenService(
        _config,
        fakeTime); // ← Передаємо фейковий час
    
    var user = UserMother.SimpleUser();
    
    // Act
    var token = service.GenerateAccessToken(user);
    
    // Assert: розпарсимо токен і перевіримо exp
    var handler = new JsonWebTokenHandler();
    var jwt = handler.ReadJsonWebToken(token);
    
    var expClaim = jwt.GetPayloadValue<long>("exp");
    var expectedExpiry = frozenTime.AddMinutes(15).ToUnixTimeSeconds();
    
    Assert.Equal(expectedExpiry, expClaim);
    // Тест стабільний, тому що час заморожений!
}

Під час debug ви можете спостерігати за станом:

Debug: FakeTimeProvider
Filter
NameTypeValue
frozenTimeDateTimeOffset2024-06-15 10:00:00 +00:00
fakeTimeFakeTimeProviderFakeTimeProvider with frozen clock
serviceJwtTokenServiceJwtTokenService instance
tokenstring"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
jwtJsonWebToken{ exp: 1718451600, iat: 1718450100 }
Running
Process: 12842

Просування Часу Вперед (Time Travel)

FakeTimeProvider.Advance() — справжня «машина часу» в тестах. Можна перемотати час на будь-яку тривалість:

[Fact]
public async Task RefreshToken_WhenExpired_ThrowsTokenExpiredException()
{
    // Arrange
    var fakeTime = new FakeTimeProvider();
    var service = new RefreshTokenService(_repository, fakeTime);
    
    // Видаємо refresh token (він дійсний 7 днів)
    var token = await service.IssueRefreshTokenAsync(userId: 1);
    
    // Act: перемотуємо час на 8 днів вперед
    fakeTime.Advance(TimeSpan.FromDays(8));
    
    // Assert: токен вже протух
    await Assert.ThrowsAsync<TokenExpiredException>(() =>
        service.ValidateRefreshTokenAsync(token));
}

[Fact]
public async Task CacheEntry_AfterTtlExpires_IsEvicted()
{
    // Arrange
    var fakeTime = new FakeTimeProvider();
    var cache = new TimedCache(fakeTime, ttl: TimeSpan.FromMinutes(10));
    
    cache.Set("product:1", new Product { Id = 1, Name = "Widget" });
    
    // Act: перемотуємо на 11 хвилин
    fakeTime.Advance(TimeSpan.FromMinutes(11));
    
    var result = cache.Get("product:1");
    
    // Assert: кеш очистився
    Assert.Null(result);
}

Тестування Recurring Jobs з FakeTimeProvider

Ще потужніший сценарій — тестування фонових завдань та планувальників:

[Fact]
public async Task CleanupJob_RunsEveryHour_DeletesExpiredSessions()
{
    // Arrange
    var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
    var repository = new InMemorySessionRepository();
    
    // Додаємо 5 сесій, що протухнуть через 30 хвилин
    var expiredSessions = SessionMother.ExpiredSessions(count: 5, expiredBefore: fakeTime.GetUtcNow().AddMinutes(30));
    await repository.AddRangeAsync(expiredSessions);
    
    var job = new ExpiredSessionCleanupJob(repository, fakeTime);
    
    // Act 1: одразу після видачі — сесії ще живі
    await job.RunAsync();
    Assert.Equal(5, await repository.CountAsync());
    
    // Перемотуємо 31 хвилину
    fakeTime.Advance(TimeSpan.FromMinutes(31));
    
    // Act 2: тепер сесії протухли
    await job.RunAsync();
    
    // Assert
    Assert.Equal(0, await repository.CountAsync());
}

PeriodicTimer з FakeTimeProvider

.NET 6 ввів PeriodicTimer — більш сучасну альтернативу Timer. FakeTimeProvider вміє керувати його тактами:

// Production-код з PeriodicTimer:
public class MetricsCollector
{
    private readonly TimeProvider _timeProvider;
    
    public async Task StartAsync(CancellationToken ct)
    {
        using var timer = _timeProvider.CreateTimer(
            _ => CollectMetrics(),
            null,
            TimeSpan.Zero,
            period: TimeSpan.FromMinutes(1));
        
        await Task.Delay(Timeout.Infinite, ct);
    }
}

// Тест:
[Fact]
public async Task MetricsCollector_CollectsEveryMinute()
{
    var fakeTime = new FakeTimeProvider();
    var collector = new MetricsCollector(fakeTime, _metricsStore);
    
    var cts = new CancellationTokenSource();
    var runTask = collector.StartAsync(cts.Token);
    
    // Чекаємо першого збору
    await Task.Delay(10); // дозволяємо першому тіку спрацювати
    Assert.Equal(1, _metricsStore.CollectionCount);
    
    // Просуваємо час на хвилину
    fakeTime.Advance(TimeSpan.FromMinutes(1));
    await Task.Delay(10);
    Assert.Equal(2, _metricsStore.CollectionCount);
    
    fakeTime.Advance(TimeSpan.FromMinutes(1));
    await Task.Delay(10);
    Assert.Equal(3, _metricsStore.CollectionCount);
    
    cts.Cancel();
}

Частина 2: Snapshot Testing з Verify

Проблема численних Assert-ів

Коли об'єкт має 20 полів, тест на його коректність перетворюється на пекло:

// ❌ 20 assertions — балісик, крихкий, нудний
Assert.Equal(42, result.Id);
Assert.Equal("John Doe", result.FullName);
Assert.Equal("john@example.com", result.Email);
Assert.Equal("Premium", result.Tier);
Assert.Equal(1500.00m, result.CreditLimit);
Assert.NotNull(result.Address);
Assert.Equal("Kyiv", result.Address.City);
Assert.Equal("Ukraine", result.Address.Country);
Assert.NotEmpty(result.Orders);
Assert.Equal(3, result.Orders.Count);
Assert.Equal(OrderStatus.Completed, result.Orders[0].Status);
// ... ще 10 assertions...

Якщо ви змінюєте структуру відповіді — всі ці assertions треба оновлювати вручну.

Snapshot Testing (або Approval Testing) — підхід, де замість написання assertions ви кажете: "перевір, що результат ТАКИЙ САМИЙ, як збережена раніше копія". При першому запуску «копія» (snapshot) зберігається автоматично. При наступних запусках — порівнюється з нею.

Бібліотека Verify

Verify — найпопулярніша бібліотека Snapshot Testing для .NET:

dotnet add package
$ dotnet add package Verify.Xunit
Successfully added Verify.Xunit to MyApp.Tests.csproj

Перший snapshot-тест

using VerifyXunit;

[UsesVerify] // ← Атрибут на рівні класу
public class CustomerDtoTests
{
    [Fact]
    public async Task GetCustomer_ReturnsCorrectDto()
    {
        // Arrange
        var customer = CustomerMother.PremiumCustomer();
        var service = new CustomerService(_repository);
        
        // Act
        var dto = await service.GetCustomerDtoAsync(customer.Id);
        
        // Assert: один рядок замість 20
        await Verify(dto);
    }
}

При першому запуску тест провалюється і створює файл-снімок у директорії __snapshots__:

// CustomerDtoTests.GetCustomer_ReturnsCorrectDto.verified.txt
{
  Id: 42,
  FullName: John Doe,
  Email: john@example.com,
  Tier: Premium,
  CreditLimit: 1500.00,
  Address: {
    City: Kyiv,
    Country: Ukraine
  },
  Orders: [
    {
      Id: 1,
      Status: Completed,
      Total: 299.99
    },
    ...
  ]
}

Ви переглядаєте файл, переконуєтесь, що він правильний, і «схвалюєте» його — або командою dotnet verify accept, або вручну перейменовуючи файл із .received.txt на .verified.txt. При наступному запуску порівняння відбувається автоматично.

Verify для API-відповідей

Ідеальне застосування Verify — тестування HTTP-відповідей у WebApplicationFactory:

[UsesVerify]
public class ProductsEndpointSnapshotTests : IClassFixture<TestWebApplicationFactory>
{
    private readonly HttpClient _client;

    public ProductsEndpointSnapshotTests(TestWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetProducts_ReturnsCorrectJsonStructure()
    {
        var response = await _client.GetAsync("/api/products?page=1&pageSize=3");
        response.EnsureSuccessStatusCode();
        
        var json = await response.Content.ReadAsStringAsync();
        
        // Verify порівняє весь JSON зі збереженим знімком
        await Verify(json)
            .UseMethodName("GetProducts_Page1"); // явна назва файлу
    }
    
    [Fact]
    public async Task CreateProduct_ReturnsCreatedProductWithCorrectShape()
    {
        var request = new { Name = "Widget Pro", Price = 199.99, CategoryId = 1 };
        var response = await _client.PostAsJsonAsync("/api/products", request);
        
        var dto = await response.Content.ReadFromJsonAsync<ProductDto>();
        
        await Verify(dto)
            .IgnoreMember<ProductDto>(p => p.Id)         // ID генерується — ігноруємо
            .IgnoreMember<ProductDto>(p => p.CreatedAt); // дата теж рандомна
    }
}

Налаштування: Ігнорування динамічних полів

Найпоширеніша проблема Snapshot Testing — динамічні поля (ID, дати, GUID, токени), що змінюються при кожному запуску. Verify надає кілька способів їх обробки:

// Варіант 1: Ігнорувати конкретні поля
await Verify(dto)
    .IgnoreMembers("Id", "CreatedAt", "UpdatedAt");

// Варіант 2: Ігнорувати за типом
await Verify(dto)
    .IgnoreMembersWithType<Guid>()  // всі Guid поля
    .IgnoreMembersWithType<DateTime>(); // всі DateTime поля

// Варіант 3: Замінити на стабільне значення
await Verify(dto)
    .ScrubMember<ProductDto>(p => p.Id) // замінює на "{ProductDto.Id}"
    .ScrubMember<ProductDto>(p => p.CreatedAt); // замінює на "{ProductDto.CreatedAt}"

Результат у .verified.txt буде виглядати так:

{
  Id: {ProductDto.Id},
  Name: Widget Pro,
  Price: 199.99,
  CreatedAt: {ProductDto.CreatedAt},
  ...
}

Оновлення Snapshot

Коли логіка змінилась і нова відповідь є правильною:

dotnet verify accept
$ dotnet verify accept
Updated 15 snapshot files
Run tests again to verify

Це набагато зручніше, ніж вручну оновлювати 20 Assert.Equal.

Verify для складних структур

Verify вміє серіалізувати практично будь-що:

// Список об'єктів
await Verify(products);

// HTTP Response (статус + заголовки + тіло)
await Verify(response);

// XML документ
await Verify(xmlDocument);

// Будь-який об'єкт з кастомним серіалізатором
await Verify(complexObject)
    .UseSerializer(myCustomSerializer);

Частина 3: Property-Based Testing з FsCheck

Проблема прикладного тестування

Традиційні тести на прикладах (Example-Based Testing) мають вроджене обмеження: ви перевіряєте рівно ті випадки, про які ви подумали. Але помилки часто ховаються у випадках, про які не думали.

Стандартна функція обернення рядка:

public static string Reverse(string s)
{
    return new string(s.Reverse().ToArray());
}

Ви пишете тести:

Assert.Equal("", Reverse(""));
Assert.Equal("a", Reverse("a"));
Assert.Equal("cba", Reverse("abc"));

Все зелене. Але що якщо передати рядок зі surrogate pairs (emoji)? Або з combining characters? Ваші 3 тести це не перевіряють.

Property-Based Testing

У Property-Based Testing ви описуєте не конкретні приклади, а властивості (properties), які мають виконуватись для будь-яких коректних вхідних даних. Фреймворк автоматично генерує сотні або тисячі різних вхідних значень і перевіряє, що властивість виконується для кожного.

dotnet add package FsCheck
$ dotnet add package FsCheck FsCheck.Xunit
Successfully added FsCheck to MyApp.Tests.csproj
Successfully added FsCheck.Xunit to MyApp.Tests.csproj

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

using FsCheck;
using FsCheck.Xunit;

public class StringReversePropertyTests
{
    // [Property] замість [Fact] — FsCheck сам генерує input
    [Property]
    public bool ReverseOfReverse_IsOriginal(string s)
    {
        if (s == null) return true; // пропускаємо null (або обробіть окремо)
        
        // Властивість: двічі обернутий рядок = оригінал
        var result = Reverse(Reverse(s));
        return result == s;
    }
    
    [Property]
    public bool Reverse_PreservesLength(string s)
    {
        if (s == null) return true;
        return Reverse(s).Length == s.Length;
    }
    
    [Property]
    public bool Reverse_SingleChar_IsItself(char c)
    {
        return Reverse(c.ToString()) == c.ToString();
    }
}

FsCheck запустить кожен тест 100 разів (за замовчуванням) з різними згенерованими рядками. Якщо знайде контрприклад — покаже саме той вхід, що зламав властивість:

Falsifiable, after 23 tests
Counterexample:
    "abc\ud83d" // surrogate pair на кінці

Властивості для бізнес-логіки

Property-Based Testing особливо корисний для алгоритмів та трансформацій:

public class DiscountService
{
    public decimal ApplyDiscount(decimal price, decimal discountPercent)
    {
        if (price < 0) throw new ArgumentException("Price cannot be negative");
        if (discountPercent < 0 || discountPercent > 100)
            throw new ArgumentException("Discount must be 0-100");
        
        return price * (1 - discountPercent / 100);
    }
}

public class DiscountServicePropertyTests
{
    private readonly DiscountService _service = new();
    
    [Property(MaxTest = 500)]
    public bool ApplyDiscount_ResultIsNeverGreaterThanOriginalPrice(
        PositiveInt rawPrice, byte discountByte)
    {
        var price = rawPrice.Get; // завжди позитивне число
        var discount = discountByte % 101; // 0-100
        
        var result = _service.ApplyDiscount(price, discount);
        
        // Властивість: знижка ніколи не збільшує ціну
        return result <= price;
    }
    
    [Property]
    public bool ZeroDiscount_ReturnsOriginalPrice(PositiveInt rawPrice)
    {
        var price = rawPrice.Get;
        var result = _service.ApplyDiscount(price, discountPercent: 0);
        return result == price;
    }
    
    [Property]
    public bool HundredPercentDiscount_ReturnsZero(PositiveInt rawPrice)
    {
        var price = rawPrice.Get;
        var result = _service.ApplyDiscount(price, discountPercent: 100);
        return result == 0;
    }
    
    [Property]
    public bool DiscountedPrice_IsNonNegative(PositiveInt rawPrice, byte discountByte)
    {
        var discount = discountByte % 101;
        var result = _service.ApplyDiscount(rawPrice.Get, discount);
        return result >= 0;
    }
}

Генератори FsCheck

FsCheck знає, як генерувати більшість базових типів. Для складніших — пишемо власні генератори:

// Власний арбітрарій для Product
public static class ProductArbitrary
{
    public static Arbitrary<Product> Products()
    {
        var nameGen = Arb.Default.NonEmptyString().Generator
            .Select(s => s.Get.Substring(0, Math.Min(s.Get.Length, 100)));
        
        var priceGen = Gen.Choose(1, 1_000_000)
            .Select(i => (decimal)i / 100); // $0.01 - $10,000.00
        
        var categoryGen = Gen.Choose(1, 10);
        
        var productGen = from name in nameGen
                         from price in priceGen
                         from categoryId in categoryGen
                         select new Product
                         {
                             Name = name,
                             Price = price,
                             CategoryId = categoryId,
                             IsActive = true,
                             Stock = 100
                         };
        
        return productGen.ToArbitrary();
    }
}

// Реєстрація арбітрарію:
[assembly: Properties(Arbitrary = new[] { typeof(ProductArbitrary) })]

// Тепер FsCheck може генерувати Product автоматично:
[Property]
public bool CreateProduct_AlwaysHasPositivePrice(Product product)
{
    // Властивість: ціна завжди позитивна (не залежно від вхідних даних)
    return product.Price > 0;
}

Stateful Property Testing

Для більш складних сценаріїв FsCheck підтримує Stateful Testing — послідовностей операцій зі станом:

// Тестуємо, що InMemoryCart завжди веде себе коректно
// незалежно від послідовності операцій
public class CartTests
{
    [Property]
    public Property Cart_ItemCount_AlwaysNonNegative()
    {
        return Prop.ForAll(
            Gen.listOf(CommandGenerator()).ToArbitrary(),
            commands =>
            {
                var cart = new ShoppingCart();
                foreach (var cmd in commands)
                {
                    cmd.Apply(cart);
                }
                return cart.ItemCount >= 0;
            });
    }
    
    private static Gen<ICartCommand> CommandGenerator()
    {
        return Gen.frequency(
            (3, Gen.Choose(1, 100).Select(id => (ICartCommand)new AddItemCommand(id))),
            (2, Gen.Choose(1, 100).Select(id => new RemoveItemCommand(id))),
            (1, Gen.Constant((ICartCommand)new ClearCartCommand()))
        );
    }
}

Поєднання Example-Based та Property-Based

Ці два підходи не виключають, а доповнюють один одного:

ПідхідВикористовуйте для
Example-Based ([Fact])Специфічних бізнес-сценаріїв, граничних значень, які ви знаєте
Property-Based ([Property])Алгоритмів, математичних операцій, серіалізації/десеріалізації
Snapshot (Verify)Великих об'єктів, API-контрактів, HTML/JSON відповідей

Хороша тестова стратегія використовує всі три:

// 1. Example-based: перевіряємо відомий бізнес-сценарій
[Fact]
public void PremiumUser_With50PercentDiscount_PaysHalf()
{
    var result = _service.ApplyDiscount(price: 200m, discountPercent: 50);
    Assert.Equal(100m, result);
}

// 2. Property-based: перевіряємо математичні властивості
[Property]
public bool Discount_ResultIsAlwaysLessThanOrEqualToOriginal(
    PositiveInt price, NonNegativeInt discountInt)
{
    var discount = discountInt.Get % 101; // 0-100
    return _service.ApplyDiscount(price.Get, discount) <= price.Get;
}

// 3. Snapshot: фіксуємо повний контракт API
[Fact]
public async Task GetPricingRules_ReturnsExpectedStructure()
{
    var result = await _client.GetAsync("/api/pricing/rules");
    await Verify(result);
}

Практика


У фінальній статті курсу ми піднімемось на найвищий рівень: навчимось тестувати саму архітектуру вашого проєкту за допомогою NetArchTest і дізнаємось, як автоматизовані architectural fitness functions захищають кодову базу від деградації.