У попередній статті ви освоїли фундаментальні атрибути xUnit: [Fact], [Theory], IClassFixture, [Collection]. Ці інструменти покривають 80% реальних потреб. Але існують сценарії, де стандартного функціоналу недостатньо:
Ця стаття відповідає на ці питання — й розкриває потужну систему розширення xUnit, яка дозволяє адаптувати фреймворк під будь-яку потребу.
Стандартний конструктор IClassFixture синхронний. Але реальна інфраструктура часто потребує async ініціалізації: підключення до Docker, запит до зовнішнього сервісу, створення схеми БД.
xUnit не підтримує async конструктори безпосередньо, але є два підходи:
Підхід 1: .GetAwaiter().GetResult() (простий, але блокуючий)
public class DatabaseFixture : IDisposable
{
public NpgsqlConnection Connection { get; }
public DatabaseFixture()
{
// Блокуючий виклик — прийнятно у контексті fixture ініціалізації
Connection = InitializeDatabaseAsync().GetAwaiter().GetResult();
}
private async Task<NpgsqlConnection> InitializeDatabaseAsync()
{
var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();
await RunMigrationsAsync(connection);
await SeedTestDataAsync(connection);
return connection;
}
public void Dispose() => Connection.Dispose();
}
Підхід 2: IAsyncLifetime (рекомендований)
xUnit надає IAsyncLifetime — інтерфейс, що дозволяє повністю async lifecycle:
public class DockerDatabaseFixture : IAsyncLifetime
{
private DockerCompose? _docker;
public NpgsqlConnection Connection { get; private set; } = null!;
public string ConnectionString { get; private set; } = null!;
// Викликається перед першим тестом (async)
public async Task InitializeAsync()
{
// Запуск Docker контейнера
_docker = new DockerCompose("docker-compose.test.yml");
await _docker.UpAsync();
// Очікуємо готовності PostgreSQL
ConnectionString = "Host=localhost;Port=5433;Database=testdb;Username=test;Password=test";
await WaitForDatabaseAsync(ConnectionString, maxWaitSeconds: 30);
Connection = new NpgsqlConnection(ConnectionString);
await Connection.OpenAsync();
// Застосовуємо міграції
await using var cmd = Connection.CreateCommand();
cmd.CommandText = await File.ReadAllTextAsync("TestSchema.sql");
await cmd.ExecuteNonQueryAsync();
}
// Викликається після останнього тесту (async)
public async Task DisposeAsync()
{
await Connection.DisposeAsync();
if (_docker != null)
await _docker.DownAsync();
}
private async Task WaitForDatabaseAsync(string connectionString, int maxWaitSeconds)
{
var deadline = DateTime.UtcNow.AddSeconds(maxWaitSeconds);
while (DateTime.UtcNow < deadline)
{
try
{
await using var conn = new NpgsqlConnection(connectionString);
await conn.OpenAsync();
return; // БД готова
}
catch
{
await Task.Delay(500);
}
}
throw new TimeoutException($"Database not ready after {maxWaitSeconds} seconds");
}
}
IAsyncLifetime у тест-класі:
// Тест-клас також може реалізовувати IAsyncLifetime
public class AsyncSetupTests : IAsyncLifetime
{
private IOrderService _service = null!;
public async Task InitializeAsync()
{
// Async setup: підключення, завантаження конфігурації, тощо
var config = await LoadTestConfigAsync();
_service = new OrderService(config);
await _service.WarmupAsync(); // Initialization logic
}
[Fact]
public async Task CreateOrder_WorksAfterAsyncSetup()
{
var result = await _service.CreateAsync(new CreateOrderDto());
result.IsSuccess.Should().BeTrue();
}
public async Task DisposeAsync()
{
await _service.ShutdownAsync();
}
}
Одна спільна fixture, але стан очищається перед кожним тестом. Патерн "Reset sandbox":
public class InMemoryDatabaseFixture : IAsyncLifetime
{
private readonly ServiceProvider _rootProvider;
public string ConnectionString { get; } = "Data Source=:memory:";
public InMemoryDatabaseFixture()
{
var services = new ServiceCollection();
services.AddDbContext<AppDbContext>(opts =>
opts.UseSqlite(ConnectionString));
_rootProvider = services.BuildServiceProvider();
}
public async Task InitializeAsync()
{
using var scope = _rootProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
}
// Метод для отримання свіжого scope у кожному тесті
public IServiceScope CreateScope() => _rootProvider.CreateScope();
public async Task ResetAsync()
{
using var scope = _rootProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Видаляємо всі дані але зберігаємо схему
await db.Orders.ExecuteDeleteAsync();
await db.Users.ExecuteDeleteAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
}
// Тест-клас з reset
public class OrderRepositoryTests : IClassFixture<InMemoryDatabaseFixture>, IAsyncLifetime
{
private readonly InMemoryDatabaseFixture _fixture;
private IServiceScope _scope = null!;
private AppDbContext _db = null!;
public OrderRepositoryTests(InMemoryDatabaseFixture fixture)
{
_fixture = fixture;
}
public async Task InitializeAsync()
{
await _fixture.ResetAsync(); // Очищаємо дані
_scope = _fixture.CreateScope(); // Новий scope для цього тесту
_db = _scope.ServiceProvider.GetRequiredService<AppDbContext>();
}
[Fact]
public async Task GetOrders_ReturnsOnlyActiveOrders()
{
// Arrange: кожен тест працює з порожньою БД
_db.Orders.AddRange(
new Order { IsActive = true },
new Order { IsActive = false }
);
await _db.SaveChangesAsync();
var repo = new OrderRepository(_db);
var result = await repo.GetActiveAsync();
result.Should().HaveCount(1);
}
public async Task DisposeAsync()
{
await _scope.DisposeAsync();
}
}
// Зчитуємо тестові дані з JSON файлу
public class JsonFileDataAttribute : DataAttribute
{
private readonly string _filePath;
private readonly string? _propertyName;
public JsonFileDataAttribute(string filePath, string? propertyName = null)
{
_filePath = filePath;
_propertyName = propertyName;
}
public override IEnumerable<object[]> GetData(MethodInfo testMethod)
{
var json = File.ReadAllText(_filePath);
var document = JsonDocument.Parse(json);
var root = _propertyName != null
? document.RootElement.GetProperty(_propertyName)
: document.RootElement;
foreach (var item in root.EnumerateArray())
{
// Десеріалізуємо кожен елемент у відповідний тип
var parameters = testMethod.GetParameters();
var args = new object[parameters.Length];
for (int i = 0; i < parameters.Length; i++)
{
var propName = parameters[i].Name!;
args[i] = item.GetProperty(propName)
.Deserialize(parameters[i].ParameterType)!;
}
yield return args;
}
}
}
// test-data/discount-cases.json
// [
// { "customerType": "Premium", "orderTotal": 1000.0, "expectedDiscount": 100.0 },
// { "customerType": "VIP", "orderTotal": 1000.0, "expectedDiscount": 200.0 }
// ]
[Theory]
[JsonFileData("test-data/discount-cases.json")]
public void CalculateDiscount_FromJsonFile(string customerType, decimal orderTotal, decimal expectedDiscount)
{
var type = Enum.Parse<CustomerType>(customerType);
var result = _calculator.Calculate(type, orderTotal);
result.Should().Be(expectedDiscount);
}
public class CsvDataAttribute : DataAttribute
{
private readonly string _filePath;
private readonly bool _hasHeader;
public CsvDataAttribute(string filePath, bool hasHeader = true)
{
_filePath = filePath;
_hasHeader = hasHeader;
}
public override IEnumerable<object[]> GetData(MethodInfo testMethod)
{
var lines = File.ReadAllLines(_filePath);
var dataLines = _hasHeader ? lines.Skip(1) : lines;
foreach (var line in dataLines.Where(l => !string.IsNullOrWhiteSpace(l)))
{
var values = line.Split(',');
var parameters = testMethod.GetParameters();
var args = values.Zip(parameters, (value, param) =>
Convert.ChangeType(value.Trim(), param.ParameterType))
.ToArray<object>();
yield return args;
}
}
}
// test-data/boundary-ages.csv
// age,expected
// 17,false
// 18,true
// 65,true
// 66,false
[Theory]
[CsvData("test-data/boundary-ages.csv", hasHeader: true)]
public void IsEligible_CsvBoundaryValues(int age, bool expected)
{
Assert.Equal(expected, _service.IsEligible(age));
}
// Генерує всі комбінації значень для кількох параметрів
public class CombinatorialDataAttribute : DataAttribute
{
private readonly object[][] _sets;
public CombinatorialDataAttribute(params object[][] sets)
{
_sets = sets;
}
public override IEnumerable<object[]> GetData(MethodInfo testMethod)
{
return CartesianProduct(_sets);
}
private IEnumerable<object[]> CartesianProduct(object[][] sets)
{
IEnumerable<object[]> result = new[] { Array.Empty<object>() };
foreach (var set in sets)
{
result = from existing in result
from value in set
select existing.Concat(new[] { value }).ToArray();
}
return result;
}
}
// Генерує всі 12 комбінацій (3 * 2 * 2)
[Theory]
[CombinatorialData(
new object[] { "Visa", "Mastercard", "Amex" },
new object[] { 100m, 10000m },
new object[] { true, false })]
public void ProcessPayment_AllCardCombinations(string cardType, decimal amount, bool is3DS)
{
// Тест виконається 12 разів
}
xUnit побудований навколо кількох інтерфейсів розширення. Розуміння їх відкриває необмежені можливості кастомізації.
За замовчуванням xUnit виконує тести у невизначеному порядку (навмисно — заохочує ізоляцію). Але іноді потрібен контрольований порядок (workflow тести, smoke тести по пріоритету):
// Атрибут для задання пріоритету
[AttributeUsage(AttributeTargets.Method)]
public class TestPriorityAttribute : Attribute
{
public int Priority { get; }
public TestPriorityAttribute(int priority) => Priority = priority;
}
// Кастомний orderer
public class PriorityOrderer : ITestCaseOrderer
{
public IEnumerable<TTestCase> OrderTestCases<TTestCase>(
IEnumerable<TTestCase> testCases) where TTestCase : ITestCase
{
return testCases.OrderBy(tc =>
{
var attr = tc.TestMethod.Method
.GetCustomAttributes(typeof(TestPriorityAttribute), false)
.FirstOrDefault() as TestPriorityAttribute;
return attr?.Priority ?? int.MaxValue;
});
}
}
// Підключення orderer до тест-класу
[TestCaseOrderer("MyProject.Tests.PriorityOrderer", "MyProject.Tests")]
public class OrderedWorkflowTests
{
[Fact, TestPriority(1)]
public async Task Step1_CreateUser() { /* ... */ }
[Fact, TestPriority(2)]
public async Task Step2_CreateOrder() { /* ... */ }
[Fact, TestPriority(3)]
public async Task Step3_ProcessPayment() { /* ... */ }
}
xUnit серіалізує параметри [Theory] для відображення у Test Explorer. Для примітивних типів це відбувається автоматично. Для власних об'єктів потрібна реалізація IXunitSerializable:
public class MoneyAmount : IXunitSerializable
{
public decimal Value { get; private set; }
public string Currency { get; private set; } = null!;
public MoneyAmount() { } // Потрібен parameterless конструктор
public MoneyAmount(decimal value, string currency)
{
Value = value;
Currency = currency;
}
public void Serialize(IXunitSerializationInfo info)
{
info.AddValue(nameof(Value), Value);
info.AddValue(nameof(Currency), Currency);
}
public void Deserialize(IXunitSerializationInfo info)
{
Value = info.GetValue<decimal>(nameof(Value));
Currency = info.GetValue<string>(nameof(Currency));
}
public override string ToString() => $"{Value} {Currency}";
}
// Тепер MoneyAmount відображається зрозуміло у Test Explorer
[Theory]
[MemberData(nameof(MoneyTestCases))]
public void AddMoney_WorksCorrectly(MoneyAmount a, MoneyAmount b, MoneyAmount expected) { }
public static TheoryData<MoneyAmount, MoneyAmount, MoneyAmount> MoneyTestCases => new()
{
{ new MoneyAmount(100, "USD"), new MoneyAmount(50, "USD"), new MoneyAmount(150, "USD") },
{ new MoneyAmount(0, "EUR"), new MoneyAmount(200, "EUR"), new MoneyAmount(200, "EUR") },
};
Стандартний [Trait("Category", "Unit")] потребує строкових аргументів. Через ITraitDiscoverer можна створити типобезпечні атрибути:
// Discoverer — клас, що розкриває traits
public class CategoryDiscoverer : ITraitDiscoverer
{
public IEnumerable<KeyValuePair<string, string>> GetTraits(IAttributeInfo traitAttribute)
{
var category = traitAttribute.GetConstructorArguments().FirstOrDefault()?.ToString();
yield return new KeyValuePair<string, string>("Category", category ?? "Unknown");
}
}
// Кастомний атрибут
[TraitDiscoverer("MyProject.Tests.CategoryDiscoverer", "MyProject.Tests")]
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class CategoryAttribute : Attribute, ITraitAttribute
{
public string Name { get; }
public CategoryAttribute(string name) => Name = name;
}
// Зручне використання
[Fact]
[Category("Integration")]
public void MyIntegrationTest() { }
[Fact]
[Category("Unit")]
public void MyUnitTest() { }
xUnit не має вбудованого DI-контейнера, але інтеграцію можна організувати через IClassFixture :
// Fixture що будує DI-контейнер
public class ServiceProviderFixture : IDisposable
{
public IServiceProvider ServiceProvider { get; }
public ServiceProviderFixture()
{
var services = new ServiceCollection();
// Реєструємо всі залежності
services.AddLogging(b => b.AddXUnit()); // Логування у xUnit output
services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
services.AddScoped<IEmailService, FakeEmailService>();
services.AddScoped<IPaymentGateway, FakePaymentGateway>();
services.AddScoped<OrderService>();
// Тестова конфігурація
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["PaymentGateway:ApiKey"] = "test-key-001",
["Email:SmtpHost"] = "localhost"
})
.Build();
services.AddSingleton<IConfiguration>(config);
ServiceProvider = services.BuildServiceProvider();
}
public T GetService<T>() where T : notnull
=> ServiceProvider.GetRequiredService<T>();
public IServiceScope CreateScope()
=> ServiceProvider.CreateScope();
public void Dispose()
=> (ServiceProvider as ServiceProvider)?.Dispose();
}
// Тест з DI
public class OrderServiceDITests : IClassFixture<ServiceProviderFixture>
{
private readonly ServiceProviderFixture _provider;
public OrderServiceDITests(ServiceProviderFixture provider)
{
_provider = provider;
}
[Fact]
public async Task CreateOrder_WithRealDependencies_Succeeds()
{
using var scope = _provider.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<OrderService>();
var fakeEmail = scope.ServiceProvider.GetRequiredService<IEmailService>() as FakeEmailService;
var result = await service.CreateOrderAsync(new CreateOrderDto { Amount = 100m });
result.IsSuccess.Should().BeTrue();
fakeEmail!.SentEmails.Should().HaveCount(1);
}
}
// Startup.cs (у тестовому проєкті)
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
services.AddScoped<OrderService>();
}
}
// Тест-клас з інжекцією через конструктор (як у ASP.NET!)
public class OrderServiceInjectedTests
{
private readonly OrderService _sut;
// xUnit.DependencyInjection інжектує залежності автоматично
public OrderServiceInjectedTests(OrderService sut)
{
_sut = sut;
}
[Fact]
public async Task CreateOrder_Succeeds()
{
var result = await _sut.CreateOrderAsync(new CreateOrderDto { Amount = 50m });
result.IsSuccess.Should().BeTrue();
}
}
public class ServiceWithLoggingTests
{
private readonly ITestOutputHelper _output;
public ServiceWithLoggingTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public async Task ProcessOrder_LogsAllSteps()
{
// Підключаємо xUnit output до Microsoft.Extensions.Logging
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddXUnit(_output) // ← вивід у xUnit Test Explorer
.SetMinimumLevel(LogLevel.Debug);
});
var logger = loggerFactory.CreateLogger<OrderService>();
var service = new OrderService(_mockRepo.Object, logger);
// Процесимо — всі Debug/Info/Warning/Error повідомлення виводяться у test output
await service.ProcessAsync(new Order { Amount = 100m });
_output.WriteLine("=== Test completed ===");
}
}
Під час debug сесії ви можете спостерігати за станом змінних:
| Name | Type | Value |
|---|---|---|
| ◢_output | ITestOutputHelper | Test output helper instance |
| ◢loggerFactory | LoggerFactory | LoggerFactory created |
| ◢logger | ILogger<OrderService> | Logger<OrderService> instance |
| ◢service | OrderService | OrderService instance |
| ◢_mockRepo | Mock<IOrderRepository> | Mock<IOrderRepository> |
| ◢order | Order | { Id: a1b2c3d4, Amount: 100m, Status: Pending } |
public class FakeLogger<T> : ILogger<T>
{
public List<LogEntry> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
=> NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId,
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception));
}
}
public record LogEntry(LogLevel Level, string Message, Exception? Exception);
// Використання у тестах
[Fact]
public async Task ProcessOrder_WhenFails_LogsError()
{
var fakeLogger = new FakeLogger<OrderService>();
var service = new OrderService(_mockRepo.Object, fakeLogger);
_mockRepo.Setup(x => x.SaveAsync(It.IsAny<Order>()))
.ThrowsAsync(new DbException("Connection lost"));
await Assert.ThrowsAsync<DbException>(() =>
service.ProcessAsync(new Order { Amount = 100m }));
fakeLogger.Entries
.Should().ContainSingle(e => e.Level == LogLevel.Error)
.Which.Message.Should().Contain("Connection lost");
}
Найчастіша причина — deadlock у async коді:
// ❌ Deadlock: .Result блокує thread, що очікує async completion
[Fact]
public void HangingTest()
{
var result = _service.GetDataAsync().Result; // DEADLOCK у певних контекстах
}
// ✅ Завжди async
[Fact]
public async Task NonHangingTest()
{
var result = await _service.GetDataAsync();
}
Налаштування timeout для всієї сборки:
// xunit.runner.json
{
"longRunningTestSeconds": 10
}
// Або per-test через CancellationToken
[Fact]
public async Task Test_WithTimeout()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await _service.LongRunningOperationAsync(cts.Token);
});
}
Тести, що іноді проходять, іноді падають без змін у коді:
Причина 1: Race condition при паралельному виконанні
// ❌ Shared mutable state між тестами
private static int _callCount = 0; // Shared між паралельними тестами!
[Fact]
public void Test1() { _callCount++; /* може зламатись паралельно */ }
// ✅ Вирішення: [Collection] або instance state
[Collection("Sequential")]
public class SequentialTests
{
private int _count = 0; // Instance state — кожен тест отримує свій
}
Причина 2: Залежність від DateTime.Now
// ❌ Flaky: різний результат залежно від часу запуску
[Fact]
public void IsExpired_ReturnsTrue_ForYesterdayDate()
{
var yesterday = DateTime.Now.AddDays(-1);
var product = new Product { ExpiryDate = yesterday };
Assert.True(product.IsExpired()); // Може зламатись опівночі
}
// ✅ Stabile: використовуємо абстракцію ITimeProvider
[Fact]
public void IsExpired_ReturnsTrue_ForPastDate()
{
var frozenTime = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc);
var mockTime = new Mock<ITimeProvider>();
mockTime.Setup(x => x.UtcNow).Returns(frozenTime);
var product = new Product(mockTime.Object) { ExpiryDate = frozenTime.AddDays(-1) };
Assert.True(product.IsExpired());
}
Причина 3: Залежність від порядку виконання
// ❌ Тест B залежить від того, що зробив тест A
// Якщо паралелізм змінює порядок — B падає
// ✅ Кожен тест — повністю незалежний:
// - Власна ініціалізація в конструкторі
// - Власні дані (не shared)
// - ResetData() перед кожним тестом
Як знайти повільні тести — через xunit.runner.json:
{
"longRunningTestSeconds": 1,
"diagnosticMessages": true
}
Всі тести довші 1 секунди будуть помічені у виводі.
Класичні причини повільних тестів:
Thread.Sleep або await Task.Delay — замінити на мок із стрибком часуWireMock.Net або MockHttpMessageHandlerIClassFixtureawaitДовільні тести з "random" даними нестабільні. Вирішення — seed-based randomness:
public class DeterministicRandomTests
{
// Один і той самий seed = один і той самий результат
private readonly Random _random = new Random(seed: 42);
private readonly Faker _faker = new Faker { Random = new Randomizer(seed: 42) };
[Fact]
public void ProcessRandomOrders_ReturnsExpectedStatistics()
{
// Ці дані ЗАВЖДИ однакові (seed = 42)
var orders = _faker.Make(100, () => new Order
{
Amount = _faker.Finance.Amount(10, 1000)
});
var stats = _calculator.ProcessBatch(orders);
// Результат детермінований — тест стабільний
stats.Average.Should().BeApproximately(expectedAverage, precision: 0.01m);
}
}
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Run Unit Tests
run: dotnet test --no-build
--filter "Category=Unit"
--logger "trx;LogFileName=unit-tests.trx"
--collect:"XPlat Code Coverage"
--results-directory ./TestResults
- name: Run Integration Tests
run: dotnet test --no-build
--filter "Category=Integration"
--logger "trx;LogFileName=integration-tests.trx"
--results-directory ./TestResults
- name: Publish Test Results
uses: dorny/test-reporter@v1
if: always()
with:
name: xUnit Test Results
path: 'TestResults/*.trx'
reporter: dotnet-trx
- name: Generate Coverage Report
run: |
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"TestResults/**/coverage.cobertura.xml"
-targetdir:"CoverageReport"
-reporttypes:"Html;Cobertura"
- name: Upload Coverage
uses: codecov/codecov-action@v3
with:
file: CoverageReport/Cobertura.xml
<!-- MyProject.Tests.csproj -->
<PropertyGroup>
<!-- Провалити build якщо покриття < 80% -->
<CoverageThreshold>80</CoverageThreshold>
</PropertyGroup>
Або через coverlet.runsettings:
<?xml version="1.0" encoding="utf-8" ?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<Format>cobertura</Format>
<Threshold>80</Threshold>
<ThresholdType>line</ThresholdType>
<ThresholdStat>minimum</ThresholdStat>
<Exclude>[*.Tests]*,[*]*.Migrations.*</Exclude>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
Mutation Testing — техніка оцінки якості тестів: інструмент вносить маленькі зміни ("мутації") у production код і перевіряє, чи впали тести. Якщо тести не впали — вони не "вбили мутанта" — це недостатньо хороші тести.
stryker-config.json:
{
"stryker-config": {
"project": "MyProject.csproj",
"reporters": ["html", "progress"],
"mutation-level": "Standard",
"threshold": {
"high": 90,
"low": 75,
"break": 60
}
}
}
Приклад мутацій:
// Оригінальний код
if (age >= 18) return true;
// Мутація 1: >= → >
if (age > 18) return true; // Тест InlineData(18, true) вб'є цього мутанта ✅
// Мутація 2: >= → <=
if (age <= 18) return true; // Тест InlineData(10, false) вб'є цього мутанта ✅
// Мутація 3: return true → return false
if (age >= 18) return false; // Тест InlineData(25, true) вб'є цього мутанта ✅
Mutation Score — відсоток "вбитих" мутантів. 80%+ вважається хорошим показником якості тестів.
Рівень 1: IAsyncLifetime
Завдання 1.1 — Async Fixture
Реалізуйте RedisFixture : IAsyncLifetime (або SQLite in-memory як спрощення), що:
InitializeAsync: встановлює підключення, створює тестові колекції/таблиціDisposeAsync: закриває підключення, видаляє тестові даніНапишіть 5+ тестів, що використовують цей fixture через IClassFixture<RedisFixture>. Переконайтесь, що підключення встановлюється лише один раз для всіх тестів.
Завдання 1.2 — JSON Data Provider
Реалізуйте JsonFileDataAttribute та напишіть файл test-cases/discount-rules.json з 10 тест-кейсами для CalculateDiscount. Переконайтесь, що Test Explorer показує зрозумілі назви для кожного кейсу.
Рівень 2: Extensibility
Завдання 2.1 — PriorityOrderer для API workflow
Реалізуйте TestPriorityAttribute та PriorityOrderer. Напишіть інтеграційний workflow-тест для REST API:
Кожен крок — окремий [Fact] з [TestPriority(N)].
Завдання 2.2 — FakeLogger + assertions
Реалізуйте FakeLogger<T> з підтримкою структурованих повідомлень. Напишіть 6 тестів, що перевіряють рівні логування вашого сервісу: Debug при початку операції, Info при успіху, Warning при retry, Error при критичній помилці.
Рівень 3: CI/CD та Mutation
Завдання 3.1 — GitHub Actions Pipeline
Налаштуйте GitHub Actions workflow для вашого проєкту:
continue-on-error + retry action)Завдання 3.2 — Mutation Testing
Встановіть Stryker.NET для вашого проєкту. Запустіть мутаційне тестування. Знайдіть 3+ "вижилих мутанти" і напишіть тести, що вбивають їх. Пояснення: чому ці мутанти вижили — яких тест-кейсів бракувало?
InitializeAsync + DisposeAsync для повністю async lifecycle у fixture та тест-класах.GetAwaiter().GetResult() (простий) або IAsyncLifetime (правильний) для async ініціалізації.ResetAsync() перед кожним тестом очищає дані.DataAttribute підклас для JSON, CSV, або Database джерел тестових даних.IClassFixture<ServiceProviderFixture> або Xunit.DependencyInjection пакет.DateTime.Now без абстракції, залежність від порядку.--logger "trx" + dorny/test-reporter, coverage threshold, розділення unit/integration jobs.Наступна стаття — Moq: Глибоке занурення в мокування.
xUnit — Факти, Теорії та Lifecycle тестів
Повний практичний огляд xUnit.net — найпопулярнішого тестового фреймворку для .NET. Атрибути Fact та Theory, всі способи передачі даних, Fixtures та спільні ресурси, паралелізм, кастомізація та розширення.
Moq — Глибоке занурення в мокування
Повний практичний та теоретичний огляд бібліотеки Moq для .NET. Setup, Returns, ReturnsAsync, Verify, Callback, Sequences, ArgumentMatchers, MockBehavior, Partial Mocks та просунуті патерни.