Projeto final: componente de UI com unit + component + a11y + E2E em CI
5 min de leitura
Hora de unir tudo. Você vai construir um componente de UI real (botão acessível com variants) com 5+ testes unit, 3+ testes de component, 1+ teste de a11y automatizado, 1+ teste E2E, e tudo rodando em GitHub Actions no PR. É o ciclo completo: escreve → testa local → sobe PR → CI verde → merge.
Esse projeto não segue o esqueleto de 3 camadas
dos outros nós. É um brief de projeto, no estilo
de projects/<slug>.mdx do aprenda-community. Lê
até o fim antes de começar.
O que você vai construir
Um componente Button acessível, com variants
(primary, secondary, danger), estados
(default, hover, disabled, loading),
suporte a onClick, aria-label, e
aria-busy durante loading. É o tipo de
componente que todo design system tem - simples
o suficiente pra fazer em 1 dia, complexo o
suficiente pra ter comportamento real (estado
interno, eventos, a11y).
A escolha do "domínio" (botão vs input vs
modal) é sua. O esqueleto dado é o caminho
feliz, desvie quando precisar e anote as
decisões no editorial-decisions.md da trilha.
Objetivo
- Consolidar Vitest + Testing Library + Playwright
- axe + CI num projeto real.
- Praticar a pirâmide: muitos unit, alguns component, 1-2 E2E, sem inversão.
- Ver o pipeline completo rodando: PR
aberto → CI dispara → unit + component
- a11y + E2E em paralelo → verde → merge.
- Configurar GitHub Actions com cache e paralelização (matriz 3 browsers × 2 shards = 6 jobs paralelos).
- Subir o badge verde no README.
Requisitos (mínimo)
Setup inicial:
-
pnpm create vite@latest meu-button -- --template react-ts - Instalar:
pnpm add -D vitest @vitest/coverage-v8 @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-axe jsdom @playwright/test @axe-core/playwright. -
pnpm exec playwright install --with-deps. - Estrutura:
src/Button.tsx(componente)src/Button.test.tsx(unit + component + a11y)tests/e2e/button.spec.ts(E2E)playwright.config.ts(config Playwright)vitest.config.ts(config Vitest)vitest.setup.ts(jest-dom matchers).github/workflows/test.yml(CI)
Componente Button.tsx:
- Props:
variant: 'primary' | 'secondary' | 'danger',disabled: boolean,loading: boolean,onClick: () => void,children: ReactNode,ariaLabel?: string. - Renderiza
<button>com classes base + classe do variant. - Quando
loading:aria-busy="true",disabled, mostra "Carregando..." no conteúdo (ou texto passado viaariaLabel). - Acessível: foco visível (CSS), label sempre presente (children ou ariaLabel).
- Suporte a
data-testid(NÃO use como query principal, mas pode estar lá pra casos extremos).
Unit tests (Vitest, Button.test.tsx):
- Renderiza children corretamente.
- Aplica classe do variant (
btn-primaryvsbtn-secondary). - Renderiza
aria-labelquando passado (sem children). - Renderiza children quando passado (sem aria-label).
- Não chama
onClickquandodisabled. - Aplica
aria-busy="true"quandoloading.
Component tests (Testing Library,
Button.test.tsx):
- Usuário clica →
onClické chamado. - Usuário clica em botão
disabled→ nada acontece. - Tab move foco pro botão.
- Enter dispara
onClick(comportamento padrão de<button>).
A11y tests (jest-axe, Button.test.tsx):
- Sem
aria-labele sem children → axe-core reporta violaçãolabel(esperado, teste documenta o problema). - Com
aria-labelválido → axe-core passa sem violações. - Com children válido → axe-core passa sem violações.
- Em estado
loading→ axe-core passa (aria-busy é correto).
E2E tests (Playwright, button.spec.ts):
- Página com botão, usuário clica, vê estado de "loading" e depois estado normal.
- Tab pelo browser chega no botão (foco visível).
- Botão desabilitado não dispara ação.
- axe-core na página passa sem violações
(com
@axe-core/playwright).
CI (.github/workflows/test.yml):
- Trigger:
pushnamain+pull_request. - Job 1: `lint + typecheck + unit + component
- a11y` (sequencial, ~2 min).
- Job 2:
e2ecom matriz[chromium, firefox] x [shard 1/2, shard 2/2](paralelo, ~3 min). - Cache de
pnpm(cache: pnpm). - Cache de Playwright browsers.
-
concurrencycomcancel-in-progress: true. -
upload-artifactemfailure()complaywright-report/. - Badge no README.
Coverage (opcional mas recomendado):
- Coverage configurado em
vitest.config.ts(provider: 'v8',reporter: ['text', 'html']). - Threshold de 80% em branches/functions/lines (quebra CI se não atingir).
Desafios extras (stretch goals)
Se você terminou o mínimo e quer ir além:
- Visual regression com
toHaveScreenshotno Playwright - screenshot de cada variant e cada estado, com tolerância de 0.1. - MSW pra mockar fetch num teste de component que faz chamada de API ao clicar.
-
storageStateno Playwright pra reutilizar sessão entre testes (login uma vez, salva state, usa em outros). - Coverage badge no README - gera SVG do coverage e commita no PR.
- PR comment com resultado do Playwright
- usa
dorny/test-reporterou similar.
- usa
-
actwarning cleanup - roda teste em strict mode, garante 0 warnings. - Storybook com Chromatic - cada variant vira uma story, Chromatic roda visual regression no PR.
-
fail-fast: falsena matriz - ver todos os erros de uma vez, não cancelar ao primeiro. - Testar navegação por teclado completa
- Tab, Shift+Tab, Enter, Space, Escape.
- axe-core em modo "serious only" - só bloqueia merge em violação séria, deixa moderate/minor pra backlog.
- Migration pra Vitest 2 quando sair (testa o caminho de upgrade).
Dicas
Por onde começar:
- Componente primeiro -
Button.tsxcom props mínimas. Sem teste ainda. - Unit + component tests - cobre a lógica e a interação. Sem a11y ainda.
- A11y tests - adiciona jest-axe e roda. Conserta o que aparecer.
- E2E test - sobe o app, renderiza o botão numa página, testa interação real.
- CI - workflow mínimo (1 job sequencial) primeiro. Depois paraleliza.
- Otimização - cache, matriz, shards.
Armadilhas comuns:
- Mockar demais em unit test - teste unitário é pra função pura. Componente com estado/evento vai em component test (Testing Library), não unit (Vitest sozinho). Misturar é fonte de teste fraco.
getByTestIdem component test - usegetByRole("button", { name: /salvar/i })em vez. Mais robusto + força a11y.- Snapshot de DOM - evite. Use visual
regression (Playwright
toHaveScreenshot) se precisar de pixel-peeping. - CI lento - se o pipeline demora 20+ min, ninguém roda. Paralelize (matriz), cacheie (browsers, node_modules), otimize (shard).
- Flaky E2E - locator por papel, auto-wait,
evitar
waitFor(1000). Se persiste,trace: "on-first-retry"e debug o trace. - axe-core bloqueando merge à toa -
comece com
critical + seriousonly, expanda quando o time estiver acostumado. - Não testar
disabledem E2E - é detalhe de component, não de fluxo. Cobre em unit + component, deixa E2E pro fluxo. - Esquecer
cancel-in-progress- PR com 5 commits roda 5 pipelines em série. Semconcurrency, é desperdício de runner-time.
Como validar que terminou:
-
pnpm test:runroda sem warning, com coverage > 80% emButton.tsx. -
pnpm exec playwright testroda em ~30s com 5+ testes E2E. -
pnpm test:e2e:ui(ouplaywright test --ui) abre o report visual e você consegue ver cada teste passando. -
pnpm dev+ abrir no browser +pnpm exec playwright testlocal - funciona offline. - Push na branch → CI dispara em <30s.
- CI completa em <10 min (5 min ideal).
- CI vermelho bloqueia merge (você força isso em branch protection rule no GitHub).
- CI verde mostra o badge no README.
Leituras que ajudam durante o projeto:
- Vitest - Configuration - opções de
defineConfigque você vai usar. - Playwright - Configuration -
playwright.config.tsa fundo. - GitHub Actions - Matrix strategy - paralelização.
O projeto final é onde a trilha vira "sua". As escolhas de framework de teste (Vitest vs Jest, Playwright vs Cypress), de provider de CI (GitHub Actions vs GitLab CI vs CircleCI), de profundidade de a11y (axe-core strict vs só critical) são todas suas. O esqueleto dado é o caminho feliz, desvie quando precisar, e anote as decisões no
editorial-decisions.mdda trilha.