Перейти до вмісту

Тестування

Як перевіряти .NET-код автоматично: модульні тести й дублери залежностей, інтеграційні тести ASP.NET Core зі справжньою базою та стратегія, яка тримає баланс між упевненістю й часом прогону. На співбесіді тут перевіряють не синтаксис атрибутів, а вміння пояснити, що, яким тестом і навіщо перевіряти.

Перед цією темою: Впровадження залежностей.

Junior+

Що таке модульний тест і як його правильно написати?

Section titled “Що таке модульний тест і як його правильно написати?”
Відповідь

Що це

Модульний тест — метод, який викликає ваш код із відомими даними й перевіряє результат. Він не чіпає базу, мережу й файли, тому виконується за мілісекунди.

Як користуватися

  1. Arrange — створити об’єкт і дані.
  2. Act — один раз викликати метод, що перевіряється.
  3. Assert — порівняти результат з очікуваним: Assert.Equal(100m, price).

Ім’я тесту складається з трьох частин: метод, сценарій, очікувана поведінка — Apply_UnknownCoupon_Throws. У xUnit звичайний тест позначають [Fact], а тест із наборами даних — [Theory] і [InlineData].

Що тестувати

Логіку з розгалуженнями: розрахунки, валідацію, граничні значення — нуль, порожній рядок, null. Автовластивості й код фреймворку тестувати не потрібно.

Типова помилка

Тест залежить від DateTime.Now або від порядку запуску: сьогодні він зелений, завтра червоний.

Як влаштовано

Фреймворк Тест і параметри Життєвий цикл класу тесту
xUnit [Fact], [Theory] і [InlineData] новий екземпляр на кожен тест; підготовка в конструкторі, очищення в Dispose
NUnit [Test], [TestCase] один екземпляр на клас; [SetUp] і [TearDown] навколо кожного тесту
MSTest [TestMethod], [DataRow] новий екземпляр на тест; [TestInitialize], [TestCleanup]
  • xUnit за замовчуванням запускає різні класи паралельно, а тести одного класу — послідовно. NUnit і MSTest ідуть послідовно, доки паралельність не ввімкнули ([Parallelizable], [assembly: Parallelize]).
  • У NUnit 4 основний стиль — Assert.That(x, Is.EqualTo(y)), а класичні Assert.AreEqual переїхали в ClassicAssert.

Пастки

  • Спільний стан у NUnit. Поле класу переживає тест, і тест, який його змінює, ламає сусідів. [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] дає екземпляр на тест.
  • Кілька дій у тесті. Перший assert, що впав, приховує решту перевірок — краще [Theory] з наборами даних.
  • Логіка в тесті. if і цикли в тесті — місце для власних багів.
  • «Один assert на тест». Правило про одне логічне твердження: перевірити кілька полів одного результату — нормально.

Правило

Одна поведінка на тест, перевірка через публічний API. Приватний метод перевіряється через публічний, який його викликає.

Під капотом

  • xUnit створює екземпляр класу на кожен тест — ізоляцію закладено в дизайн. Дорогий спільний ресурс ділять явно: IClassFixture<T> на клас, ICollectionFixture<T> на групу класів, у v3 — [assembly: AssemblyFixture] на збірку.
  • xUnit v3 (пакет xunit.v3) збирає тестовий проєкт у виконуваний файл і потребує .NET 8+ або .NET Framework 4.7.2+. IAsyncLifetime у ньому працює з ValueTask, а TestContext.Current.CancellationToken сигналізує про скасування прогону або тайм-аут.

Ціна

  • Тест, прив’язаний до реалізації — порядку викликів, приватних методів, внутрішніх полів, — падає під час рефакторингу без зміни поведінки. Такі хибні спрацювання привчають команду не вірити тестам.
  • Недетермінований тест гірший за відсутній: його перезапускають, доки не позеленіє, і разом із ним пропускають справжні падіння.

Компроміси

  • Детермінізм дають залежності замість статики: TimeProvider (.NET 8+) і FakeTimeProvider з Microsoft.Extensions.TimeProvider.Testing для часу, генератор із фіксованим seed для випадкових чисел, явна CultureInfo для форматування.
  • «Одиниця» — це поведінка, а не клас. Тест агрегату разом з його value objects без підмін стійкіший, ніж окремий тест на кожен маленький клас.

