Тестування

xUnit — Факти, Теорії та Lifecycle тестів

Повний практичний огляд xUnit.net — найпопулярнішого тестового фреймворку для .NET. Атрибути Fact та Theory, всі способи передачі даних, Fixtures та спільні ресурси, паралелізм, кастомізація та розширення.

xUnit: Факти, Теорії та Lifecycle тестів

Чому xUnit обрала Microsoft

Коли Microsoft вирішила переписати .NET Core з нуля у 2014–2016 роках, команда постала перед питанням: який тестовий фреймворк використовувати для тестування самого .NET runtime, BCL (Base Class Library) та ASP.NET Core?

Вибір впав на xUnit.net — і не випадково. Архітектура xUnit відповідала ключовим вимогам нової платформи:

  • Кросплатформеність: xUnit із самого початку підтримував Windows, Linux та macOS
  • Новий екземпляр на тест: жодного shared state між тестами — критично для тисяч тестів у великому проєкті
  • Паралельне виконання за замовчуванням: прискорює CI/CD pipeline
  • Розширюваність: кастомні атрибути, custom runners, extensible assertions

Сьогодні весь .NET Runtime та ASP.NET Core тестуються через xUnit — і якщо ви відкриєте репозиторій dotnet/aspnetcore на GitHub, ви побачите сотні тисяч xUnit тестів.

У цій статті розберемо xUnit детально: від базових концепцій до просунутих можливостей — таких як fixtures для shared ресурсів, параметризація через різні джерела даних та паралелізм.


Встановлення та налаштування

Перш ніж писати перший [Fact] — створимо правильну структуру проєкту. Є два типових сценарії: тестування звичайного .NET проєкту (Console / Class Library) та тестування ASP.NET Minimal API.

Сценарій 1: Console App або Class Library

Найпростіший варіант — ваш основний проєкт не пов'язаний з HTTP. Це може бути business logic library, утиліта, Domain layer тощо.

Крок 1: Структура Solution

bash
$ mkdir MyApp && cd MyApp
$ dotnet new classlib -n MyApp.Core
Project created: MyApp.Core.csproj
$ dotnet new xunit -n MyApp.Tests
Project created: MyApp.Tests.csproj
$ dotnet new sln -n MyApp
Solution created: MyApp.sln
$ dotnet sln add MyApp.Core/MyApp.Core.csproj MyApp.Tests/MyApp.Tests.csproj
Projects added to solution
$ dotnet add MyApp.Tests reference MyApp.Core/MyApp.Core.csproj
Reference added successfully

Після цих команд структура папок:

Крок 2: Додати залежності до тестового проєкту

cd MyApp.Tests && dotnet add package
$ dotnet add package FluentAssertions
Successfully added FluentAssertions to MyApp.Tests.csproj
$ dotnet add package Moq
Successfully added Moq to MyApp.Tests.csproj
$ dotnet add package AutoFixture AutoFixture.Xunit2 Bogus
Successfully added 3 packages

Підсумковий MyApp.Tests.csproj:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
    <PackageReference Include="xunit" Version="2.*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
    <PackageReference Include="coverlet.collector" Version="6.*" />
    <PackageReference Include="FluentAssertions" Version="6.*" />
    <PackageReference Include="Moq" Version="4.*" />
    <PackageReference Include="AutoFixture" Version="4.*" />
    <PackageReference Include="AutoFixture.Xunit2" Version="4.*" />
    <PackageReference Include="Bogus" Version="34.*" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\MyApp.Core\MyApp.Core.csproj" />
  </ItemGroup>

</Project>

Крок 3: GlobalUsings.cs — уникаємо повторення using в кожному файлі:

// MyApp.Tests/GlobalUsings.cs
global using Xunit;
global using FluentAssertions;
global using Moq;
global using AutoFixture;
global using AutoFixture.Xunit2;
global using MyApp.Core; // простір імен основного проєкту

Крок 4: Перший тест

// MyApp.Tests/CalculatorTests.cs
public class CalculatorTests
{
    [Fact]
    public void Add_TwoPlusThree_ReturnsFive()
    {
        var calculator = new Calculator();
        var result = calculator.Add(2, 3);
        result.Should().Be(5);
    }
}

Крок 5: Запустити тести

dotnet test
$ dotnet test
Passed! - Failed: 0, Passed: 12, Skipped: 0
Total tests: 12
Passed in 1.82s
dotnet test --verbosity normal
$ dotnet test --verbosity normal
CalculatorTests.Add_TwoPlusTwo_ReturnsFour [1ms]
CalculatorTests.Divide_ByZero_ThrowsException [2ms]
StringHelperTests.Reverse_String_ReturnsReversed [0ms]
Passed! - Failed: 0, Passed: 12, Skipped: 0
dotnet test --collect
$ dotnet test --collect:"XPlat Code Coverage"
Passed! - Failed: 0, Passed: 12, Skipped: 0
Results file: /path/to/coverage.cobertura.xml
Code coverage applied

Тестування ASP.NET Minimal API має специфіку: для інтеграційних тестів потрібен Microsoft.AspNetCore.Mvc.Testing для запуску додатку in-memory. Unit тести ендпоінтів тестують бізнес-логіку окремо від HTTP шару.

