Mert Özen Her Satırda Daha İleri
Test ve Kalite Performance ve Load Testing — #03

k6 ile Load Test: Script, Scenario'lar, Arrival-Rate Executor'ları, Check, Threshold ve expect()

Mert Özen 14 Kas 2026 12 dk 14 görüntülenme
k6 ile Load Test: Script, Scenario'lar, Arrival-Rate Executor'ları, Check, Threshold ve expect()

k6 2.x ile gerçekçi bir load test, her kullanıcı akışı için ayrı bir arrival-rate scenario'su, Little's Law ile hesaplanmış VU sayısı, endpoint bazında threshold'lar, setup()'ta expect() ile hızlı başarısızlık ve JSON özetle kurulur. Bookstore üzerinde TypeScript script'i koşturup çıktıyı satır satır okuyor, veritabanı gidince abortOnFail'in testi nasıl kestiğini ve k6'ya tek çekirdek verince p95'in dokuz kat arttığını gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; yük testleri k6 2.4.0 (k6-testing 0.6.1 ile) ve TypeScript script ile, Bookstore.Api ve SQL Server 2025 Docker Compose üzerinde çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) load test'i hazır: 02'de çıkardığımız trafik karışımı, 40.000 kitaplık veri ve ödeme stub'ı. Üç soru. Test sürerken bir check başarısız olursa test durur mu? Veritabanı testin ortasında giderse k6 ne yapar: sonuna kadar yük göndermeye devam mı eder, hemen mi durur? Son olarak, sunucuya hiç dokunmadan yalnızca k6'ya tek bir CPU çekirdeği verirsen raporlanan p95 değişir mi? Bu yazıda workload model'i tam bir k6 testine çeviriyor, her soruyu gerçek bir koşuyla cevaplıyoruz.

k6 2.x ve TypeScript

k6 2.0, 12 Mayıs 2026'da GA (genel kullanıma açık sürüm) oldu. Bu yazı yazılırken son sürüm 12 Kasım 2026'da çıkan 2.4.0. 2.0 ile uzun süredir deprecated olan bazı komut ve seçenekler kaldırıldı (externally-controlled executor'ü, --no-summary gibi). Playwright'tan esinlenen expect() assertion'ları ve native OpenTelemetry output'u geldi. Windows'ta kurulum tek komut:

winget install k6 --source winget
k6 version

k6 script'leri JavaScript, ama .ts uzantılı bir dosyayı da doğrudan çalıştırır. Bu destek v0.57'den beri varsayılan olarak açık. k6, TypeScript'i esbuild ile JavaScript'e çevirirken type bilgisini siler; type kontrolü yapmaz. Type hatalarını görmek istiyorsan editörün ya da ayrı bir tsc --noEmit adımı lazım. Bizim için kazancı şu: setup()'tan dönen verinin ve fonksiyon parametrelerinin şekli kodda yazılı duruyor.

Akış Başına Bir Scenario: Arrival-Rate Executor'ları

02'deki script tek bir scenario'da oturum başına rastgele bir akış seçiyordu. Burada her akışa kendi scenario'sunu verdim. Böylece her akışın hızı ayrı ayarlanabiliyor (kampanya akşamında checkout'u üç katına çıkarmak gibi) ve sonuçlar scenario bazında ayrı raporlanıyor:

import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';
import { expect } from 'https://jslib.k6.io/k6-testing/0.6.1/index.js';

// Bookstore average-load test'i: her kullanıcı akışı için bir open-model scenario; her akış kendi hızını korur.
// Kullanım: k6 run -e SCALE=1 bookstore.ts   (SCALE=2 bütün hızları ikiye katlar)
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5080';
const SCALE = Number(__ENV.SCALE || 1);
const DURATION = __ENV.DURATION || '5m';

const isbns = new SharedArray('isbns', () =>
    open('../data/isbns.txt').split(/\r?\n/).filter((line: string) => line.length === 13));
const terms = ['river', 'garden', 'kingdom', 'silent', 'lighthouse', 'library', 'winter', 'dune', 'austen', 'turing'];