Як перевірити

  • Тест, який падає лише в повному прогоні, залежить від спільного стану або порядку.
  • Навмисно зламайте рядок, що перевіряється: якщо тест не впав, він нічого не перевіряє.

Підсумок: модульний тест перевіряє спостережувану поведінку й детермінований; усе, що прив’язує його до реалізації чи до оточення, перетворює його на шум.

Приклад
xunit.v3
using Xunit;
public class DiscountCalculatorTests
{
[Fact]
public void Apply_NoCoupon_ReturnsOriginalPrice()
{
// Arrange
var calculator = new DiscountCalculator();
// Act
var price = calculator.Apply(100m, coupon: null);
// Assert
Assert.Equal(100m, price);
}
// Один тест — кілька наборів даних
[Theory]
[InlineData("SALE10", 90)]
[InlineData("SALE50", 50)]
public void Apply_KnownCoupon_ReducesPrice(string coupon, int expected)
{
var price = new DiscountCalculator().Apply(100m, coupon);
Assert.Equal(expected, price);
}
[Fact]
public void Apply_UnknownCoupon_Throws() =>
Assert.Throws<ArgumentException>(() => new DiscountCalculator().Apply(100m, "BOGUS"));
}
public class DiscountCalculator
{
public decimal Apply(decimal price, string? coupon) => coupon switch
{
null => price,
"SALE10" => price * 0.9m,
"SALE50" => price * 0.5m,
_ => throw new ArgumentException($"Unknown coupon: {coupon}", nameof(coupon))
};
}
Те саме на NUnit 4
// NuGet: NUnit
using NUnit.Framework;
[TestFixture]
public class DiscountCalculatorTests
{
[Test]
public void Apply_NoCoupon_ReturnsOriginalPrice() =>
Assert.That(new DiscountCalculator().Apply(100m, null), Is.EqualTo(100m));
// Аналог [Theory] + [InlineData]
[TestCase("SALE10", 90)]
[TestCase("SALE50", 50)]
public void Apply_KnownCoupon_ReducesPrice(string coupon, int expected) =>
Assert.That(new DiscountCalculator().Apply(100m, coupon), Is.EqualTo(expected));
}
Middle+

Що таке моки, стаби й фейки і коли їх використовувати?

Section titled “Що таке моки, стаби й фейки і коли їх використовувати?”
Відповідь

Що це

Test double — заміна справжньої залежності в тесті. OrderService отримує IEmailSender через конструктор, тому в тест можна передати дублера замість справжнього надсилання листів.

Які бувають

Вид Що робить
Stub (стаб) повертає заготовлені відповіді
Mock (мок) запам’ятовує виклики, тест їх перевіряє
Fake (фейк) працює по-справжньому, але спрощено: репозиторій на словнику

Як користуватися

  • NSubstitute: Substitute.For<IStockService>() створює дублера, stock.IsAvailable("SKU-1").Returns(true) задає відповідь, email.Received(1).Send(...) перевіряє виклик.
  • Moq: те саме через new Mock<T>(), Setup(...) і Verify(...).

Типова помилка

Перевіряти кожен виклик кожної залежності. Якщо метод лише читає дані, перевіряйте результат, а не сам факт виклику.

Як влаштовано

Класифікація Месароша з книжки «xUnit Test Patterns»:

  • Dummy — передається заради сигнатури й не використовується.
  • Stub — повертає заготовлені значення: керує входами.
  • Spy — стаб, який записує виклики для перевірки після дії.
  • Mock — перевіряє взаємодію: які виклики і з якими аргументами.
  • Fake — спрощена робоча реалізація: репозиторій у пам’яті, FakeTimeProvider.

Бібліотека створює об’єкт, який може зіграти будь-яку роль; роль задає тест.

Коли що обирати

Дія NSubstitute Moq
Створити Substitute.For<T>() new Mock<T>(), потім .Object
Задати відповідь x.Get(1).Returns(5) Setup(x => x.Get(1)).Returns(5)
Перевірити виклик x.Received(1).Get(1) Verify(x => x.Get(1), Times.Once())

