Тестування

Тестування Продуктивності: BenchmarkDotNet, NBomber та k6

Ваш API витримає 1000 одночасних запитів? BenchmarkDotNet вимірює продуктивність на мікрорівні, NBomber симулює навантаження зсередини .NET, k6 — повноцінне load testing з CLI. Від мікробенчмарків до стрес-тестів.

Тестування Продуктивності: BenchmarkDotNet, NBomber та k6

Ви написали unit-тести. Вони всі зелені. Але чи означає це, що ваш API готовий до production? Не обов'язково. Є ціла категорія проблем, яку функціональні тести не виявляють: повільні SQL-запити під навантаженням, memory leaks при тривалій роботі, деградація швидкодії при зростанні обсягу даних, неефективне використання CPU на критичних шляхах.

Ось реальний сценарій: ендпоінт GET /api/products у тестах повертає 10 ms. На staging із 50 000 продуктів у базі — 3 500 ms. При 100 одночасних користувачах — таймаут. Жоден із ваших xUnit-тестів це не виявив, бо вони завжди запускаються з порожньою базою.

Тестування продуктивності — це окрема дисципліна, і вона поділяється на три рівні:

  1. Мікробенчмарки (BenchmarkDotNet) — вимірювання швидкості конкретних методів і алгоритмів
  2. Load Testing in-process (NBomber) — симуляція навантаження зсередини .NET-процесу
  3. Load Testing external (k6, Artillery, Gatling) — повноцінне навантаження через справжні HTTP-запити

Рівень 1: BenchmarkDotNet — Мікробенчмарки

Навіщо потрібен BenchmarkDotNet

Вимірювання продуктивності у .NET вручну — ненадійна справа. Stopwatch дає приблизні результати, на які впливають: JIT-компіляція (перший запуск завжди повільний), garbage collection (може спрацювати під час вимірювання), CPU branching, кешування даних, scheduler OS. BenchmarkDotNet враховує всі ці фактори: прогріває JIT, виконує сотні ітерацій, відкидає аномалії, рахує середнє, медіану і стандартне відхилення.

dotnet add package
$ dotnet add package BenchmarkDotNet
Successfully added BenchmarkDotNet to MyApp.Benchmarks.csproj

Перший бенчмарк

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

// Клас з бенчмарками
[MemoryDiagnoser] // Показує алокації пам'яті
[SimpleJob(RuntimeMoniker.Net80)]
public class ProductSearchBenchmarks
{
    private List<Product> _products = null!;
    private HashSet<int> _productIds = null!;

    [Params(100, 1_000, 10_000)]
    public int ProductCount; // BenchmarkDotNet запустить тест для кожного значення

    [GlobalSetup]
    public void Setup()
    {
        // Виконується один раз перед всіма ітераціями
        var faker = new Faker<Product>()
            .RuleFor(p => p.Id, f => f.IndexFaker + 1)
            .RuleFor(p => p.Name, f => f.Commerce.ProductName())
            .RuleFor(p => p.Price, f => f.Finance.Amount(1, 1000));

        _products = faker.Generate(ProductCount);
        _productIds = _products.Select(p => p.Id).ToHashSet();
    }

    [Benchmark(Baseline = true)]
    public Product? SearchWithLinq()
    {
        var targetId = ProductCount / 2;
        return _products.FirstOrDefault(p => p.Id == targetId);
    }

    [Benchmark]
    public Product? SearchWithDictionary()
    {
        // Припускаємо, що словник вже побудований
        var targetId = ProductCount / 2;
        var dict = _products.ToDictionary(p => p.Id);
        return dict.TryGetValue(targetId, out var p) ? p : null;
    }

    [Benchmark]
    public bool ContainsWithHashSet()
    {
        return _productIds.Contains(ProductCount / 2);
    }
}

// Program.cs для бенчмарків (окремий проєкт!)
BenchmarkRunner.Run<ProductSearchBenchmarks>();

Запуск:

dotnet run -c Release
$ dotnet run -c Release
// BenchmarkDotNet v0.14.1
// Running product...
| Method | Mean | Error | StdDev | Median |
|------- |------------:|----------:|----------:|------------:|
| FindLinear | 124.50 us | 2.310 us | 4.082 us | 123.00 us |
| FindBinary | 8.21 us | 0.150 us | 0.265 us | 8.19 us |
Benchmark complete

Читаємо результати BenchmarkDotNet

