Cinco linhas de setup de login copiadas em 40 testes significa atualizar 40 arquivos quando o fluxo de login muda. Fixtures customizadas eliminam isso com test.extend(): o código antes de await use(value) roda como setup, o código depois roda como teardown, independente de o teste ter passado ou falhado.

Por que as fixtures nativas não são suficientes

O Playwright vem com fixtures como page, browser, context e request. Elas cobrem o básico. Mas não sabem nada sobre sua aplicação: seu fluxo de login, seu estado autenticado, seus objetos de domínio.

A partir do momento em que você tem mais do que um punhado de testes, começa a se deparar com o mesmo problema repetidamente:

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

test('usuário pode adicionar item de viagem', async ({ page }) => {
  // Setup — repetido em cada teste
  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();

  // Teste em si
  await page.getByRole('button', { name: 'Add Item' }).click();
  await page.getByLabel('Item name').fill('Passaporte');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByRole('cell', { name: 'Passaporte' })).toBeVisible();
});

Cinco linhas de login antes de chegar ao teste. Multiplique por 40 testes e você tem 200 linhas de código que não agregam nenhum valor à suite. Mude o fluxo de login e terá que atualizar todos os 40 arquivos.

Fixtures customizadas movem o setup para fora do teste e para uma definição compartilhada. O teste recebe o resultado (uma página autenticada, um page object pronto para uso) sem precisar saber como foi preparado.

O padrão test.extend()

A API é test.extend(). Você passa um objeto onde cada chave é um nome de fixture e cada valor é uma função assíncrona que recebe fixtures existentes e um callback use.

O exemplo mínimo: uma fixture que faz login antes do teste rodar.

// 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();

    // Entrega a página autenticada para o teste
    await use(page);

    // O teardown roda depois que use() retorna
    // (nada a limpar aqui — a página fecha automaticamente)
  },
});

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

Agora o arquivo de teste importa test do arquivo de fixture em vez de diretamente do Playwright:

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

test('usuário pode adicionar item de viagem', async ({ authenticatedPage }) => {
  const page = authenticatedPage;

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

O teste começa no dashboard. O login sumiu. A intenção é imediatamente óbvia.

Sempre re-exporte expect do seu arquivo de fixture: export { expect } from '@playwright/test'. Assim os arquivos de teste precisam de apenas uma linha de import, e você não corre o risco de usar o expect padrão do Playwright em vez de uma versão customizada.

Fixtures com page objects

Fixtures e page objects resolvem problemas diferentes, mas funcionam muito bem juntos. Um page object encapsula o como de interagir com uma página. Uma fixture cuida do quando: configurar o objeto e injetá-lo no teste.

Comece com 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; // subtrai a linha de cabeçalho
  }
}

// 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();
  }
}

Agora conecte como 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';

Os testes declaram exatamente os objetos de que precisam:

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

test('dashboard mostra itens existentes', async ({ page, dashboardPage }) => {
  await page.goto('https://lab.becomeqa.com');
  // ... steps de login
  await dashboardPage.isLoaded();

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

Setup e teardown em fixtures

O callback use é a linha divisória entre setup e teardown. O código antes de await use(value) roda antes do teste. O código depois roda após o término do teste, seja ele aprovado ou não.

É aqui que as fixtures começam a brilhar para qualquer coisa que precise de cleanup:

// 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: cria um item de viagem via API antes do teste
    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();

    // Entrega o ID para o teste
    await use(id);

    // Teardown: deleta o item depois do teste
    await apiContext.delete(`/items/${id}`);
    await apiContext.dispose();
  },
});

O teste não gerencia o ciclo de vida do item:

test('usuário pode deletar item de viagem', async ({ page, testItemId }) => {
  // O item já existe. Só navegar e deletar.
  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();
});

Se o teste falhar no meio, o teardown ainda roda. A chamada de API ainda deleta o item. Seu banco não acumula dados de teste sobrando.

Não lance erros dentro do bloco de teardown. Se o código de cleanup lançar um erro, o Playwright pode reportar uma falha confusa no teste ou mascarar a falha original. Envolva o teardown em try/catch e registre erros em vez de relançá-los.

Escopo de fixture: test vs worker

Por padrão, cada fixture é criada do zero para cada teste. Esse é o scope: 'test'. É o padrão seguro — testes ficam isolados e não há vazamento de estado entre eles.

Mas autenticação é cara. Navegar, clicar, preencher, esperar: isso leva de 1 a 3 segundos por teste. Com 100 testes, todos logando do zero, são potencialmente 3 minutos gastos só em login.

O escopo de worker roda a fixture uma vez por processo worker e compartilha o resultado entre todos os testes daquele worker. A abordagem certa para autenticação é salvar o storage state do navegador uma vez e reutilizá-lo:

// 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) => {
      // Isso roda uma vez por worker, não uma vez por teste
      const browser = await chromium.launch();
      const context = await browser.newContext();
      const page = await context.newPage();

      // Faz login e salva estado
      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' },
  ],
});

Note o segundo argumento { scope: 'worker' }. É assim que você opta pelo escopo de worker. Fixtures com escopo de worker são declaradas no segundo parâmetro genérico de extend(), não no primeiro.

A implicação prática: fixtures com escopo de worker compartilham estado entre testes. Isso é ok para um contexto autenticado de leitura. Vira problema se testes modificam o estado compartilhado (um teste faz logout, o próximo não encontra a sessão). Use escopo de worker para coisas caras de criar e seguras de compartilhar; use escopo de test para todo o resto.

Compondo fixtures

Fixtures podem usar outras fixtures. É aqui que o padrão escala para projetos reais.