function flow(exec: string, sessionsPerSecond: number, secondsPerSession: number) {
    const rate = sessionsPerSecond * SCALE;
    return {
        executor: 'ramping-arrival-rate',
        exec,
        startRate: 0,
        timeUnit: '1s',
        // Little's Law ve yüzde 50 pay: k6 test sırasında VU oluşturmak zorunda kalmasın.
        preAllocatedVUs: Math.ceil(rate * secondsPerSession * 1.5),
        maxVUs: 500,
        stages: [
            { target: rate, duration: '30s' },  // ramp-up (ısınma)
            { target: rate, duration: DURATION },
        ],
        tags: { flow: exec },
    };
}

ramping-arrival-rate open model'dir: k6, cevapları beklemeden stages'te yazan hızda yeni iterasyon başlatır. Her iterasyon, exec'te adı geçen fonksiyonun bir çalışmasıdır, yani bir oturum. Hızı ilk 30 saniyede sıfırdan hedefe çıkarmak bir warm-up aşaması sağlıyor. 02'de soğuk başlangıcın ortancayı üç kat artırdığını görmüştük. Ramp-up bu etkiyi azaltır ama sonuçtan çıkarmaz; warm-up'ı tamamen dışarıda bırakmak istersen ayrı bir warm-up scenario'su yazıp threshold'ları yalnız ana scenario'nun tag'ine bağlarsın.