AutoFixture створює об’єкти з довільними даними (fixture.Create<Order>()), з AutoNSubstituteCustomization підставляє дублерів замість інтерфейсів, а [AutoData] з AutoFixture.Xunit3 передає згенеровані аргументи в тест.

Пастки

  • Дублер класу. Обидві бібліотеки перехоплюють лише інтерфейси й віртуальні члени: невіртуальний метод класу виконається по-справжньому.
  • Перевірка запитів. Received() у методу-читання дублює реалізацію й нічого не додає до перевірки результату.
  • Громіздкий матчер. Велика лямбда в Arg.Is крихка; простіше захопити аргумент і перевірити його окремо.

Правило

Стаб — для вхідних даних, мок — лише для вихідних команд із побічним ефектом.

Компроміси

  • Класична й лондонська школи. Класична підміняє лише зовнішні та спільні залежності й тестує поведінку групи класів. Лондонська ізолює кожен клас моками: точніше локалізує помилку, але зв’язує тести з реалізацією.
  • Надлишок моків. П’ять дублерів у конструкторі й перевірка кожного виклику — тест переказує код рядок за рядком. Це сигнал: у класу забагато обов’язків або тест написано не на тому рівні.
  • Не мокайте те, чим не володієте. Мок HttpClient або SDK фіксує ваше уявлення про чужий API, а не його поведінку. Свій адаптер із вузьким інтерфейсом підміняють у модульних тестах, а сам адаптер перевіряють проти справжнього сервісу чи емулятора. З тієї ж причини Microsoft не радить мокати DbContext: LINQ-запит чесно не підмінити.

Ціна: ліцензії

  • Moq і SponsorLink. У серпні 2023 Moq 4.20.0 додав аналізатор SponsorLink: під час збирання він читав email з налаштувань git і надсилав його хеш на зовнішній сервіс для перевірки спонсорства. Після критики його прибрали в 4.20.2, але частина компаній заборонила пакет і перейшла на NSubstitute.
  • FluentAssertions 8. Із січня 2025 бібліотека виходить під Xceed Community License: безплатно для некомерційного використання, для комерційного — платна ліцензія на розробника. Версія 7 залишилася під Apache 2.0. Варіанти: залишитися на 7.x, перейти на форк AwesomeAssertions, на Shouldly (result.ShouldBe(5)) або на вбудовані Assert.

Як перевірити

Чистий рефакторинг не повинен змінювати тести. Якщо в такому PR правлять десятки тестів, вони перевіряють реалізацію, а не поведінку.

Підсумок: підміняйте лише некероване й чуже за своїм адаптером, а тестові бібліотеки обирайте з урахуванням ліцензії так само, як продуктові.

Приклад
// NuGet: xunit.v3, NSubstitute
using NSubstitute;
using Xunit;
public class OrderServiceTests
{
private readonly IStockService _stock = Substitute.For<IStockService>(); // стаб: дає дані
private readonly IEmailSender _email = Substitute.For<IEmailSender>(); // мок: перевіряємо виклик
[Fact]
public void Place_InStock_SendsConfirmation()
{
_stock.IsAvailable("SKU-1").Returns(true);
var service = new OrderService(_stock, _email);
var placed = service.Place("SKU-1", "a@b.c");
Assert.True(placed);
_email.Received(1).Send("a@b.c", Arg.Is<string>(s => s.Contains("SKU-1")));
}
[Fact]
public void Place_OutOfStock_SendsNothing()
{
// IsAvailable без налаштування поверне false
var placed = new OrderService(_stock, _email).Place("SKU-2", "a@b.c");
Assert.False(placed);
_email.DidNotReceiveWithAnyArgs().Send(default!, default!);
}
}
public interface IStockService { bool IsAvailable(string sku); }
public interface IEmailSender { void Send(string to, string body); }
public class OrderService(IStockService stock, IEmailSender email)
{
public bool Place(string sku, string customer)
{
if (!stock.IsAvailable(sku)) return false;
email.Send(customer, $"Order for {sku} placed");
return true;
}
}
Те саме на Moq
// NuGet: Moq
using Moq;
var stock = new Mock<IStockService>();
stock.Setup(s => s.IsAvailable("SKU-1")).Returns(true); // стаб
var email = new Mock<IEmailSender>(); // мок
var placed = new OrderService(stock.Object, email.Object).Place("SKU-1", "a@b.c");
email.Verify(e => e.Send("a@b.c", It.Is<string>(s => s.Contains("SKU-1"))), Times.Once());
Console.WriteLine(placed); // True
// Ті самі типи, що в прикладі з NSubstitute
public interface IStockService { bool IsAvailable(string sku); }
public interface IEmailSender { void Send(string to, string body); }
public class OrderService(IStockService stock, IEmailSender email)
{
public bool Place(string sku, string customer)
{
if (!stock.IsAvailable(sku)) return false;
email.Send(customer, $"Order for {sku} placed");
return true;
}
}
Middle+

