Quando cada teste importa page objects diretamente e cuida do próprio setup de login, uma única mudança de fixture exige atualizar dezenas de arquivos. A solução é um índice de fixtures: um arquivo que mescla todas as definições de fixture, para que os testes importem de ../../fixtures e de nada mais.

A estrutura de pastas que realmente escala

Decisões de arquitetura tomadas no nível de pastas se propagam para todo lugar. Uma estrutura que reflete o propósito do framework (separação entre lógica de teste, interação com páginas, setup de dados e utilitários) mantém a complexidade gerenciável conforme a suite cresce.

my-app-tests/
  tests/
    auth/
      login.spec.ts
      logout.spec.ts
    items/
      items-crud.spec.ts
      items-search.spec.ts
  pages/
    BasePage.ts
    LoginPage.ts
    DashboardPage.ts
  fixtures/
    index.ts
    auth.fixture.ts
    pages.fixture.ts
    data.fixture.ts
  data/
    factories/
      userFactory.ts
      itemFactory.ts
    seeds/
      seedItems.ts
  helpers/
    waitHelpers.ts
    apiHelpers.ts
  utils/
    envConfig.ts
    logger.ts
  playwright.config.ts
  tsconfig.json
  .eslintrc.json
  .env.example

As regras que fazem isso funcionar: tests/ contém apenas arquivos spec e nenhuma lógica compartilhada. pages/ contém apenas classes de page object. fixtures/ é a camada de cola que conecta tudo. data/ possui toda a criação e seed de dados de teste. helpers/ guarda funções reutilizáveis que não pertencem a uma página específica. utils/ guarda infraestrutura: config, logging, qualquer coisa de nível de framework.

Testes importam de fixtures/index.ts e de nada mais. Essa única restrição mantém o grafo de dependências limpo.

A classe base de page object

Todo page object em uma suite em crescimento precisa do mesmo conjunto de capacidades. Navegação, espera pela página atingir um estado conhecido, e uma forma consistente de lidar com padrões comuns de UI. Sem uma classe base, esses padrões são copiados entre páginas e divergem com o tempo.

// pages/BasePage.ts
import { Page, Locator, expect } from '@playwright/test';
import { envConfig } from '../utils/envConfig';

export abstract class BasePage {
  protected readonly page: Page;
  abstract readonly path: string;

  constructor(page: Page) {
    this.page = page;
  }

  async navigate(params?: Record<string, string>): Promise<void> {
    const url = new URL(this.path, envConfig.baseURL);
    if (params) {
      Object.entries(params).forEach(([key, value]) => {
        url.searchParams.set(key, value);
      });
    }
    await this.page.goto(url.toString());
    await this.waitForPageLoad();
  }

  protected async waitForPageLoad(): Promise<void> {
    await this.page.waitForLoadState('domcontentloaded');
  }

  async waitForVisible(locator: Locator, timeout = 10_000): Promise<void> {
    await locator.waitFor({ state: 'visible', timeout });
  }

  async waitForHidden(locator: Locator, timeout = 10_000): Promise<void> {
    await locator.waitFor({ state: 'hidden', timeout });
  }

  async assertHeading(text: string): Promise<void> {
    await expect(this.page.getByRole('heading', { name: text })).toBeVisible();
  }

  async assertURL(expectedPath: string): Promise<void> {
    await expect(this.page).toHaveURL(new RegExp(expectedPath));
  }

  async dismissModal(): Promise<void> {
    const overlay = this.page.locator('[data-testid="modal-overlay"]');
    if (await overlay.isVisible()) {
      await this.page.keyboard.press('Escape');
      await this.waitForHidden(overlay);
    }
  }
}

Page objects estendem a base e definem apenas o que é específico àquela página:

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

export class DashboardPage extends BasePage {
  readonly path = '/dashboard';
  readonly addItemButton: Locator;
  readonly itemsTable: Locator;
  readonly searchInput: Locator;

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

  protected override async waitForPageLoad(): Promise<void> {
    await super.waitForPageLoad();
    await this.itemsTable.waitFor({ state: 'visible' });
  }

  async getRowCount(): Promise<number> {
    const rows = this.itemsTable.getByRole('row');
    return (await rows.count()) - 1;
  }

  async searchFor(term: string): Promise<void> {
    await this.searchInput.fill(term);
    await this.page.waitForResponse('**/api/items?search=**');
  }
}

