Тестування

xUnit Advanced — Fixtures, Кастомізація та Розширення

Просунуті можливості xUnit.net — TestServer з WebApplicationFactory, складні Fixture сценарії, кастомні Theory data providers, xUnit extensibility points, інтеграція з Microsoft DI, конфігурація для CI/CD та діагностика проблем.

xUnit Advanced: Fixtures, Кастомізація та Розширення

Від базового до просунутого

У попередній статті ви освоїли фундаментальні атрибути xUnit: [Fact], [Theory], IClassFixture, [Collection]. Ці інструменти покривають 80% реальних потреб. Але існують сценарії, де стандартного функціоналу недостатньо:

  • Де зберігати тестові дані, що читаються з файлу JSON або бази даних?
  • Як налаштувати спільний HTTP-сервер для кількох тест-класів?
  • Як змінити поведінку xUnit на рівні runner'а?
  • Як інтегрувати xUnit з Microsoft DI Container?
  • Як дебажити тести, що зависають або падають нестабільно?

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


Складні Fixture сценарії

Async Fixture Initialization

Стандартний конструктор 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 між тестами

Одна спільна 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();
    }
}

Кастомні Theory Data Providers

TheoryData з файлу

// Зчитуємо тестові дані з 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);
}

TheoryData з CSV файлу

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

Комбінаторний Data Provider (декартовий добуток)

// Генерує всі комбінації значень для кількох параметрів
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 Extensibility Points

xUnit побудований навколо кількох інтерфейсів розширення. Розуміння їх відкриває необмежені можливості кастомізації.

ITestCaseOrderer: порядок виконання тестів

За замовчуванням 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() { /* ... */ }
}
Впорядковані тести — antipattern для unit-тестів! Якщо тест B залежить від стану після тесту A — це shared state, порушення ізоляції. Використовуйте PriorityOrderer лише для інтеграційних workflow-тестів, де кожен крок ізольований та ідемпотентний.

IXunitSerializable: серіалізація складних об'єктів у Theory

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") },
};

ITraitDiscoverer: кастомні Trait атрибути

Стандартний [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() { }

Інтеграція з Microsoft Dependency Injection

xUnit не має вбудованого DI-контейнера, але інтеграцію можна організувати через IClassFixture :

ServiceProviderFixture

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

xunit.di пакет (третьостороннє розширення)

dotnet add package
$ dotnet add package Xunit.DependencyInjection
Successfully added Xunit.DependencyInjection to MyApp.Tests.csproj
// 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();
    }
}

Логування у тестах

xUnit + Microsoft.Extensions.Logging

dotnet add package
$ dotnet add package Xunit.Extensions.Logging
Successfully added Xunit.Extensions.Logging to MyApp.Tests.csproj
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 сесії ви можете спостерігати за станом змінних:

Local Variables - Test Execution
Filter
NameTypeValue
_outputITestOutputHelperTest output helper instance
loggerFactoryLoggerFactoryLoggerFactory created
loggerILogger<OrderService>Logger<OrderService> instance
serviceOrderServiceOrderService instance
_mockRepoMock<IOrderRepository>Mock<IOrderRepository>
orderOrder{ Id: a1b2c3d4, Amount: 100m, Status: Pending }
Running
Process: 12842

Перехоплення LogMessages для assertions

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");
}

Діагностика проблем у xUnit

Проблема 1: Тест зависає (Hanging Test)

Найчастіша причина — 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);
    });
}

Проблема 2: Нестабільні тести (Flaky Tests)

Тести, що іноді проходять, іноді падають без змін у коді:

Причина 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() перед кожним тестом

Проблема 3: Повільні тести

dotnet test --verbosity normal
$ dotnet test --verbosity normal
OrderServiceTests.CreateOrder_ValidData_Success [12ms]
EmailServiceTests.SendEmail_ReturnsTrue [8ms]
PaymentServiceTests.ProcessPayment_Success [45ms]
InventoryServiceTests.CheckStock_OutOfStock [120ms]
UserServiceTests.RegisterUser_DuplicateEmail [3ms]
Passed! - Failed: 0, Passed: 42, Skipped: 2
Total tests: 44
Passed in 5.21s (SLOW!)
dotnet test --logger
$ dotnet test --logger "json;LogFileName=results.json"
Results saved to results.json
Contains detailed timing for each test