BenchmarkDotNet Results
BenchmarkDotNet v0.13.10, Windows 11
Intel Core i7-12700K, 1 CPU, 20 logical cores
.NET SDK 8.0.200
| Method | ProductCount | Mean | Error | Ratio | Allocated |
|----------------------|-------------|-------------|----------|-------|-----------|
| SearchWithLinq | 100 | 235.4 ns | 2.3 ns | 1.00 | - |
| SearchWithDictionary | 100 | 4,821 ns | 38.1 ns | 20.5x | 4.8 KB |
| ContainsWithHashSet | 100 | 12.1 ns | 0.1 ns | 0.05x | - |
| SearchWithLinq | 10000 | 24,381 ns | 187 ns | 1.00 | - |
| SearchWithDictionary | 10000 | 389,421 ns| 2.8 µs | 15.9x | 468 KB |
| ContainsWithHashSet | 10000 | 12.3 ns | 0.2 ns | 0.00x | - |

Висновок з результатів: при 10 000 елементах HashSet (12 ns) у 2000 разів швидший за LINQ (24 µs) і не алокує пам'яті. ToDictionary — найгірший варіант: будує нову структуру при кожному виклику.

Корисні атрибути BenchmarkDotNet

[MemoryDiagnoser]          // Показати алокації (Gen0/Gen1/Gen2 GC, Bytes allocated)
[ThreadingDiagnoser]       // Статистика потоків
[HardwareCounters(...)]    // CPU counters (cache misses, branch mispredictions)
[SimpleJob(warmupCount: 5, iterationCount: 20)] // Налаштування кількості запусків
[Orderer(SummaryOrderPolicy.FastestToSlowest)]  // Сортування результатів

[Benchmark]
[Arguments(100)]           // Окремий аргумент для одного методу
[Arguments(1_000)]
public void MyMethod(int count) { }

Бенчмарки Serialization: System.Text.Json vs Newtonsoft

Типовий практичний кейс — порівняння продуктивності серіалізаторів:

[MemoryDiagnoser]
public class SerializationBenchmarks
{
    private Product _product = null!;
    private string _json = null!;

    [GlobalSetup]
    public void Setup()
    {
        _product = new Product
        {
            Id = 42, Name = "Widget Pro", Price = 99.99m,
            CreatedAt = DateTime.UtcNow, Tags = ["electronics", "premium"]
        };
        _json = System.Text.Json.JsonSerializer.Serialize(_product);
    }

    [Benchmark(Baseline = true)]
    public string SystemTextJson_Serialize()
        => System.Text.Json.JsonSerializer.Serialize(_product);

    [Benchmark]
    public string Newtonsoft_Serialize()
        => Newtonsoft.Json.JsonConvert.SerializeObject(_product);

    [Benchmark]
    public Product? SystemTextJson_Deserialize()
        => System.Text.Json.JsonSerializer.Deserialize<Product>(_json);

    [Benchmark]
    public Product? Newtonsoft_Deserialize()
        => Newtonsoft.Json.JsonConvert.DeserializeObject<Product>(_json);
}

Бенчмарки для Minimal API Handlers

Окрема цінна практика — бенчмарк самого handler-а без мережевого стека:

[MemoryDiagnoser]
public class ProductEndpointBenchmarks
{
    private ServiceProvider _serviceProvider = null!;

    [GlobalSetup]
    public void Setup()
    {
        var services = new ServiceCollection();
        services.AddDbContext<AppDbContext>(opt =>
            opt.UseInMemoryDatabase("BenchmarkDb"));
        services.AddScoped<IProductRepository, ProductRepository>();
        services.AddScoped<ProductService>();

        _serviceProvider = services.BuildServiceProvider();

        // Заповнюємо InMemory базу тестовими даними
        using var scope = _serviceProvider.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        db.Products.AddRange(Enumerable.Range(1, 1000)
            .Select(i => new Product { Id = i, Name = $"Product {i}", Price = i * 1.5m }));
        db.SaveChanges();
    }

    [Benchmark]
    public async Task<IResult> GetAllProducts()
    {
        using var scope = _serviceProvider.CreateScope();
        var service = scope.ServiceProvider.GetRequiredService<ProductService>();
        var products = await service.GetAllAsync();
        return TypedResults.Ok(products);
    }

    [Params(1, 100, 500, 1000)]
    public int ProductId;

    [Benchmark]
    public async Task<IResult> GetProductById()
    {
        using var scope = _serviceProvider.CreateScope();
        var service = scope.ServiceProvider.GetRequiredService<ProductService>();
        var product = await service.GetByIdAsync(ProductId);
        return product is null ? TypedResults.NotFound() : TypedResults.Ok(product);
    }