Крок 1: Структура Solution

bash
$ mkdir MyApi && cd MyApi
$ dotnet new webapi -n MyApi --use-minimal-apis
Project created: MyApi.csproj
$ dotnet new xunit -n MyApi.Tests
Project created: MyApi.Tests.csproj
$ dotnet new sln -n MyApi && dotnet sln add MyApi/MyApi.csproj MyApi.Tests/MyApi.Tests.csproj
Solution created, projects added
$ dotnet add MyApi.Tests reference MyApi/MyApi.csproj
Reference added successfully

Структура після налаштування:

Крок 2: Пакети для тестового проєкту ASP.NET

Ключова відмінність від простого проєкту — пакет Microsoft.AspNetCore.Mvc.Testing для запуску API in-memory:

cd MyApi.Tests && dotnet add package
$ dotnet add package FluentAssertions Moq AutoFixture AutoFixture.Xunit2
Successfully added 4 packages
$ dotnet add package Microsoft.AspNetCore.Mvc.Testing
Successfully added Microsoft.AspNetCore.Mvc.Testing to MyApi.Tests.csproj
$ dotnet add package WireMock.Net
Successfully added WireMock.Net to MyApi.Tests.csproj

Підсумковий MyApi.Tests.csproj:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
    <PackageReference Include="xunit" Version="2.*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
    <PackageReference Include="coverlet.collector" Version="6.*" />

    <!-- Assertions та мокування -->
    <PackageReference Include="FluentAssertions" Version="6.*" />
    <PackageReference Include="Moq" Version="4.*" />

    <!-- Тестові дані -->
    <PackageReference Include="AutoFixture" Version="4.*" />
    <PackageReference Include="AutoFixture.Xunit2" Version="4.*" />
    <PackageReference Include="Bogus" Version="34.*" />

    <!-- ASP.NET інтеграційне тестування -->
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.*" />

    <!-- HTTP mocking для зовнішніх сервісів -->
    <PackageReference Include="WireMock.Net" Version="1.*" />

    <!-- Testcontainers для Docker БД -->
    <PackageReference Include="Testcontainers.PostgreSql" Version="3.*" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\MyApi\MyApi.csproj" />
  </ItemGroup>

</Project>

Важливий крок: Program.cs має бути доступним для тестів

Для WebApplicationFactory тестовий проєкт повинен "бачити" точку входу вашого API. У сучасних .NET проєктах це вирішується одним з двох способів:

// Спосіб 1: Додати у кінець Program.cs один рядок
// (після всіх реєстрацій і налаштувань)
app.Run();

// Для тестів — цей рядок робить Program доступним для теста:
public partial class Program { } // ← додати в кінці Program.cs

Або через .csproj основного проєкту:

<!-- MyApi/MyApi.csproj -->
<ItemGroup>
  <InternalsVisibleTo Include="MyApi.Tests" />
</ItemGroup>

Крок 3: GlobalUsings.cs для ASP.NET тестів

// MyApi.Tests/GlobalUsings.cs
global using Xunit;
global using FluentAssertions;
global using Moq;
global using AutoFixture;
global using AutoFixture.Xunit2;
global using System.Net;
global using System.Net.Http.Json;
global using Microsoft.AspNetCore.Mvc.Testing;
global using Microsoft.Extensions.DependencyInjection;
global using MyApi; // простір імен основного проєкту

Крок 4: Unit тест (без HTTP)

// MyApi.Tests/Unit/Services/OrderServiceTests.cs
public class OrderServiceTests
{
    private readonly Mock<IOrderRepository> _mockRepo = new();
    private readonly OrderService _sut;

    public OrderServiceTests()
    {
        _sut = new OrderService(_mockRepo.Object);
    }

    [Fact]
    public async Task CreateOrder_WithValidData_ReturnsSuccess()
    {
        _mockRepo
            .Setup(x => x.SaveAsync(It.IsAny<Order>()))
            .ReturnsAsync(true);

        var result = await _sut.CreateAsync(new CreateOrderDto { Amount = 100m });

        result.IsSuccess.Should().BeTrue();
    }
}

Крок 5: Інтеграційний тест (через HTTP)

// MyApi.Tests/Integration/Endpoints/OrderEndpointsTests.cs
public class OrderEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public OrderEndpointsTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetOrders_ReturnsOk()
    {
        var response = await _client.GetAsync("/api/orders");

        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }

    [Fact]
    public async Task CreateOrder_WithValidData_Returns201()
    {
        var dto = new CreateOrderDto { Amount = 150m, ProductId = Guid.NewGuid() };

        var response = await _client.PostAsJsonAsync("/api/orders", dto);

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        var order = await response.Content.ReadFromJsonAsync<OrderDto>();
        order.Should().NotBeNull();
        order!.Amount.Should().Be(150m);
    }
}
Детально WebApplicationFactory, підміна залежностей у тестах (WithWebHostBuilder), налаштування тестового середовища — у статті про інтеграційне тестування.

Запуск тестів ASP.NET проєкту:

dotnet test
$ dotnet test
Passed! - Failed: 0, Passed: 28, Skipped: 2
Total tests: 30
Passed in 4.12s
dotnet test --filter
$ dotnet test --filter "Category=Unit"
Passed! - Failed: 0, Passed: 18, Skipped: 0
Total tests: 18
Passed in 1.23s
$ dotnet test --filter "Category=Integration"
Passed! - Failed: 0, Passed: 10, Skipped: 2
Total tests: 12
Passed in 2.89s
reportgenerator
$ dotnet test --collect:"XPlat Code Coverage"
Passed! - Failed: 0, Passed: 28, Skipped: 2
Results file: coverage.cobertura.xml
$ reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:Html
Generated HTML report: coveragereport/index.html
Швидке порівняння двох сценаріїв:
Console/Class LibraryASP.NET Minimal API
Тестовий шаблонdotnet new xunitdotnet new xunit
Ключовий додатковий пакетMicrosoft.AspNetCore.Mvc.Testing
Unit тестиТак, стандартніТак, окремий шар сервісів
Інтеграційні тестиЗалежить від архітектуриWebApplicationFactory<Program>
Program.cs зміниНе потрібніpublic partial class Program { }

Fact: Простий тест

[Fact] — найбазовіший атрибут xUnit. Він позначає метод як тест без параметрів, що перевіряє одне конкретне твердження ("факт") про систему.

public class CalculatorTests
{
    [Fact]
    public void Add_TwoPlusThree_ReturnsFive()
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        var result = calculator.Add(2, 3);

        // Assert
        Assert.Equal(5, result);
    }
}

Правила методу Fact

  1. Публічний метод — xUnit використовує Reflection, private методи не видно
  2. Без параметрів — параметри з'являються у [Theory]
  3. Повертає void або Task — для async тестів
  4. Може бути async — xUnit підтримує async Task нативно, без жодних обгорток
// ✅ Синхронний Fact
[Fact]
public void Synchronous_Test() { }

// ✅ Асинхронний Fact — повністю підтримується
[Fact]
public async Task Asynchronous_Test()
{
    var result = await _service.GetDataAsync();
    result.Should().NotBeNull();
}

// ✅ Async з ValueTask (більш ефективний у деяких сценаріях)
[Fact]
public async ValueTask ValueTask_Test()
{
    var result = await _service.FastOperationAsync();
    result.Should().BeTrue();
}
Не використовуйте .Result або .Wait() для async коду в тестах! Це призводить до deadlocks в певних контекстах. Завжди await + async Task.
// ❌ Небезпечно: потенційний deadlock
[Fact]
public void Bad_Async_Test()
{
    var result = _service.GetDataAsync().Result; // Deadlock можливий!
}

// ✅ Правильно
[Fact]
public async Task Good_Async_Test()
{
    var result = await _service.GetDataAsync();
}

Пропуск тестів: Skip

// Пропустити тест з поясненням
[Fact(Skip = "Відкладено: баг #1234 у зовнішньому API, виправляється у v2.1")]
public void Test_WithKnownBug() { }

// Умовний пропуск через власний атрибут
[SkipUnlessEnvironment("Integration")]
public void Test_OnlyInIntegrationEnvironment() { }

DisplayName: зрозумілі назви у Test Explorer

// За замовчуванням показується ім'я методу
[Fact]
public void CreateOrder_WithValidData_ReturnsSuccess() { }

// З DisplayName: показується у Test Explorer та звітах
[Fact(DisplayName = "Створення замовлення з валідними даними повертає успіх")]
public void CreateOrder_WithValidData_ReturnsSuccess_WithName() { }

// Корисно для тестів з нетехнічними читачами (BDD стиль)
[Fact(DisplayName = "Клієнт може скасувати замовлення до відправки")]
public void Customer_CanCancelOrder_BeforeShipping() { }

Theory: Параметризовані тести

[Theory] — атрибут для параметризованих тестів. Один метод виконується кілька разів з різними наборами даних. Кожен виклик — окремий тест у результатах.

Фундаментальна архітектурна різниця від [Fact]:

  • [Fact] не має параметрів і виконується рівно один раз
  • [Theory] має параметри і виконується рівно стільки разів, скільки наборів даних надано

InlineData: вбудовані дані

Найпростіший спосіб передати дані — [InlineData] атрибут:

[Theory]
[InlineData(2, 3, 5)]
[InlineData(-1, 1, 0)]
[InlineData(0, 0, 0)]
[InlineData(int.MaxValue, 0, int.MaxValue)]
[InlineData(int.MinValue, 0, int.MinValue)]
public void Add_ReturnsCorrectSum(int a, int b, int expected)
{
    var result = new Calculator().Add(a, b);
    Assert.Equal(expected, result);
}

Тест буде запущений 5 разів. У Test Explorer / звітах вони з'являються як:

Add_ReturnsCorrectSum(a: 2, b: 3, expected: 5) ✅
Add_ReturnsCorrectSum(a: -1, b: 1, expected: 0) ✅
Add_ReturnsCorrectSum(a: 0, b: 0, expected: 0) ✅
...

InlineData підтримує всі примітивні типи:

[Theory]
[InlineData("hello", true)]
[InlineData("", false)]
[InlineData(null, false)]
[InlineData("   ", false)]
public void IsValidName_ReturnsCorrectResult(string? name, bool expected)
{
    Assert.Equal(expected, NameValidator.IsValid(name));
}

Обмеження InlineData: значення мають бути компайл-тайм константами. Не можна передати new DateTime(...), new List<>() або складні об'єкти.


MemberData: дані з властивостей та методів

[MemberData] посилається на статичну властивість або метод класу, що повертає IEnumerable<object[]>.

public class DiscountCalculatorTests
{
    // Статична властивість — джерело даних
    public static IEnumerable<object[]> DiscountTestCases =>
        new List<object[]>
        {
            new object[] { CustomerType.Standard, 1000m, 0m },
            new object[] { CustomerType.Premium, 1000m, 100m },   // 10%
            new object[] { CustomerType.VIP,     1000m, 200m },   // 20%
            new object[] { CustomerType.Premium, 0m,    0m },     // 0 замовлення
            new object[] { CustomerType.VIP,     500m,  100m },   // 20% від 500
        };

    [Theory]
    [MemberData(nameof(DiscountTestCases))]
    public void CalculateDiscount_ReturnsCorrectAmount(
        CustomerType type, decimal orderTotal, decimal expectedDiscount)
    {
        var result = _calculator.CalculateDiscount(type, orderTotal);
        Assert.Equal(expectedDiscount, result);
    }
}

MemberData з методом (для динамічних даних):

// Дані можна генерувати динамічно
public static IEnumerable<object[]> GenerateBoundaryData()
{
    yield return new object[] { -1, false };
    yield return new object[] { 0, false };
    yield return new object[] { 1, true };

    // Динамічна генерація
    foreach (var age in new[] { 18, 21, 65, 66 })
        yield return new object[] { age, age >= 18 && age <= 65 };

    yield return new object[] { 120, false };
}

[Theory]
[MemberData(nameof(GenerateBoundaryData))]
public void IsValidAge_ReturnsCorrectResult(int age, bool expected) { }

MemberData з іншого класу (спільні тестові дані):

// SharedTestData.cs — спільні дані для кількох тест-класів
public static class SharedTestData
{
    public static IEnumerable<object[]> ValidUsers =>
        new List<object[]>
        {
            new object[] { "John", "Doe", "john@test.com", 25 },
            new object[] { "Jane", "Smith", "jane@test.com", 30 },
        };
}

// В тест-класі — посилання на інший клас
[Theory]
[MemberData(nameof(SharedTestData.ValidUsers), MemberType = typeof(SharedTestData))]
public void CreateUser_WithValidData_Succeeds(
    string firstName, string lastName, string email, int age) { }

ClassData: дані з окремого класу

[ClassData] дозволяє інкапсулювати тестові дані у власний клас, що реалізує IEnumerable<object[]>. Це найчистіший спосіб для великих і складних наборів даних.

// Окремий клас для тестових даних
public class PasswordValidationTestData : IEnumerable<object[]>
{
    private readonly List<object[]> _data = new()
    {
        // password, isValid, errorMessage
        new object[] { "Short1!", false, "Too short" },
        new object[] { "nouppercase1!", false, "No uppercase" },
        new object[] { "NOLOWERCASE1!", false, "No lowercase" },
        new object[] { "NoDigitHere!", false, "No digit" },
        new object[] { "NoSpecial1", false, "No special char" },
        new object[] { "Valid1Pass!", true, null! },
        new object[] { "AnotherValid2@", true, null! },
    };

    public IEnumerator<object[]> GetEnumerator() => _data.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

// Використання
[Theory]
[ClassData(typeof(PasswordValidationTestData))]
public void ValidatePassword_ReturnsCorrectResult(
    string password, bool isValid, string? expectedError)
{
    var result = _validator.Validate(password);
    Assert.Equal(isValid, result.IsValid);
    if (expectedError != null)
        Assert.Contains(expectedError, result.Errors);
}

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


Typed Theory Data (xUnit v3 / нший підхід)

У новіших версіях xUnit доступний більш типобезпечний підхід через TheoryData<T>:

// TheoryData<T> — типобезпечна альтернатива IEnumerable<object[]>
public static TheoryData<int, bool> AgeData => new()
{
    { 17, false },
    { 18, true },
    { 65, true },
    { 66, false },
};

[Theory]
[MemberData(nameof(AgeData))]
public void IsEligible_ByAge_ReturnsCorrectResult(int age, bool expected)
{
    Assert.Equal(expected, _service.IsEligible(age));
}

TheoryData<T1, T2> перевіряється компілятором — не можна передати неправильний тип. Це усуває runtime помилки, характерні для object[].


Lifecycle xUnit тестів: детально

Стандартний lifecycle без fixtures

Для кожного тест-методу xUnit виконує наступну послідовність:

1. Конструктор тест-класу (= Setup)
2. Тест-метод
3. Dispose() тест-класу (= Teardown, якщо клас реалізує IDisposable)
public class OrderServiceTests : IDisposable
{
    private readonly OrderService _sut;
    private readonly Mock<IOrderRepository> _mockRepo;