Як писати інтеграційні тести для ASP.NET Core?

Section titled “Як писати інтеграційні тести для ASP.NET Core?”
Відповідь

Що це

Інтеграційний тест запускає застосунок майже повністю й перевіряє, що частини працюють разом: запит доходить до ендпоінта, дані зберігаються в базу, відповідь серіалізується в JSON.

Як користуватися

  1. Додати в тестовий проєкт пакет Microsoft.AspNetCore.Mvc.Testing і посилання на проєкт API.
  2. Підключити фабрику до класу тестів: IClassFixture<WebApplicationFactory<Program>>.
  3. Отримати клієнта: var client = factory.CreateClient();.
  4. Надіслати запит і перевірити відповідь: Assert.Equal(HttpStatusCode.OK, response.StatusCode).

До .NET 10 у Program.cs дописували public partial class Program { }, щоб тест бачив клас. У .NET 10 це робить генератор коду.

Типова помилка

Запускати тести проти спільної бази розробки. Тести псують дані одне одному й падають випадково.

Як влаштовано

  • Фабрика виконує Program.cs застосунку, але замість Kestrel ставить TestServer: запит проходить увесь конвеєр, лише без сокетів.
  • ConfigureWebHost змінює оточення (UseEnvironment("Testing")) і налаштування. ConfigureTestServices виконується після реєстрацій застосунку, тому сервіси замінюють у ньому.

Коли що обирати

База в тестах Плюс Мінус
InMemory-провайдер EF Core нічого не треба налаштовувати не реляційний: немає транзакцій і SQL; Microsoft не рекомендує
SQLite in-memory швидко, без Docker інший діалект SQL, рядки порівнюються з урахуванням регістру
Testcontainers та сама СУБД і версія, що в продакшені потрібен Docker, старт контейнера — секунди

Пастки

  • Повторний AddDbContext. Він не прибирає налаштування провайдера з Program.cs. В EF Core 9+ спочатку видаляють IDbContextOptionsConfiguration<TContext>, потім реєструють контекст наново.
  • Спільні дані. Тести пишуть в одну базу. Ізоляція — унікальні дані на тест, транзакція з відкатом або скидання таблиць перед тестом.
  • Паралельність. xUnit запускає класи паралельно: якщо вони ділять базу, вони ділять і дані.

Правило

Підміняти лише зовнішнє й некероване: платежі, пошту, чужі API. База, серіалізація й DI залишаються справжніми.

Під капотом

  • Фабрика перехоплює побудову хоста в Program.cs і застосовує свої налаштування поверх застосунку. CreateClient повертає HttpClient, чий обробник передає запит прямо в TestServer того самого процесу.
  • З .NET 10 UseKestrel() піднімає замість TestServer справжній Kestrel на порту — це потрібно браузерним тестам на Playwright.

Ціна

Старт хоста — сотні мілісекунд, контейнер PostgreSQL — секунди, SQL Server — довше. Тому фабрику й контейнер створюють на клас або колекцію через IClassFixture<T> і ICollectionFixture<T>, а в xUnit v3 — на збірку через [assembly: AssemblyFixture].

Компроміси

