Є три класи задач у тестуванні, для яких стандартні підходи або незручні, або недостатні.
Перша — код, що залежить від поточного часу. DateTime.Now, DateTimeOffset.UtcNow, таймери, розклади — все це є джерелом нестабільних тестів. Як перевірити, що щойно виданий JWT токен містить правильний exp-клейм, якщо час кожен раз різний?
Друга — складні об'єкти чи документи, де потрібно перевірити десятки полів. 50 Assert.Equal(...) — це not only багатослівно, але й крихко: при зміні структури об'єкта вам треба переписувати десятки тверджень.
Третя — пошук граничних випадків, про які ви не подумали. Ваша функція сортування правильна? Ви написали 5 [InlineData]-тест. Але чи гарантує це, що вона правильна для всіх можливих вхідних даних?
У цій статті — по одному потужному інструменту для кожної з цих задач.
Розглянемо типовий код генерації токену:
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 — абстрактний клас у 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);
Пакет Microsoft.Extensions.TimeProvider.Testing надає FakeTimeProvider — реалізацію TimeProvider, яку можна повністю контролювати:
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 ви можете спостерігати за станом:
| Name | Type | Value |
|---|---|---|
| ◢frozenTime | DateTimeOffset | 2024-06-15 10:00:00 +00:00 |
| ◢fakeTime | FakeTimeProvider | FakeTimeProvider with frozen clock |
| ◢service | JwtTokenService | JwtTokenService instance |
| ◢token | string | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." |
| ◢jwt | JsonWebToken | { exp: 1718451600, iat: 1718450100 } |
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);
}
Ще потужніший сценарій — тестування фонових завдань та планувальників:
[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());
}
.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();
}
Коли об'єкт має 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 — найпопулярніша бібліотека Snapshot Testing для .NET:
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 — тестування 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},
...
}
Коли логіка змінилась і нова відповідь є правильною:
Це набагато зручніше, ніж вручну оновлювати 20 Assert.Equal.
Verify вміє серіалізувати практично будь-що:
// Список об'єктів
await Verify(products);
// HTTP Response (статус + заголовки + тіло)
await Verify(response);
// XML документ
await Verify(xmlDocument);
// Будь-який об'єкт з кастомним серіалізатором
await Verify(complexObject)
.UseSerializer(myCustomSerializer);
Традиційні тести на прикладах (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 ви описуєте не конкретні приклади, а властивості (properties), які мають виконуватись для будь-яких коректних вхідних даних. Фреймворк автоматично генерує сотні або тисячі різних вхідних значень і перевіряє, що властивість виконується для кожного.
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 знає, як генерувати більшість базових типів. Для складніших — пишемо власні генератори:
// Власний арбітрарій для 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;
}
Для більш складних сценаріїв 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 ([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);
}
У вас є клас SubscriptionService:
public class SubscriptionService
{
private readonly TimeProvider _timeProvider;
public bool IsSubscriptionActive(Subscription sub) =>
sub.ExpiresAt > _timeProvider.GetUtcNow();
public bool IsTrialPeriod(Subscription sub) =>
_timeProvider.GetUtcNow() < sub.CreatedAt.AddDays(14);
}
Напишіть тести:
IsSubscriptionActive_WhenExpiryInFuture_ReturnsTrue — час заморожений до expiryIsSubscriptionActive_WhenExpired_ReturnsFalse — Advance() за expiryIsTrialPeriod_On13thDay_ReturnsTrueIsTrialPeriod_On15thDay_ReturnsFalseВикористовуйте FakeTimeProvider з фіксованою початковою датою.
Verify.Xunit.GET /api/products (через WebApplicationFactory).Id, CreatedAt, UpdatedAt..verified.txt.Description). Запустіть знову — переконайтесь, що тест провалюється з diff.dotnet verify accept.Маєте функцію розрахунку вартості доставки:
// Правила: безкоштовно від $100; 5% від суми, мін. $3, макс. $25
public decimal CalculateShipping(decimal orderTotal) { ... }
Напишіть property-based тести:
>= 0 (ніколи не від'ємний)>= 100 — shipping == 0< 100 — shipping >= 3 і shipping <= 25У фінальній статті курсу ми піднімемось на найвищий рівень: навчимось тестувати саму архітектуру вашого проєкту за допомогою NetArchTest і дізнаємось, як автоматизовані architectural fitness functions захищають кодову базу від деградації.
Патерни та Анти-патерни Тестування: Test Smells
Тести, які важче підтримувати ніж production-код — це Test Smells. Розбираємо каталог поганих практик і вивчаємо патерни Object Mother, Test Data Builder та бібліотеку Bogus для чистих і виразних тестів.
Тестування Архітектури з NetArchTest
Хто перевірить, що розробники не зроблять посилання з Domain шару на Infrastructure? NetArchTest дозволяє писати тести на архітектурні правила — Fitness Functions, що захищають вашу кодову базу від деградації.