    // CONSTRUCTOR = SETUP — виконується перед кожним тестом
    public OrderServiceTests()
    {
        _mockRepo = new Mock<IOrderRepository>();
        _sut = new OrderService(_mockRepo.Object);

        // Налаштовуємо загальний stubs, що потрібні більшості тестів
        _mockRepo
            .Setup(x => x.ExistsAsync(It.IsAny<Guid>()))
            .ReturnsAsync(true);
    }

    [Fact]
    public async Task CreateOrder_WithValidData_ShouldSucceed()
    {
        // _sut та _mockRepo свіжі для цього тесту
        var result = await _sut.CreateAsync(new CreateOrderDto());
        result.IsSuccess.Should().BeTrue();
    }

    [Fact]
    public async Task CreateOrder_WithDuplicateId_ShouldFail()
    {
        // Повністю ізольовано від попереднього тесту
        _mockRepo
            .Setup(x => x.ExistsAsync(It.IsAny<Guid>()))
            .ReturnsAsync(false); // Перевизначаємо для цього конкретного тесту

        var result = await _sut.CreateAsync(new CreateOrderDto());
        result.IsSuccess.Should().BeFalse();
    }

    // DISPOSE = TEARDOWN — виконується після кожного тесту
    public void Dispose()
    {
        // Звільняємо ресурси: закриваємо з'єднання, видаляємо файли тощо
        _sut.Dispose(); // якщо _sut є IDisposable
    }
}

IClassFixture: shared ресурс для класу

Проблема: деякі ресурси дорого ініціалізувати (з'єднання з базою даних, Docker контейнер, HTTP-сервер). Ми не хочемо їх перестворювати для кожного тесту — але хочемо ізоляцію даних між тестами.

Рішення: IClassFixture<TFixture> — один екземпляр fixture для всіх тестів у класі.

// Fixture: ресурс, що дорого ініціалізувати
public class SqliteDatabaseFixture : IDisposable
{
    public IDbConnection Connection { get; private set; }
    public string DatabaseName { get; } = $"test_{Guid.NewGuid():N}";

    public SqliteDatabaseFixture()
    {
        // Це виконується ОДИН РАЗ для всіх тестів у класі
        Connection = new SqliteConnection($"Data Source={DatabaseName}.db");
        Connection.Open();

        // Створюємо схему
        Connection.Execute("""
            CREATE TABLE Users (
                Id TEXT PRIMARY KEY,
                Name TEXT NOT NULL,
                Email TEXT NOT NULL
            )
        """);
    }

    public void ResetData()
    {
        // Очищення між тестами
        Connection.Execute("DELETE FROM Users");
    }

    public void Dispose()
    {
        Connection.Dispose();
        File.Delete($"{DatabaseName}.db");
    }
}

// Тест-клас з fixture
public class UserRepositoryTests : IClassFixture<SqliteDatabaseFixture>
{
    private readonly SqliteDatabaseFixture _db;

    // xUnit інжектує fixture через конструктор
    public UserRepositoryTests(SqliteDatabaseFixture db)
    {
        _db = db;
        _db.ResetData(); // Очищаємо дані перед кожним тестом
        // Але САМО ПІДКЛЮЧЕННЯ not recreated — тільки дані
    }

    [Fact]
    public async Task GetById_WhenUserExists_ReturnsUser()
    {
        // Arrange: вставляємо дані
        await _db.Connection.ExecuteAsync(
            "INSERT INTO Users VALUES (@Id, @Name, @Email)",
            new { Id = "1", Name = "John", Email = "john@test.com" });

        var repo = new UserRepository(_db.Connection);

        // Act
        var user = await repo.GetByIdAsync("1");

        // Assert
        user.Should().NotBeNull();
        user!.Name.Should().Be("John");
    }

    [Fact]
    public async Task GetById_WhenUserNotExists_ReturnsNull()
    {
        var repo = new UserRepository(_db.Connection);

        // База порожня (ResetData в конструкторі)
        var user = await repo.GetByIdAsync("nonexistent");

        user.Should().BeNull();
    }
}

Lifecycle з ClassFixture:

SqliteDatabaseFixture() — 1 раз для всього класу

  Для кожного тесту:
    UserRepositoryTests() (конструктор)
      db.ResetData() — очищення між тестами
    UserRepositoryTests.TestMethod()
    UserRepositoryTests.Dispose() якщо є

SqliteDatabaseFixture.Dispose() — 1 раз після всіх тестів

ICollectionFixture: shared ресурс для кількох класів

Якщо один дорогий ресурс потрібен кільком тест-класам:

// 1. Визначаємо колекцію
[CollectionDefinition("Database Collection")]
public class DatabaseCollection : ICollectionFixture<SqliteDatabaseFixture>
{
    // Порожній клас — тільки маркер
}

// 2. Позначаємо тест-класи як частину колекції
[Collection("Database Collection")]
public class UserRepositoryTests
{
    private readonly SqliteDatabaseFixture _db;
    public UserRepositoryTests(SqliteDatabaseFixture db) => _db = db;
    // ...
}

[Collection("Database Collection")]
public class OrderRepositoryTests
{
    private readonly SqliteDatabaseFixture _db;
    public OrderRepositoryTests(SqliteDatabaseFixture db) => _db = db;
    // ...
}

Тепер обидва класи поділяють один екземпляр SqliteDatabaseFixture. Підключення до БД встановлюється один раз — незалежно від кількості тест-класів у колекції.

Важливо: тести всередині колекції виконуються послідовно (не паралельно), щоб уникнути конфліктів спільного ресурсу.


Паралелізм у xUnit

xUnit виконує тести паралельно між класами за замовчуванням. Тести всередині одного класу виконуються послідовно.

Чому паралелізм між класами, а не всередині?

Тести в одному класі поділяють fixture та потенційно стан (навіть якщо кожен тест отримує новий екземпляр класу, fixture спільна). Паралелізм всередині класу призвів би до race conditions при використанні ClassFixture.

Налаштування паралелізму

// AssemblyInfo.cs або атрибут на рівні namespace

// Вимкнути паралелізм для всієї сборки
[assembly: CollectionBehavior(DisableTestParallelization = true)]

// Встановити максимальну кількість паралельних потоків
[assembly: CollectionBehavior(MaxParallelThreads = 4)]

// Дозволити паралелізм всередині класу (небезпечно — тільки якщо клас thread-safe)
[assembly: CollectionBehavior(CollectionBehavior.CollectionPerAssembly)]

xunit.runner.json — конфігурація через JSON файл:

{
  "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
  "parallelizeAssembly": false,
  "parallelizeTestCollections": true,
  "maxParallelThreads": 8,
  "methodDisplay": "method",
  "diagnosticMessages": false,
  "longRunningTestSeconds": 10
}

Колекції для контролю паралелізму

// Два класи в одній колекції — виконуються послідовно між собою
// (але паралельно з класами без колекції або з іншими колекціями)
[Collection("Sequential Integration")]
public class DatabaseIntegrationTests { }

[Collection("Sequential Integration")]
public class ApiIntegrationTests { }

Output у xUnit: ITestOutputHelper

Стандартний Console.WriteLine у тестах не відображається у Test Explorer (xUnit його перехоплює). Для виводу потрібен ITestOutputHelper:

public class OrderServiceTests
{
    private readonly ITestOutputHelper _output;
    private readonly OrderService _sut;

    // xUnit автоматично інжектує ITestOutputHelper
    public OrderServiceTests(ITestOutputHelper output)
    {
        _output = output;
        _sut = new OrderService();
    }

    [Fact]
    public async Task ProcessOrder_LogsProgress()
    {
        _output.WriteLine("Starting ProcessOrder test...");

        var order = new Order { Amount = 1000m };
        var result = await _sut.ProcessOrder(order);

        _output.WriteLine($"Result: {result.Status}, Duration: {result.ProcessingMs}ms");

        result.IsSuccess.Should().BeTrue();
    }
}

Вивід з'являється у Test Explorer при проваленому тесті (є можливість побачити також при пройденому):

Starting ProcessOrder test...
Result: Success, Duration: 45ms

Під час налагодження складних тестів ви можете спостерігати за станом об'єктів:

Debug: Order Processing
Filter
NameTypeValue
_outputITestOutputHelperTest output helper instance
_sutOrderServiceOrderService instance
orderOrder{ Id: a1b2c3d4, Amount: 1000m, CustomerId: guid }
resultOrderResult{ Status: Success, ProcessingMs: 45, IsSuccess: true }
Running
Process: 12842

ITestOutputHelper для налагодження складних тестів:

[Fact]
public void DebugComplexCalculation()
{
    var input = GenerateComplexInput();
    _output.WriteLine($"Input: {JsonSerializer.Serialize(input)}");

    var result = _calculator.Process(input);
    _output.WriteLine($"Result: {JsonSerializer.Serialize(result)}");
    _output.WriteLine($"Intermediate steps: {string.Join(", ", _calculator.Steps)}");

    result.Total.Should().Be(expected);
}

Traits: категоризація та фільтрація тестів

[Trait] дозволяє категоризувати тести для фільтрації при запуску:

public class OrderEndpointTests
{
    [Fact]
    [Trait("Category", "Integration")]
    [Trait("Feature", "Orders")]
    public async Task GetOrders_ReturnsAllOrders() { }

    [Fact]
    [Trait("Category", "Unit")]
    [Trait("Feature", "Orders")]
    public void CalculateOrderTotal_IsCorrect() { }
}

Фільтрація при запуску:

dotnet test --filter
$ dotnet test --filter "Category=Unit"
Passed! - Failed: 0, Passed: 18, Skipped: 0
Total tests: 18
Passed in 1.23s
$ dotnet test --filter "Feature=Orders"
Passed! - Failed: 0, Passed: 8, Skipped: 0
Total tests: 8
Passed in 0.89s
$ dotnet test --filter "Category=Integration&Feature=Orders"
Passed! - Failed: 0, Passed: 5, Skipped: 0
Total tests: 5
Passed in 1.45s

Кастомні атрибути-скорочення для Traits:

// Замість [Trait("Category", "Unit")] — чистіший синтаксис
public class UnitTestAttribute : TraitAttribute
{
    public UnitTestAttribute() : base("Category", "Unit") { }
}

public class IntegrationTestAttribute : TraitAttribute
{
    public IntegrationTestAttribute() : base("Category", "Integration") { }
}

// Використання
[Fact]
[UnitTest]
public void My_Unit_Test() { }

[Fact]
[IntegrationTest]
public async Task My_Integration_Test() { }

Expectation: перевірка exceptions

// Базова перевірка exception
[Fact]
public void Divide_ByZero_ThrowsDivideByZeroException()
{
    var calc = new Calculator();
    Assert.Throws<DivideByZeroException>(() => calc.Divide(5, 0));
}

// Отримання exception для детальної перевірки
[Fact]
public void RegisterUser_WithExistingEmail_ThrowsWithCorrectMessage()
{
    var service = new UserService();

    var exception = Assert.Throws<DuplicateEmailException>(
        () => service.Register("existing@email.com", "password"));

    Assert.Equal("User with this email already exists", exception.Message);
    Assert.Equal("existing@email.com", exception.Email);
}

// Async exceptions
[Fact]
public async Task CreateOrder_WithInvalidData_ThrowsValidationException()
{
    var service = new OrderService();

    await Assert.ThrowsAsync<ValidationException>(
        async () => await service.CreateAsync(new CreateOrderDto { Amount = -1 }));
}

// FluentAssertions — більш читабельно
[Fact]
public async Task CreateOrder_Fluent_ThrowsValidationException()
{
    var service = new OrderService();

    Func<Task> act = () => service.CreateAsync(new CreateOrderDto { Amount = -1 });

    await act.Should()
        .ThrowAsync<ValidationException>()
        .WithMessage("*amount*")
        .Where(ex => ex.Errors.Count > 0);
}

Assert методи: повний огляд

xUnit має власну assertion library. Розглянемо найважливіші методи.

Базові порівняння

// Рівність
Assert.Equal(expected, actual);
Assert.NotEqual(notExpected, actual);

// Рівність з custom tolerance для чисел з плаваючою кропкою
Assert.Equal(3.14159, result, precision: 5); // 5 знаків після коми
Assert.Equal(3.14, result, tolerance: 0.005); // абсолютна різниця ≤ 0.005

// Null перевірки
Assert.Null(value);
Assert.NotNull(value);

// Boolean
Assert.True(condition);
Assert.False(condition);
Assert.True(condition, "Пояснення чому умова мала бути true");

Колекції

// Порожня / непорожня
Assert.Empty(collection);
Assert.NotEmpty(collection);

// Наявність елементу
Assert.Contains(3, collection);
Assert.DoesNotContain(99, collection);

// Наявність за умовою
Assert.Contains(collection, item => item.IsActive);
Assert.DoesNotContain(collection, item => item.IsDeleted);

// Кількість елементів
Assert.Single(collection);     // рівно 1
Assert.Equal(5, collection.Count());

// Порядок (xUnit 2.5+)
Assert.All(collection, item => Assert.True(item.Price > 0));

Типи та рефлексія

// Перевірка типу
Assert.IsType<OrderDto>(result);
Assert.IsNotType<ErrorResponse>(result);

// Перевірка наслідування
Assert.IsAssignableFrom<IDisposable>(service);

// Конвертація з перевіркою
var dto = Assert.IsType<OrderDto>(result); // Повертає typed result
dto.Id.Should().NotBeEmpty(); // Далі можна використовувати typed

Same / NotSame: посилання

// Один і той самий об'єкт у пам'яті (reference equality)
Assert.Same(expectedObject, actualObject);
Assert.NotSame(object1, object2);

Custom Assert: розширення для доменної логіки

При складній доменній логіці варто створювати власні assertion методи:

// Розширення для доменних об'єктів
public static class OrderAssertions
{
    public static void ShouldBeSuccessful(this Order order)
    {
        Assert.NotNull(order);
        Assert.Equal(OrderStatus.Confirmed, order.Status);
        Assert.NotEqual(Guid.Empty, order.Id);
        Assert.True(order.TotalAmount > 0, "Order total should be positive");
        Assert.NotEmpty(order.Items);
    }

    public static void ShouldBeRefunded(this Order order, decimal expectedRefund)
    {
        Assert.Equal(OrderStatus.Refunded, order.Status);
        Assert.Equal(expectedRefund, order.RefundAmount);
        Assert.NotNull(order.RefundedAt);
    }
}

// Використання у тестах
[Fact]
public async Task CreateOrder_WithValidData_ShouldSucceed()
{
    var order = await _service.CreateAsync(validDto);

    order.ShouldBeSuccessful(); // Один рядок замість 5 assertions
}

// З FluentAssertions: ще потужніший підхід
public class OrderAssertionExtensions
{
    public static AndConstraint<OrderAssertions> BeSuccessful(
        this ObjectAssertions assertions)
    {
        var order = assertions.Subject as Order;
        order.Should().NotBeNull();
        order!.Status.Should().Be(OrderStatus.Confirmed);
        order.Id.Should().NotBeEmpty();
        // ...
        return new AndConstraint<OrderAssertions>(new OrderAssertions(order));
    }
}

xunit.runner.json: конфігурація

{
  "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",

  // Виведення назви методу або повного кваліфікованого імені
  "methodDisplay": "method",       // "method" або "classAndMethod"
  "methodDisplayOptions": "all",   // Перетворення назв методів

  // Паралелізм
  "parallelizeAssembly": true,
  "parallelizeTestCollections": true,
  "maxParallelThreads": -1,        // -1 = автоматично

  // Таймаути
  "longRunningTestSeconds": 5,     // Попередження для тестів > 5 секунд

  // Вивід
  "diagnosticMessages": false,     // true = більше деталей про runner

  // Відображення
  "preEnumerateTheories": true,    // Попередньо показати всі Theory iterations
  "stopOnFail": false              // true = зупинитись після першого провалу
}

Практичні завдання

Рівень 1: Базові атрибути

Завдання 1.1 — Theory в усіх варіантах

Реалізуйте метод string CategorizeBMI(double bmi):

  • < 18.5 → "Underweight"
  • 18.5–24.9 → "Normal"
  • 25.0–29.9 → "Overweight"
  • ≥ 30 → "Obese"
  • < 0 або > 100 → throw ArgumentOutOfRangeException

Напишіть тести трьома способами: [InlineData], [MemberData], [ClassData]. Переконайтесь, що покривають всі граничні значення (on-point/off-point).

Завдання 1.2 — Async tests

Реалізуйте async Task<User?> FindUserByEmailAsync(string email) з затримкою 50ms (симуляція I/O). Напишіть 4 async [Fact] тести: користувач знайдений, не знайдений, null email, порожній email. Переконайтесь, що не використовуєте .Result або .Wait().

Завдання 1.3 — Skip та DisplayName

Є список задач (тестів), частина з яких реалізована, частина — ні. Для нереалізованих задач напишіть тести з Skip="reason". Для всіх тестів встановіть DisplayName у форматі BDD — "Given X, When Y, Then Z".

Рівень 2: Fixtures та Lifecycle

Завдання 2.1 — IClassFixture

Реалізуйте DatabaseFixture для SQLite in-memory database з методами: InitSchema(), SeedTestData(), CleanData(). Напишіть тест-клас ProductRepositoryTests : IClassFixture<DatabaseFixture> з 8+ тестами: CRUD операції, пошук, пагінація. Продемонструйте, що фіксчер ініціалізується один раз, а дані очищаються перед кожним тестом.

Завдання 2.2 — ICollectionFixture

Два сервіси: ProductService та CategoryService. Обидва залежать від тієї самої DatabaseFixture. Налаштуйте ICollectionFixture щоб один екземпляр БД використовувався обома тест-класами. Переконайтесь, що тести послідовні.

Рівень 3: Розширення та кастомізація

Завдання 3.1 — Custom Attributes

Створіть:

  • [UnitTest] — trait Category=Unit
  • [IntegrationTest] — trait Category=Integration
  • [SlowTest] — trait Speed=Slow (для тестів > 1 секунди)
  • [SkipOnLinux] — пропускає тест на Linux (Environment.OSVersion)
  • [SkipOnCI] — пропускає у CI середовищі (перевірка змінної оточення CI=true)

Напишіть тести, що демонструють роботу кожного атрибуту. Запустіть dotnet test --filter "Category=Unit" і переконайтесь, що фільтрація працює.

Завдання 3.2 — Domain-specific assertions

Для системи банківських переказів реалізуйте fluent assertion extensions:

  • transfer.Should().BeSuccessful() — статус Success, amount > 0, timestamp заповнено
  • transfer.Should().BeRejected().WithReason("Insufficient funds")
  • account.Should().HaveBalance(expectedAmount)
  • account.Should().HaveTransactionCount(n)

Напишіть 10 тестів, що використовують ці розширення. Оцініть, наскільки читабельнішою стала тест-специфікація.


Підсумок

Ключові думки цієї статті:
  • Fact: простий тест без параметрів. Підтримує async Task, DisplayName, Skip. Публічний метод.
  • Theory з джерелами даних: [InlineData] (константи) → [MemberData] (статичні методи/властивості) → [ClassData] (окремий клас). TheoryData<T> — типобезпечна альтернатива.
  • Lifecycle: Конструктор → Тест-метод → Dispose. xUnit — новий екземпляр на кожен тест. Жодного [SetUp] атрибуту.
  • IClassFixture: один ресурс для всіх тестів у класі. Fixture ініціалізується один раз, але клас — на кожен тест.
  • ICollectionFixture: один ресурс для кількох тест-класів. Тести всередині колекції — послідовні.
  • Паралелізм: між класами — паралельно за замовчуванням. Всередині класу — послідовно. Колекції — послідовні.
  • ITestOutputHelper: правильний спосіб виводу в xUnit. Видимий у Test Explorer при провалах.
  • Trait: категоризація для dotnet test --filter. Власні атрибути-скорочення.
  • Assert: рівність, null, boolean, колекції, типи. Custom extensions для доменної специфіки.

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