O override de waitForPageLoad em DashboardPage é o padrão que elimina testes flaky em escala. Cada página define sua própria condição de "pronta", e a navegação aguarda essa condição antes de retornar. Testes nunca precisam adicionar esperas manuais.

A camada de fixtures

Fixtures são o sistema de injeção de dependências do framework. Um arquivo exporta tudo que os testes precisam: o objeto test estendido, todos os page objects, helpers de dados e expect. Arquivos de teste importam de exatamente um lugar.

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

type AuthFixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto(envConfig.baseURL);
    await page.getByRole('button', { name: 'Login' }).click();
    await page.getByLabel('Username').fill(envConfig.testUser.email);
    await page.getByLabel('Password').fill(envConfig.testUser.password);
    await page.getByRole('button', { name: 'Submit' }).click();
    await page.getByRole('heading', { name: 'Dashboard' }).waitFor();

    await use(page);
  },
});

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

type PageFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

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

// fixtures/index.ts
import { mergeTests, mergeExpects } from '@playwright/test';
import { test as authTest } from './auth.fixture';
import { test as pagesTest } from './pages.fixture';
import { test as dataTest } from './data.fixture';

export const test = mergeTests(authTest, pagesTest, dataTest);
export { expect } from '@playwright/test';

mergeTests é a ferramenta certa aqui: compõe fixtures de múltiplos arquivos sem perder type safety. Todo teste do projeto agora tem o mesmo import:

import { test, expect } from '../../fixtures';

Esse único import dá a cada teste acesso a authenticatedPage, dashboardPage, loginPage e todas as fixtures de dados. Adicionar uma nova fixture significa editar um arquivo em fixtures/ e ela fica disponível em todo lugar imediatamente.

Mantenha o arquivo de índice de fixtures enxuto: apenas chamadas mergeTests e re-exports. No momento em que você colocar lógica de fixture diretamente em index.ts, fica mais difícil localizar onde uma fixture específica está definida. Um arquivo de fixture por domínio (auth, pages, data) mantém a navegabilidade.

Gerenciamento de config entre ambientes

URLs hardcoded são a forma mais rápida de tornar uma suite de testes impossível de manter. A config específica de ambiente precisa de uma única fonte de verdade que o resto do framework lê.

// utils/envConfig.ts
import * as dotenv from 'dotenv';
import * as path from 'path';

const envFile = process.env.TEST_ENV
  ? `.env.${process.env.TEST_ENV}`
  : '.env';

dotenv.config({ path: path.resolve(process.cwd(), envFile) });

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(
      `Missing required environment variable: ${name}. ` +
      `Did you copy .env.example to ${envFile}?`
    );
  }
  return value;
}

export const envConfig = {
  baseURL: requireEnv('BASE_URL'),
  apiBaseURL: requireEnv('API_BASE_URL'),
  testUser: {
    email: requireEnv('TEST_USER_EMAIL'),
    password: requireEnv('TEST_USER_PASSWORD'),
  },
  apiToken: requireEnv('API_TOKEN'),
  environment: (process.env.TEST_ENV ?? 'local') as 'local' | 'staging' | 'prod',
} as const;

Três arquivos .env ficam na raiz do projeto e são commitados no repositório (secrets vão em variáveis de CI, não aqui):

# .env.example
BASE_URL=http://localhost:3000
API_BASE_URL=http://localhost:3001/api
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=replace_me
API_TOKEN=replace_me

# .env.staging
BASE_URL=https://staging.myapp.com
API_BASE_URL=https://staging.myapp.com/api
TEST_USER_EMAIL=staging-test@myapp.com
TEST_USER_PASSWORD=
API_TOKEN=

playwright.config.ts lê de envConfig em vez de process.env diretamente:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { envConfig } from './utils/envConfig';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: envConfig.environment !== 'local',
  retries: envConfig.environment === 'local' ? 0 : 2,
  workers: envConfig.environment === 'local' ? undefined : 4,
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['./utils/slackReporter.ts'],
  ],
  use: {
    baseURL: envConfig.baseURL,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
});

Rodar contra staging agora é uma variável de ambiente: TEST_ENV=staging npx playwright test.

Estratégia de dados de teste

Testes que dependem de dados criados por um teste anterior são os testes mais frágeis de qualquer suite. Cada teste deve possuir seus dados desde a criação até a limpeza. Três padrões lidam com cenários diferentes: factories para dados dentro do teste, builders para objetos complexos, e seed via API para pré-condições custosas.

Factories geram objetos válidos com defaults sensatos e deixam os testes sobrescrever apenas o que importa para o cenário específico:

// data/factories/itemFactory.ts
import { faker } from '@faker-js/faker';

export interface ItemData {
  name: string;
  category: 'Documents' | 'Electronics' | 'Clothing' | 'Other';
  quantity: number;
  notes?: string;
}

export function buildItem(overrides: Partial<ItemData> = {}): ItemData {
  return {
    name: faker.commerce.productName(),
    category: 'Documents',
    quantity: faker.number.int({ min: 1, max: 10 }),
    ...overrides,
  };
}

export function buildItems(count: number, overrides: Partial<ItemData> = {}): ItemData[] {
  return Array.from({ length: count }, () => buildItem(overrides));
}

Para objetos complexos com muitas dependências, o padrão builder dá aos testes uma API fluente:

// data/factories/userFactory.ts
import { faker } from '@faker-js/faker';

export interface UserData {
  email: string;
  password: string;
  firstName: string;
  lastName: string;
  role: 'admin' | 'member' | 'viewer';
}

export class UserBuilder {
  private data: UserData = {
    email: faker.internet.email(),
    password: 'TestPass123!',
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    role: 'member',
  };

  withRole(role: UserData['role']): this {
    this.data.role = role;
    return this;
  }

  withEmail(email: string): this {
    this.data.email = email;
    return this;
  }

  asAdmin(): this {
    this.data.role = 'admin';
    return this;
  }

  build(): UserData {
    return { ...this.data };
  }
}

O seed via API lida com o caso onde criação pela UI é muito lenta ou cria estado não confiável. A fixture de dados conecta tudo e cuida da limpeza:

// fixtures/data.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { buildItem, ItemData } from '../data/factories/itemFactory';
import { envConfig } from '../utils/envConfig';

type DataFixtures = {
  apiRequest: APIRequestContext;
  seededItem: ItemData & { id: string };
};

export const test = base.extend<DataFixtures>({
  apiRequest: async ({ playwright }, use) => {
    const context = await playwright.request.newContext({
      baseURL: envConfig.apiBaseURL,
      extraHTTPHeaders: {
        Authorization: `Bearer ${envConfig.apiToken}`,
        'Content-Type': 'application/json',
      },
    });
    await use(context);
    await context.dispose();
  },

  seededItem: async ({ apiRequest }, use) => {
    const itemData = buildItem();
    const response = await apiRequest.post('/items', { data: itemData });
    const created = await response.json() as ItemData & { id: string };

    await use(created);

    // Limpeza roda independente de o teste passar ou falhar
    await apiRequest.delete(`/items/${created.id}`).catch(() => {
      console.warn(`Cleanup failed for item ${created.id} — may need manual removal`);
    });
  },
});

O .catch() no teardown é intencional. Se a limpeza lançar um erro, o resultado do teste não deve ser afetado. Logue o aviso e siga em frente.

Nunca dependa da ordem de execução dos testes para configurar estado compartilhado. Testes rodam em paralelo por padrão e a ordem não é garantida. Cada teste deve criar e destruir seus próprios dados. A única exceção são dados de referência somente-leitura (tabelas de lookup, categorias) que nunca mudam: faça o seed desses uma vez por execução via um script de setup global.

Reporters: HTML e notificações no Slack

O reporter HTML embutido é suficiente para desenvolvimento local. Pipelines de CI precisam de algo que entregue os resultados onde o time realmente olha. Na maioria dos casos, Slack.

Um reporter customizado implementa a interface Reporter do Playwright:

// utils/slackReporter.ts
import type {
  Reporter,
  FullConfig,
  Suite,
  TestCase,
  TestResult,
  FullResult,
} from '@playwright/test/reporter';
import * as https from 'https';

export default class SlackReporter implements Reporter {
  private passed = 0;
  private failed = 0;
  private skipped = 0;
  private failedTests: string[] = [];
  private startTime = Date.now();

  onBegin(_config: FullConfig, _suite: Suite): void {
    this.startTime = Date.now();
  }

  onTestEnd(test: TestCase, result: TestResult): void {
    if (result.status === 'passed') this.passed++;
    else if (result.status === 'skipped') this.skipped++;
    else {
      this.failed++;
      this.failedTests.push(test.titlePath().join(' > '));
    }
  }

