
Testes E2E com Playwright — a virada de qualidade
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:
- Codar a feature
- Dar
pnpm deve testar manualmente no browser - 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-sandboxefullyParallel
// 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:
- Injetar token no localStorage — funciona no início, mas o
supabase-jschamaonAuthStateChangeque detecta token inválido e desloga - Login via REST — bom, mas o token expira em 1h e você precisa gerenciar refresh
- 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:
- Injeta
window.__E2E_SESSIONcom dados mock viaaddInitScript - Define
localStoragecomdw_access_tokenedw_user - 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 é:
- Lint — ESLint verifica padrão de código
- Unit tests — Vitest roda suite completa
- Build — Vite build com env vars
- E2E tests (⬅️ aqui!) — Preview server + Playwright
- Deploy — Só se TODOS os passos passarem
- 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.
3. Cookie banner é o inimigo silencioso dos E2E
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.