Тестування

Тестування Архітектури з NetArchTest

Хто перевірить, що розробники не зроблять посилання з Domain шару на Infrastructure? NetArchTest дозволяє писати тести на архітектурні правила — Fitness Functions, що захищають вашу кодову базу від деградації.

Тестування Архітектури з NetArchTest

Проходить час. Команда зростає. Юніт-тести пишуться охайно, integration-тести покривають критичні шляхи. Але непомітно відбувається щось інше: один розробник додав пряме звернення до DbContext прямо зі шару контролерів «тимчасово». Інший підключив NuGet-пакет з інфраструктурними залежностями прямо в Domain-проєкт «швидко виправити баг». Третій назвав клас ProductManager замість ProductService — «ну, так зрозуміліше».

Кожна з цих змін — дрібниця. Але через рік вашa Clean Architecture перетворилась на Big Ball of Mud: шари перемішані, залежності йдуть в обидві сторони, і додати новий feature без торкання 5 різних шарів неможливо. Архітектурна деградація — одна з найбільш підступних і повільних форм технічного боргу.

Традиційне рішення — code reviews та документація архітектурних рішень. Але люди помиляються, пам'ять хибує, і ніхто не читає документацію. Automated Architectural Fitness Functions — це тести, які автоматично перевіряють архітектурні правила при кожному білді. Якщо правило порушене — CI падає.

Що таке Fitness Functions?

Термін «Fitness Functions» ввели Ніл Форд, Ребека Парсонс і Патрік Куа у книзі «Building Evolutionary Architectures». Це автоматизовані перевірки, що захищають якості системи, які важливі для вашої архітектури.

Fitness functions бувають різних видів:

  • Структурні: перевіряють залежності між компонентами
  • Процесні: перевіряють дотримання workflows (всі PR проходять code review)
  • Часові: перевіряють performance характеристики (запит < 200ms)

NetArchTest реалізує структурні fitness functions: перевіряє залежності між .NET namespace-ами та assembly.

Встановлення та Основи

dotnet add package
$ dotnet add package NetArchTest.Rules
Successfully added NetArchTest.Rules to MyApp.Tests.csproj

Як NetArchTest читає код

NetArchTest аналізує скомпільовані assembly (DLL-файли), використовуючи reflection та IL-парсинг. Він не читає вихідний код — він читає готові збірки. Це означає, що тести запускаються після компіляції і отримують реальну картину залежностей.

using NetArchTest.Rules;

public class ArchitectureTests
{
    // Завантажуємо типи зі всього рішення
    private static readonly Types AllTypes = Types.InCurrentDomain();
    
    // Або з конкретних assembly:
    private static readonly Types DomainTypes = 
        Types.InAssembly(typeof(Domain.Entities.Product).Assembly);
    
    private static readonly Types InfrastructureTypes = 
        Types.InAssembly(typeof(Infrastructure.Data.AppDbContext).Assembly);
    
    private static readonly Types ApplicationTypes =
        Types.InAssembly(typeof(Application.Services.ProductService).Assembly);
}

Базова структура правила

Кожна перевірка NetArchTest складається з трьох частин:

var result = Types
    .InAssembly(assembly)           // 1. ЗВІДКИ — вибір assembly або namespace
    .That()                         // 2. ФІЛЬТР — які типи перевіряємо
    .ResideInNamespace("Domain")    //    (опис типів)
    .Should()                       // 3. УМОВА — що повинно бути вірно
    .NotHaveDependencyOn("Infrastructure") // (архітектурне правило)
    .GetResult();                   // Отримуємо результат

// Перевіряємо результат у тесті
Assert.True(result.IsSuccessful, 
    string.Join(Environment.NewLine, result.FailingTypeNames));

Архітектурні Правила для Clean Architecture

Розглянемо типову структуру Clean Architecture проєкту:

Loading diagram...
graph TD
    API["🌐 API Layer<br/>(MyApp.Api)"] --> Application
    Application["📋 Application Layer<br/>(MyApp.Application)"] --> Domain
    Infrastructure["🗄️ Infrastructure Layer<br/>(MyApp.Infrastructure)"] --> Application
    Infrastructure --> Domain
    Domain["🏛️ Domain Layer<br/>(MyApp.Domain)"]

    style API fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style Application fill:#8b5cf6,stroke:#6d28d9,color:#ffffff
    style Infrastructure fill:#f59e0b,stroke:#b45309,color:#ffffff
    style Domain fill:#10b981,stroke:#047857,color:#ffffff

Ключові правила цієї архітектури:

  1. Domain не залежить ні від кого
  2. Application залежить тільки від Domain
  3. Infrastructure залежить від Application та Domain, але не від API
  4. API може залежати від всіх (але тільки для DI-реєстрації)

Правило 1: Domain — незалежний острів

[Fact]
public void Domain_ShouldNotDependOnAnyOtherLayer()
{
    var result = Types
        .InAssembly(typeof(Domain.Entities.Product).Assembly)
        .ShouldNot()
        .HaveDependencyOnAny(
            "MyApp.Application",
            "MyApp.Infrastructure",
            "MyApp.Api")
        .GetResult();

    Assert.True(result.IsSuccessful,
        "Domain шар містить залежності на інші шари:\n" +
        string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>()));
}

Правило 2: Application — залежить лише від Domain

[Fact]
public void Application_ShouldNotDependOnInfrastructureOrApi()
{
    var result = Types
        .InAssembly(typeof(Application.Services.ProductService).Assembly)
        .ShouldNot()
        .HaveDependencyOnAny("MyApp.Infrastructure", "MyApp.Api")
        .GetResult();

    Assert.True(result.IsSuccessful,
        "Application шар залежить від Infrastructure або Api:\n" +
        string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>()));
}

Правило 3: Заборона на пряме використання DbContext поза Infrastructure

[Fact]
public void DbContext_ShouldOnlyBeUsedInInfrastructure()
{
    var result = Types
        .InCurrentDomain()
        .That()
        .DoNotResideInNamespace("MyApp.Infrastructure")
        .And()
        .DoNotResideInNamespace("MyApp.Tests") // виключаємо тести
        .ShouldNot()
        .HaveDependencyOn("Microsoft.EntityFrameworkCore.DbContext")
        .GetResult();

    Assert.True(result.IsSuccessful,
        "DbContext використовується поза Infrastructure шаром:\n" +
        string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>()));
}

Правило 4: Entities — тільки у Domain

[Fact]
public void Entities_ShouldResideInDomainNamespace()
{
    // Всі класи-нащадки BaseEntity мають бути в Domain
    var result = Types
        .InCurrentDomain()
        .That()
        .Inherit(typeof(Domain.Common.BaseEntity)) // або ваш базовий клас
        .Should()
        .ResideInNamespace("MyApp.Domain")
        .GetResult();

    Assert.True(result.IsSuccessful,
        "Entity знайдена поза Domain namespace:\n" +
        string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>()));
}

Правила Іменування

Іменування — часто недооцінений аспект архітектури. Консистентні назви допомагають орієнтуватись у коді. NetArchTest дозволяє автоматично перевіряти конвенції іменування.

Конвенції для Handlers (CQRS/MediatR)

[Fact]
public void RequestHandlers_ShouldHaveHandlerSuffix()
{
    var result = Types
        .InAssembly(typeof(Application.Commands.CreateProductCommand).Assembly)
        .That()
        .ImplementInterface(typeof(MediatR.IRequestHandler<,>))
        .Should()
        .HaveNameEndingWith("Handler")
        .GetResult();

    Assert.True(result.IsSuccessful,
        "IRequestHandler реалізації, що не закінчуються на 'Handler':\n" +
        string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>()));
}