Ізоляція даних Плюс Мінус
Транзакція з відкатом швидко запит через HttpClient іде у своєму scope, і спільну транзакцію туди не передати
Respawn перед тестом схема залишається, видаляються лише дані тести зі спільною базою йдуть послідовно
База на клас повна ізоляція й паралельність час старту й пам’ять

HTTP-клієнти

  • Модульно: typed-клієнту передають HttpClient з обробником-заглушкою й перевіряють URL, заголовки й розбір відповіді.
  • Інтеграційно: WireMock.Net піднімає HTTP-сервер із заготовленими відповідями, затримками й помилками, а адресу клієнта у фабриці спрямовують на нього. Так перевіряються тайм-аути й повтори.

Підсумок: інтеграційні тести перевіряють стики на справжній інфраструктурі; їхню ціну тримають під контролем спільні fixture і швидке скидання даних.

Приклад
// NuGet: xunit.v3, Microsoft.AspNetCore.Mvc.Testing, Testcontainers.PostgreSql, Npgsql.EntityFrameworkCore.PostgreSQL
using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Testcontainers.PostgreSql;
using Xunit;
public class OrdersApiTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
[Fact]
public async Task GetOrder_Unknown_Returns404()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/orders/999", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
// Одна фабрика й один контейнер на клас тестів
public class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder("postgres:17-alpine").Build();
public async ValueTask InitializeAsync()
{
await _db.StartAsync();
using var scope = Services.CreateScope(); // тут будується хост застосунку
await scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.MigrateAsync();
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await _db.DisposeAsync();
}
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.UseEnvironment("Testing").ConfigureTestServices(services =>
{
// Прибираємо провайдер із Program.cs (EF Core 9+) і підключаємо контейнер
services.RemoveAll<IDbContextOptionsConfiguration<AppDbContext>>();
services.AddDbContext<AppDbContext>(o => o.UseNpgsql(_db.GetConnectionString()));
// Зовнішній сервіс — фейк
services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
});
}
Тест HTTP-клієнта без мережі
xunit.v3
using System.Globalization;
using System.Net;
using Xunit;
public class RatesClientTests
{
[Fact]
public async Task GetRate_ParsesApiResponse()
{
var handler = new StubHandler(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("41.5")
});
var client = new RatesClient(new HttpClient(handler) { BaseAddress = new Uri("https://rates.test/") });
var rate = await client.GetRateAsync("USD", TestContext.Current.CancellationToken);
Assert.Equal(41.5m, rate);
Assert.Equal("/rates/USD", handler.LastRequest?.RequestUri?.AbsolutePath);
}
}
public class RatesClient(HttpClient http)
{
public async Task<decimal> GetRateAsync(string currency, CancellationToken ct) =>
decimal.Parse(await http.GetStringAsync($"rates/{currency}", ct), CultureInfo.InvariantCulture);
}
// Заглушка замість мережі: запам'ятовує запит і повертає готову відповідь
public class StubHandler(HttpResponseMessage response) : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
LastRequest = request;
return Task.FromResult(response);
}
}
Senior+

Як вибудувати стратегію тестування проєкту?

Section titled “Як вибудувати стратегію тестування проєкту?”
Відповідь

Що це

Стратегія тестування відповідає на три питання: що перевіряти, якими тестами і коли їх запускати.

Рівні тестів

Рівень Що перевіряє Швидкість
Модульний одну одиницю логіки мілісекунди
Інтеграційний частини разом: API і базу від десятків мілісекунд до секунд
Наскрізний (E2E) систему очима користувача секунди й хвилини

Піраміда: унизу найбільше модульних тестів, нагорі найменше наскрізних.

Як користуватися

  • Баг із продакшену спершу відтворюють тестом, потім лагодять.
  • Тести запускаються в CI на кожен pull request.
  • Покриття дивляться, щоб знайти неперевірений код, а не заради цифри.

Типова помилка

Гнатися за 100 % покриття. Тест без перевірок теж піднімає відсоток.

Як влаштовано

  • Піраміда (Майк Кон): основа — модульні тести, середина — інтеграційні, вершина — UI та E2E.
  • Трофей (Кент Доддс): основа — статичний аналіз, тонкий шар модульних, найширший шар — інтеграційні, згори E2E. Для веб-API з тонкою логікою тести через WebApplicationFactory дають більше впевненості на рядок тесту.

