Les fixtures personnalisées de Playwright permettent d'injecter des objets préconfigurés dans les tests, exactement comme page et request fonctionnent nativement. Elles éliminent le code de setup répété et rendent les tests lisibles comme une description directe de ce qu'ils vérifient.

Pourquoi les fixtures natives ne suffisent pas

Playwright fournit des fixtures comme page, browser, context et request. Elles couvrent les bases. Mais elles ne savent rien de votre application : votre flux de connexion, votre état authentifié, vos objets métier.

Dès que vous avez plus d'une poignée de tests, le même problème revient :

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

test('l\'utilisateur peut ajouter un élément', async ({ page }) => {
  // Setup — répété dans chaque test
  await page.goto('https://lab.becomeqa.com');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByLabel('Username').fill('admin@becomeqa.com');
  await page.getByLabel('Password').fill('testpass123');
  await page.getByRole('button', { name: 'Submit' }).click();
  await page.getByText('My Travel Items').waitFor();

  // Le vrai test
  await page.getByRole('button', { name: 'Add Item' }).click();
  await page.getByLabel('Item name').fill('Passeport');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByRole('cell', { name: 'Passeport' })).toBeVisible();
});

Cinq lignes de connexion avant d'arriver au test. Multipliez par 40 tests et vous avez 200 lignes qui n'apportent aucune valeur à votre suite. Changez le flux de connexion et vous mettez à jour 40 fichiers.

Les fixtures personnalisées déplacent le setup hors du test vers une définition partagée. Le test reçoit le résultat (une page authentifiée, un objet page prêt à l'emploi) sans se soucier de comment il a été préparé.

Le schéma test.extend()

L'API est test.extend(). Vous lui passez un objet où chaque clé est un nom de fixture et chaque valeur est une fonction async qui reçoit les fixtures existantes et un callback use.

Voici l'exemple minimal : une fixture qui se connecte avant l'exécution du test.

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

type Fixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<Fixtures>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('https://lab.becomeqa.com');
    await page.getByRole('button', { name: 'Login' }).click();
    await page.getByLabel('Username').fill('admin@becomeqa.com');
    await page.getByLabel('Password').fill('testpass123');
    await page.getByRole('button', { name: 'Submit' }).click();
    await page.getByText('My Travel Items').waitFor();

    // Passer la page authentifiée au test
    await use(page);

    // Le teardown s'exécute après le retour de use()
    // (rien à nettoyer ici — la page se ferme automatiquement)
  },
});

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

Le fichier de test importe maintenant test depuis le fichier de fixture plutôt que depuis Playwright directement :

// tests/items.spec.ts
import { test, expect } from '../fixtures/auth.fixture';

test('l\'utilisateur peut ajouter un élément', async ({ authenticatedPage }) => {
  const page = authenticatedPage;

  await page.getByRole('button', { name: 'Add Item' }).click();
  await page.getByLabel('Item name').fill('Passeport');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByRole('cell', { name: 'Passeport' })).toBeVisible();
});

Le test démarre directement sur le tableau de bord. La connexion a disparu. L'intention est immédiatement lisible.

Réexportez toujours expect depuis votre fichier de fixture : export { expect } from '@playwright/test'. Les fichiers de test n'ont besoin que d'une seule ligne d'import, et vous ne risquez pas d'utiliser accidentellement le expect de Playwright au lieu d'une version personnalisée.

Fixtures avec Page Objects

Les fixtures et les page objects résolvent des problèmes différents mais fonctionnent très bien ensemble. Un page object encapsule le comment d'interaction avec une page. Une fixture gère le quand : configurer l'objet et l'injecter dans le test.

Commencez par des page objects simples :

// pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';

export class DashboardPage {
  readonly page: Page;
  readonly addItemButton: Locator;
  readonly itemsTable: Locator;

  constructor(page: Page) {
    this.page = page;
    this.addItemButton = page.getByRole('button', { name: 'Add Item' });
    this.itemsTable = page.getByRole('table');
  }

  async isLoaded() {
    await this.page.getByText('My Travel Items').waitFor({ state: 'visible' });
  }

  async getRowCount() {
    const rows = this.page.getByRole('row');
    return (await rows.count()) - 1; // soustraire la ligne d'en-tête
  }
}

// pages/AddItemModal.ts
import { Page, Locator } from '@playwright/test';

