Testes E2E com Playwright — a virada de qualidade
🐶 Dogwalk·

Testes E2E com Playwright — a virada de qualidade

📖 14 min de leitura← Voltar para timeline

Contexto

O Dogwalk nasceu como um experimento — uma plataforma de passeio de pets que, como todo side project, começou com “vou só fazer o básico funcionar”. E funcionou. Por um tempo.

O problema é que side projects têm um ciclo de vida cruel: primeiro é divertido porque tudo é novo. Depois vem a fase dos “pequenos bugs”. E então, sem você perceber, cada novo deploy vira uma aposta. Você mexe numa linha de CSS e descobre que quebrou o fluxo de agendamento. Você troca um ícone e o dashboard do tutor some.

Essa é a história de como o Dogwalk parou de apostar e começou a testar.

O começo: sem testes, sem rede de segurança

No começo, não tinha teste nenhum. Zero. Nem unitários, nem integração, muito menos E2E. O fluxo era:

  1. Codar a feature
  2. Dar pnpm dev e testar manualmente no browser
  3. Se parecia funcionar, commitava e deployava

Funcionou bem por umas boas semanas — o app era pequeno, dava pra manter tudo na cabeça. Mas chegou um ponto em que tocar numa coisa quebrava outra completamente diferente.

Lembro de um dia específico: fiz uma mudança no layout da landing page (só CSS!) e, de quebra, o modal de login parou de abrir. Descobri porque um amigo testou e falou “tentei logar e não acontece nada”. Péssima sensação.

A primeira tentativa: testes unitários

O primeiro passo foi óbvio: testes unitários. Adicionei Vitest, configurei cobertura básica, e comecei a testar hooks, serviços, e componentes isolados.

pnpm add -D vitest @testing-library/react

Os testes unitários são ótimos pra pegar lógica pura — useProximity com Haversine, useHeatAlert, funções de formatação de data. Eles rodam em milissegundos e dão feedback rápido.

Mas tinha um problema fundamental: testes unitários não testam o que o usuário vê.

Eles testam se uma função retorna o valor certo, mas não se o botão está na tela, se o modal abre, se a navegação funciona. O usuário não importa uma calculaDistancia(), ele quer clicar em “Agendar” e ver o passeio confirmado.

A virada: Playwright entra em cena

Foi aí que decidi: preciso de testes que naveguem pelo app como um usuário real.

A escolha foi Playwright. Por quê?

  • Multi-browser nativo (Chromium, Firefox, WebKit) sem plugins
  • API limpa com page.goto(), page.getByText(), page.getByRole()
  • addInitScript() — perfeito pra mock de sessão (spoiler: isso foi crucial)
  • Report rico em HTML com traces e vídeos
  • CI-friendly com --no-sandbox e fullyParallel
// playwright.config.js — a configuração que mudou tudo
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testMatch: ['tests/e2e/**/*.spec.{js,ts}'],
  timeout: 30_000,
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  use: {
    baseURL: process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:5173',
    headless: true,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    locale: 'pt-BR',
    timezoneId: 'America/Sao_Paulo',
  },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium', viewport: { width: 1280, height: 800 } } },
    { name: 'firefox', use: { browserName: 'firefox', viewport: { width: 1280, height: 800 } } },
    { name: 'mobile-chrome', use: { browserName: 'chromium', ...devices['Pixel 5'] } },
  ],
})

O grande desafio: autenticação em E2E

O maior obstáculo dos testes E2E numa SPA é a autenticação. O Dogwalk tem login com Turnstile (Cloudflare CAPTCHA), JWT, refresh token, e ProtectedRoute com verificação de role.

Num browser headless, o Turnstile não renderiza. Soluções clássicas:

  1. Injetar token no localStorage — funciona no início, mas o supabase-js chama onAuthStateChange que detecta token inválido e desloga
  2. Login via REST — bom, mas o token expira em 1h e você precisa gerenciar refresh
  3. Mockar chamadas de API — o AuthContext escuta onAuthStateChange, que morre com token expirado

A solução que funcionou foi um híbrido elegante: window.__E2E_SESSION.

window.__E2E_SESSION — o padrão que salvou os testes

A ideia é simples: no AuthContext, antes de qualquer lógica de autenticação real, verificar se window.__E2E_SESSION existe:

// AuthContext — suporte nativo a mock de sessão
if (window.__E2E_SESSION) {
  setUser(window.__E2E_SESSION.user)
  setSession(window.__E2E_SESSION)
  setLoading(false)
  return // zero chamadas de auth
}

Nos testes, isso vira:

import { test } from '@playwright/test'
import { setupTutorSession } from './helpers/auth.js'

test('dashboard tutor carrega', async ({ page }) => {
  await setupTutorSession(page)
  await page.goto('/tutor/dashboard')
  await expect(page.getByText('Meus Pets')).toBeVisible()
})

A setupTutorSession faz três coisas:

  1. Injeta window.__E2E_SESSION com dados mock via addInitScript
  2. Define localStorage com dw_access_token e dw_user
  3. Intercepta chamadas FastAPI via page.route() e retorna dados controlados

O resultado? Zero segundos de espera por autenticação. ProtectedRoute resolve na hora. Sem Turnstile, sem 401, sem refresh token.

// helpers/auth.js — dados mock realistas
const MOCK_PETS = [
  { id: 'mock-pet-001', name: 'Rex', species: 'dog', breed: 'Labrador', age: 3, weight: 28.5, emoji: '🐕' },
  { id: 'mock-pet-002', name: 'Luna', species: 'dog', breed: 'Golden Retriever', age: 2, weight: 22.0, emoji: '🐩' },
]

const MOCK_WALKERS = [
  { id: 'mock-walker-001', name: 'Maria Passeadora', rating: 4.8, review_count: 42, price_per_walk: 35 },
  { id: 'mock-walker-002', name: 'Carlos PetLover', rating: 4.5, review_count: 28, price_per_walk: 25 },
]

Dois passes no CI — o problema do JSON reporter

Playwright tem um bug chato: o JSON reporter só grava o arquivo depois que todos os testes terminam. Se o processo morre (timeout, OOM), você perde o resultado.

A solução foi rodar duas vezes no CI:

# Pass 1: output humano (list reporter)
npx playwright test --project=chromium --timeout=30000 || true

# Pass 2: JSON report (pipe pra arquivo)
npx playwright test --project=chromium --timeout=30000 --reporter=json 2>/dev/null > /tmp/e2e-results.json

# Parse e notifica
python3 scripts/track-flaky.py /tmp/e2e-results.json
bash scripts/notify-e2e.sh

O primeiro pass roda com list reporter pra vermos os resultados em tempo real no log do CI. O segundo pass roda com json reporter redirecionado pra arquivo — se morrer, ainda temos o output do primeiro.

Isso também alimenta o sistema de flaky tracking:

# track-flaky.py — histórico CSV de flaky tests
def record(results_json_path: str):
    data = json.load(open(results_json_path))
    stats = data.get("stats", {})
    passed = stats.get("expected", 0)
    failed = stats.get("unexpected", 0)
    total = stats.get("total", 0)

    with open("test-results/flaky-history.csv", "a") as f:
        writer = csv.writer(f)
        writer.writerow([datetime.now(), passed, failed, total])

A trindade do CI: testes E2E antes do deploy

O deploy do Dogwalk tem uma pipeline CI/CD com qualidade como gate. O fluxo é:

  1. Lint — ESLint verifica padrão de código
  2. Unit tests — Vitest roda suite completa
  3. Build — Vite build com env vars
  4. E2E tests (⬅️ aqui!) — Preview server + Playwright
  5. Deploy — Só se TODOS os passos passarem
  6. Health check — Multi-rota curl pós-deploy
# Trecho do deploy.yml
- name: Run E2E tests
  if: github.ref_name == 'master'
  env:
    PLAYWRIGHT_TEST_URL: http://localhost:4173
  run: pnpm test:e2e:ci

- name: Health Check — Multi-Route
  if: github.ref_name == 'master'
  run: |
    for ROUTE in "/" "/login" "/planos"; do
      CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://seu.pet${ROUTE}")
      echo "${ROUTE} → $CODE"
    done

Isso significa: cada push no master roda os E2E contra o build exato que vai pro ar. Se quebrar, não deploya.

A pipeline também foi otimizada com cache dos browsers Playwright — o primeiro build instala os browsers (~300MB), os seguintes usam cache:

- name: Cache Playwright browsers
  uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: playwright-\${{ hashFiles('pnpm-lock.yaml') }}

A cobertura atual: o que cada teste cobre

Hoje os testes E2E cobrem 7 áreas críticas:

Teste Linhas O que cobre
full-user-flow.spec.js 170 Fluxo completo: landing → login mock → dashboard → agendamento
e2e-complete-flow.spec.js 155 6 steps compactos: landing, login modal, tutor dash, schedule, walker dash, route guard
auth-flow.spec.js ~80 Login, logout, rotas protegidas, sessão inválida
booking-flow.spec.js ~60 Agendamento com calendário, seleção de pet, confirmação
dashboard-flow.spec.js ~70 Dashboard do tutor e walker com dados mock
payment-chat-map.spec.js ~80 Integração Stripe, chat, mapa de tracking
visual.spec.js 43 Renderização, viewport mobile, elementos essenciais
security.spec.js ~50 Rotas protegidas sem token
onboarding-flow.spec.js ~40 Fluxo de primeiro acesso
signup-edge-cases.spec.js ~50 Validações de cadastro

Total: ~13 spec files, ~800+ linhas de testes.

Os aprendizados da trincheira

Depois de semanas escrevendo e mantendo esses testes, alguns aprendizados ficaram:

1. Mock de API é melhor que teste contra produção

No começo tentei testar contra backend real. Resultado: testes flaky por dependência externa, dados inconsistentes, lentidão. Com dados mock no addInitScript, os testes são determinísticos e rápidos.

2. waitForTimeout é um mal necessário (mas controlado)

O Playwright tem waitForLoadState('networkidle') que funciona na maioria dos casos, mas no Dogwalk o SPA faz chamadas assíncronas que nunca “idleiam” completamente. A solução foi waitForTimeout(2000-3000) combinado com waitForSelector pra elementos específicos.

Menos elegante, porém mais confiável.

O banner de cookies do Dogwalk tem pointer-events: none no container pra não bloquear cliques, mas ainda assim alguns overlays (como o SlowConnectionBanner) interceptavam interações. Criamos um helper que varre e remove todos os overlays fixos:

export async function dismissCookieBanner(page) {
  await page.evaluate(() => {
    document.querySelectorAll('[style*="position: sticky"], [style*="position:fixed"]').forEach(el => {
      if (el.style.top === '0px') el.style.display = 'none'
    })
  })
}

4. Setup de sessão é o gargalo — resolva ele primeiro

O maior tempo de setup dos testes era o login. Com window.__E2E_SESSION, o setup caiu de ~20s pra ~100ms. Isso transformou a DX — testes que demoravam 2 minutos pra configurar agora rodam em segundos.

5. Teste mobile primeiro

Samuel (sim, eu mesmo) acessa o Dogwalk mais pelo celular. A lição: sempre testar mobile antes de desktop. O projeto Playwright tem mobile-chrome como terceiro project, e a preferência é escrever o teste com viewport 375x844 antes de 1280x800.

Resultados práticos

Métrica Antes Depois
Confiança no deploy Baixa (testava manualmente) Alta (CI bloqueia se falhar)
Tempo de feedback Dias (usuário reportava) Minutos (CI notifica)
Regressões não detectadas Constantes Raras
Setup de teste E2E ~20s (login via UI) ~100ms (mock session)
Duração total E2E ~3min ~45s (paralelo)
Manutenção de teste N/A ~10% do tempo de desenvolvimento

Próximos passos

Os E2E são uma base sólida, mas ainda tem chão:

  • GitHub Actions E2E matrix: rodar chromium + firefox + mobile em paralelo
  • Visual regression testing: capturar screenshots e comparar com baseline
  • API E2E dedicados: testar backend direto sem mock (além dos testes com mock)
  • Cobertura de walker flows: hoje temos mais testes de tutor que walker
  • Testes de pagamento reais: usar Stripe test mode num ambiente isolado

No fim das contas, a maior mudança não foi técnica — foi mental. Antes, deploy era ansiedade. Agora, é um processo. O CI fala: “pode subir, eu testei”. E quando ele fala que tá verde, eu confio.

A jornada é documentada enquanto acontece. Este post é um registro de uma fase em que o Dogwalk deixou de ser um protótipo e começou a se comportar como um produto de verdade.


Comandos úteis

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$