Коли Microsoft вирішила переписати .NET Core з нуля у 2014–2016 роках, команда постала перед питанням: який тестовий фреймворк використовувати для тестування самого .NET runtime, BCL (Base Class Library) та ASP.NET Core?
Вибір впав на xUnit.net — і не випадково. Архітектура xUnit відповідала ключовим вимогам нової платформи:
Сьогодні весь .NET Runtime та ASP.NET Core тестуються через xUnit — і якщо ви відкриєте репозиторій dotnet/aspnetcore на GitHub, ви побачите сотні тисяч xUnit тестів.
У цій статті розберемо xUnit детально: від базових концепцій до просунутих можливостей — таких як fixtures для shared ресурсів, параметризація через різні джерела даних та паралелізм.
Перш ніж писати перший [Fact] — створимо правильну структуру проєкту. Є два типових сценарії: тестування звичайного .NET проєкту (Console / Class Library) та тестування ASP.NET Minimal API.
Найпростіший варіант — ваш основний проєкт не пов'язаний з HTTP. Це може бути business logic library, утиліта, Domain layer тощо.
Крок 1: Структура Solution
Після цих команд структура папок:
Крок 2: Додати залежності до тестового проєкту
Підсумковий 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: Запустити тести
Тестування ASP.NET Minimal API має специфіку: для інтеграційних тестів потрібен Microsoft.AspNetCore.Mvc.Testing для запуску додатку in-memory. Unit тести ендпоінтів тестують бізнес-логіку окремо від HTTP шару.
Крок 1: Структура Solution
Структура після налаштування:
Крок 2: Пакети для тестового проєкту ASP.NET
Ключова відмінність від простого проєкту — пакет Microsoft.AspNetCore.Mvc.Testing для запуску API in-memory:
Підсумковий 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 проєкту:
| Console/Class Library | ASP.NET Minimal API | |
|---|---|---|
| Тестовий шаблон | dotnet new xunit | dotnet new xunit |
| Ключовий додатковий пакет | — | Microsoft.AspNetCore.Mvc.Testing |
| Unit тести | Так, стандартні | Так, окремий шар сервісів |
| Інтеграційні тести | Залежить від архітектури | WebApplicationFactory<Program> |
Program.cs зміни | Не потрібні | public partial class Program { } |
[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);
}
}
private методи не видно[Theory]void або Task — для async тестів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();
}
// Пропустити тест з поясненням
[Fact(Skip = "Відкладено: баг #1234 у зовнішньому API, виправляється у v2.1")]
public void Test_WithKnownBug() { }
// Умовний пропуск через власний атрибут
[SkipUnlessEnvironment("Integration")]
public void Test_OnlyInIntegrationEnvironment() { }
// За замовчуванням показується ім'я методу
[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] — атрибут для параметризованих тестів. Один метод виконується кілька разів з різними наборами даних. Кожен виклик — окремий тест у результатах.
Фундаментальна архітектурна різниця від [Fact]:
[Fact] не має параметрів і виконується рівно один раз[Theory] має параметри і виконується рівно стільки разів, скільки наборів даних наданоНайпростіший спосіб передати дані — [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] посилається на статичну властивість або метод класу, що повертає 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] дозволяє інкапсулювати тестові дані у власний клас, що реалізує 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: коли тестові дані складні, потребують логіки генерації, або використовуються у кількох файлах — і ви хочете ізолювати цю логіку у власному класі.
У новіших версіях 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[].
Для кожного тест-методу 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
}
}
Проблема: деякі ресурси дорого ініціалізувати (з'єднання з базою даних, 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 раз після всіх тестів
Якщо один дорогий ресурс потрібен кільком тест-класам:
// 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 виконує тести паралельно між класами за замовчуванням. Тести всередині одного класу виконуються послідовно.
Тести в одному класі поділяють 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 { }
Стандартний 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
Під час налагодження складних тестів ви можете спостерігати за станом об'єктів:
| Name | Type | Value |
|---|---|---|
| ◢_output | ITestOutputHelper | Test output helper instance |
| ◢_sut | OrderService | OrderService instance |
| ◢order | Order | { Id: a1b2c3d4, Amount: 1000m, CustomerId: guid } |
| ◢result | OrderResult | { Status: Success, ProcessingMs: 45, IsSuccess: true } |
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);
}
[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() { }
}
Фільтрація при запуску:
Кастомні атрибути-скорочення для 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() { }
// Базова перевірка 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);
}
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
// Один і той самий об'єкт у пам'яті (reference equality)
Assert.Same(expectedObject, actualObject);
Assert.NotSame(object1, object2);
При складній доменній логіці варто створювати власні 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));
}
}
{
"$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):
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 тестів, що використовують ці розширення. Оцініть, наскільки читабельнішою стала тест-специфікація.
async Task, DisplayName, Skip. Публічний метод.[InlineData] (константи) → [MemberData] (статичні методи/властивості) → [ClassData] (окремий клас). TheoryData<T> — типобезпечна альтернатива.[SetUp] атрибуту.dotnet test --filter. Власні атрибути-скорочення.Наступна стаття — Moq: глибоке занурення в мокування.
Тестові Фреймворки — Навіщо вони і що всередині
Глибоке розуміння того, що являє собою тестовий фреймворк, навіщо він потрібен і що відбувається "під капотом". Порівняння xUnit, NUnit та MSTest. Огляд екосистеми інструментів тестування в .NET.
xUnit Advanced — Fixtures, Кастомізація та Розширення
Просунуті можливості xUnit.net — TestServer з WebApplicationFactory, складні Fixture сценарії, кастомні Theory data providers, xUnit extensibility points, інтеграція з Microsoft DI, конфігурація для CI/CD та діагностика проблем.