export class AddItemModal {
  readonly itemNameInput: Locator;
  readonly categorySelect: Locator;
  readonly saveButton: Locator;

  constructor(page: Page) {
    this.itemNameInput = page.getByLabel('Item name');
    this.categorySelect = page.getByLabel('Category');
    this.saveButton = page.getByRole('button', { name: 'Save' });
  }

  async fillAndSave(name: string, category: string) {
    await this.itemNameInput.fill(name);
    await this.categorySelect.selectOption(category);
    await this.saveButton.click();
  }
}

Connectez-les ensuite en tant que fixtures :

// fixtures/pages.fixture.ts
import { test as base } from '@playwright/test';
import { DashboardPage } from '../pages/DashboardPage';
import { AddItemModal } from '../pages/AddItemModal';

type PageFixtures = {
  dashboardPage: DashboardPage;
  addItemModal: AddItemModal;
};

export const test = base.extend<PageFixtures>({
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
  addItemModal: async ({ page }, use) => {
    await use(new AddItemModal(page));
  },
});

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

Les tests déclarent exactement les objets dont ils ont besoin :

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

test('le tableau de bord affiche les éléments existants', async ({ page, dashboardPage }) => {
  await page.goto('https://lab.becomeqa.com');
  // ... étapes de connexion
  await dashboardPage.isLoaded();

  const count = await dashboardPage.getRowCount();
  expect(count).toBeGreaterThan(0);
});

Setup et teardown dans les fixtures

Le callback use est la frontière entre setup et teardown. Le code avant await use(value) s'exécute avant le test. Le code après s'exécute après la fin du test, que celui-ci ait réussi ou échoué.

C'est là que les fixtures brillent pour tout ce qui nécessite un nettoyage :

// fixtures/data.fixture.ts
import { test as base, request } from '@playwright/test';

type DataFixtures = {
  testItemId: string;
};

export const test = base.extend<DataFixtures>({
  testItemId: async ({}, use) => {
    // Setup : créer un élément via l'API avant le test
    const apiContext = await request.newContext({
      baseURL: 'https://lab.becomeqa.com/api',
      extraHTTPHeaders: {
        Authorization: 'Bearer test-token-123',
      },
    });

    const response = await apiContext.post('/items', {
      data: { name: 'Fixture Item', category: 'Documents' },
    });
    const { id } = await response.json();

    // Passer l'ID au test
    await use(id);

    // Teardown : supprimer l'élément après le test
    await apiContext.delete(`/items/${id}`);
    await apiContext.dispose();
  },
});

Le test ne gère pas du tout le cycle de vie de l'élément :

test('l\'utilisateur peut supprimer un élément', async ({ page, testItemId }) => {
  // L'élément existe déjà. Navigation et suppression directement.
  await page.goto(`https://lab.becomeqa.com/items`);
  await page.getByTestId(`item-row-${testItemId}`).getByRole('button', { name: 'Delete' }).click();
  await page.getByRole('button', { name: 'Confirm' }).click();

  await expect(page.getByTestId(`item-row-${testItemId}`)).not.toBeVisible();
});

Si le test échoue à mi-chemin, le teardown s'exécute quand même. L'appel API supprime l'élément. Votre base de données n'accumule pas de données de test résiduelles.

N'utilisez pas throw dans le bloc de teardown. Si votre code de nettoyage lève une erreur, Playwright peut signaler un échec de test confus ou masquer l'échec d'origine. Enveloppez le teardown dans un try/catch et loggez les erreurs plutôt que de les relancer.

Scope des fixtures : test vs worker

Par défaut, chaque fixture est créée pour chaque test. C'est le scope: 'test'. Valeur sûre par défaut. Les tests sont isolés, sans partage d'état.

Mais l'authentification est coûteuse. Navigation, clic, remplissage, attente : 1 à 3 secondes par test. Avec 100 tests qui se connectent chacun depuis zéro, c'est potentiellement 3 minutes passées sur la connexion seule.

Le scope worker exécute la fixture une fois par processus worker et partage le résultat entre tous les tests de ce worker. Pour l'authentification, l'approche correcte est de sauvegarder l'état de stockage du navigateur une fois et de le réutiliser :

// fixtures/worker-auth.fixture.ts
import { test as base, chromium, BrowserContext } from '@playwright/test';

type WorkerFixtures = {
  workerContext: BrowserContext;
};

