k6'yı Sürece Koymak: CI'da Performance Gate, OpenTelemetry Output, Distributed Test ve Browser Senaryoları
k6'yı sürece koymak, PR'da kısa bir performance gate ve her gece tam bir load test koşturmak, metric'leri OpenTelemetry ile uygulama telemetrisinin yanına göndermek, büyük yükü k6 Operator ile pod'lara bölmek ve kullanıcının gördüğü süreyi browser modülüyle ölçmek demek. Bookstore için GitHub Actions workflow'unu, Collector'a giden metric'leri, iki runner'lı dağıtık testi ve k6 archive'ın options'ı dondurduğu tuzağı gerçek koşularla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; yük testleri k6 2.4.0 ile, Bookstore.Api ve SQL Server 2025 Docker Compose üzerinde çalıştırılmıştır. OpenTelemetry Collector 0.165.0, kind 0.33 üzerinde Kubernetes 1.37 ve k6 Operator 1.7.0, workflow doğrulaması için actionlint 1.7.12 kullanılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) load test'i 03'te tek komutla koşar hâle geldi. Şimdi bu komutu sürece koyuyoruz. Üç soru. Her pull request'te (PR) GitHub'ın paylaşımlı runner'ında koşan bir dakikalık test hangi tür regresyonu yakalayabilir, hangisini yakalayamaz? Testi Kubernetes'te iki pod'a böldüğünde threshold'ları kim değerlendirir, toplam sonucu kim raporlar? Son olarak: k6 script'ini k6 archive ile paketleyip çalıştırırken -e DURATION=1m verirsen test bir dakika mı sürer? Üçüncü sorunun cevabını ben de bir koşu boşa gidince öğrendim.
Hangi Test Nerede Koşar?
01'deki planı hatırlayalım: smoke ve kısa bir average load her PR'da, tam average load her gece, stress, spike ve soak planlı. Bu ayrımın sebebi süre ve gürültü. PR gate birkaç dakikada bitmeli, yoksa geliştirici beklemez ve testi devre dışı bırakır. Bir dakikalık test, endpoint'in yarı yarıya yavaşlaması, hata oranının artması, bir endpoint'in 500 dönmeye başlaması gibi büyük ve belirgin regresyonları yakalar. Yüzde 10'luk bir yavaşlamayı ya da saatler sonra ortaya çıkan bir leak'i yakalayamaz. Bunlar nightly ve soak testin işi. GitHub Actions'taki job, matrix ve concurrency ayrıntıları GitHub Actions serisinin "GitHub Actions'ta Job'lar: needs, matrix, concurrency, timeout ve Service Container'lar" yazısında. Burada yalnız load test'e özgü kısımları anlatıyorum.
GitHub Actions'ta Performance Gate
Workflow, aynı job'u PR'da kısa, gece ise tam sürede koşturuyor. Ortam 01'deki Compose dosyası; SQL Server şifresi her koşuda üretilip atılıyor, secret gerekmiyor:
name: load-test
on:
pull_request:
paths: ["src/**", "load/**", "compose.yaml"]
schedule:
# her gece, 02:00 UTC
- cron: "0 2 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: load-test-${{ github.ref }}
cancel-in-progress: true
jobs:
load-test:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
API_PORT: "5080"
# PR: kısa gate (1 dk, yarı hız). Nightly: tam average load (10 dk).
DURATION: ${{ github.event_name == 'pull_request' && '1m' || '10m' }}
SCALE: ${{ github.event_name == 'pull_request' && '0.5' || '1' }}
steps:
- uses: actions/checkout@v7
- name: Generate a throwaway SQL Server password
run: echo "SA_PASSWORD=Lt$(openssl rand -hex 16)!" >> "$GITHUB_ENV"
- name: Start SQL Server and Bookstore.Api
run: |
docker compose up -d --build --wait
# API açılışta migration uygular; cevap verene kadar bekle.
for i in $(seq 1 30); do curl -sf "http://localhost:$API_PORT/api/categories" > /dev/null && break; sleep 2; done
- name: Seed the load-test catalog
run: |
docker compose cp load/data/seed-catalog.sql sql:/tmp/seed-catalog.sql
docker compose exec -T sql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P "$SA_PASSWORD" \
-d BookstoreDb -b -i /tmp/seed-catalog.sql
- uses: grafana/setup-k6-action@v1
with:
k6-version: "2.4.0"
- name: Smoke test
run: k6 run load/k6/smoke.js
- name: Average load test (performance gate)
run: |
mkdir -p load/results
k6 run -e DURATION="$DURATION" -e SCALE="$SCALE" \
--new-machine-readable-summary --summary-export load/results/summary.json \
load/k6/bookstore.ts
- name: Publish p95 per endpoint to the job summary
if: always() && hashFiles('load/results/summary.json') != ''
run: |
{
echo "### k6: p95 per endpoint (${DURATION}, scale ${SCALE})"
echo "| endpoint | p95 (ms) |"
echo "|---|---|"
jq -r '.results.metrics[] | select(.name | startswith("http_req_duration{name:"))
| "| \(.name | ltrimstr("http_req_duration{name:") | rtrimstr("}")) | \(.values.p95 * 10 | round / 10) |"' \
load/results/summary.json
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v7
if: always()
with:
name: k6-results-${{ github.run_id }}
path: load/results/
retention-days: 30
- name: API logs on failure
if: failure()
run: docker compose logs api --tail 200
Gate'in kendisi ayrı bir adım değil, k6'nın exit code'u. Threshold aşılırsa k6 99 ile çıkar, adım kırmızı olur, PR'ın merge edilmesi branch protection ile engellenebilir. Diğer adımlar if: always() ile her durumda çalışır: test başarısız olsa da JSON özet artifact olarak saklanır ve job özetine endpoint başına p95 tablosu yazılır. Grafana'nın run-k6-action'ı yerine k6'yı doğrudan çağırdım, çünkü o action varsayılan olarak sonuçları Grafana Cloud k6'ya yüklemek üzere ayarlı ve bunun için bir hesap gerekiyor. setup-k6-action yalnızca k6'yı kuruyor; sürümü sabitlemek her koşunun aynı araçla yapılmasını sağlıyor.
Workflow'u actionlint ile doğruladım ve adımlarını aynı komutlarla yerelde, PR modunda (1 dakika, SCALE=0.5) koşturdum. GitHub'ın kendi runner'ında çalıştırmadım; oradaki süreler farklı olacaktır. Yerel koşunun job özeti:
### k6: p95 per endpoint (1m, scale 0.5)
| endpoint | p95 (ms) |
|---|---|
| GET /api/books?search | 192.4 |
| POST /api/orders | 17.2 |
| GET /api/books/{isbn} | 5.5 |
Container'ların ayağa kalkması 15 saniye, workflow'un tamamı 133 saniye sürdü. Bu süre PR için kabul edilebilir. Siparişin p95'i burada 17 ms, çünkü CI'da ödeme stub'ı yok ve sipariş "Placed" kalıyor. Gerçek ödeme gecikmesini görmek istersen stub'ı da bir adımda başlatırsın (02).
Paylaşımlı runner'ın gürültüsünü hesaba kat. GitHub'ın hosted runner'ları sanal makine; aynı fiziksel donanımı başka işlerle paylaşabilir, CPU modeli koşudan koşuya değişebilir. Bu yüzden PR gate'teki eşikleri sıkı tutma: amaç "p95 bir önceki commit'ten yüzde 5 kötü" demek değil, "endpoint ikiye katlandı" ya da "hata oranı arttı" demek. Küçük farkları görmek için nightly sonuçlarını artifact'lardan toplayıp zaman içinde izle, ya da yalnız bu iş için ayrılmış bir self-hosted runner kullan.
OpenTelemetry Output: Test Metric'lerini Telemetrinin Yanında Görmek
Terminal özeti testin sonunda gelir ve tek bir sayı verir. Test sırasında neyin ne zaman bozulduğunu görmek için k6 metric'lerini uygulamanın kendi metric'leri ve trace'leriyle aynı zaman ekseninde görmek gerekir. k6 2.x bunu native OpenTelemetry output'u ile yapıyor: metric'leri OTLP (OpenTelemetry Protocol; telemetri verisinin standart taşıma protokolü) ile bir Collector'a gönderiyor. Collector'ın kurulumu ve Prometheus ile Grafana'ya bağlanması ilerideki Observability serisinin konusu. Burada gelen veriyi görmek için en küçük Collector configuration'ını kullandım:
# Yerel load test'ler için en küçük OpenTelemetry Collector: OTLP alır ve geleni yazdırır.
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
exporters:
debug:
verbosity: normal
service:
pipelines:
metrics:
receivers: [otlp]
exporters: [debug]
docker run -d --name otel -p 4317:4317 -p 4318:4318 `
-v ${PWD}/load/otel/collector.yaml:/etc/otelcol/config.yaml:ro `
otel/opentelemetry-collector:0.165.0 --config /etc/otelcol/config.yaml
$env:K6_OTEL_GRPC_EXPORTER_INSECURE = "true"
$env:K6_OTEL_METRIC_PREFIX = "k6_"
$env:K6_OTEL_SERVICE_NAME = "bookstore-load-test"
k6 run -o opentelemetry --tag testid=pr-1234 -e DURATION=30s -e SCALE=0.5 load/k6/bookstore.ts
Collector'ın debug çıktısında gelenler (satırlar kısaltıldı):
ResourceMetrics #0 [https://opentelemetry.io/schemas/1.43.0] service.name=bookstore-load-test service.version=2.4.0 ...
k6_http_req_duration{expected_response=true,flow=checkout,group=::checkout,method=POST,name=POST /api/orders,proto=HTTP/1.1,scenario=checkout,status=201,testid=pr-1234,url=POST /api/orders} count=11 sum=189.02 min=6.411 max=65.221 le0=0 le5=0 le10=1 le25=9 le50=0 le75=1 le100=0 ...
k6_http_req_failed.total{condition=zero,expected_response=true,flow=browse,group=::browse,method=GET,name=GET /api/books/{isbn},...,testid=pr-1234,...} 630
k6_checks.total{check=book 200,condition=nonzero,flow=browse,group=::browse,scenario=browse,testid=pr-1234} 630
k6_vus{testid=pr-1234} 1
k6_vus_max{testid=pr-1234} 127
Üç şeye dikkat et. Script'teki bütün tag'ler (name, flow, scenario, status) OpenTelemetry attribute'u oluyor. --tag testid=... ile verdiğin değer de her metric'e ekleniyor; dashboard'da tek bir koşuyu ayırmanın yolu bu. Rate metric'leri (http_req_failed, checks) tek bir counter olarak, condition attribute'u ile geliyor. Bu biçim 2.0'da değişti; eski dashboard'lar başka iki metric adı bekliyorsa güncellemek gerekir. Son olarak trend metric'leri histogram oluyor; buradaki bucket sınırları (5, 10, 25, 50 ms…) OpenTelemetry SDK'sının varsayılanları ve milisaniyelik endpoint'ler için kaba. Daha ince çözünürlük istersen k6'nın deneysel native-histograms feature flag'ine bak.
Prometheus remote write output'u hâlâ var, ama k6 2.4.0 sürüm notları InfluxDB ve Prometheus remote write output'larını OpenTelemetry output'u lehine deprecated ilan ediyor. Yeni bir kurulumda OpenTelemetry ile başla ve Prometheus'a gerekirse Collector üzerinden yaz. 2.4.0 ayrıca test koşusunu, scenario'ları ve iterasyonları span olarak yayan deneysel bir native-tracing özelliği getirdi. Bunu bu yazıda denemedim.
k6 Operator ile Dağıtık Test
Tek bir makinenin üretebileceği yükün bir sınırı var (03'teki CPU deneyi). Daha fazlası için yükü birden fazla makineye bölmek gerekir. k6 Operator bunu Kubernetes'te yapıyor: bir TestRun resource'u oluşturursun, operator aynı script'i parallelism kadar runner pod'unda başlatır ve her pod'a yükün bir dilimini verir. k6 2.0 duyurusunda Grafana, operator'ün 1.0 ile stabil olduğunu vurguladı. Yazım anında son sürüm 15 Ekim 2026 tarihli 1.7.0. Kubernetes'in kendisi Kubernetes serisinde. Ben denemeyi kind ile kurduğum yerel bir cluster'da yaptım; runner pod'ları API'ye host.docker.internal üzerinden ulaştı:
# k6 Operator ile dağıtık koşu: aynı test 2 runner pod'a bölünür.
# Arşiv options'ı (scenario, hız, süre) sabitler; onları belirleyen -e değerlerini burada ver:
# k6 archive -e DURATION=1m -e SCALE=0.5 load/k6/bookstore.ts -O archive.tar
# kubectl create configmap bookstore-load --from-file=archive.tar
apiVersion: k6.io/v1alpha1
kind: TestRun
metadata:
name: bookstore-load
spec:
parallelism: 2
script:
configMap:
name: bookstore-load
file: archive.tar
# Script tarafından çalışma anında okunur (options tarafından değil).
arguments: -e BASE_URL=http://host.docker.internal:5080
runner:
image: grafana/k6:2.4.0
kubectl apply --server-side -f https://raw.githubusercontent.com/grafana/k6-operator/v1.7.0/bundle.yaml
kubectl apply -f load/k8s/testrun.yaml
kubectl get testrun bookstore-load -o jsonpath='{.status.stage}'
kubectl logs bookstore-load-1-2ctmk
Üçüncü tahmin sorusunun cevabı ilk denememde çıktı. Arşivi -e vermeden oluşturmuş, DURATION=1m ve SCALE=0.5'i TestRun'ın arguments alanına yazmıştım. Test bir dakika yerine beş buçuk dakika, yarım hız yerine tam hızla koştu. Sebep şu: k6 options'ı arşivi oluştururken hesaplayıp arşive yazıyor. Script scenario'ları __ENV'den kurduğu için arşiv oluşturulurken geçerli olan varsayılanlar donmuş oldu. Çalışma anında verilen -e değerleri, script'in iterasyonlarda okuduğu BASE_URL gibi değerlere yine ulaşıyor; options'a ulaşmıyor. Arşivi -e DURATION=1m -e SCALE=0.5 ile yeniden oluşturunca iki runner'ın sonucu şöyle oldu:
== pod/bookstore-load-1-2ctmk
✓ 'p(95)<300' p(95)=213.51ms
✓ 'p(95)<100' p(95)=9.28ms
✓ 'p(95)<500' p(95)=33.4ms
✓ 'rate<0.005' rate=0.00%
http_reqs..........................: 1093 10.290183/s
iterations.........................: 376 3.539898/s
== pod/bookstore-load-2-fk5c7
✓ 'p(95)<300' p(95)=218.09ms
✓ 'p(95)<100' p(95)=7.8ms
✓ 'p(95)<500' p(95)=37.86ms
✓ 'rate<0.005' rate=0.00%
http_reqs..........................: 1084 10.627476/s
iterations.........................: 373 3.656871/s
Yük ikiye bölündü: 376 ve 373 oturum, toplam 749. Tek makinede aynı ayarla beklenen sayı da bu. Ama ikinci tahmin sorusunun cevabı da burada: her pod kendi özetini basıyor ve threshold'ları yalnız kendi gördüğü isteklere göre değerlendiriyor. Toplam bir p95 yok; iki pod'un p95'inin ortalaması da toplam p95 değildir (06'da göreceğiz). Bu yüzden dağıtık testte metric'leri bir yere toplamalısın: yukarıdaki OpenTelemetry output'u ile Collector'a gönderip p95'i tek bir dağılım üzerinden hesaplatırsın. Grafana Cloud k6 bu toplamayı kendi yapıyor; bu serideki kural gereği bulut hesabı açmadım, o kısım çalıştırılmadı.
Browser Modülü: Kullanıcının Gördüğü Süre
Protokol seviyesindeki test sunucunun cevap süresini ölçer. Kullanıcı ise sayfanın çizilmesini, JavaScript'in çalışmasını ve görsellerin yüklenmesini bekler. k6'nın browser modülü, 2.0 ile Playwright API'sine daha da yaklaşan bir arayüzle gerçek bir Chromium açar ve Core Web Vitals metric'lerini toplar. Bookstore'un Angular istemcisinde katalog, kitap detayı ve sepete ekleme akışını şöyle yazdım:
import { browser } from 'k6/browser';
import { expect } from 'https://jslib.k6.io/k6-testing/0.6.1/index.js';
// Browser testi: protokol seviyesindeki yükün yanında birkaç gerçek tarayıcı; kullanıcının gördüğünü ölçer.
// Kullanım: k6 run browser.ts (Angular istemcisi çalışıyor olmalı: cd web/bookstore-web; npm start)
const WEB_URL = __ENV.WEB_URL || 'http://localhost:4200';
export const options = {
scenarios: {
ui: {
executor: 'shared-iterations',
vus: 1,
iterations: 5,
options: { browser: { type: 'chromium' } },
},
},
thresholds: {
browser_web_vital_lcp: ['p(75)<2500'],
browser_web_vital_cls: ['p(75)<0.1'],
},
};
export default async function () {
const page = await browser.newPage();
try {
await page.goto(WEB_URL);
// Kendini tekrarlayan assertion'lar: katalog yüklenene kadar bekler.
await expect(page.getByTestId('book-count')).toBeVisible();
await page.locator('.book-card a').first().click();
await expect(page.locator('h1')).toBeVisible();
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByText('Added.')).toBeVisible();
} finally {
await page.close();
}
}
█ THRESHOLDS
browser_web_vital_cls
✓ 'p(75)<0.1' p(75)=0
browser_web_vital_lcp
✓ 'p(75)<2500' p(75)=148ms
...
WEB_VITALS
browser_web_vital_cls.......: avg=0 min=0 med=0 max=0 p(90)=0 p(95)=0
browser_web_vital_fcp.......: avg=85.6ms min=76ms med=80ms max=96ms p(90)=96ms p(95)=96ms
browser_web_vital_inp.......: avg=16ms min=16ms med=16ms max=16ms p(90)=16ms p(95)=16ms
browser_web_vital_lcp.......: avg=166.4ms min=124ms med=144ms max=284ms p(90)=229.6ms p(95)=256.79ms
browser_web_vital_ttfb......: avg=2.06ms min=1.5ms med=1.9ms max=3.19ms p(90)=2.71ms p(95)=2.95ms
Script expect()'in kendini tekrarlayan biçimini kullanıyor: element görünene kadar bekliyor, sabit sleep gerekmiyor. k6 sistemde kurulu Chromium tabanlı bir tarayıcıyı kendisi bulur; belirli bir build kullanmak istersen K6_BROWSER_EXECUTABLE_PATH ile yolunu verirsin. Sayılar ng serve ile çalışan geliştirme sunucusundan; production build'in boyutu ve süreleri farklıdır. LCP (Largest Contentful Paint; sayfadaki en büyük içeriğin çizildiği an), CLS (Cumulative Layout Shift; sayfa yüklenirken içeriğin kayma miktarı) ve INP'nin (Interaction to Next Paint; etkileşimden sonraki ilk çizime kadar geçen süre) anlamı ve nasıl iyileştirileceği ilerideki Web Performansı serisinin konusu. Burada bilmen gereken şu: her browser VU (virtual user; k6'nın sanal kullanıcısı)'su gerçek bir tarayıcı, yani yüzlerce MB bellek ve ciddi CPU harcıyor. Yükü browser ile üretme. Yükü protokol seviyesindeki scenario'lar üretir, bir iki browser VU'su da aynı anda kullanıcının gördüğünü ölçer.
Alternatifler: NBomber, Crank ve k6'nın AI Komutları
.NET ekibi için en yakın alternatif NBomber: senaryolar C# ile yazılıyor, test kodu uygulamayla aynı dilde ve aynı IDE'de duruyor, HTTP dışındaki protokoller için kendi client'ını yazmak kolay. Dikkat edilecek nokta lisans: NBomber kişisel ve akademik kullanımda ücretsiz, kurumsal kullanım için ücretli lisans istiyor. .NET ekibinin kullandığı Crank ise bir load test aracı değil, bir benchmark altyapısı: bir agent uygulamayı belirli bir makinede deploy edip çalıştırıyor, bombardier ya da wrk ile yük veriyor ve sonuçları JSON olarak topluyor. Framework'ün kendi performansını commit commit izlemek için tasarlanmış; iş akışı test etmek için k6 daha uygun. JMeter'ın hâlâ doğru araç olduğu durumlar bir sonraki yazının konusu.
k6 2.0 ile gelen k6 x alt komutları extension'ların CLI'ya eklediği komutlar: k6 x docs dokümantasyonu terminalden okutuyor, k6 x explore extension kataloğunu listeliyor, k6 x mcp AI asistanlarına MCP (Model Context Protocol; AI asistanlarının dış araçlara bağlandığı protokol) üzerinden script doğrulama ve çalıştırma araçları sunuyor, k6 x agent da asistanlar için hazır configuration kuruyor. Bunlara temkinli yaklaş. Bu komutlar extension olarak ayrıca indiriliyor. Bir AI asistanının script yazmasında sorun yok, ama load test'i çalıştırma yetkisini ona verirsen hangi hedefe ne kadar yük gönderdiğini insan kontrol etmeli. Yanlış bir BASE_URL, sahibi olmadığın bir sisteme yük göndermek demek. Ben bu komutları yalnızca yardım çıktılarıyla inceledim, çalıştırmadım.
Üretimde Dikkat
Gate'in güvenilir olması tekrarlanabilirliğe bağlı. Her koşu sıfırdan kurulan ortamla, aynı seed verisiyle ve aynı k6 sürümüyle yapılmalı. Önceki koşudan kalan cache'ler ya da büyümüş tablolar sonucu kaydırır. Ortamı her koşuda Compose ile sıfırdan kurmak bu yüzden iyi bir tercih. Ödediğin bedel her PR'da 15 saniyelik başlangıç.
Testin hangi koşulda kırmızı olacağını ekiple konuş. Kırmızı gate ama "bu gürültü, tekrar çalıştır" alışkanlığı, gate'in tamamen yok sayılmasıyla sonuçlanır. Eşikleri ilk haftalarda gevşek tut, nightly verisiyle normal aralığı öğren, sonra sıkılaştır. Başarısız bir gate'te API log'larını ve JSON özeti otomatik toplamak (workflow'daki son iki adım) teşhisi hızlandırır.
Dağıtık testte ve OpenTelemetry output'unda test verisine dikkat et: testid gibi düşük cardinality'li tag'ler iyi, kullanıcı ID'si ya da tam URL gibi yüksek cardinality'li tag'ler metric backend'ini şişirir. 03'teki name tag'i bu yüzden burada da şart.
Sık Düşülen Tuzaklar
- Paylaşımlı CI runner'ında yüzde 5'lik farklara göre gate koymak.
run-k6-action'ın varsayılan bulut yüklemesini fark etmeden kullanmak.- Test başarısız olunca JSON özeti ve log'ları saklamamak.
k6 archive'dan sonra options'ı-eile değiştirebileceğini sanmak.- Dağıtık testte her runner'ın kendi threshold sonucunu toplam sonuç sanmak ya da p95'lerin ortalamasını almak.
- Browser VU'larıyla yük üretmeye çalışmak.
- Bir AI asistanına hedefi kontrol edilmeyen bir load test çalıştırma yetkisi vermek.
Küçük Bir Deneme
★☆☆ Workflow'a 03'teki handleSummary() ile ürettiğin Markdown tabloyu ekle ve job özetinde göster. PR'a yorum olarak yazmak için hangi izin (permissions) gerekir?
★★☆ OpenTelemetry Collector'a Prometheus exporter ekle, k6'yı iki kez farklı testid ile koştur ve iki koşunun p95'ini Prometheus'ta histogram_quantile ile ayrı ayrı hesapla. Bucket sınırlarının sonuca etkisini terminal özetindeki p95 ile karşılaştırarak gör.
★★★ TestRun'ı parallelism: 4 ile koştur ve dört runner'ın metric'lerini OpenTelemetry üzerinden toplayıp tek bir p95 hesapla. Aynı toplam yükü tek bir k6 ile verdiğinde p95 farklı mı?
k6 Bookstore'un HTTP API'si için iyi bir araç, ama tek araç değil. Bir sonraki yazıda JMeter'a bakıyoruz: geniş protokol desteğiyle hâlâ doğru seçim olduğu yerler, test plan anatomisi, CLI modunda koşum ve HTML dashboard'u okumak.