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