[Fact]
public void Queries_ShouldHaveQuerySuffix()
{
    var result = Types
        .InNamespace("MyApp.Application.Queries")
        .Should()
        .HaveNameEndingWith("Query")
        .GetResult();

    Assert.True(result.IsSuccessful,
        "Класи у Queries namespace без суфіксу 'Query':\n" +
        string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>()));
}

Конвенції для Репозиторіїв та Сервісів

[Fact]
public void Services_ShouldHaveServiceSuffix()
{
    var result = Types
        .InAssembly(applicationAssembly)
        .That()
        .ImplementInterface(typeof(Application.Interfaces.IService))
        .Should()
        .HaveNameEndingWith("Service")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

[Fact]
public void Repositories_InInfrastructure_ShouldImplementInterface()
{
    // Всі класи з Repository у назві мають реалізовувати IRepository
    var result = Types
        .InAssembly(infrastructureAssembly)
        .That()
        .HaveNameEndingWith("Repository")
        .Should()
        .ImplementInterface(typeof(Domain.Interfaces.IRepository<>))
        .GetResult();

    Assert.True(result.IsSuccessful,
        "Repository без реалізації IRepository:\n" +
        string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>()));
}

Перевірка Sealed та Abstract класів

Деякі архітектурні рішення вимагають певних модифікаторів класів:

[Fact]
public void DomainEvents_ShouldBeSealed()
{
    // Domain Events не мають успадковуватись — вони leaf типи
    var result = Types
        .InAssembly(domainAssembly)
        .That()
        .ImplementInterface(typeof(Domain.Events.IDomainEvent))
        .Should()
        .BeSealed()
        .GetResult();

    Assert.True(result.IsSuccessful,
        "DomainEvent не є sealed:\n" +
        string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>()));
}

[Fact]
public void ValueObjects_InDomain_ShouldBeRecords()
{
    // Value Objects краще реалізовувати як record (незмінні)
    var result = Types
        .InAssembly(domainAssembly)
        .That()
        .Inherit(typeof(Domain.Common.ValueObject))
        .Should()
        // Records у C# — це sealed класи з IsRecord=true в reflection
        .BeSealed()
        .GetResult();

    Assert.True(result.IsSuccessful);
}

Перевірка Мінімальних API Ендпоінтів

Для Minimal API можна перевіряти, що всі ендпоінти реалізують певний контракт:

[Fact]
public void EndpointGroups_ShouldImplementIEndpointGroup()
{
    // Всі класи-групи ендпоінтів мають реалізовувати інтерфейс
    var result = Types
        .InAssembly(apiAssembly)
        .That()
        .HaveNameEndingWith("Endpoints")
        .Should()
        .ImplementInterface(typeof(Api.Interfaces.IEndpointGroup))
        .GetResult();

    Assert.True(result.IsSuccessful,
        "EndpointGroup без IEndpointGroup інтерфейсу:\n" +
        string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>()));
}

