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.exampleAs 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.
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.
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 doisAtualize 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 },
},
],
});// 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
});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.
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.
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.
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.