constant-vus ve ramping-vus gibi VU (virtual user; k6'nın sanal kullanıcısı) tabanlı executor'lardan farkı 01'deki closed model meselesi: orada iterasyon hızı sistemin cevap süresine bağlı, burada değil. Arrival-rate executor'ünde VU yalnızca iterasyonu taşıyan bir çalışandır. Hepsi meşgulse yeni iterasyon başlatılamaz ve dropped_iterations artar. Bu metric iki şeyden birini söyler: ya baştan yeterli VU ayırmadın, ya da sistem yavaşladığı için iterasyonlar uzadı ve VU'lar tükendi. Birinci durumu preAllocatedVUs'u Little's Law ile hesaplayarak eliyorum. Gezinme oturumu ortalama 9 saniye ve saniyede 14 oturum geliyor; aynı anda 126 oturum, yüzde 50 payla 189 VU. İlk denemede her scenario'ya sabit 20 VU vermiştim; 15 saniyelik denemede bile dropped_iterations threshold'u aşıldı.

setup(), SharedArray ve expect()

Test verisi init aşamasında, yani her VU oluşturulurken çalışan üst seviye kodda okunuyor. SharedArray dosyayı bir kez okur ve bütün VU'lar aynı salt okunur kopyayı kullanır. Normal bir array'de 251 VU'nun her biri 40.000 ISBN'in (International Standard Book Number; kitabın uluslararası kimlik numarası) kendi kopyasını tutardı.

setup() yük başlamadan önce bir kez çalışır; döndürdüğü değer her iterasyona parametre olarak gelir. Kimlik doğrulaması isteyen bir API'de token'ı burada bir kez alırsın. Bookstore'un referans uygulamasında authentication olmadığı için burada ortamın hazır olduğunu doğruluyorum:

type SetupData = { categories: string[] };

export function setup(): SetupData {
    // Hızlı başarısızlık: ortam yanlışsa assertion, yük gönderilmeden testi durdurur.
    const health = http.get(`${BASE_URL}/api/categories`);
    expect(health.status).toBe(200);
    const categories = (health.json() as { slug: string }[]).map((c) => c.slug);
    expect(categories.length).toBeGreaterThan(0);
    const book = http.get(`${BASE_URL}/api/books/${isbns[isbns.length - 1]}`);
    expect(book.status, 'test data is loaded (run seed-catalog.sql)').toBe(200);
    return { categories };
}

expect(), k6 2.0 ile tanıtılan assertion API'si. k6-testing adlı resmi bir jslib'den import ediliyor, built-in bir module değil. check()'ten farkı davranışta. Başarısız bir check yalnızca bir metric üretir ve test devam eder; zaten yük altında yüzde kaç isteğin başarısız olduğunu ölçmek için kullanılır. Başarısız bir expect() ise testi keser: senaryonun geçersiz olduğunu söyler. Bu yüzden assertion'ı yük altındaki isteklerde değil, setup()'ta ortamı doğrulamak için kullanıyorum. Testi yanlış porta yönelttiğimde k6 tek bir istek attı ve durdu:

level=warning msg="Request Failed" error="Get \"http://localhost:5381/api/categories\": dial tcp 127.0.0.1:5381: connect: connection refused"
...
level=error msg="test aborted:

   Error: expect(received).toBe(expected)
      At: load/k6/bookstore.ts:57:31

Expected: 200
Received: 0

Exit code 108 oldu; threshold ihlalindeki 99'dan farklı. CI'da bu ikisini ayırmak işe yarar: 108 testin script tarafından kesildiğini (burada ortamın hazır olmadığını), 99 ise sistemin hedefi tutturamadığını söyler. Expect'in bir de expect.soft() biçimi var: testi başarısız sayar ama kesmez.

Tag, Group ve Endpoint Bazında Metric

Bir kullanıcı akışı içinde istekler hem tag'lenir hem gruplanır:

export function checkout() {
    group('checkout', () => {
        const isbn = pickIsbn();
        viewBook(isbn);
        think(2, 5);
        const json = { headers: { 'Content-Type': 'application/json' } };
        const cart = http.post(`${BASE_URL}/api/carts`, null, { tags: { name: 'POST /api/carts' } });
        check(cart, { 'cart 201': (r) => r.status === 201 });
        const cartId = cart.json('id');
        http.put(`${BASE_URL}/api/carts/${cartId}/items/${isbn}`, JSON.stringify({ quantity: 1 }),
            { ...json, tags: { name: 'PUT /api/carts/{id}/items/{isbn}' } });
        think(5, 15);
        const order = http.post(`${BASE_URL}/api/orders`,
            JSON.stringify({ cartId, customerName: 'Grace Hopper', customerEmail: `grace.${__VU}@example.com` }),
            { ...json, tags: { name: 'POST /api/orders' } });
        check(order, { 'order 201': (r) => r.status === 201 });
    });
}

name tag'i önemli. k6 varsayılan olarak her isteği URL'siyle etiketler. 40.000 farklı ISBN, 40.000 farklı zaman serisi demek. Bu hem k6'nın belleğini şişirir hem de sonucu okunmaz yapar. name ile aynı endpoint'in bütün istekleri tek bir şablon altında toplanır. Scenario'ya verdiğim flow tag'i ve group() ise akış bazında kırılım sağlar. --summary-mode=full ile çalıştırırsan özet her scenario ve group için ayrı bir bölüm basar.

Check, Threshold ve abortOnFail

Check tek bir cevabı doğrular ve sonucunu checks metric'ine yazar. Threshold ise bütün testin sonucunu bir metric üzerinden değerlendirir ve testin geçip kalmadığını belirler. Kabul kriterini threshold'lara endpoint bazında yazdım; 01'deki tek ve genel p(95)<300, hızlı kitap detayının yanında yavaş aramayı gizlerdi:

    thresholds: {
        // SLO'dan gelen kabul kriteri, önemli endpoint'ler için ayrı ayrı.
        'http_req_duration{name:GET /api/books/{isbn}}': ['p(95)<100'],
        'http_req_duration{name:GET /api/books?search}': ['p(95)<300'],
        'http_req_duration{name:POST /api/orders}': ['p(95)<500'],
        http_req_failed: [{ threshold: 'rate<0.005', abortOnFail: true, delayAbortEval: '30s' }],
        checks: ['rate>0.995'],
        // Dropped iteration, başlatmak isteyip başlatamadığımız bir oturum: 1.000'de 1'den fazla olmasın.
        dropped_iterations: ['count<30'],
    },

abortOnFail, threshold aşıldığı anda testi durdurur; test sonuna kadar beklemez. delayAbortEval ise ilk 30 saniyede bu kararı ertelemek içindir, çünkü ilk birkaç istekteki tek bir hata oranı yüzde 100 gösterebilir. Tahmin sorusundaki durumu denedim: test başladıktan yaklaşık 75 saniye sonra SQL Server container'ını durdurdum (docker compose stop sql). Hata oranı yükseldi ve k6, durdurmadan 17 saniye sonra testi kesti:

    http_req_failed
    ✗ 'rate<0.005' rate=5.77%
...
    { name:GET /api/books/{isbn} }...: avg=679.35ms min=189µs    med=1.44ms   max=14.81s p(90)=4.13ms   p(95)=10.92s
...
level=error msg="thresholds on metrics 'checks, dropped_iterations, http_req_duration{name:GET /api/books/{isbn}}, http_req_duration{name:GET /api/books?search}, http_req_duration{name:POST /api/orders}, http_req_failed' were crossed; at least one has abortOnFail enabled, stopping test prematurely"

Exit code 99. Hataların çoğu hızlı bir 500 değil, 15 saniyeye yakın bekleyip düşen istekler oldu. Bu da SqlClient'ın bağlantı açma için varsayılan 15 saniyelik timeout'u. Bu ayrıntı da load test'in verdiği bir bilgi: veritabanı gidince API, cevabı 15 saniye bekletiyor. Bu davranışın timeout ve circuit breaker ile nasıl düzeltileceği System Design serisinin "Reliability Pattern'leri" yazısında. İlk tahmin sorusunun cevabı da burada: check'ler başarısız olduğu halde testi durduran şey check değil, abortOnFail'li threshold oldu.

Çıktıyı Satır Satır Okumak

Ana koşu: üç akış, ilk 30 saniye ramp-up, ardından 3 dakika sabit hız (saniyede 14 gezinme, 4 arama, 2 checkout oturumu). Ödeme stub'ı 100-300 ms gecikmeyle çalışıyor:

k6 run -e DURATION=3m --new-machine-readable-summary --summary-export summary.json bookstore.ts
     scenarios: (100.00%) 3 scenarios, 1500 max VUs, 4m0s max duration (incl. graceful stop):
              * browse: Up to 14.00 iterations/s for 3m30s over 2 stages (maxVUs: 189-500, exec: browse, gracefulStop: 30s)
              * checkout: Up to 2.00 iterations/s for 3m30s over 2 stages (maxVUs: 41-500, exec: checkout, gracefulStop: 30s)
              * search: Up to 4.00 iterations/s for 3m30s over 2 stages (maxVUs: 21-500, exec: search, gracefulStop: 30s)

  █ THRESHOLDS

    checks
    ✓ 'rate>0.995' rate=100.00%

    dropped_iterations
    ✓ 'count<30' count=0

    http_req_duration{name:GET /api/books?search}
    ✓ 'p(95)<300' p(95)=184.03ms

    http_req_duration{name:GET /api/books/{isbn}}
    ✓ 'p(95)<100' p(95)=3.21ms

    http_req_duration{name:POST /api/orders}
    ✓ 'p(95)<500' p(95)=301.8ms

    http_req_failed
    ✓ 'rate<0.005' rate=0.00%

  █ TOTAL RESULTS

    checks_total.......: 10917   47.982643/s
    checks_succeeded...: 100.00% 10917 out of 10917
    ...
    HTTP
    http_req_duration..................: avg=22.28ms  min=190µs    med=2.14ms   max=368.82ms p(90)=90.52ms  p(95)=163.08ms
      { expected_response:true }.......: avg=22.28ms  min=190µs    med=2.14ms   max=368.82ms p(90)=90.52ms  p(95)=163.08ms
      { name:GET /api/books?search }...: avg=131.64ms min=88.31ms  med=113.38ms max=292.94ms p(90)=175.09ms p(95)=184.03ms
      { name:GET /api/books/{isbn} }...: avg=1.63ms   min=190µs    med=1.39ms   max=152.17ms p(90)=2.48ms   p(95)=3.21ms
      { name:POST /api/orders }........: avg=215.99ms min=111.97ms med=216.69ms max=368.82ms p(90)=295.32ms p(95)=301.8ms
    http_req_failed....................: 0.00%  0 out of 11309
    http_reqs..........................: 11309  49.70557/s

    EXECUTION
    dropped_iterations.................: 0      0/s
    iteration_duration.................: avg=8.41s    min=2.1s     med=8.69s    max=20.03s   p(90)=11.78s   p(95)=13.71s
    iterations.........................: 3899   17.136972/s
    vus................................: 1      min=0          max=182
    vus_max............................: 251    min=251        max=251

Yukarıdan aşağı okuyalım. scenarios satırı neyin koşacağını söyler: 3 dakika 30 saniye (ramp-up dahil) ve VU aralıkları. Önce bunun planladığın şey olduğunu kontrol et. THRESHOLDS bölümü kararın kendisi: altı kriterin hepsi geçti, exit code 0. checks yüzde 100: hiçbir cevap beklenen status code'dan farklı değil. Hata oranına bakmadan süreye bakma; hızlı dönen bir hata cevabı p95'i iyi gösterir.

http_req_duration'ın genel satırı (p95 163 ms) burada az şey anlatıyor, çünkü 1,6 ms'lik kitap detayıyla 216 ms'lik siparişi aynı dağılımda topluyor. Asıl bilgi alt satırlarda. Arama p95'te 184 ms ile sınırın (300) altında ama en pahalı okuma; 40.000 satırda LIKE '%term%' taraması yapıyor. Siparişin yaklaşık 200 ms'si ödeme stub'ının gecikmesi. min ve max ilk bakışta önemli görünür ama tek bir isteği temsil eder. Percentile'ların nasıl okunacağını 06'da ayrıntılı ele alıyoruz.

EXECUTION bölümünde dropped_iterations 0: istenen her oturum başlatıldı. iterations saniyede 17,1; hedef 20 değil mi? Hayır, çünkü 3 dakika 30 saniyenin ilk 30 saniyesi ramp-up ve iterasyonların ortalama hızı bu yüzden düşüyor. vus'un en yüksek değeri 182: Little's Law'un 20 oturum × 8,41 saniye = 168 tahmininin biraz üstünde, çünkü uzun checkout oturumları ortalamayı aşıyor. vus_max 251: ayırdığımız VU'ların toplamı.

Makinenin Okuyacağı Özet: JSON

Terminal çıktısı insan için. Run'ları saklamak, karşılaştırmak ya da CI'da işlemek için k6 bir JSON özet yazabiliyor. --new-machine-readable-summary bayrağı, k6 v1.5'te gelen ve şeması grafana/k6-summary reposunda yayımlanan yeni formatı açar. Dokümantasyon bu formatın 2.x'te varsayılan olacağını söylüyor; 2.4.0'da ise hâlâ bu bayrakla açılıyor. Dosyada config (süre, script'in kendisi), metadata (k6 sürümü, zaman) ve results (check'ler ve metric'ler) var. Metric'ler, tag'li alt metric'ler dahil, bir liste:

            {
                "contains": "time",
                "name": "http_req_duration{name:POST /api/orders}",
                "type": "trend",
                "values": {
                    "avg": 215.99504358974366,
                    "max": 368.828,
                    "med": 216.693,
                    "min": 111.976,
                    "p90": 295.32169999999996,
                    "p95": 301.80775
                }
            },