export const test = base.extend<{}, WorkerFixtures>({
  workerContext: [
    async ({}, use) => {
      // S'exécute une fois par worker, pas une fois par test
      const browser = await chromium.launch();
      const context = await browser.newContext();
      const page = await context.newPage();

      await page.goto('https://lab.becomeqa.com');
      await page.getByRole('button', { name: 'Login' }).click();
      await page.getByLabel('Username').fill('admin@becomeqa.com');
      await page.getByLabel('Password').fill('testpass123');
      await page.getByRole('button', { name: 'Submit' }).click();
      await page.getByText('My Travel Items').waitFor();

      await use(context);

      await context.close();
      await browser.close();
    },
    { scope: 'worker' },
  ],
});

Notez le second argument { scope: 'worker' }. C'est comment vous optez pour ce mode. Les fixtures scopées au worker sont déclarées dans le second paramètre générique de extend() plutôt que dans le premier.

Conséquence pratique : les fixtures scopées au worker partagent l'état entre les tests. C'est acceptable pour un contexte authentifié en lecture seule. C'est problématique si les tests modifient l'état partagé (un test se déconnecte, le suivant ne trouve plus la session). Utilisez le scope worker pour les éléments coûteux à créer et sûrs à partager ; utilisez le scope test pour tout le reste.

Composer les fixtures

Les fixtures peuvent utiliser d'autres fixtures. C'est là que le schéma passe à l'échelle sur de vrais projets.

Vous avez une fixture d'auth qui gère la connexion. Vous avez des fixtures de page objects. Vous voulez une fixture qui livre un tableau de bord déjà authentifié, combinant les deux :

// fixtures/index.ts
import { test as base, Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { AddItemModal } from '../pages/AddItemModal';

type AppFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  addItemModal: AddItemModal;
  loggedInDashboard: DashboardPage;
};

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

  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },

  addItemModal: async ({ page }, use) => {
    await use(new AddItemModal(page));
  },

  // Cette fixture utilise loginPage et dashboardPage
  loggedInDashboard: async ({ loginPage, dashboardPage }, use) => {
    await loginPage.goto();
    await loginPage.login('admin@becomeqa.com', 'testpass123');
    await dashboardPage.isLoaded();

    await use(dashboardPage);
  },
});

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

Les tests qui ont besoin d'un tableau de bord authentifié l'obtiennent en un mot :

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

test('le tableau de bord affiche au moins un élément', async ({ loggedInDashboard }) => {
  const count = await loggedInDashboard.getRowCount();
  expect(count).toBeGreaterThan(0);
});

test('l\'utilisateur peut ouvrir la modale d\'ajout', async ({ loggedInDashboard, addItemModal }) => {
  await loggedInDashboard.addItemButton.click();
  await expect(addItemModal.itemNameInput).toBeVisible();
});

Playwright résout automatiquement le graphe de dépendances des fixtures. Quand un test demande loggedInDashboard, Playwright voit qu'il dépend de loginPage et dashboardPage, les crée en premier, puis exécute le setup de loggedInDashboard. Vous ne gérez jamais cette résolution manuellement.

La structure de dossiers qui émerge de cette approche :

03_enterprise_pom/
  pages/
    LoginPage.ts
    DashboardPage.ts
    AddItemModal.ts
  fixtures/
    index.ts          ← toutes les fixtures exportées depuis un seul endroit
  tests/
    items/
      items-list.spec.ts
      items-crud.spec.ts
    payments/
      payment-flow.spec.ts
  playwright.config.ts

Chaque fichier de test importe depuis ../fixtures et obtient tout ce dont il a besoin sans boilerplate.

Quand ne pas utiliser les fixtures

Les fixtures sont assez puissantes pour que les équipes les surutilisent parfois. Une fixture utilisée par un seul test n'est pas une fixture, c'est du setup inline avec plus de cérémonie. Avant de créer une fixture, demandez-vous : au moins trois tests vont-ils l'utiliser, ou ce setup complique-t-il vraiment le test s'il est inline ?

Quelques cas spécifiques où les fixtures ajoutent de la friction plutôt qu'ils n'en réduisent :