Як знайти повільні тести — через xunit.runner.json:

{
  "longRunningTestSeconds": 1,
  "diagnosticMessages": true
}

Всі тести довші 1 секунди будуть помічені у виводі.

Класичні причини повільних тестів:

  1. Thread.Sleep або await Task.Delay — замінити на мок із стрибком часу
  2. Реальні HTTP-запити — замінити на WireMock.Net або MockHttpMessageHandler
  3. Важкий fixture, що перебудовується для кожного тесту — перейти на IClassFixture
  4. Синхронні операції у async методах — перевірити наявність справжнього await

Reproducible Randomness: детермінований рандом у тестах

Довільні тести з "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);
    }
}

Конфігурація для CI/CD

GitHub Actions

# .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>
dotnet test --settings
$ dotnet test --settings coverlet.runsettings
Passed! - Failed: 0, Passed: 42, Skipped: 2
Coverage: 78.5%

Mutation Testing зі Stryker.NET

Mutation Testing — техніка оцінки якості тестів: інструмент вносить маленькі зміни ("мутації") у production код і перевіряє, чи впали тести. Якщо тести не впали — вони не "вбили мутанта" — це недостатньо хороші тести.

dotnet tool install
$ dotnet tool install -g dotnet-stryker
Tool 'dotnet-stryker' (version 4.x.x) was installed
cd MyProject.Tests && dotnet stryker
$ dotnet stryker
Starting mutation run
Files mutated: 42
Mutants killed: 38 (90.5%)
4 mutants survived - check these tests!
Mutation score: 90.5%
dotnet stryker --config-file
$ dotnet stryker --config-file stryker-config.json
Loading config: stryker-config.json
Starting mutation run with custom config
Complete! Mutation score: 92.3%

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:

  • Крок 1: POST /api/users (реєстрація)
  • Крок 2: POST /api/auth/login (отримання токена)
  • Крок 3: POST /api/orders (з токеном)
  • Крок 4: GET /api/orders/{id} (перевірка)
  • Крок 5: DELETE /api/orders/{id} (очистка)

Кожен крок — окремий [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 для вашого проєкту:

  • Паралельний запуск Unit та Integration тестів у різних jobs
  • Публікація TRX звіту як артефакту
  • Coverage report з Codecov
  • Fail build якщо coverage < 75%
  • Оголошення тесту "flaky" якщо він падає при першому запуску, але проходить при retry (використайте GitHub Actions continue-on-error + retry action)

Завдання 3.2 — Mutation Testing

Встановіть Stryker.NET для вашого проєкту. Запустіть мутаційне тестування. Знайдіть 3+ "вижилих мутанти" і напишіть тести, що вбивають їх. Пояснення: чому ці мутанти вижили — яких тест-кейсів бракувало?


Підсумок

Ключові думки цієї статті:
  • IAsyncLifetime: InitializeAsync + DisposeAsync для повністю async lifecycle у fixture та тест-класах.
  • Async Fixture Pattern: GetAwaiter().GetResult() (простий) або IAsyncLifetime (правильний) для async ініціалізації.
  • Reset Sandbox: fixture ініціалізується один раз, але ResetAsync() перед кожним тестом очищає дані.
  • Custom Data Providers: DataAttribute підклас для JSON, CSV, або Database джерел тестових даних.
  • ITestCaseOrderer: контрольований порядок виконання для workflow тестів (обережно — antipattern для unit тестів).
  • IXunitSerializable: серіалізація власних об'єктів для відображення у Test Explorer.
  • DI Integration: IClassFixture<ServiceProviderFixture> або Xunit.DependencyInjection пакет.
  • Flaky Tests: race conditions при паралелізмі, DateTime.Now без абстракції, залежність від порядку.
  • CI/CD: --logger "trx" + dorny/test-reporter, coverage threshold, розділення unit/integration jobs.
  • Mutation Testing: Stryker.NET вимірює реальну якість тестів. Mutation Score 80%+ — хороший результат.

Наступна стаття — Moq: Глибоке занурення в мокування.