O Playwright cria um BrowserContext novo para cada teste automaticamente, então o estado do browser (cookies, localStorage, sessão) já fica isolado. O estado da aplicação não. Um let testUserId no nível de módulo, escrito por um teste e lido pelo próximo, quebra no momento em que outro worker paralelo roda esses testes fora de ordem.
O que isolamento de testes realmente significa
Isolamento significa que um teste não faz suposições sobre o mundo antes de rodar e não deixa rastro depois de terminar. Cada teste provisiona o que precisa, faz o seu trabalho, e o ambiente após o teste é idêntico ao ambiente antes de começar.
Essa definição parece óbvia até você ver o que "estado" cobre em um projeto real. Tem o estado do browser (cookies, localStorage, dados de sessão), o estado da aplicação (registros no banco, contas de usuário, feature flags), e o estado do código de teste. Variáveis de nível de módulo e fixtures compartilhadas com efeitos colaterais fazem parte desta última categoria. Qualquer um desses pode vazar entre testes.
As fixtures page e context do Playwright já cuidam do isolamento de estado do browser. Cada teste recebe automaticamente um BrowserContext novo: uma sessão limpa sem cookies, sem localStorage, nada carregado de outro teste. Não é uma feature que você ativa; é o comportamento padrão. Se você usa a fixture page, já está isolado no nível do browser.
// Cada teste recebe um contexto de browser completamente novo. É automático.
test('usuário anônimo vê o botão de login', async ({ page }) => {
await page.goto('/dashboard');
// Sem cookies, sem sessão. Realmente novo.
await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible();
});
test('também anônimo, o teste anterior não deixou rastro', async ({ page }) => {
await page.goto('/dashboard');
// Mesmo estado limpo, independente do que rodou antes
await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible();
});O difícil é o estado da aplicação. O Playwright não isola seu banco de dados. Isso é responsabilidade sua.
Falhas clássicas de isolamento: padrões que você vai reconhecer
A falha de isolamento mais comum tem esta cara. Um arquivo de teste com um teste de setup que cria dados, alguns testes que usam esses dados, e um teste de teardown que os remove. Alguém escreveu assim para evitar repetir o código de criação.
// tests/user-profile.spec.ts
import { test, expect } from '@playwright/test';
// Este é o estado compartilhado, a raiz do problema
let testUserId: number;
test('setup: criar usuário de teste', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Test User', email: 'testuser@example.com' }
});
testUserId = (await response.json()).id;
});
test('consegue visualizar o perfil', async ({ page }) => {
await page.goto(`/users/${testUserId}`);
await expect(page.getByRole('heading', { name: 'Test User' })).toBeVisible();
});
test('consegue editar o nome', async ({ page }) => {
await page.goto(`/users/${testUserId}/edit`);
// ...
});
test('teardown: deletar usuário de teste', async ({ request }) => {
await request.delete(`/api/users/${testUserId}`);
});Funciona perfeitamente quando os testes rodam em sequência na ordem do arquivo. Quebra de quatro formas assim que as condições mudam. Se você ativar fullyParallel, outro worker pode pegar os testes fora de ordem. Se outro teste de outra suite deletar seu usuário, o testUserId não existe mais. Se o teste de setup falhar, testUserId fica undefined para todos os seguintes. E se o arquivo entrar num split de --shard, setup e teardown podem acabar em máquinas diferentes.
A segunda falha clássica é a colisão de email. Um teste cria um usuário com email: 'alice@test.com'. O teste passa. Na próxima execução, o usuário já existe porque o teardown da execução anterior falhou (crash do browser, timeout no CI, erro no teste que pulou o afterAll). Agora você tem um erro 409 Conflict que parece um bug no formulário de cadastro.
// RUIM: email fixo vai colidir na segunda execução
test('cadastrar novo usuário', async ({ page }) => {
await page.goto('/register');
await page.getByLabel('Email').fill('alice@test.com');
await page.getByLabel('Password').fill('Password123!');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Welcome, alice')).toBeVisible();
});
// BOM: email único por execução, sem colisão possível
test('cadastrar novo usuário', async ({ page }) => {
const email = `alice-${Date.now()}@test.com`;
await page.goto('/register');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill('Password123!');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Welcome, alice')).toBeVisible();
});Date.now() é a estratégia de unicidade mais simples. Para IDs mais legíveis, combine com um sufixo aleatório: ` alice-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@test.com . O formato exato não importa, desde que não colida.
Isolamento de dados: usando a API para controlar o mundo do seu teste
O modelo correto de isolamento: cada teste cria tudo que precisa via API no início, faz o trabalho, e deleta no final via
afterEach ou uma fixture de limpeza. Nenhum teste depende de outro ter criado algo.
import { test, expect } from '@playwright/test';
test('admin consegue desativar uma conta de usuário', async ({ page, request }) => {
// Cria os dados que este teste precisa, de propriedade exclusiva deste teste
const createResponse = await request.post('/api/users', {
data: {
name: 'Temporary User',
email: `temp-${Date.now()}@example.com`,
role: 'member'
}
});
expect(createResponse.ok()).toBeTruthy();
const { id: userId } = await createResponse.json();
try {
// O teste em si
await page.goto(`/admin/users/${userId}`);
await page.getByRole('button', { name: 'Deactivate account' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByTestId('account-status')).toHaveText('Inactive');
} finally {
// Limpeza roda mesmo se o teste falhar
await request.delete(`/api/users/${userId}`);
}
});
O padrão
try/finally importa. Se você colocar a limpeza no final do teste sem finally, uma falha vai pular a limpeza e deixar dados órfãos no banco. Com dezenas de execuções de teste, esses registros se acumulam e causam falhas imprevisíveis.
Uma forma mais limpa no Playwright é uma fixture customizada que encapsula o ciclo de vida automaticamente:
// fixtures/api-fixtures.ts
import { test as base, expect } from '@playwright/test';
type ApiFixtures = {
createUser: (overrides?: Partial<{ name: string; email: string; role: string }>) => Promise<{ id: number; email: string }>;
};
export const test = base.extend<ApiFixtures>({
createUser: async ({ request }, use) => {
const createdIds: number[] = [];
const factory = async (overrides = {}) => {
const response = await request.post('/api/users', {
data: {
name: 'Test User',
email: `test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@example.com`,
role: 'member',
...overrides
}
});
const user = await response.json();
createdIds.push(user.id);
return user;
};
await use(factory);
// Limpa todos os usuários criados por este teste, roda após cada teste automaticamente
for (const id of createdIds) {
await request.delete(`/api/users/${id}`);
}
}
});
Agora qualquer teste pode usar
createUser e a limpeza é garantida:
import { test } from '../fixtures/api-fixtures';
import { expect } from '@playwright/test';
test('editor consegue atualizar o perfil do usuário', async ({ page, createUser }) => {
const user = await createUser({ name: 'Jane', role: 'editor' });
await page.goto(`/users/${user.id}`);
await page.getByRole('button', { name: 'Edit profile' }).click();
await page.getByLabel('Display name').fill('Jane Updated');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('heading', { name: 'Jane Updated' })).toBeVisible();
});
O teste é legível, a limpeza é invisível e automática, e criar vários usuários em um teste é só chamar
createUser duas vezes.
Construa suas factories de dados como fixtures desde o início. Refatorar uma suite existente é muito mais difícil do que começar com elas. Um conjunto de fixtures createUser, createOrder e createProduct cobre 80% das necessidades típicas de dados de teste em e-commerce.
storageState e isolamento de auth: login uma vez, sessões isoladas
A fixture
createUser cuida do isolamento de dados. Autenticação é uma preocupação separada. Você não quer que cada teste faça um fluxo completo de login no browser, isso é lento. Mas também não quer que os testes compartilhem uma sessão de browser ativa, porque um teste que faz logout ou muda configurações de conta vai quebrar todos os testes concorrentes.
O padrão correto: fazer login uma vez por worker, não por teste nem globalmente. Salve o
storageState autenticado em um arquivo e carregue-o no início de cada teste. Cada teste recebe seu próprio contexto de browser que começa em estado autenticado, mas os contextos não compartilham nenhuma sessão ativa.
// tests/auth.setup.ts roda uma vez por worker antes da suite
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../.auth/user.json');
setup('autenticar', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// Aguarda passar da página de login
await page.waitForURL('/dashboard');
await expect(page.getByRole('navigation')).toBeVisible();
// Salva o estado de autenticação
await page.context().storageState({ path: authFile });
});
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import path from 'path';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: '**/auth.setup.ts',
},
{
name: 'authenticated tests',
dependencies: ['setup'],
use: {
storageState: path.join(__dirname, '.auth/user.json'),
},
testMatch: '**/*.spec.ts',
},
],
});
Com essa configuração, cada teste começa em estado autenticado sem passar pelo fluxo de login. Como o
storageState carrega de um arquivo para um BrowserContext completamente novo, as sessões são totalmente isoladas. O que o teste A faz na sua sessão não afeta a sessão do teste B.
Se sua aplicação tem múltiplos roles (admin, editor, viewer), crie um arquivo storageState separado para cada role durante o setup. Suas fixtures podem carregar o estado certo com base no que o teste precisa. É muito mais rápido do que fazer login com credenciais diferentes dentro dos testes individuais.
Isolamento é o que torna o paralelismo seguro
Isolamento de testes e execução paralela têm uma relação direta. Você não pode rodar testes em paralelo com segurança se eles compartilham estado, e não pode aproveitar todo o benefício do paralelismo sem isolamento adequado. São dois lados da mesma moeda.
Quando o Playwright roda testes em paralelo, workers diferentes rodam simultaneamente. Não há garantia de ordem entre workers. Se o teste A no worker 1 cria um usuário com
email: 'admin@test.com', o teste B no worker 2 não pode criar o mesmo. Um deles vai falhar com violação de unicidade. Qual? Depende de uma race condition. Essa é a definição de teste flaky.
// playwright.config.ts. Só funciona se os testes forem verdadeiramente isolados.
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // Cada teste em cada arquivo roda de forma concorrente
workers: process.env.CI ? 4 : '50%',
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
},
});
fullyParallel: true é a mudança de configuração de maior valor que você pode fazer em uma suite madura. Uma suite de 150 testes a 3 segundos cada leva 7,5 minutos em sequência. Com 4 workers e isolamento adequado, cai para cerca de 2 minutos. A restrição não é a capacidade do Playwright. É se seus testes estão isolados o suficiente para rodar sem interferir entre si.
Não adicione retries para mascarar falhas de isolamento. Retries são uma ferramenta legítima para flakiness real (timeouts de rede, instabilidades de serviços externos). Mas se um teste falha porque rodou ao mesmo tempo que outro e os dois pisaram nos dados um do outro, o retry provavelmente vai passar, e você nunca vai saber que tem um problema de isolamento até ele se agravar em algo pior. Corrija o isolamento primeiro, depois adicione retries se necessário.
Problemas de estado compartilhado escalam com a quantidade de workers. Com um worker: os testes rodam em uma ordem em que o problema não se manifesta. Com dois workers: falhas ocasionais. Com oito workers: a suite quebra de forma confiável. Se aumentar os workers deixa a suite mais flaky, esse é um sinal quase certo de estado compartilhado em algum lugar.
Encontrando vazamentos de isolamento em uma suite existente
Se você herda uma suite e suspeita de problemas de isolamento, esses são os passos concretos para encontrá-los.
Passo 1: Rode com --workers=1 e compare. Se a suite passa com um worker e falha com dois ou mais, você tem um problema de isolamento. Os testes que falham são as vítimas; os testes que os quebram são mais difíceis de encontrar.
# A suite passa em sequência?
npx playwright test --workers=1
# Ainda passa com paralelismo?
npx playwright test --workers=4
Passo 2: Aleatorize a ordem. Alguns bugs de isolamento só aparecem quando o teste A roda antes do teste B, mas eles sempre rodam na mesma ordem, então você nunca vê a falha. O Playwright não tem randomização de ordem integrada, mas você pode dividir os testes manualmente e rodá-los em sequências diferentes para investigar dependências de ordem.
Passo 3: Procure esses padrões de código especificamente. Variáveis de nível de módulo que os testes escrevem são a causa número 1.
// Faça grep nos seus arquivos de teste por esses padrões. Cada um é um vazamento potencial.
// Variável de nível de módulo sendo atribuída dentro de um teste
let userId: number;
let authToken: string;
let createdRecord: any;
// test.beforeAll criando dados usados por vários testes
test.beforeAll(async ({ request }) => {
// Se algo aqui cria estado mutável compartilhado, você tem um vazamento
});
// Emails fixos, nomes de usuário ou qualquer identificador único fixo
data: { email: 'fixed@test.com' }
data: { username: 'testadmin' }
data: { id: 1 }
Passo 4: Verifique seus caminhos de limpeza. Procure por test.afterAll e verifique que cada chamada de limpeza também está coberta por afterEach ou teardown de fixture. afterAll roda uma vez por suite. Se um teste falha no meio, afterAll ainda roda, mas a limpeza pode estar operando em estado parcial.
Passo 5: Adicione o título do teste aos registros do banco. Durante o desenvolvimento, nomeie seus dados de teste com base no teste que os cria:
const user = await createUser({
name: `Test user for: ${test.info().title}`,
email: `test-${Date.now()}@example.com`
});
Quando você olhar seu banco de teste e ver dez linhas chamadas "Test user for: admin can deactivate a user account", você imediatamente sabe que são falhas de limpeza órfãs daquele teste, e qual teste investigar.
Como aplicar isso na segunda-feira de manhã
Se você tem uma suite existente com problemas de isolamento, não tente corrigir tudo de uma vez. Uma abordagem priorizada que entrega valor imediato:
Primeiros 30 minutos: audite variáveis de nível de módulo nos arquivos de teste. Qualquer let ou var declarado no nível de módulo que é escrito dentro de um bloco test() é um problema. Mova essas declarações para dentro do teste, use beforeEach para criar estado novo, e verifique que os testes ainda passam.
Próxima hora: substitua todos os identificadores únicos fixos em dados de teste. Emails, usernames, telefones, qualquer campo com restrição de unicidade: torne-os dinâmicos com Date.now() ou estratégia similar. Isso elimina a classe de falhas "teste falha na segunda execução".
Esta semana: construa uma fixture createUser (ou seja lá qual for sua entidade mais comum). Coloque a lógica de criação e deleção em um lugar só, torne-a automática, e migre os cinco arquivos de teste mais problemáticos para usá-la. Você vai ver imediatamente o quanto esses testes ficam mais simples.
Esta sprint: ative fullyParallel: true com dois workers e observe as falhas. Cada nova falha é um vazamento de isolamento que estava escondido. Corrija cada um conforme aparecer. Quando a suite estiver limpa com dois workers, suba para quatro. Continue até atingir o limite da máquina ou sua suite terminar em menos de dois minutos.
O objetivo não é isolamento perfeito como princípio abstrato. É uma suite que você pode rodar com
--workers=8` e confiar nos resultados. Isolamento é o mecanismo; feedback rápido e confiável é o objetivo. Com testes stateless, paralelismo é só uma mudança de configuração.
→ Veja também: Fixtures do Playwright Explicadas: Das Integradas às Personalizadas | Depurando Testes Instáveis: Um Guia Prático | Execução Paralela no Playwright: Workers, Shards e Sharding para Velocidade | Testes Instáveis: Por que Acontecem e Como Eliminá-los | Gerenciamento de Dados de Teste no Playwright: Estratégias e Padrões