95% de cobertura em unit tests mede funções isoladas. O cadastro, o login e o pagamento passam por frontend, API, autenticação e sessão ao mesmo tempo, e esse caminho continua sem teste. Os bugs que chegam ao usuário aparecem nas integrações entre componentes e nos fluxos que ninguém rodou de ponta a ponta.
Playwright e Cypress lado a lado
Ambos rodam em navegador real e interceptam rede. Playwright entrega execução paralela por worker, Chromium, Firefox e WebKit no mesmo setup, várias abas e iframes na mesma API. Times que já escrevem testes em Vitest ou Jest reconhecem a API do Playwright na primeira sessão. Cypress segue como referência em DX no ecossistema Node, e a migração custa caro nos dois sentidos, então escolha uma vez.
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30000,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
trace: 'on-first-retry',
},
webServer: {
command: 'pnpm dev',
port: 3000,
reuseExistingServer: !process.env.CI,
},
})
Seu primeiro teste: login completo
O fluxo de login valida frontend, API, autenticação e sessão em um único teste. Comece por ele.
// e2e/login.spec.ts
import { test, expect } from '@playwright/test'
test('user can log in and see dashboard', async ({ page }) => {
await page.goto('/login')
await page.fill('[data-testid="email"]', 'user@example.com')
await page.fill('[data-testid="password"]', 'password123')
await page.click('[data-testid="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('Welcome')
})
A página de best practices do Playwright pede que o teste dependa de atributos visíveis ao usuário e evite a estrutura do DOM. Ordem de preferência: papel (getByRole), label, texto e test id. O guia de locators confirma que data-testid é o fallback quando papel e texto não identificam o elemento sozinhos. Classes CSS de layout quebram no primeiro ajuste de design.
Page Object para seletores repetidos
Vinte testes apontando para o mesmo formulário de login viram manutenção cara quando o markup muda. Page Objects concentram os seletores em uma classe por página:
// e2e/pages/LoginPage.ts
import { Page, expect } from '@playwright/test'
export class LoginPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.page.fill('[data-testid="email"]', email)
await this.page.fill('[data-testid="password"]', password)
await this.page.click('[data-testid="submit"]')
}
async expectError(message: string) {
await expect(this.page.locator('[data-testid="error"]'))
.toContainText(message)
}
}
// e2e/login.spec.ts
test('failed login shows error', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('wrong@example.com', 'bad')
await loginPage.expectError('Credenciais inválidas')
})
Quando o front muda, você ajusta um arquivo e os vinte testes continuam verdes.
Estados de erro pedem interceptação de rede
Timeout de API, queda de conexão e estado de loading não aparecem em teste unitário. page.route intercepta a chamada e devolve a falha:
test('shows error when API is down', async ({ page }) => {
// Intercepta a API e aborta a conexão
await page.route('**/api/orders', route => route.abort('connectionrefused'))
await page.goto('/orders')
await expect(page.locator('[data-testid="error-message"]'))
.toContainText('Não foi possível carregar os pedidos')
await expect(page.locator('[data-testid="retry-button"]'))
.toBeVisible()
})
O teste confirma a mensagem e o botão de retry, que é o que o usuário enxerga quando o backend cai.
Dados de teste criados e apagados na fixture
E2E exige dado previsível. Fixtures do Playwright criam o usuário via API antes do teste e removem depois:
// e2e/fixtures.ts
import { test as base } from '@playwright/test'
export const test = base.extend<{ testUser: User }>({
testUser: async ({ request }, use) => {
// Cria usuário de teste via API
const res = await request.post('/api/test/users', {
data: { email: `test-${Date.now()}@example.com`, password: 'test123' }
})
const user = await res.json()
await use(user)
// Remove depois do teste
await request.delete(`/api/test/users/${user.id}`)
},
})
Dado criado por teste elimina dependência entre casos e deixa a ordem de execução irrelevante.
CI com browsers e artifacts
O pipeline instala os browsers, roda a suíte e publica o relatório quando algo falha:
- name: Install Playwright
run: pnpm exec playwright install --with-deps
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
Com trace: 'on-first-retry', o CI grava a sessão que reproduz o bug. Você abre o trace no lugar de reproduzir o cenário na mão.
Onde o E2E para de valer a pena
Teste E2E é lento e caro de manter. John Micco, no post Flaky Tests at Google and How We Mitigate Them (Google Testing Blog, 2016), relatou 1,5% de resultados flaky no corpus do Google e algum grau de flakiness em 16% dos testes. Ham Vocke, em The Practical Test Pyramid (martinfowler.com, 2018), defende muitos unit tests, alguns integration tests e poucos testes de ponta a ponta. Reserve E2E para login, checkout, criação de conta e pagamento. O resto fica com unit e integration.
Referências
- Playwright docs: Best Practices
- Playwright docs: Locators
- The Practical Test Pyramid, Ham Vocke (martinfowler.com, 2018)
- Test Pyramid, Martin Fowler (martinfowler.com, 2012), sobre a metáfora de Mike Cohn em Succeeding with Agile (2009)
- Flaky Tests at Google and How We Mitigate Them, John Micco (Google Testing Blog, 2016)
Curtiu o conteúdo?
Construo produtos web e soluções com IA do jeito certo — arquitetura sólida, código sustentável e entrega real.
Vamos conversar