İçeriğe geç

Playwright fixture’ları ve test izolasyonu: her test kendi dünyasında çalışsın

Testleriniz tek tek çalışırken geçiyor, --workers=4 ile çalıştırınca rastgele üçü kırmızıya dönüyor. Ya da tam tersi: suite bütün olarak geçiyor ama tek bir testi --grep ile izole koşturunca “element bulunamadı” hatası alıyorsunuz. İki durumun kök nedeni aynı: kurulum kodunuz testin içinde değil, testin etrafında duruyor ve test bunu bilmiyor.

Playwright’ın fixture mekanizması bu problemi çözmek için var. Ama çoğu ekip Playwright’a JUnit/TestNG veya Cypress alışkanlığıyla geçtiği için beforeEach zincirini olduğu gibi taşıyor ve fixture’ları “page nesnesini aldığım yer” sanıyor. Bu yazı fixture’ı bir bağımlılık enjeksiyonu aracı olarak kurmayı, kapsam kararlarını doğru vermeyi ve testleri gerçekten izole hâle getirmeyi anlatıyor.

beforeEach zinciri neden bir noktada çöker

Aşağıdaki dosya, çoğu projede gerçekten karşılaşacağınız hâliyle yazılmış bir örnek:

// tests/orders.spec.ts
import { test, expect } from '@playwright/test';

let orderId: string;
let customerEmail: string;

test.describe('Sipariş akışı', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('E-posta').fill('de**@*****le.com');
    await page.getByLabel('Şifre').fill('Passw0rd!');
    await page.getByRole('button', { name: 'Giriş yap' }).click();
    await expect(page.getByTestId('user-menu')).toBeVisible();
  });

  test('yeni sipariş oluşturulur', async ({ page }) => {
    customerEmail = `musteri-${Date.now()}@example.com`;
    await page.goto('/orders/new');
    await page.getByLabel('Müşteri e-postası').fill(customerEmail);
    await page.getByRole('button', { name: 'Kaydet' }).click();
    orderId = (await page.getByTestId('order-id').textContent()) ?? '';
    expect(orderId).not.toEqual('');
  });

  test('sipariş detayı görüntülenir', async ({ page }) => {
    await page.goto(`/orders/${orderId}`);
    await expect(page.getByTestId('customer-email')).toHaveText(customerEmail);
  });

  test('sipariş iptal edilir', async ({ page }) => {
    await page.goto(`/orders/${orderId}`);
    await page.getByRole('button', { name: 'İptal et' }).click();
    await expect(page.getByTestId('order-status')).toHaveText('İptal edildi');
  });
});

Burada üç ayrı problem var ve hepsi beforeEachin doğasından geliyor.

Modül seviyesi state. orderId ve customerEmail dosya kapsamında yaşıyor. İkinci ve üçüncü test, birincinin bu değişkenleri doldurmuş olmasına bel bağlıyor. Playwright aynı dosyadaki testleri varsayılan olarak tek worker’da sırayla çalıştırdığı için bu bir süre işler. fullyParallel: true verdiğiniz anda ya da testleri ayrı dosyalara böldüğünüz anda orderId undefined olur.

Sıra varsayımı. Üçüncü test siparişi iptal ediyor. İkinci test tekrar çalışırsa iptal edilmiş bir kaydı görür. --repeat-each=2 bu suite’i tek başına yıkar.

Görünmez kurulum. test('sipariş detayı görüntülenir', async ({ page }) => ...) imzasına bakan biri, bu testin oturum açmış bir kullanıcıya ve önceden var olan bir siparişe ihtiyaç duyduğunu göremez. İhtiyaç dosyanın başka bir yerinde, başka bir blokta duruyor.

beforeEach bir kurulum aracı değil; sıralı yan etki uygulayan bir kancadır. “Her testten önce şunu yap” der, “bu test şuna ihtiyaç duyuyor” demez. Aradaki fark, suite büyüdüğünde izolasyonun tamamını belirler.

Fixture nedir: kurulumu talep eden test belirler

