Flaky test nedir, ne değildir
Aynı commit üzerinde, aynı ortamda, hiçbir şey değişmemişken bazen geçen bazen kalan test flaky’dir. Kararsızlığın kaynağı testin kendisindedir veya testin çalıştığı ortamdadır; ürün kodundaki bir hatayı yakalayan test flaky değildir, sadece rahatsız edicidir. Bu ayrımı yapmayan ekip her kırmızıyı “yine flaky” diye geçiştirir ve gerçek regresyonları kendi eliyle gömer. Süitin güvenilirliği tek bir yanlış etiketle başlar erozyona uğramaya.
retries bir teşhis aracı değil, bir maskeleme aracıdır. retries: 2 yazdığında CI yeşile döner ama kararsızlığın nedeni yerinde durur; sadece görünürlüğünü kapatmış olursun. Retry’ın tek meşru kullanımı, kararsızlığı raporlamaya devam ederken build’i akışta tutmaktır — yani “flaky” işaretli sonuçları topladığın ve okuduğun sürece. Kimsenin bakmadığı bir flaky sayacı, retry’ı sessiz bir çöp kutusuna çevirir.
Kararsızlığı ölçmeden teşhis edemezsin
“Bu test flaky” bir hipotezdir. Kanıt, test bazında kalma oranıdır: aynı testin son N çalıştırmasında kaç kez kaldığı. Playwright’ın JSON reporter’ı bu veriyi zaten üretiyor; tek eksik onu biriktirip sıralamak.
Önce reporter’ı aç:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
retries: process.env.CI ? 2 : 0,
reporter: [
['list'],
['json', { outputFile: 'reports/results.json' }],
],
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
},
});
Sonra çıktıyı test bazında özetle:
// scripts/flakiness.mjs
// Kullanım: node scripts/flakiness.mjs reports/results.json [reports/*.json ...]
import { readFileSync } from 'node:fs';
const rows = new Map();
function visitSuite(suite, titlePath) {
const path = [...titlePath, suite.title].filter(Boolean);
for (const spec of suite.specs ?? []) {
const key = `${spec.file} › ${[...path, spec.title].join(' › ')}`;
const row = rows.get(key) ?? { key, total: 0, failed: 0 };
for (const test of spec.tests ?? []) {
for (const result of test.results ?? []) {
if (result.status === 'skipped') continue;
row.total += 1;
if (result.status !== 'passed') row.failed += 1;
}
}
rows.set(key, row);
}
for (const child of suite.suites ?? []) visitSuite(child, path);
}
for (const file of process.argv.slice(2)) {
const report = JSON.parse(readFileSync(file, 'utf8'));
for (const suite of report.suites ?? []) visitSuite(suite, []);
}
const ranked = [...rows.values()]
.filter((r) => r.failed > 0 && r.failed < r.total)
.map((r) => ({ ...r, rate: r.failed / r.total }))
.sort((a, b) => b.rate - a.rate || b.total - a.total);
if (ranked.length === 0) {
console.log('Kararsız test bulunamadı.');
} else {
console.log('oran\tkalan/toplam\ttest');
for (const r of ranked) {
console.log(`${(r.rate * 100).toFixed(1)}%\t${r.failed}/${r.total}\t${r.key}`);
}
}
failed > 0 && failed < total filtresi kritik: her zaman kalan test flaky değil, bozuk. Bu script’i CI’da her koşudan sonra çalıştır, JSON dosyalarını artifact olarak sakla ve bir haftalık dosyaları aynı komuta ver. Tek koşudan kararsızlık oranı çıkmaz; seri lazım.
Tek bir testin kararsızlığını hızlıca ölçmek istiyorsan CI verisini beklemene gerek yok:
npx playwright test tests/checkout.spec.ts --grep "kupon kodu" \
--repeat-each=30 --workers=4 --retries=0
30 koşuda 3 kalma, %10’luk bir kararsızlık demektir — ve bu, 200 testlik bir süitte her build’in kırmızı olması için fazlasıyla yeterlidir. Süit büyüdükçe tek testin küçük kararsızlığı build düzeyinde katlanarak görünür hale gelir; paralel çalıştırmanın maliyet tarafı için test otomasyonunda performans optimizasyonu yazısına bakabilirsin.
Kararsızlığın beş kök nedeni
Flaky testlerin nedenleri rastgele değil, sınıflandırılabilir. Her sınıfın ayırt edici bir teşhis işareti ve farklı bir çözümü var. Sınıflandırmayı atlayıp doğrudan timeout değerini büyütmek, beş farklı hastalığa aynı ağrı kesiciyi vermektir.
Zamanlama ve yarış koşulları
En yaygın neden: test, uygulamanın hazır olduğu andan önce veya sonra hareket ediyor. waitForTimeout ile sabit bekleme, animasyon sırasında yakalanan buton, henüz event handler’ı bağlanmamış elemana yapılan tıklama.
Playwright’ın auto-waiting’i eleman görünür, stabil, etkin ve olay alabilir hale gelene kadar bekler. Kapsamadığı şey uygulamanın iç durumudur: bir buton DOM’da olabilir, görünür olabilir, hatta tıklanabilir olabilir — ama React henüz hydration’ı bitirmediği için tıklama hiçbir şey yapmaz. Test kalır, sen “auto-waiting var ya” dersin, neden orada durur.
Çözüm sabit bekleme eklemek değil, gözlemlenebilir bir duruma assert etmektir:
import { test, expect } from '@playwright/test';
test('sepete ekleme sayacı günceller', async ({ page }) => {
await page.goto('/urun/42');
// Yanlış: sabit bekleme, hem yavaş hem kararsız.
// await page.waitForTimeout(2000);
// await page.click('.add-to-cart');
const ekle = page.getByRole('button', { name: 'Sepete ekle' });
// Uygulamanın hazır olduğunu gözlemlenebilir bir durumdan doğrula.
await expect(ekle).toBeEnabled();
// İsteğin sonucunu beklemeyi tıklamayla aynı anda başlat.
const istek = page.waitForResponse(
(r) => r.url().includes('/api/cart') && r.request().method() === 'POST'
);
await ekle.click();
await istek;
// Web-first assertion: koşul sağlanana kadar yeniden dener.
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
expect(locator) ailesi kendi içinde yeniden deneme yapar; expect(await locator.textContent()).toBe('1') yapmaz. Bu iki satır arasındaki fark, süitindeki kararsız testlerin önemli bir bölümüdür.
Test verisi ve paylaşılan durum
Teşhis işareti nettir: test tek başına geçiyor, süit içinde kalıyor. Ya da --workers=1 ile geçiyor, --workers=4 ile kalıyor. Bir de sinsi hali var: testlerin sırası değişince sonuç değişiyor.
Nedeni genelde aynı: iki test aynı kullanıcıyı, aynı sipariş kaydını veya aynı veritabanı satırını paylaşıyor. Biri siliyor, diğeri okuyor. Çözüm her testin kendi verisini üretmesi — ve bunu test gövdesinde değil fixture’da yapması:
// tests/fixtures.ts
import { test as base, expect } from '@playwright/test';
import { randomUUID } from 'node:crypto';
type Kullanici = { email: string; sifre: string; id: string };
export const test = base.extend<{ kullanici: Kullanici }>({
kullanici: async ({ request }, use) => {
const email = `test-${randomUUID()}@example.test`;
const sifre = 'Gecici!2024';
const res = await request.post('/api/test/users', {
data: { email, password: sifre },
});
expect(res.ok()).toBeTruthy();
const { id } = await res.json();
await use({ email, sifre, id });
// Test bitince kendi verisini topla.
await request.delete(`/api/test/users/${id}`);
},
});
export { expect };
// tests/profil.spec.ts
import { test, expect } from './fixtures';
test('kullanıcı görünen adını değiştirebilir', async ({ page, kullanici }) => {
await page.goto('/giris');
await page.getByLabel('E-posta').fill(kullanici.email);
await page.getByLabel('Parola').fill(kullanici.sifre);
await page.getByRole('button', { name: 'Giriş yap' }).click();
await page.goto('/profil');
await page.getByLabel('Görünen ad').fill('Deneme Kullanıcı');
await page.getByRole('button', { name: 'Kaydet' }).click();
await expect(page.getByRole('status')).toHaveText('Profil güncellendi');
});
Her test kendi kullanıcısıyla çalışıyorsa worker sayısını artırmak kararsızlık üretmez. Bu kalıbın büyük süitlerdeki uzantıları için test otomasyonunda ölçeklenebilirlik yazısı işe yarar.
Ağ ve dış bağımlılıklar
Analytics script’i, CDN’den gelen font, üçüncü parti ödeme iframe’i, yavaş cevap veren bir arama servisi. Teşhis işareti zamansal korelasyondur: kalmalar belirli saatlerde, deploy pencerelerinde veya yoğun CI trafiğinde kümeleniyorsa dış bağımlılığa bakacaksın.
Karar vermen gereken soru şu: bu bağımlılık gerçekten testin konusu mu? Ödeme akışını test ediyorsan sağlayıcının sandbox’ı testin parçasıdır. Sepet sayacını test ediyorsan Google Analytics’in yüklenmesi testin parçası değildir — sadece gürültüdür. Gürültüyü kes:
test.beforeEach(async ({ context }) => {
await context.route(/(googletagmanager|hotjar|segment)\.com/, (route) =>
route.abort()
);
});
test('arama sonuçları listelenir', async ({ page }) => {
await page.route('**/api/search?*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [{ id: 1, title: 'Deneme ürün' }] }),
})
);
await page.goto('/arama?q=deneme');
await expect(page.getByRole('listitem')).toHaveCount(1);
});
Mock’un nerede biteceği ayrı bir tartışma; sınırları mocking ve stubbing yazısında ele alınmıştı. Kural basit: kendi ekibinin sahip olmadığı ve testin konusu olmayan her şey deterministik hale getirilmeli.
Ortam farkları: lokal yeşil, CI kırmızı
CI makinesi senin dizüstünden yavaştır, CPU paylaşımlıdır, headless render farklıdır, viewport ve locale farklı olabilir. CPU kısıtı altında CSS animasyonu daha uzun sürer, toHaveText timeout’una takılır.
Lokalde üretilemeyen hatayı lokalde üretmenin yolu CI koşullarını taklit etmektir. Üç kaldıraç var: aynı container image, daha fazla worker, daha az CPU.
# CI ile aynı image; sürümü kendi config'indeki sürümle eşitle.
docker run --rm -it \
--cpus="1.0" \
-v "$PWD":/work -w /work \
mcr.microsoft.com/playwright:v1.49.0-jammy \
npx playwright test tests/checkout.spec.ts --workers=4 --repeat-each=20 --retries=0
--cpus kısıtı, zamanlamaya duyarlı testleri lokalde de kırar. Kırılıyorsa hipotezin doğrulanmıştır: sorun ortam değil, testin hıza bağımlı olması. Locale ve timezone farkını da config’de sabitle, CI’ya bırakma:
use: {
locale: 'tr-TR',
timezoneId: 'Europe/Istanbul',
viewport: { width: 1280, height: 720 },
}
Kırılgan seçiciler
.css-1x9ab2 veya div:nth-child(3) > button gibi seçiciler her UI değişikliğinde kopar. Ama bu genelde flaky değil, sürekli kırık üretir — teşhisi kolaydır. Kırılgan seçici flaky hale geldiğinde asıl tehlikelidir: liste sıralaması değiştiğinde, sunucudan gelen kayıtların sırası garanti edilmediğinde, lazy-loading yüzünden eleman konumu kaydığında.
// Kırılgan: sıra değişince yanlış satıra tıklar.
await page.locator('table tr:nth-child(2) button.btn-primary').click();
// Sağlam: içerikten kimliklenen satır.
const satir = page.getByRole('row', { name: /SP-10234/ });
await satir.getByRole('button', { name: 'Detay' }).click();
await expect(page.getByRole('heading', { name: 'Sipariş SP-10234' })).toBeVisible();
// Rol/etiket yetmiyorsa test-id.
await page.getByTestId('order-row-SP-10234').getByTestId('detail-button').click();
Test-id’yi ürün kodunda taşımaya karşı çıkan ekipler olur; kararsız bir süitin maliyeti, birkaç data-testid niteliğinin maliyetinden yüksektir.
Playwright’ın verdiği kanıtları okumak
Trace viewer teşhisin merkezidir. Kalan bir koşunun trace’ini açtığında dört şeye bakacaksın: hangi action timeout’a düştü, o anın DOM snapshot’ında hedef eleman var mıydı ve hangi haldeydi, network sekmesinde beklemede kalmış istek var mı, action öncesi ve sonrası snapshot arasında ne değişti. “Element not found” ile “element var ama başka bir eleman üstünü kapatıyor” tamamen farklı iki kök nedendir ve bunu ancak snapshot’a bakarak ayırırsın.
// playwright.config.ts — kanıtı yalnızca gerektiğinde topla
use: {
trace: 'on-first-retry',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
},
on-first-retry, her koşuda trace üretmenin maliyetinden kaçınırken kararsız testlerin tamamını yakalar: flaky test ilk denemede kalır, retry’da trace toplanır. CI’da test-results/ klasörünü artifact olarak yükle, yoksa kanıt build ile birlikte silinir.