Scénarios de test ponctuels. Si un test vérifie un comportement après un état très spécifique et inhabituel, le setup inline est plus clair. C'est le cas d'un élément avec un champ corrompu ou d'une session sur le point d'expirer. Le caractère inhabituel du setup est lui-même une documentation. Tests qui doivent vérifier le setup. Si votre test porte sur le flux de connexion, vous voulez que les étapes de connexion soient visibles dans le test. Les masquer derrière une fixture loggedInDashboard va à l'encontre du but. Les tests sur l'authentification doivent utiliser la fixture page brute et configurer l'état explicitement. Setup qui varie significativement entre les tests. Si chaque test a besoin de données initiales légèrement différentes, une fixture qui tente d'accommoder toutes les variations va accumuler des paramètres. Elle devient plus difficile à comprendre qu'un simple setup inline. Une fonction factory (une fonction TypeScript ordinaire appelée par le test) est souvent plus propre qu'une fixture paramétrée.

// Au lieu d'une fixture paramétrée complexe, utilisez une fonction factory
// helpers/createItem.ts
import { APIRequestContext } from '@playwright/test';

export async function createItem(
  request: APIRequestContext,
  overrides: Partial<{ name: string; category: string }> = {}
) {
  const response = await request.post('https://lab.becomeqa.com/api/items', {
    data: {
      name: 'Default Item',
      category: 'Documents',
      ...overrides,
    },
  });
  return response.json();
}

// tests/items-edge-cases.spec.ts
import { test, expect } from '@playwright/test';
import { createItem } from '../helpers/createItem';

test('un élément avec un nom très long est tronqué dans le tableau', async ({ page, request }) => {
  const item = await createItem(request, { name: 'A'.repeat(256) });

  await page.goto('https://lab.becomeqa.com/items');
  // ... reste du test
});

La distinction mérite d'être claire : les fixtures servent à l'infrastructure (état authentifié, contexte partagé, page objects). La logique métier et le setup de données spécifiques aux tests appartient souvent aux fonctions helper.

La fonctionnalité storageState de Playwright est une alternative pratique aux fixtures de connexion pour certains projets. Vous exécutez un script auth.setup.ts une fois qui sauvegarde l'état du navigateur connecté dans un fichier JSON, puis tous les tests chargent cet état via playwright.config.ts. Cette approche est plus rapide qu'une fixture de connexion par test, mais nécessite un projet setup dans votre config. Les deux approches sont valides.

FAQ

Peut-on surcharger une fixture pour un test spécifique ?

Oui. Utilisez test.extend() à nouveau pour créer une version plus spécifique, ou utilisez test.use() dans un bloc describe pour surcharger les options de fixture pour ce groupe. test.use() accepte un objet avec des valeurs de fixture et les applique à tous les tests dans la portée courante.

Faut-il mettre toutes les fixtures dans un seul fichier ou les séparer ?

Séparez par domaine quand le fichier grossit. Un projet peut avoir auth.fixture.ts, data.fixture.ts et pages.fixture.ts, puis tout réexporter depuis un index.ts. Les fichiers de test importent depuis l'index et n'ont jamais besoin de savoir quel fichier contient quelle fixture.

Les fixtures fonctionnent-elles avec test.describe ?

Oui. Les fixtures sont disponibles dans n'importe quel bloc describe. Vous pouvez aussi utiliser test.describe.configure({ mode: 'parallel' }) dans un bloc describe. Les fixtures respectent automatiquement le paramètre de parallélisme.

Que se passe-t-il si le setup d'une fixture lève une erreur ?

Playwright marque le test comme échoué et tente quand même d'exécuter le code de teardown des fixtures qui ont complété leur phase de setup. Les fixtures qui n'ont jamais atteint await use() n'ont pas leur code de teardown exécuté.

Peut-on utiliser des fixtures dans beforeAll ou beforeEach ?

Pas directement. beforeAll et beforeEach ne reçoivent pas de fixtures en arguments. Si vous avez besoin d'un setup partagé qui utilise des fixtures, convertissez le beforeEach en fixture avec son propre scope. C'est l'une des meilleures raisons d'adopter les fixtures : elles rendent beforeAll/beforeEach largement inutiles.

→ See also: Fixtures Playwright Expliquées: Des Intégrées aux Personnalisées | Page Object Model dans Playwright: Du Chaos à la Maintenabilité | Gérer l'Authentification dans Playwright avec storageState (Sans Se Connecter à Chaque Test) | Données de Test Réutilisables: Factories, Fixtures et Faker.js dans Playwright