Де покриття бреше

coverlet (dotnet test --collect:"XPlat Code Coverage") рахує виконані рядки й гілки. Але:

  • тест без assert дає 100 % рядків: код виконано, але не перевірено;
  • покриття рядків приховує гілки: if без else покрито одним сценарієм.

Мутаційне тестування

Stryker.NET (dotnet stryker) вносить у код маленькі зміни — мутанти: > на >=, && на ||, видалення виклику. Потім запускає тести. Якщо жоден не впав, мутант «вижив»: цю поведінку не перевірено. Частка вбитих мутантів — mutation score.

Пастки

  • Поріг покриття як KPI породжує тести заради відсотка.
  • Stryker на весь проєкт працює в рази довше за звичайний прогін. У PR його запускають лише за зміненим кодом (--since).

Правило

Покриття — щоб знайти неперевірений код, мутаційний score — щоб оцінити якість тестів на критичній логіці.

Компроміси

  • Портфель. Модульні — на розгалужену доменну логіку, інтеграційні зі справжньою базою — на основні сценарії, E2E — на кілька критичних шляхів.
  • Тести архітектури (NetArchTest, ArchUnitNET) фіксують правила залежностей: домен не посилається на EF Core.
  • Контрактні тести (Pact, PactNet): споживач записує очікування до API в контракт, постачальник перевіряє його у своєму CI. Ламальну зміну між сервісами видно без спільного стенду.

Flaky-тести

Причини: час і тайм-аути, Task.Delay замість очікування умови, спільний стан, паралельність, зовнішні сервіси, культура й часовий пояс. Порядок роботи:

  1. Виявити: повторний прогін тестів, що впали, історія падінь у CI.
  2. Ізолювати в карантин із задачею на лагодження.
  3. Усунути причину.

Автоповтор (Microsoft.Testing.Extensions.Retry, --retry-failed-tests) — тимчасовий захід для інтеграційних тестів: він ховає гонки, які проявляться й у продакшені.

Час прогону в CI

Швидкі тести — на кожен коміт, повільні — окремою стадією за фільтром категорії: [Trait("Category", "Integration")] у xUnit, [Category("Integration")] у NUnit. Контейнери — на колекцію, а не на тест.

Legacy без тестів

Для Майкла Фезерса legacy — це код без тестів. Порядок:

  1. Знайти місце зміни й точку, де видно результат.
  2. Створити шов: віртуальний метод, параметр-інтерфейс, TimeProvider.
  3. Зафіксувати поточну поведінку характеризаційними тестами, навіть дивну.
  4. Лише потім рефакторити.

Ті самі тести страхують перенесення системи — див. Архітектурні стилі.

Підсумок: стратегія — портфель тестів під ризики проєкту; метрики й CI служать йому, а не навпаки.

Приклад
xunit.v3
using Xunit;
// Характеризаційний тест: фіксує, як код працює зараз, а не як «має»
public class LateFeeCalculatorTests
{
[Theory]
[InlineData(0, 0)]
[InlineData(10, 50)]
[InlineData(31, 200)] // не 155: так рахує продакшен, фіксуємо як є
public void Fee_MatchesCurrentBehavior(int daysLate, int expectedFee)
{
var due = new DateTime(2026, 1, 1);
var calculator = new TestableLateFeeCalculator(now: due.AddDays(daysLate));
Assert.Equal(expectedFee, calculator.Fee(due));
}
}
// Шов: підклас підміняє джерело часу, не чіпаючи логіку
public class TestableLateFeeCalculator(DateTime now) : LateFeeCalculator
{
protected override DateTime Now() => now;
}
// Legacy-клас: єдина правка — DateTime.Now винесено у віртуальний метод
public class LateFeeCalculator
{
protected virtual DateTime Now() => DateTime.Now;
public int Fee(DateTime dueDate)
{
var days = (Now() - dueDate).Days;
if (days <= 0) return 0;
if (days > 30) return 200;
return days * 5;
}
}