Fixture, teste dışarıdan enjekte edilen bir kaynaktır. Test imzasında adını yazarak istersiniz, Playwright kurar, teste verir, test bitince temizler. İstemeyen test o fixture’ı hiç çalıştırmaz — kurulum tembeldir.

Zaten her gün üç fixture kullanıyorsunuz: browser, context, page. page isteyen test yeni bir BrowserContext alır; istemeyen test tarayıcı bile açmaz. Kendi fixture’larınız aynı mekanizmanın üstüne biner.

// fixtures/base.ts
import { test as base, expect, APIRequestContext } from '@playwright/test';

type Fixtures = {
  apiClient: APIRequestContext;
  authToken: string;
};

export const test = base.extend<Fixtures>({
  authToken: async ({ request }, use) => {
    const response = await request.post('/api/auth/login', {
      data: { email: 'de**@*****le.com', password: 'Passw0rd!' },
    });
    const body = await response.json();
    await use(body.token as string);
  },

  apiClient: async ({ playwright, authToken, baseURL }, use) => {
    const client = await playwright.request.newContext({
      baseURL,
      extraHTTPHeaders: { Authorization: `Bearer ${authToken}` },
    });
    await use(client);
    await client.dispose();
  },
});

export { expect };

apiClient, authToken‘a bağımlı. authToken da yerleşik request fixture’ına bağımlı. Bağımlılık sırasını siz kurmuyorsunuz; Playwright imzalardan çıkarıp çözüyor. Bir test yalnızca apiClient isterse zincirin tamamı kurulur; hiçbiri istenmezse hiçbiri kurulmaz.

Yaşam döngüsü ve kapsam: test mi, worker mı

Fixture gövdesi tek bir işaretle ikiye bölünür: use(). Öncesi kurulum, sonrası temizlik. use() çağrısı test bitene kadar bekler.

myFixture: async ({}, use) => {
  const kaynak = await olustur();   // kurulum
  await use(kaynak);                // test burada çalışır
  await kaynak.kapat();             // temizlik
},

Kapsam ikinci karardır. Varsayılan olan test-scoped fixture her test için sıfırdan kurulur; izolasyonu garanti eden şey budur. Worker-scoped fixture worker süreci boyunca bir kez kurulur, o worker’daki bütün testler aynı örneği paylaşır.

Worker süreci, bir kez kurulan worker-scoped fixture'ı Test 1 ve Test 2 ile paylaşır; test-scoped fixture yeniden kurulur.

Worker scope pahalı kaynaklar için vardır: ayağa kaldırılan bir servis, açılan bir veritabanı bağlantı havuzu, bir kez alınan servis hesabı token’ı. Karar kuralı tek cümle: test tarafından mutasyona uğrayan hiçbir şey worker scope’a çıkmaz. Worker scope’a çıkardığınız bir kullanıcı hesabı, o worker’daki her testin aynı hesabın sepetini, bakiyesini veya profilini değiştirmesi anlamına gelir. Bu tür bir arıza tipik olarak şöyle görünür: hata yalnızca --workers sayısını artırdığınızda ortaya çıkar, tek tek her test geçer, hata mesajı da “beklenen 1 kayıt, bulunan 3 kayıt” gibi veri düzeyinde bir şeydir. Teşhis yolu, fixture’ın kapsamını geçici olarak teste çekip hatanın kaybolup kaybolmadığına bakmaktır.

Worker scope’u güvenli kullanmanın yolu, paylaşılan kaynağı worker’a göre ayırmaktır. testInfo.parallelIndex her worker için farklı bir sayı verir:

// fixtures/tenant.ts
import { test as base } from '@playwright/test';

type WorkerFixtures = {
  tenantName: string;
};

export const test = base.extend<{}, WorkerFixtures>({
  tenantName: [
    async ({}, use, workerInfo) => {
      const name = `tenant-w${workerInfo.parallelIndex}`;
      await use(name);
    },
    { scope: 'worker' },
  ],
});

Artık iki worker aynı tenant üzerinde çalışmaz. Aynı desen veritabanı şeması, dosya dizini ve port numarası için de geçerlidir.