PowerShell ile endpoint başına p95'i çekmek tek satır:

$s = Get-Content summary.json -Raw | ConvertFrom-Json
$s.results.metrics | Where-Object name -like "http_req_duration{name:*" |
    Select-Object name, @{ n = "p95"; e = { [math]::Round($_.values.p95, 1) } }
name                                              p95
----                                              ---
http_req_duration{name:POST /api/orders}      301.800
http_req_duration{name:GET /api/books/{isbn}}   3.200
http_req_duration{name:GET /api/books?search} 184.000

Dosyada threshold sonuçları yok; testin geçip kalmadığını exit code söyler. Dosyada script'in tam metni de duruyor; içinde bir secret varsa artifact olarak saklamadan önce bunu hesaba kat. Özeti tamamen kendi biçimine sokmak istersen script'e bir handleSummary() fonksiyonu yazarsın. k6 test sonunda bu fonksiyonu özet object'iyle çağırır, döndürdüğün her değer bir dosyaya ya da stdout'a yazılır.

Üretimde Dikkat

Üçüncü tahmin sorusu: k6'nın CPU'su yetmezse ne olur? Cache'ten 0,2 ms'de dönen kitap detayına saniyede 4.000 istek gönderdim. Önce normal, sonra Go runtime'ını tek çekirdekle sınırlayarak (GOMAXPROCS=1). Sunucu tarafında hiçbir şey değişmedi:

$env:GOMAXPROCS = "1"
k6 run -e POPULARITY=single -e RATE=4000 -e DURATION=30s book-details.js
k6p50p90p95dropped_iterations
Sınırsız134 µs190 µs269 µs503
Tek çekirdek141 µs911 µs2,49 ms1.209

p95 dokuz kat arttı ve başlatılamayan iterasyon iki katından fazlasına çıktı. Sunucu aynıydı; değişen, k6'nın cevabı okuyup yeni isteği zamanında gönderme kapasitesi. k6'nın ölçtüğü süre, isteğin istemcide bekleyişini de içerir. Bu yüzden test boyunca yük üreten makinenin CPU'sunu izle (Windows'ta Task Manager ya da Get-Counter '\Processor(_Total)\% Processor Time'). Yüzde 70-80'i geçiyorsa sonucu sunucuya yazma; yükü birden fazla makineye böl (04'te dağıtık test) ya da script'teki pahalı işleri azalt. Büyük JSON cevaplarını gereksiz yere parse etmek ve check'lerde ağır regex kullanmak bu işlerin başında gelir.

k6 run'larının bütün çıktısını sakla: terminal çıktısı, JSON özet ve hangi commit'le, hangi veriyle, hangi ortamda koşulduğu. Bu yazıdaki sayılar tek bir makinede, k6 ile API aynı donanımdayken alındı ve mutlak değerleri makineye özel. Bir sonraki run'la karşılaştırılabilir olmaları için ortam notu şart.