Yeniden üretme döngüsü
Kanıt topladıktan sonra hipotezi doğrulamadan düzeltme yapma. Düzeltmeyi doğrulamadan uygularsan, testin bir sonraki kalmasında aynı yere ikinci bir yama eklersin ve kimse ilk yamanın neden orada olduğunu bilmez.
Döngü dört adım: izole et, N kez koş, tek bir değişkeni değiştir, tekrar ölç.
# 1) Temel çizgi: kararsızlık oranını ölç.
npx playwright test --grep "kupon kodu" --repeat-each=30 --workers=1 --retries=0
# 30 koşuda 0 kalma → tek başına stabil, paralellik veya paylaşılan duruma bak.
# 2) Tek değişkeni değiştir: paralellik.
npx playwright test --grep "kupon kodu" --repeat-each=30 --workers=6 --retries=0
# Burada kalıyorsa hipotez: paylaşılan durum.
# 3) Tek değişkeni değiştir: CPU baskısı.
docker run --rm --cpus="0.5" -v "$PWD":/work -w /work \
mcr.microsoft.com/playwright:v1.49.0-jammy \
npx playwright test --grep "kupon kodu" --repeat-each=30 --workers=1 --retries=0
# Burada kalıyorsa hipotez: zamanlama bağımlılığı.
# 4) Düzeltmeyi uygula, hipotezi kıran komutu aynen tekrarla.
Aynı anda iki şeyi değiştirirsen hangisinin işe yaradığını bilemezsin. Kararsızlık istatistiksel bir olgudur; tek bir yeşil koşu kanıt değildir. Düzeltmeden önce kaldıran komutun düzeltmeden sonra 30 koşuda 0 kalma vermesi kanıttır.
Karantina: çözüm değil, geçici hasar kontrolü
Kök nedeni bugün bulamıyorsan testi süitin ana yolundan çıkarmak meşrudur. Meşru olmayan, test.skip yazıp geçmektir — çünkü skip edilmiş test hiçbir veri üretmez, kimse sahiplenmez ve altı ay sonra ürün kodu değişmişken kimse o testin ne yaptığını hatırlamaz.
Doğru yol, sahibi ve son tarihi olan bir karantina etiketi:
test('kupon kodu indirimi uygular @quarantine', async ({ page, kullanici }) => {
// Karantina: 2024-11-04, sahip: @ekip-odeme
// Hipotez: kupon servisi yanıtı 2 sn'yi aşınca UI eski toplamı gösteriyor.
});
# Ana pipeline: karantinadakiler hariç, kırmızı = build durur.
npx playwright test --grep-invert "@quarantine"
# Ayrı job: karantinadakiler çalışır, sonuç build'i düşürmez ama raporlanır.
npx playwright test --grep "@quarantine" --repeat-each=5 --retries=0 || true
Karantinadaki test çalışmaya devam etmeli, yoksa kararsızlık oranını ölçemez ve düzeltmenin işe yarayıp yaramadığını doğrulayamazsın. Karantina listesinin sınırsız büyümesine izin veren ekip, önce birkaç testi, sonra süitin tamamına olan güvenini kaybeder. Listeye üst sınır koymak — örneğin “aynı anda en fazla beş test, en fazla iki sprint” — teknik bir kural değil, süreç kuralıdır ve CI’da değil ekip anlaşmasında yaşar. Ama sınırın aşıldığını CI raporlayabilir.
Kararsızlığı baştan engelleyen kurallar
Teşhisten öğrendiğini kalıcılaştırmazsan aynı kök nedeni altı ay sonra yeni bir testte yeniden bulursun. Kurallar kod review’da hatırlatmayla değil, CI’da zorlamayla yaşar.
Sabit beklemeyi lint ile yasakla:
// eslint.config.js
export default [
{
files: ['tests/**/*.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
"CallExpression[callee.property.name='waitForTimeout']",
message:
'Sabit bekleme kararsızlık üretir. Web-first assertion veya waitForResponse kullan.',
},
{
selector:
"CallExpression[callee.object.name='test'][callee.property.name='skip']",
message:
'Test devre dışı bırakmak yerine @quarantine etiketi ve sahip ekle.',
},
],
},
},
];
Yeni testleri PR’da birden fazla kez koştur:
# .github/workflows/e2e.yml
name: e2e
on: pull_request
jobs:
suite:
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.49.0-jammy
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright test --grep-invert "@quarantine"
- run: node scripts/flakiness.mjs reports/results.json
if: always()
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-kanit
path: |
reports/
test-results/
yeni-testler:
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.49.0-jammy
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: npm ci
- name: Değişen spec dosyalarını 10 kez koştur
run: |
FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD \
| grep -E '^tests/.*\.spec\.ts$' || true)
if [ -z "$FILES" ]; then
echo "Değişen test yok."; exit 0
fi
npx playwright test $FILES --repeat-each=10 --retries=0
İkinci job, kararsız bir testin süite girmesini girdiği gün engeller. Kararsızlığı sonradan avlamak, girişte durdurmaktan kat kat pahalıdır.
Buradan sonra ne yapmalı
Süitin tamamını düzeltmeye kalkma; bu iş bir listeyle değil tek testle başlar.
Bu hafta şunu yap: son yedi günün CI artifact’lerindeki JSON raporlarını topla, flakiness.mjs script’ini hepsine birden çalıştır, en yüksek kalma oranına sahip beş testi çıkar. Her birini bu yazıdaki beş kök nedene göre sınıflandır — sınıflandırma için tek soru yeterli: tek başına geçiyor mu, --workers artınca kalıyor mu, kalmalar saate göre kümeleniyor mu, CPU kısıtlı container’da kırılıyor mu.
Sonra sadece en yüksek frekanslı olanı al ve yeniden üretme döngüsüne sok. Hipotezini kıran komutu bul, tek değişkeni değiştir, düzelt, aynı komutla 30 koşuda doğrula. Diğer dördü bekleyebilir; çoğu zaman ilk testin kök nedeni geri kalanların da nedenidir. Aracın seçimi bu döngüyü değiştirmez, sadece komutların adını değiştirir — araçların karşılaştırması için popüler test otomasyon araçları yazısına bakabilirsin.