Kendi fixture’ını yazmak: test.extend ve tip güvenliği

Fixture’ın dönüş değeri bir sayfa nesnesi, bir API istemcisi veya bir domain nesnesi olabilir. Tipleri ayrı bir blokta tanımlayıp extende generic olarak vermek, testte otomatik tamamlamayı ve derleme zamanı kontrolünü çalıştırır.

// fixtures/pages.ts
import { test as base, expect, Page } from '@playwright/test';

class LoginPage {
  constructor(private page: Page) {}

  async open() {
    await this.page.goto('/login');
  }

  async signIn(email: string, password: string) {
    await this.page.getByLabel('E-posta').fill(email);
    await this.page.getByLabel('Şifre').fill(password);
    await this.page.getByRole('button', { name: 'Giriş yap' }).click();
    await expect(this.page.getByTestId('user-menu')).toBeVisible();
  }
}

class OrdersPage {
  constructor(private page: Page) {}

  async openOrder(id: string) {
    await this.page.goto(`/orders/${id}`);
  }

  customerEmail() {
    return this.page.getByTestId('customer-email');
  }
}

type PageFixtures = {
  loginPage: LoginPage;
  ordersPage: OrdersPage;
};

export const test = base.extend<PageFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  ordersPage: async ({ page }, use) => {
    await use(new OrdersPage(page));
  },
});

export { expect };

Test tarafında beforeEach yok:

// tests/login.spec.ts
import { test, expect } from '../fixtures/pages';

test('geçerli kimlik bilgileriyle giriş yapılır', async ({ loginPage, page }) => {
  await loginPage.open();
  await loginPage.signIn('de**@*****le.com', 'Passw0rd!');
  await expect(page).toHaveURL(/\/dashboard/);
});

İmzaya bakan biri bu testin neye ihtiyaç duyduğunu görüyor. ordersPage istemediği için o fixture hiç kurulmuyor.

Her test kendi verisini fixture içinde üretir ve geri alır

Veri kurulumunu UI üzerinden yapmak iki hata birleştirir: yavaşlık ve yanlış hata yeri. Sipariş detay sayfasını test ederken sipariş oluşturma formu bozulursa testiniz yanlış yerde kırılır. Kurulumu API’den yapın, UI’ı yalnızca test ettiğiniz davranış için kullanın.

// fixtures/order.ts
import { test as base, expect, APIRequestContext } from '@playwright/test';

type Order = { id: string; customerEmail: string };

type OrderFixtures = {
  order: Order;
};

const runId = process.env.RUN_ID ?? `local-${Date.now()}`;

export const test = base.extend<OrderFixtures>({
  order: async ({ request }, use, testInfo) => {
    const customerEmail =
      `e2e-${runId}-w${testInfo.parallelIndex}-${testInfo.testId}@example.com`;

    const created = await request.post('/api/orders', {
      data: { customerEmail, items: [{ sku: 'SKU-1', quantity: 1 }] },
    });
    expect(created.ok()).toBeTruthy();
    const body = await created.json();

    await use({ id: body.id, customerEmail });

    await request.delete(`/api/orders/${body.id}`);
  },
});

export { expect };
// tests/order-detail.spec.ts
import { test, expect } from '../fixtures/order';

test('sipariş detayında müşteri e-postası görünür', async ({ page, order }) => {
  await page.goto(`/orders/${order.id}`);
  await expect(page.getByTestId('customer-email')).toHaveText(order.customerEmail);
});

use() sonrasındaki temizlik, test assertion hatasıyla başarısız olsa bile çalışır. Ama süreç çökerse, CI runner’ı öldürürse veya timeout worker’ı düşürürse çalışmaz. Bu yüzden ürettiğiniz verinin adında çalıştırma kimliği taşıması gerekir: artık kalan kayıtları e2e-<runId>- önekiyle toplu silebilir, ortamı kirletmeden ilerlersiniz. Veri sahipliği, benzersiz kimlik üretimi ve artık temizleme stratejilerinin tamamı için test verisi yönetimi yazısına bakın.