    [GlobalCleanup]
    public void Cleanup() => _serviceProvider.Dispose();
}

Рівень 2: NBomber — Load Testing у .NET

Що таке NBomber

NBomber — .NET-бібліотека для написання load tests на C#. На відміну від зовнішніх інструментів (k6, JMeter), тести пишуться на рідній мові проєкту, можна реюзати весь .NET-стек (HttpClientFactory, авторизацію, Bogus для генерації даних) і запускати тести командою dotnet test.

dotnet add package NBomber
$ dotnet add package NBomber NBomber.Http
Successfully added NBomber to MyApp.LoadTests.csproj
Successfully added NBomber.Http to MyApp.LoadTests.csproj

Концепції NBomber

  • Scenario — один тестовий сценарій (наприклад, «перегляд каталогу»). Містить логіку одного «user journey».
  • Step — один крок у сценарії (один HTTP-запит або одна операція).
  • LoadSimulation — стратегія навантаження: скільки користувачів, як довго, яка інтенсивність.

Перший NBomber тест

using NBomber.CSharp;
using NBomber.Http.CSharp;

public class LoadTests
{
    [Fact]
    public void GetProducts_UnderLoad_MeetsPerformanceSLA()
    {
        // Arrange: HTTP-клієнт для тестів
        var httpClient = new HttpClient { BaseAddress = new Uri("http://localhost:5000") };

        // Сценарій: отримання списку продуктів
        var scenario = Scenario.Create("get_products", async context =>
            {
                var request = Http.CreateRequest("GET", "/api/products")
                    .WithHeader("Accept", "application/json");

                var response = await Http.Send(httpClient, request);
                return response;
            })
            // Стратегія навантаження: 50 одночасних користувачів протягом 30 секунд
            .WithLoadSimulations(
                Simulation.Inject(rate: 50, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromSeconds(30))
            );

        // Act: запускаємо тест
        var stats = NBomberRunner
            .RegisterScenarios(scenario)
            .WithReportFolder("reports")
            .WithReportFormats(ReportFormat.Html, ReportFormat.Csv)
            .Run();

        // Assert: перевіряємо SLA (Service Level Agreement)
        var scenarioStats = stats.ScenarioStats.First();

        // P50 < 100ms (медіана менша за 100ms)
        Assert.True(scenarioStats.Ok.Latency.Percent50 < 100,
            $"P50 latency: {scenarioStats.Ok.Latency.Percent50}ms (expected < 100ms)");

        // P95 < 500ms (95% запитів швидші за 500ms)
        Assert.True(scenarioStats.Ok.Latency.Percent95 < 500,
            $"P95 latency: {scenarioStats.Ok.Latency.Percent95}ms (expected < 500ms)");

        // Error rate < 1%
        var errorRate = scenarioStats.Fail.Request.Count * 100.0 /
                        (scenarioStats.Ok.Request.Count + scenarioStats.Fail.Request.Count);
        Assert.True(errorRate < 1.0,
            $"Error rate: {errorRate:F2}% (expected < 1%)");

        // Мінімальний throughput: 100 RPS
        Assert.True(scenarioStats.Ok.Request.RPS > 100,
            $"RPS: {scenarioStats.Ok.Request.RPS:F1} (expected > 100)");
    }
}

Стратегії Навантаження (Load Simulations)

NBomber має кілька моделей навантаження:

// 1. Inject (Open Model) — постійний потік нових запитів
// Незалежно від часу відповіді, кожну секунду приходить N нових запитів
// Симулює реальний трафік (users не чекають відповіді)
Simulation.Inject(rate: 100, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromMinutes(5))

// 2. KeepConstant (Closed Model) — постійна кількість паралельних користувачів
// Кожен "user" чекає відповіді перед новим запитом
// Симулює пул воркерів або connection pool
Simulation.KeepConstant(copies: 50, during: TimeSpan.FromMinutes(2))

// 3. RampingInject — поступове збільшення до target rate
// Плавне зростання навантаження
Simulation.RampingInject(rate: 200, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromMinutes(3))

// 4. RampingConstant — поступове збільшення паралельних користувачів
Simulation.RampingConstant(copies: 100, during: TimeSpan.FromMinutes(3))

// 5. InjectRandom — варіативне навантаження (симуляція пікового трафіку)
Simulation.InjectRandom(
    minRate: 10, maxRate: 200,
    interval: TimeSpan.FromSeconds(1),
    during: TimeSpan.FromMinutes(5))