[Fact]
public void Endpoints_ShouldNotDirectlyDependOnDbContext()
{
    // Ендпоінти не мають безпосередньо звертатись до БД
    var result = Types
        .InAssembly(apiAssembly)
        .That()
        .HaveNameEndingWith("Endpoints")
        .ShouldNot()
        .HaveDependencyOn("Microsoft.EntityFrameworkCore")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

Складніші Перевірки з Predicates

Для нестандартних правил NetArchTest надає Predicate API:

[Fact]
public void PublicClasses_InDomain_ShouldNotHavePublicSetters()
{
    // Value Objects і Entities не повинні мати публічних setters (immutability)
    var failingTypes = Types
        .InAssembly(domainAssembly)
        .That()
        .ArePublic()
        .And()
        .AreClasses()
        .GetTypes()
        .Where(type => type.GetProperties()
            .Any(p => p.SetMethod?.IsPublic == true))
        .ToList();

    Assert.Empty(failingTypes);
}

[Fact]
public void Controllers_ShouldNotExist()
{
    // У Minimal API не має бути класичних MVC-контролерів
    var result = Types
        .InAssembly(apiAssembly)
        .That()
        .Inherit(typeof(Microsoft.AspNetCore.Mvc.ControllerBase))
        .ShouldNot()
        .Exist()
        .GetResult();

    Assert.True(result.IsSuccessful, 
        "Знайдені MVC Controller у Minimal API проєкті. Використовуйте Minimal API.");
}

Повний Набір Архітектурних Тестів

Зберіть всі правила у один виразний тестовий клас:

public class ArchitectureFitnessFunctions
{
    // Assembly-and references — завантаження раз для всього класу
    private static readonly Assembly DomainAssembly = 
        typeof(Domain.Entities.Product).Assembly;
    private static readonly Assembly ApplicationAssembly = 
        typeof(Application.Commands.CreateProductCommand).Assembly;
    private static readonly Assembly InfrastructureAssembly = 
        typeof(Infrastructure.Data.AppDbContext).Assembly;
    private static readonly Assembly ApiAssembly = 
        typeof(Api.Program).Assembly;

    // ════════════════════════════════════════════
    // Перевірки ЗАЛЕЖНОСТЕЙ між шарами
    // ════════════════════════════════════════════

    [Fact]
    public void Domain_ShouldNotHaveOutwardDependencies()
    {
        var result = Types.InAssembly(DomainAssembly)
            .ShouldNot()
            .HaveDependencyOnAny(
                "MyApp.Application", 
                "MyApp.Infrastructure", 
                "MyApp.Api",
                "Microsoft.EntityFrameworkCore",
                "Microsoft.Extensions.DependencyInjection")
            .GetResult();

        Assert.True(result.IsSuccessful, FormatFailure("Domain layer dependencies", result));
    }

    [Fact]
    public void Application_ShouldNotDependOnInfrastructureOrPresentation()
    {
        var result = Types.InAssembly(ApplicationAssembly)
            .ShouldNot()
            .HaveDependencyOnAny("MyApp.Infrastructure", "MyApp.Api")
            .GetResult();

        Assert.True(result.IsSuccessful, FormatFailure("Application layer dependencies", result));
    }

    [Fact]
    public void Infrastructure_ShouldNotDependOnApi()
    {
        var result = Types.InAssembly(InfrastructureAssembly)
            .ShouldNot()
            .HaveDependencyOn("MyApp.Api")
            .GetResult();

        Assert.True(result.IsSuccessful, FormatFailure("Infrastructure → Api dependency", result));
    }

    // ════════════════════════════════════════════
    // Конвенції ІМЕНУВАННЯ
    // ════════════════════════════════════════════

    [Fact]
    public void RequestHandlers_ShouldBeNamedXxxHandler()
    {
        var result = Types.InAssembly(ApplicationAssembly)
            .That().ImplementInterface(typeof(MediatR.IRequestHandler<,>))
            .Should().HaveNameEndingWith("Handler")
            .GetResult();

        Assert.True(result.IsSuccessful, FormatFailure("Handler naming convention", result));
    }

    [Fact]
    public void Repositories_ShouldBeNamedXxxRepository()
    {
        var result = Types.InAssembly(InfrastructureAssembly)
            .That().ImplementInterface(typeof(Domain.Interfaces.IRepository<>))
            .Should().HaveNameEndingWith("Repository")
            .GetResult();

        Assert.True(result.IsSuccessful, FormatFailure("Repository naming convention", result));
    }

    // ════════════════════════════════════════════
    // Структурні ОБМЕЖЕННЯ
    // ════════════════════════════════════════════

    [Fact]
    public void DomainEvents_ShouldBeSealed()
    {
        var result = Types.InAssembly(DomainAssembly)
            .That().ImplementInterface(typeof(Domain.Events.IDomainEvent))
            .Should().BeSealed()
            .GetResult();

        Assert.True(result.IsSuccessful, FormatFailure("Domain events sealing", result));
    }

    [Fact]
    public void DomainEntities_ShouldNotHavePublicParameterlessConstructors()
    {
        // Entity має створюватись через фабричний метод або конструктор з параметрами
        var failingTypes = Types.InAssembly(DomainAssembly)
            .That().Inherit(typeof(Domain.Common.BaseEntity))
            .GetTypes()
            .Where(t => t.GetConstructors()
                .Any(c => c.IsPublic && c.GetParameters().Length == 0))
            .ToList();

        Assert.Empty(failingTypes);
    }

    [Fact]
    public void Controllers_ShouldNotExistInMinimalApiProject()
    {
        var result = Types.InAssembly(ApiAssembly)
            .That().Inherit(typeof(Microsoft.AspNetCore.Mvc.ControllerBase))
            .ShouldNot().Exist()
            .GetResult();

        Assert.True(result.IsSuccessful, 
            "Знайдені MVC Controller. Проєкт використовує Minimal API!");
    }

    // ════════════════════════════════════════════
    // Заборонені ЗАЛЕЖНОСТІ
    // ════════════════════════════════════════════

    [Fact]
    public void NoTypeOutsideInfrastructure_ShouldDependOnEntityFramework()
    {
        var result = Types.InCurrentDomain()
            .That()
            .DoNotResideInNamespace("MyApp.Infrastructure")
            .And()
            .DoNotResideInNamespace("MyApp.Tests")
            .ShouldNot()
            .HaveDependencyOn("Microsoft.EntityFrameworkCore")
            .GetResult();

        Assert.True(result.IsSuccessful, FormatFailure("EF Core outside Infrastructure", result));
    }

    // ════════════════════════════════════════════
    // Допоміжний метод для форматування помилок
    // ════════════════════════════════════════════

    private static string FormatFailure(string ruleName, TestResult result)
    {
        var failingTypes = result.FailingTypeNames ?? Array.Empty<string>();
        return $"Порушення правила '{ruleName}':\n  " +
               string.Join("\n  ", failingTypes);
    }
}

Інтеграція у CI/CD Pipeline

Архітектурні тести — це звичайні xUnit-тести. Вони запускаються командою dotnet test разом з усіма іншими тестами. Ніякої додаткової конфігурації CI не потрібно.

Але є одна важлива деталь: архітектурні тести можна виокремити в окремий проєкт для кращої організації:

# .github/workflows/ci.yml
- name: Run all tests (including architecture)
  run: dotnet test --filter "Category!=Integration" --logger "junit" 

# Або запускати архітектурні тести окремо (швидко, без docker)
- name: Architecture Tests
  run: dotnet test tests/MyApp.ArchitectureTests/ --logger "junit"
Швидкість: Архітектурні тести зазвичай виконуються за 1-3 секунди, тому що вони лише аналізують assembly через reflection — не піднімають бази даних, не роблять HTTP-запитів. Їх можна запускати постійно навіть у slow environments.

Поступове Впровадження

Якщо ви додаєте архітектурні тести у вже існуючий проєкт, де є порушення — не намагайтесь виправити все одразу. Стратегія поступового впровадження:

Крок 1: Аудит поточного стану

Запустіть перевірки без Assert — просто отримайте список порушень:

[Fact]
public void AuditReport_CurrentArchitectureViolations()
{
    var result = Types.InAssembly(DomainAssembly)
        .ShouldNot()
        .HaveDependencyOnAny("MyApp.Infrastructure")
        .GetResult();
    
    // Логуємо, але НЕ падаємо — інвентаризація порушень
    _output.WriteLine($"Порушень знайдено: {result.FailingTypeNames?.Count() ?? 0}");
    foreach (var type in result.FailingTypeNames ?? Array.Empty<string>())
    {
        _output.WriteLine($"  - {type}");
    }
    
    // Тест завжди проходить — він призначений для звіту
    Assert.True(true); 
}

Крок 2: Список виключень (Allowlist)

Зафіксуйте поточний список відомих порушень і перевіряйте, що нові не додаються:

private static readonly HashSet<string> KnownViolations = new()
{
    "MyApp.Domain.Services.LegacyOrderService", // TODO: виправити в Q2
    "MyApp.Domain.Helpers.DatabaseHelper"       // TODO: перенести в Infrastructure
};

[Fact]
public void Domain_NoNewDependenciesOnInfrastructure()
{
    var result = Types.InAssembly(DomainAssembly)
        .ShouldNot()
        .HaveDependencyOn("MyApp.Infrastructure")
        .GetResult();
    
    var newViolations = (result.FailingTypeNames ?? Array.Empty<string>())
        .Where(t => !KnownViolations.Contains(t))
        .ToList();
    
    Assert.Empty(newViolations); // Нові порушення заборонені
}

Крок 3: Поступове очищення

Виправляйте порушення по одному, видаляючи їх зі списку KnownViolations.

Крок 4: Строгий режим

Коли список empty — переходьте на строгий тест без allowlist.

Архітектурні Тести + Living Documentation

Combine NetArchTest з документацією: ваші тести є документацією архітектури. Вони перевіряються автоматично і завжди актуальні — на відміну від Wiki-сторінки, яка може застаріти.

Корисна практика — генерувати звіт архітектурних правил у форматі Markdown як частину CI:

// Власний xUnit reporter, що генерує Architecture Decision Record
[Fact]
public void GenerateArchitectureReport()
{
    var rules = new[]
    {
        ("Domain незалежний", DomainIndependenceCheck()),
        ("Application не знає Infrastructure", ApplicationCheck()),
        ("Handlers мають суфікс Handler", HandlerNamingCheck()),
        // ... інші правила
    };
    
    var report = new StringBuilder();
    report.AppendLine("# Architecture Fitness Functions Report");
    report.AppendLine($"Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC");
    report.AppendLine();
    
    foreach (var (name, result) in rules)
    {
        var status = result.IsSuccessful ? "✅" : "❌";
        report.AppendLine($"## {status} {name}");
        if (!result.IsSuccessful)
        {
            foreach (var type in result.FailingTypeNames ?? Array.Empty<string>())
                report.AppendLine($"- `{type}`");
        }
    }
    
    File.WriteAllText("architecture-report.md", report.ToString());
    
    // Після генерації звіту — перевіряємо, що всі правила виконані
    Assert.All(rules, r => Assert.True(r.Item2.IsSuccessful, r.Item1));
}

Практика


Підсумок Курсу

Ми пройшли довгий шлях. Від перших принципів — що таке тест, пірамідa тестування, школи TDD — до практики ізольованого тестування Minimal API з реальною базою даних, автоматизованих колекцій у Postman, ізоляції HTTP-клієнтів через WireMock, та нарешті — архітектурних fitness functions, що не дають вашому коду деградувати.

📐 Архітектура тестів

Піраміда тестування, школи TDD/BDD, стратегія «що тестувати» — фундамент, на якому будується все інше.

🧪 xUnit та Мокування

Fluent тести з xUnit, ізоляція залежностей через Moq/NSubstitute, тестування бізнес-логіки без зовнішніх залежностей.

🔗 Інтеграційне тестування

WebApplicationFactory, Testcontainers з реальною PostgreSQL, тестування авторизації та middleware.

🌐 HTTP та Зовнішні сервіси

Postman Professional, MockHttpMessageHandler, WireMock.Net для реального HTTP-стеку, тестування Polly resilience.

🎨 Якість тестового коду

Test Smells, Object Mother, Test Data Builder, Bogus — інструменти для підтримки тестового коду в чистоті.

⚡ Просунуті інструменти

TimeProvider для тестування часу, Verify для snapshot testing, FsCheck для property-based тестів.

Тестування — це не данина процесу. Це інвестиція, яка окуповується сторицею: впевненість при рефакторингу, швидкий feedback при зміні коду, живa документація поведінки системи. Код без тестів — завжди код у борг.

Вивчайте, практикуйте, і нехай ваші CI-пайплайни завжди залишаються зеленими. 🟢