Oturum izolasyonu: storageState’i fixture ile yönetmek

Her testte UI’dan login olmak, 300 testlik bir suite’e 300 kez form doldurma maliyeti ekler. Tüm testlerin aynı oturumu paylaşması ise izolasyonu bozar. Doğru orta yol: oturumu bir kez üret, dosyaya yaz, testlere salt-okunur ver.

// global-setup.ts
import { request, FullConfig } from '@playwright/test';
import fs from 'node:fs';

export default async function globalSetup(config: FullConfig) {
  const { baseURL } = config.projects[0].use;
  fs.mkdirSync('.auth', { recursive: true });

  for (const role of ['admin', 'customer'] as const) {
    const ctx = await request.newContext({ baseURL });
    await ctx.post('/api/auth/login', {
      data: {
        email: process.env[`${role.toUpperCase()}_EMAIL`]!,
        password: process.env[`${role.toUpperCase()}_PASSWORD`]!,
      },
    });
    await ctx.storageState({ path: `.auth/${role}.json` });
    await ctx.dispose();
  }
}
// fixtures/roles.ts
import { test as base, Page } from '@playwright/test';

type RoleFixtures = {
  adminPage: Page;
  customerPage: Page;
};

export const test = base.extend<RoleFixtures>({
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: '.auth/admin.json' });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
  customerPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: '.auth/customer.json' });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
});

export { expect } from '@playwright/test';

Dosyaya hiçbir test yazmaz; her test kendi BrowserContextini o dosyadan doldurur. Çerez veya token ömrü kısa ise setup’ı CI job’ının başında çalıştırmak yeterlidir.

Sınırı bilin: aynı kullanıcı iki testte paralel koşuyorsa ve testler o kullanıcının sepetini, profilini veya bildirim listesini değiştiriyorsa storageState paylaşımı veri düzeyinde çakışır. O noktada rol başına tek hesap yetmez; worker index’e göre hesap dağıtan bir kullanıcı havuzuna geçmeniz gerekir — .auth/customer-w0.json, .auth/customer-w1.json gibi.

Otomatik fixture’lar: her testte istediğin davranışı zorlamak

auto: true verilen fixture, test imzasında istenmeden de her testte çalışır. Kural koymak için kullanılır.

// fixtures/guards.ts
import { test as base, expect } from '@playwright/test';

export const test = base.extend<{ consoleGuard: void; featureFlags: void }>({
  consoleGuard: [
    async ({ page }, use) => {
      const errors: string[] = [];
      page.on('console', (msg) => {
        if (msg.type() === 'error') errors.push(msg.text());
      });
      page.on('pageerror', (err) => errors.push(err.message));

      await use();

      expect(errors, `Sayfada konsol hatası bulundu:\n${errors.join('\n')}`).toEqual([]);
    },
    { auto: true },
  ],

  featureFlags: [
    async ({ context }, use) => {
      await context.addInitScript(() => {
        window.localStorage.setItem('ff.newCheckout', 'true');
      });
      await use();
    },
    { auto: true },
  ],
});

export { expect };

Artık hiçbir test konsol hatasını görmezden gelemez ve hiçbir test feature flag’i kurmayı unutamaz. Aynı desen ağ isteklerini kaydetmek veya her teste trace ek notu yazmak için de kullanılır.

Maliyeti de görün: otomatik fixture, suite’teki her testin ödediği görünmez bir vergidir. Ağır bir auto fixture — örneğin her testten önce veritabanını tohumlayan biri — 500 testte 500 kez çalışır ve neden yavaşladığını kimse imzalara bakarak bulamaz. autoyu ucuz ve kural niteliğinde şeylere saklayın.

Fixture’ları dosyalara bölmek ve birleştirmek

Tek bir fixtures.ts dosyası ilk ay iyi görünür, altıncı ayda 600 satırlık ve her PR’da çakışan bir çöp kutusuna döner. Alternatifi alan bazlı bölmek ve mergeTests ile birleştirmek:

// fixtures/index.ts
import { mergeTests, mergeExpects, expect as baseExpect } from '@playwright/test';
import { test as pageTest } from './pages';
import { test as orderTest } from './order';
import { test as roleTest } from './roles';
import { test as guardTest } from './guards';

export const test = mergeTests(pageTest, orderTest, roleTest, guardTest);
export const expect = mergeExpects(baseExpect);

Bunun tipik klasör düzeni şudur: fixtures/ altında alan başına bir dosya (pages.ts, order.ts, roles.ts, guards.ts) ve hepsini birleştiren bir index.ts. Testler yalnızca index.ts‘i tanır.

Farklı projeler farklı fixture setleri kullanabilir; playwright.config.ts içinde projelere ayrı use değerleri vererek aynı fixture’ları farklı parametrelerle çalıştırırsınız (örneğin mobil viewport ile masaüstü).

Kritik nokta import disiplini. Bir test dosyası @playwright/testten doğrudan test import ederse bütün fixture katmanını atlar ve kimse fark etmez. Bunu lint ile kapatın:

// eslint.config.js
export default [
  {
    files: ['tests/**/*.spec.ts'],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          paths: [
            {
              name: '@playwright/test',
              importNames: ['test'],
              message: "Testlerde 'test' yalnızca fixtures/index dosyasından import edilir.",
            },
          ],
        },
      ],
    },
  },
];

beforeEach’in hâlâ doğru cevap olduğu yerler

Her şeyi fixture’a taşımak da bedava değil: her fixture bir dolaylılık katmanı, bir import bağı ve okuyacak yeni bir dosyadır. Tek bir dosyada geçerli, tek satırlık, kaynak yaratmayan ve temizlik gerektirmeyen adımlar beforeEach içinde kalabilir — belirli bir URL’e gitmek, çerez banner’ını kapatmak, bir sekmeyi açmak gibi.

Karar için dört soru yeter:

  1. Bu adım bir kaynak yaratıyor mu (kayıt, kullanıcı, dosya, context)?
  2. Temizlik gerekiyor mu?
  3. Birden fazla test dosyası kullanacak mı?
  4. Testin bu ihtiyacı imzada görünmeli mi?

Herhangi birine “evet” diyorsanız fixture. Dördüne de “hayır” diyorsanız beforeEach bırakın; ideoloji için kod taşımayın.

Bundan sonra ne yapmalı

Şu sırayla ilerleyin.

Bağımlılığı ölçün. Suite’i npx playwright test --repeat-each=3 --workers=4 ile çalıştırın, sonra şüpheli dosyaları --grep ile tek tek koşturun. Tek başına geçip beraber kırılan test komşusuna bağımlıdır; beraber geçip tek başına kırılan test kurulumunu başkasından alıyordur. İkisi de aynı hastalığın iki yüzü.

En çok tekrarlanan beforeEach bloğunu seçin. Projede kaç dosyada aynı login veya aynı “kayıt oluştur” bloğu var, sayın. En yüksek sayılıyı fixture’a çevirin; ilk geçiş burada en fazla kazanç verir. Bu tür bir dönüşümün tipik sonucu koşu süresinin kısalması değil — asıl kazanç, fullyParallel açmanın artık güvenli hâle gelmesi ve kırmızı testin hata mesajının doğru yeri göstermesidir.

Worker scope envanteri çıkarın. scope: 'worker' yazan her fixture’ı açın ve tek soruyu sorun: bunu bir test değiştiriyor mu? Cevap “evet” ise ya test scope’a indirin ya da parallelIndex ile bölün.

Import yolunu tek noktaya bağlayın. fixtures/index.ts oluşturun, lint kuralını ekleyin, mevcut testlerin importlarını tek seferde değiştirin. Bu adım atlanırsa fixture katmanı altı ay içinde yarım kalır.

Bu adımlar sonrası hâlâ rastgele kırılan testler varsa problem izolasyonda değil, bekleme ve zamanlama katmanındadır: flaky testleri teşhis etmek yazısı oradan devam ediyor.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir

Bu site istenmeyenleri azaltmak için Akismet kullanır. Yorum verilerinizin nasıl işlendiğini öğrenin.