Складний Сценарій: Авторизований CRUD Workflow

public class AuthenticatedLoadTests
{
    private readonly string _baseUrl = "http://localhost:5000";

    [Fact]
    public void ProductCrudWorkflow_UnderLoad_MeetsSLA()
    {
        var httpFactory = ClientFactory.Create(
            name: "api_client",
            clientCount: 10, // пул з 10 клієнтів
            initClient: (number, context) =>
            {
                var client = new HttpClient { BaseAddress = new Uri(_baseUrl) };
                return Task.FromResult(client);
            });

        var scenario = Scenario.Create("product_crud", async context =>
        {
            var client = context.Data.GetClient<HttpClient>(httpFactory);

            // Крок 1: Авторизація
            var loginReq = Http.CreateRequest("POST", "/api/auth/login")
                .WithJsonBody(new { username = "loadtest@example.com", password = "LoadTest_123" });
            var loginResp = await Http.Send(client, loginReq, context);

            if (loginResp.StatusCode != HttpStatusCode.OK)
                return Response.Fail("Login failed");

            var token = (await loginResp.Content.ReadFromJsonAsync<AuthResponse>())?.Token;

            // Крок 2: Отримання списку
            var listReq = Http.CreateRequest("GET", "/api/products?page=1&pageSize=20")
                .WithHeader("Authorization", $"Bearer {token}");
            var listResp = await Http.Send(client, listReq, context);

            if (!listResp.IsSuccessStatusCode)
                return Response.Fail("Get products failed");

            // Крок 3: Створення продукту
            var faker = new Faker();
            var createReq = Http.CreateRequest("POST", "/api/products")
                .WithHeader("Authorization", $"Bearer {token}")
                .WithJsonBody(new
                {
                    name = faker.Commerce.ProductName(),
                    price = faker.Finance.Amount(1, 999),
                    categoryId = faker.Random.Int(1, 5)
                });
            var createResp = await Http.Send(client, createReq, context);

            if (createResp.StatusCode != HttpStatusCode.Created)
                return Response.Fail($"Create failed: {createResp.StatusCode}");

            var created = await createResp.Content.ReadFromJsonAsync<ProductDto>();

            // Крок 4: Видалення створеного
            var deleteReq = Http.CreateRequest("DELETE", $"/api/products/{created!.Id}")
                .WithHeader("Authorization", $"Bearer {token}");
            await Http.Send(client, deleteReq, context);

            return Response.Ok();
        })
        .WithClientFactory(httpFactory)
        .WithLoadSimulations(
            // Поступово збільшуємо до 30 concurrent users
            Simulation.RampingConstant(copies: 30, during: TimeSpan.FromMinutes(2)),
            // Тримаємо 30 users протягом 5 хвилин
            Simulation.KeepConstant(copies: 30, during: TimeSpan.FromMinutes(5))
        );

        var stats = NBomberRunner
            .RegisterScenarios(scenario)
            .WithReportFolder("load-reports")
            .Run();

        var s = stats.ScenarioStats.First();
        Assert.True(s.Ok.Latency.Percent95 < 2000, $"P95 > 2s: {s.Ok.Latency.Percent95}ms");
        Assert.True(s.Ok.Request.RPS > 5, "Throughput too low");
    }
}

Читання NBomber Звіту

NBomber генерує HTML-звіт з детальними метриками:

NBomber Report
Scenario: get_products | duration: 30s | ok: 12,847 | failed: 3
━━━ Load Simulation ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
inject | rate: 50/s | interval: 1s | during: 30s
━━━ OK Stats ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
count: 12,847 | RPS: 428.2
━━━ Latency (milliseconds) ━━━━━━━━━━━━━━━━━━━━━━━
p50: 18.3 ms p75: 32.1 ms
p95: 89.4 ms p99: 241.7 ms
min: 1.2 ms max: 891.3 ms
━━━ FAIL Stats ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
count: 3 | RPS: 0.1 | error rate: 0.02%

Assertions на Метрики з NBomber