Sık Düşülen Tuzaklar

  • Check'in başarısız olunca testi durduracağını sanmak; durduran şey threshold ve abortOnFail.
  • Yük altındaki isteklerde expect() kullanmak: ilk hatada test kesilir ve hata oranını ölçemezsin.
  • Dinamik URL'leri name tag'i olmadan göndermek: binlerce zaman serisi ve okunamayan bir özet.
  • Tek bir genel http_req_duration threshold'u koymak: hızlı endpoint'ler yavaş olanı gizler.
  • preAllocatedVUs'u tahminle vermek; Little's Law ile hesapla ve dropped_iterations'a threshold koy.
  • Ramp-up'ı ve warm-up'ı kararlı durum sonucuyla aynı ortalamaya katmak.
  • k6'nın CPU'sunu izlemeden p95'i sunucuya yazmak.
  • JSON özetteki script metnini, içinde secret varken artifact olarak saklamak.

Küçük Bir Deneme

★☆☆ Testi --summary-mode=full ile çalıştır ve her scenario ile group için ayrı basılan bölümleri incele. Arama scenario'sunun iterasyon süresi Little's Law'daki 3,5 saniye varsayımına uyuyor mu?

★★☆ Bir warm-up scenario'su ekle: aynı browse fonksiyonunu ilk 30 saniye düşük hızda koştursun ve { phase: 'warmup' } tag'i taşısın. Ana scenario'lara startTime: '30s' ve { phase: 'main' } ver, threshold'ları yalnız phase:main'e bağla.

★★★ handleSummary() yaz: endpoint başına p50, p95 ve istek sayısını Markdown bir tabloya çevirip summary.md dosyasına, ham özeti summary.json'a yazsın. Bu dosya 04'te pull request yorumu olacak.

Test artık tek komutla koşuyor, sonucu exit code ve JSON olarak veriyor. Bir sonraki yazıda bunu sürece koyuyoruz: GitHub Actions'ta her pull request (PR) için performance gate ve nightly test, k6'nın OpenTelemetry output'u ile test metric'lerini uygulama telemetrisiyle aynı yerde görmek, k6 Operator ile dağıtık test ve browser modülü.