Você tem uma fixture de auth para o login. Tem fixtures de page objects. Quer uma fixture que entregue um dashboard já autenticado, combinando as duas:

// 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));
  },

  // Esta fixture usa loginPage e 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';

Testes que precisam de um dashboard autenticado recebem com uma palavra:

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

test('dashboard mostra pelo menos um item', async ({ loggedInDashboard }) => {
  const count = await loggedInDashboard.getRowCount();
  expect(count).toBeGreaterThan(0);
});

test('usuário pode abrir modal de adicionar item', async ({ loggedInDashboard, addItemModal }) => {
  await loggedInDashboard.addItemButton.click();
  await expect(addItemModal.itemNameInput).toBeVisible();
});

O Playwright resolve o grafo de dependências das fixtures automaticamente. Quando um teste solicita loggedInDashboard, o Playwright vê que depende de loginPage e dashboardPage, cria esses primeiro, depois roda o setup de loggedInDashboard. Você nunca gerencia essa resolução manualmente.

A estrutura de pastas que emerge dessa abordagem:

projeto/
  pages/
    LoginPage.ts
    DashboardPage.ts
    AddItemModal.ts
  fixtures/
    index.ts          ← todas as fixtures exportadas de um lugar
  tests/
    items/
      items-list.spec.ts
      items-crud.spec.ts
    payments/
      payment-flow.spec.ts
  playwright.config.ts

Todo arquivo de teste importa de ../fixtures e recebe tudo que precisa sem boilerplate.

Quando NÃO usar fixtures

Fixtures são poderosas o suficiente para times às vezes over-engineerarem. Uma fixture usada por um único teste não é uma fixture. É só setup inline com cerimônia extra. Antes de criar uma fixture, pergunte: pelo menos três testes vão usar isso, ou esse setup vai genuinamente complicar um teste se for inline?

Alguns casos específicos onde fixtures adicionam atrito em vez de removê-lo:

Cenários de teste únicos. Se você tem um teste que verifica comportamento após um estado muito específico e incomum (um item com campo corrompido, uma sessão prestes a expirar), o setup inline é mais claro. A natureza incomum do setup é em si uma documentação. Testes que precisam verificar o próprio setup. Se o seu teste é sobre o fluxo de login, você quer os steps de login visíveis no teste. Escondê-los atrás de uma fixture loggedInDashboard derrota o propósito. Testes sobre autenticação devem usar a fixture page diretamente e configurar o estado explicitamente. Setup que varia muito entre testes. Se cada teste precisa de dados iniciais ligeiramente diferentes, uma fixture que tenta acomodar todas as variações vai crescer. A lista de parâmetros fica mais difícil de entender do que escrever o setup inline. Uma função factory (uma função TypeScript comum que o teste chama) é frequentemente mais limpa do que uma fixture parametrizada.

// Em vez de uma fixture parametrizada complexa, use uma função 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('item com nome muito longo é truncado na tabela', async ({ page, request }) => {
  const item = await createItem(request, { name: 'A'.repeat(256) });

  await page.goto('https://lab.becomeqa.com/items');
  // ... resto do teste
});

A distinção vale ser dita claramente: fixtures são para infraestrutura, como estado autenticado, contexto compartilhado e page objects. Lógica de negócio e setup de dados específicos de testes frequentemente pertencem a funções helper.

O storageState do Playwright é uma alternativa prática a uma fixture de login para alguns projetos. Você roda um script auth.setup.ts uma vez que salva o estado do navegador logado em um arquivo JSON, depois todos os testes carregam esse estado via playwright.config.ts. Essa abordagem é mais rápida do que um login por fixture por teste, mas requer um projeto de setup no config. As duas abordagens são válidas.

FAQ

Posso sobrescrever uma fixture para um teste específico?

Sim. Use test.extend() novamente para criar uma versão mais específica, ou use test.use() dentro de um bloco describe para sobrescrever opções de fixture para aquele grupo.

Devo colocar todas as fixtures em um arquivo ou dividir?

Divida por domínio quando o arquivo ficar grande. Um projeto pode ter auth.fixture.ts, data.fixture.ts e pages.fixture.ts, depois re-exportar tudo de um index.ts. Os arquivos de teste importam do index e nunca precisam saber em qual arquivo cada fixture vive.

As fixtures funcionam com test.describe?

Sim. Fixtures estão disponíveis dentro de qualquer bloco describe. Você também pode usar test.describe.configure({ mode: 'parallel' }) dentro de um bloco describe. As fixtures respeitam a configuração de paralelismo automaticamente.

O que acontece se o setup de uma fixture lançar erro?

O Playwright marca o teste como falhado e ainda tenta rodar o código de teardown em fixtures que completaram sua fase de setup. Fixtures que nunca chegaram ao await use() não têm seu código de teardown executado.

Posso usar fixtures em beforeAll ou beforeEach?

Não diretamente. beforeAll e beforeEach não recebem fixtures como argumentos. Se você precisa de setup compartilhado que usa fixtures, converta o beforeEach em uma fixture com seu próprio escopo. Essa é uma das motivações mais limpas para adotar fixtures: elas tornam beforeAll/beforeEach largamente desnecessários.

→ Veja também: Fixtures do Playwright Explicadas: Das Integradas às Personalizadas | Page Object Model no Playwright: Do Caos à Manutenibilidade | Autenticação no Playwright com storageState (Sem Login em Cada Teste) | Dados de Teste Reutilizáveis: Factories, Fixtures e Faker.js no Playwright | Arquivo de Configuração do Playwright Explicado: Todas as Opções