// Допоміжний метод для читання результатів:
private static void AssertSLA(ScenarioStats stats, PerformanceSLA sla)
{
    var latency = stats.Ok.Latency;
    var fails = stats.Fail.Request.Count;
    var total = stats.Ok.Request.Count + fails;

    var failures = new List<string>();

    if (latency.Percent50 > sla.P50MaxMs)
        failures.Add($"P50: {latency.Percent50}ms > {sla.P50MaxMs}ms");

    if (latency.Percent95 > sla.P95MaxMs)
        failures.Add($"P95: {latency.Percent95}ms > {sla.P95MaxMs}ms");

    if (latency.Percent99 > sla.P99MaxMs)
        failures.Add($"P99: {latency.Percent99}ms > {sla.P99MaxMs}ms");

    if (total > 0 && (fails * 100.0 / total) > sla.MaxErrorRatePercent)
        failures.Add($"Error rate: {fails * 100.0 / total:F2}% > {sla.MaxErrorRatePercent}%");

    if (stats.Ok.Request.RPS < sla.MinRPS)
        failures.Add($"RPS: {stats.Ok.Request.RPS:F1} < {sla.MinRPS}");

    Assert.True(!failures.Any(), "SLA violations:\n" + string.Join("\n", failures));
}

public record PerformanceSLA(
    double P50MaxMs = 100,
    double P95MaxMs = 500,
    double P99MaxMs = 2000,
    double MaxErrorRatePercent = 1.0,
    double MinRPS = 50);

Рівень 3: k6 — Зовнішнє Load Testing

Чому зовнішній інструмент

NBomber чудовий для .NET-стеку, але є ситуації, коли хочеться повного ізолювання: тест як окремий процес, незалежний від мови і платформи, з власною моделлю конкурентності. k6 — це Go-based інструмент від Grafana Labs, що виконує JavaScript-скрипти навантаження. Він надзвичайно ефективний: один процес k6 може генерувати десятки тисяч запитів на секунду.

Встановлення k6

choco install k6 (Windows)
$ choco install k6
k6 v0.54.0 installed successfully
brew install k6 (macOS)
$ brew install k6
k6 v0.54.0 installed successfully
docker pull grafana/k6
$ docker pull grafana/k6
Status: Downloaded newer image for grafana/k6:latest

Базовий k6 Скрипт

// tests/performance/get-products.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';

// Кастомні метрики
const errorRate = new Rate('error_rate');
const getProductsDuration = new Trend('get_products_duration', true); // true = мілісекунди

// Конфігурація навантаження
export const options = {
    stages: [
        { duration: '1m', target: 50 },   // Warm up: рампинг до 50 users
        { duration: '3m', target: 50 },   // Основне навантаження: 50 users 3 хвилини
        { duration: '1m', target: 100 },  // Пік: рампинг до 100 users
        { duration: '2m', target: 100 },  // Стрес: 100 users 2 хвилини
        { duration: '1m', target: 0 },    // Cool down
    ],
    
    // Threshold (SLA) — тест провалюється якщо порушені
    thresholds: {
        'http_req_duration': ['p(50)<100', 'p(95)<500', 'p(99)<2000'],
        'http_req_failed': ['rate<0.01'],   // < 1% помилок
        'error_rate': ['rate<0.01'],
        'http_reqs': ['rate>100'],          // > 100 RPS
    },
};

const BASE_URL = __ENV.BASE_URL || 'http://localhost:5000';

// Функція виконується для кожного "virtual user" кожну ітерацію
export default function () {
    const response = http.get(`${BASE_URL}/api/products?page=1&pageSize=20`, {
        headers: { 'Accept': 'application/json' },
        timeout: '5s',
    });

    // Перевірки
    const ok = check(response, {
        'status is 200': (r) => r.status === 200,
        'response time < 500ms': (r) => r.timings.duration < 500,
        'has data array': (r) => {
            try {
                const body = r.json();
                return Array.isArray(body) || (body && Array.isArray(body.data));
            } catch {
                return false;
            }
        },
    });

    // Записуємо кастомні метрики
    errorRate.add(!ok);
    getProductsDuration.add(response.timings.duration);

    sleep(0.5); // Пауза між запитами (симуляція "think time")
}

k6 Скрипт: Авторизований Workflow

// tests/performance/authenticated-workflow.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { SharedArray } from 'k6/data';

// SharedArray — завантажується один раз, доступна всім VUs
const users = new SharedArray('test users', function () {
    return JSON.parse(open('./test-users.json')); // масив {email, password}
});

export const options = {
    stages: [
        { duration: '30s', target: 20 },
        { duration: '2m', target: 20 },
        { duration: '30s', target: 0 },
    ],
    thresholds: {
        'http_req_duration{group:::Login}': ['p(95)<300'],
        'http_req_duration{group:::Get Products}': ['p(95)<200'],
        'http_req_duration{group:::Create Product}': ['p(95)<500'],
    },
};