  async onEnd(result: FullResult): Promise<void> {
    const webhookUrl = process.env.SLACK_WEBHOOK_URL;
    if (!webhookUrl) return;

    const duration = ((Date.now() - this.startTime) / 1000).toFixed(1);
    const status = result.status === 'passed' ? ':white_check_mark:' : ':x:';
    const total = this.passed + this.failed + this.skipped;

    const blocks = [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `${status} *Playwright Tests — ${process.env.TEST_ENV ?? 'local'}*\n${this.passed}/${total} passed in ${duration}s`,
        },
      },
    ];

    if (this.failedTests.length > 0) {
      blocks.push({
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*Failed tests:*\n${this.failedTests.map(t => `• ${t}`).join('\n')}`,
        },
      });
    }

    const payload = JSON.stringify({ blocks });
    await this.postToSlack(webhookUrl, payload);
  }

  private postToSlack(webhookUrl: string, payload: string): Promise<void> {
    return new Promise((resolve, reject) => {
      const url = new URL(webhookUrl);
      const req = https.request(
        { hostname: url.hostname, path: url.pathname, method: 'POST',
          headers: { 'Content-Type': 'application/json' } },
        () => resolve()
      );
      req.on('error', reject);
      req.write(payload);
      req.end();
    });
  }
}

Registre o reporter no playwright.config.ts:

reporter: [
  ['html', { outputFolder: 'playwright-report', open: 'never' }],
  envConfig.environment !== 'local' ? ['./utils/slackReporter.ts'] : ['list'],
],

O reporter do Slack só ativa em ambientes não-locais. Sem ruído durante o desenvolvimento local.

TypeScript strict mode e linting

Código de teste é código de produção. Ele roda no CI e afeta decisões de release. Bugs em testes são mais difíceis de detectar do que bugs no código da aplicação porque não há nada testando os testes. TypeScript strict mode e ESLint detectam categorias inteiras de problemas antes que cheguem a um membro do time.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "exactOptionalPropertyTypes": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "outDir": "./dist",
    "baseUrl": ".",
    "paths": {
      "@fixtures": ["./fixtures/index.ts"],
      "@pages/*": ["./pages/*"],
      "@data/*": ["./data/*"],
      "@helpers/*": ["./helpers/*"],
      "@utils/*": ["./utils/*"]
    }
  },
  "include": ["**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

A configuração de paths permite imports limpos nos arquivos de teste:

import { test, expect } from '@fixtures';
import { buildItem } from '@data/factories/itemFactory';

Para o ESLint, as regras mais importantes para qualidade de testes são as que previnem erros comuns específicos do Playwright:

// .eslintrc.json
{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint", "playwright"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/strict-type-checked",
    "plugin:playwright/recommended"
  ],
  "parserOptions": {
    "project": "./tsconfig.json"
  },
  "rules": {
    "@typescript-eslint/no-floating-promises": "error",
    "@typescript-eslint/await-thenable": "error",
    "playwright/no-wait-for-timeout": "error",
    "playwright/no-conditional-in-test": "warn",
    "playwright/prefer-web-first-assertions": "error",
    "playwright/no-networkidle": "warn"
  }
}

no-floating-promises é a regra mais importante em uma suite de testes Playwright. Esquecer um await antes de uma chamada do Playwright é uma fonte comum de falsos positivos. A assertion roda antes da ação completar, o teste passa, e a UI fica em estado inesperado para o próximo passo. O TypeScript sozinho não detecta isso; a regra de linting vai detectar.

Crescendo junto com uma suite existente: o padrão strangler fig

O padrão strangler fig descreve a substituição incremental de um sistema antigo fazendo crescer um novo ao redor dele. Gradualmente, o tráfego é roteado do antigo para o novo até que nada mais toque o sistema antigo e ele possa ser removido. A mesma abordagem se aplica a frameworks de teste.

Começar uma "reescrita de framework" como um esforço paralelo sempre falha. O novo framework fica em uma branch separada, a suite antiga continua mudando, a branch nunca faz merge. A abordagem strangler fig mantém o time fazendo commit de testes na estrutura antiga enquanto a nova estrutura os absorve gradualmente.

Os passos práticos:

Passo 1: Crie a nova estrutura de pastas ao lado dos testes existentes. Não mova nada ainda.

tests/          ← estrutura plana existente, sem alterações
framework/      ← nova estrutura, começa vazia
  tests/
  pages/
  fixtures/
  ...
playwright.config.ts  ← atualizado para rodar os dois

Atualize playwright.config.ts para incluir ambos os diretórios de teste:

export default defineConfig({
  projects: [
    {
      name: 'legacy',
      testDir: './tests',
      use: { baseURL: envConfig.baseURL },
    },
    {
      name: 'framework',
      testDir: './framework/tests',
      use: { baseURL: envConfig.baseURL },
    },
  ],
});

Passo 2: Ao escrever um novo teste, sempre escreva na nova estrutura. Nunca adicione à pasta plana antiga. Isso para o crescimento da estrutura antiga. Passo 3: Ao modificar um teste existente (para corrigir ou porque a feature que ele cobre mudou), mova-o para a nova estrutura como parte do mesmo PR. O teste melhora e migra em uma única mudança.

// framework/tests/items/items-search.spec.ts
// Migrado de tests/items-search.spec.ts
// Migração: LoginPage extraída, conectada às fixtures, URL hardcoded removida

import { test, expect } from '../../fixtures';

test('search filtra itens por nome', async ({ authenticatedPage, dashboardPage }) => {
  await dashboardPage.navigate();
  await dashboardPage.searchFor('Passport');

  await expect(dashboardPage.itemsTable.getByRole('row')).toHaveCount(2); // cabeçalho + 1 resultado
});

Passo 4: Adicione uma regra de lint ou uma verificação simples no CI que falhe se o diretório tests/ ganhar novos arquivos:

// package.json
"scripts": {
  "check:no-new-legacy-tests": "node scripts/checkLegacyTests.js",
  "test": "playwright test",
  "test:framework": "playwright test --project=framework",
  "test:legacy": "playwright test --project=legacy",
  "lint": "eslint . --ext .ts",
  "typecheck": "tsc --noEmit"
}

O script de verificação lê o git diff e falha se algum arquivo .spec.ts novo aparecer em tests/. Os times param de adicionar à estrutura antiga não por causa de uma regra, mas porque a nova estrutura é claramente melhor. A verificação serve de rede de segurança para quem ainda não percebeu o padrão.

Após alguns meses disso, o diretório legacy contém apenas testes antigos que ninguém tocou. Nesse ponto, um sprint dedicado de migração converte o restante, e o diretório legacy é deletado. A migração aconteceu de forma incremental, o time fez entregas o tempo todo, e o framework está em produção desde o primeiro dia.

FAQ

Quantas páginas uma classe de page object deve cobrir?

Uma página por classe, um modal por classe. Se uma página tem duas seções completamente separadas (uma sidebar e um painel principal com responsabilidades diferentes), divida em duas classes e componha-as na fixture. Uma classe que cobre duas páginas é sinal de que o limite foi traçado no lugar errado.

Fixtures devem conter assertions?

Não. Fixtures configuram e limpam estado. Uma assertion em uma fixture torna impossível dizer se uma falha de teste veio da lógica do teste ou do setup. Se você precisa verificar que o setup completou com sucesso, use waitFor do Playwright com uma condição em vez de uma assertion. Assertions pertencem exclusivamente aos arquivos de teste.

Como lidar com testes que precisam de diferentes roles de usuário?

Crie fixtures de auth separadas, uma por role: adminPage, memberPage, viewerPage. Cada fixture faz login como um usuário diferente e entrega a página autenticada ao teste. Se o número de roles crescer, considere um padrão de factory: authenticatedAs('admin') retorna a fixture certa baseada em um parâmetro.

Qual é o número certo de workers para execução paralela?

Comece com workers: '50%' no playwright.config.ts (metade dos cores de CPU disponíveis). Monitore o uso de recursos do seu runner de CI ao longo de várias execuções. Se os testes começarem a ser flaky por disputa de recursos, reduza os workers. Se o runner tiver folga, aumente. O número certo depende da spec do runner e do quanto de recursos cada teste consome, não de uma fórmula universal.

Quando usar test.describe vs arquivos spec separados?

Arquivos spec separados para features separadas. test.describe para agrupamentos lógicos dentro de uma feature: caminho feliz vs casos extremos, ou operações de leitura vs escrita. A regra prática: se dois grupos de testes precisam de configuração test.use() diferente (overrides de fixture diferentes), eles pertencem a blocos describe separados ou arquivos separados. Se usam o mesmo setup, agrupar é uma escolha de estilo.

→ Veja também: Fixtures Personalizados no Playwright: O Padrão que Torna os Testes Legíveis | Page Object Model no Playwright: Do Caos à Manutenibilidade | Execução Paralela no Playwright: Workers, Shards e Sharding para Velocidade | Isolamento de Testes: Por que Cada Teste Playwright Deve Ser sem Estado