export default function () {
    // Кожен VU бере свого юзера з пулу
    const user = users[__VU % users.length];
    let token = '';

    group('Login', function () {
        const resp = http.post(`${__ENV.BASE_URL}/api/auth/login`,
            JSON.stringify({ username: user.email, password: user.password }),
            { headers: { 'Content-Type': 'application/json' } });

        check(resp, {
            'login success': (r) => r.status === 200,
            'has token': (r) => r.json('accessToken') !== undefined,
        });

        token = resp.json('accessToken');
    });

    if (!token) return; // Якщо логін не вдався — пропускаємо решту

    const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };

    group('Get Products', function () {
        const resp = http.get(`${__ENV.BASE_URL}/api/products`, { headers });
        check(resp, { 'products returned': (r) => r.status === 200 });
        sleep(0.5);
    });

    group('Create Product', function () {
        const productName = `Load Test Product ${Math.random().toString(36).slice(2)}`;
        const resp = http.post(`${__ENV.BASE_URL}/api/products`,
            JSON.stringify({ name: productName, price: 99.99, categoryId: 1 }),
            { headers });

        check(resp, { 'product created': (r) => r.status === 201 });

        if (resp.status === 201) {
            const productId = resp.json('id');
            // Видаляємо після перевірки
            http.del(`${__ENV.BASE_URL}/api/products/${productId}`, null, { headers });
        }
    });

    sleep(1);
}

Запуск k6

k6 run
$ k6 run --env BASE_URL=http://localhost:5000 authenticated-workflow.js
/\ Grafana /‾‾/
/\ / \ | (‾) |
/ \/ \ | Ξ | |
/ \ | (‾) |
execution: local | script: authenticated-workflow.js
output: -
scenarios: (100.00%) 1 scenario, 20 max VUs
running (3m00.0s), 00/20 VUs, 1243 complete iterations
login success ✓ 1243 / ✗ 0
has token ✓ 1243 / ✗ 0
products returned ✓ 1243 / ✗ 0
product created ✓ 1230 / ✗ 13
http_req_duration............: avg=87ms p(50)=71ms p(95)=218ms p(99)=489ms
http_req_failed..............: 0.34% ✓ 13 ✗ 3720
http_reqs....................: 3733 | rps=20.7/s
WARN [0]: 13 checks failed: "product created" — 13 instances (429 Too Many Requests)
✓ all thresholds passed

k6 у GitHub Actions CI

# .github/workflows/performance.yml
name: Performance Tests

on:
  schedule:
    - cron: '0 3 * * *'    # Щоночі о 3:00 UTC
  workflow_dispatch:         # Або ручний запуск

jobs:
  performance:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup k6
        uses: grafana/setup-k6-action@v1
      
      - name: Start API
        run: |
          docker compose up -d
          sleep 10  # Чекаємо на старт
      
      - name: Run k6 Smoke Test
        run: |
          k6 run \
            --env BASE_URL=http://localhost:5000 \
            --vus 5 --duration 30s \  # Легкий smoke test у CI
            tests/performance/get-products.js
      
      - name: Upload k6 Results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: k6-results
          path: results/
      
      - name: Stop API
        if: always()
        run: docker compose down

Типи Тестів Продуктивності

Розрізняйте різні стратегії навантажувального тестування:

Performance Testing Pyramid

Всі три рівні доповнюють один одного:

Loading diagram...
graph TD
    A["🔬 BenchmarkDotNet<br/>Мікробенчмарки<br/>(методи, алгоритми)"] --> B
    B["🔧 NBomber<br/>In-process Load Tests<br/>(scenarios, workflows)"] --> C
    C["🌐 k6 / Artillery<br/>External Load Tests<br/>(real HTTP, multi-machine)"]

    style A fill:#10b981,stroke:#047857,color:#ffffff
    style B fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style C fill:#8b5cf6,stroke:#6d28d9,color:#ffffff
ІнструментРівеньШвидкістьCI-friendlyІзоляція
BenchmarkDotNetМікроХвилини✅ (окремий проєкт)Повна
NBomberСервісХвилиниЧерез WebAppFactory
k6СистемХвилини-Години⚠️ (smoke тільки)Вимагає запущений API

Практика

:: ::


Тепер ваш арсенал інструментів тестування повністю сформований: від unit-тестів до архітектурних перевірок і навантажувального тестування. Якісний software потребує всіх цих рівнів — і кожен із них захищає ваш продукт від різного класу проблем.