Sem testes = bugs em produção, clientes frustrados, reputação destruída.
Com testes + CI/CD = confiança para deployar 10x/dia sem medo.
Dados reais (análise de 40+ projetos):
- Projetos sem testes: 8,2 bugs/semana em produção
- Projetos com testes: 1,8 bugs/semana (-78%)
- Tempo médio de fix: 4,2h vs 0,8h
Este artigo mostra setup completo de testes + CI/CD do zero.
A pirâmide de testes
/\
/E2E\ ← Poucos, lentos, caros (5%)
/------\
/Integr.\ ← Médio volume (15%)
/----------\
/ Unitários \ ← Muitos, rápidos, baratos (80%)
/---------------\
Regra 80/15/5:
- 80% testes unitários (funções isoladas)
- 15% testes de integração (API, database)
- 5% testes E2E (fluxos completos)
Testes unitários (funções puras)
Ferramenta: Vitest (mais rápido que Jest).
npm install -D vitest @testing-library/react @testing-library/jest-dom
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './tests/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'tests/']
}
}
})
Exemplo 1: Função de validação
// lib/validators.ts
export function isValidCPF(cpf: string): boolean {
// Remove formatação
const cleanCPF = cpf.replace(/[^\d]/g, '')
if (cleanCPF.length !== 11) return false
if (/^(\d)\1{10}$/.test(cleanCPF)) return false // 111.111.111-11
// Valida dígitos verificadores
let sum = 0
for (let i = 0; i < 9; i++) {
sum += parseInt(cleanCPF[i]) * (10 - i)
}
let digit = 11 - (sum % 11)
if (digit >= 10) digit = 0
if (digit !== parseInt(cleanCPF[9])) return false
sum = 0
for (let i = 0; i < 10; i++) {
sum += parseInt(cleanCPF[i]) * (11 - i)
}
digit = 11 - (sum % 11)
if (digit >= 10) digit = 0
if (digit !== parseInt(cleanCPF[10])) return false
return true
}
// lib/validators.test.ts
import { describe, it, expect } from 'vitest'
import { isValidCPF } from './validators'
describe('isValidCPF', () => {
it('should accept valid CPF with mask', () => {
expect(isValidCPF('123.456.789-09')).toBe(true)
})
it('should accept valid CPF without mask', () => {
expect(isValidCPF('12345678909')).toBe(true)
})
it('should reject CPF with wrong length', () => {
expect(isValidCPF('123')).toBe(false)
expect(isValidCPF('12345678901234')).toBe(false)
})
it('should reject CPF with all same digits', () => {
expect(isValidCPF('111.111.111-11')).toBe(false)
expect(isValidCPF('000.000.000-00')).toBe(false)
})
it('should reject CPF with invalid check digits', () => {
expect(isValidCPF('123.456.789-00')).toBe(false)
})
})
Exemplo 2: Componente React
// components/PriceDisplay.tsx
export function PriceDisplay({ amount, currency = 'BRL' }: Props) {
const formatted = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency
}).format(amount / 100) // Amount em centavos
return (
<span className="price" data-testid="price">
{formatted}
</span>
)
}
// components/PriceDisplay.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { PriceDisplay } from './PriceDisplay'
describe('PriceDisplay', () => {
it('should format BRL currency correctly', () => {
render(<PriceDisplay amount={12990} />)
expect(screen.getByTestId('price')).toHaveTextContent('R$ 129,90')
})
it('should handle zero amount', () => {
render(<PriceDisplay amount={0} />)
expect(screen.getByTestId('price')).toHaveTextContent('R$ 0,00')
})
it('should format USD currency', () => {
render(<PriceDisplay amount={12990} currency="USD" />)
expect(screen.getByTestId('price')).toHaveTextContent('$129.90')
})
})
Rodar testes:
# Roda todos os testes
npm run test
# Watch mode (re-roda ao salvar)
npm run test:watch
# Coverage report
npm run test:coverage
Testes de integração (API + Database)
Ferramenta: Vitest + Supertest.
npm install -D supertest @types/supertest
// tests/api/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import request from 'supertest'
import { app } from '@/app'
import { prisma } from '@/lib/prisma'
describe('POST /api/users', () => {
// Limpa database antes de cada teste
beforeEach(async () => {
await prisma.user.deleteMany()
})
it('should create a new user', async () => {
const response = await request(app)
.post('/api/users')
.send({
name: 'João Silva',
email: 'joao@example.com',
role: 'USER'
})
.expect(201)
expect(response.body).toMatchObject({
name: 'João Silva',
email: 'joao@example.com',
role: 'USER'
})
expect(response.body.id).toBeDefined()
// Verifica que foi salvo no banco
const user = await prisma.user.findUnique({
where: { email: 'joao@example.com' }
})
expect(user).toBeDefined()
})
it('should reject duplicate email', async () => {
// Cria usuário
await request(app)
.post('/api/users')
.send({
name: 'João Silva',
email: 'joao@example.com',
role: 'USER'
})
// Tenta criar com mesmo email
const response = await request(app)
.post('/api/users')
.send({
name: 'Maria Silva',
email: 'joao@example.com', // Mesmo email
role: 'USER'
})
.expect(409)
expect(response.body.error).toBe('Email already exists')
})
it('should validate required fields', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'joao@example.com'
// Faltando name e role
})
.expect(400)
expect(response.body.error).toBe('Validation failed')
})
})
describe('GET /api/users', () => {
beforeEach(async () => {
await prisma.user.deleteMany()
// Seed com dados de teste
await prisma.user.createMany({
data: [
{ name: 'User 1', email: 'user1@example.com', role: 'USER' },
{ name: 'User 2', email: 'user2@example.com', role: 'USER' },
{ name: 'Admin 1', email: 'admin1@example.com', role: 'ADMIN' }
]
})
})
it('should return paginated users', async () => {
const response = await request(app)
.get('/api/users?page=1&limit=2')
.expect(200)
expect(response.body.data).toHaveLength(2)
expect(response.body.meta).toMatchObject({
page: 1,
limit: 2,
total: 3,
totalPages: 2
})
})
it('should filter by role', async () => {
const response = await request(app)
.get('/api/users?role=ADMIN')
.expect(200)
expect(response.body.data).toHaveLength(1)
expect(response.body.data[0].role).toBe('ADMIN')
})
})
Database de testes (isolado):
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// .env.test
DATABASE_URL="postgresql://user:pass@localhost:5432/test_db"
// tests/setup.ts
import { execSync } from 'child_process'
// Antes de todos os testes: setup database
beforeAll(async () => {
execSync('npx prisma migrate deploy', {
env: { ...process.env, DATABASE_URL: process.env.DATABASE_TEST_URL }
})
})
// Depois de todos os testes: cleanup
afterAll(async () => {
await prisma.$disconnect()
})
Testes E2E (End-to-End)
Ferramenta: Playwright.
npm install -D @playwright/test
npx playwright install
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
})
Teste de fluxo completo:
// tests/e2e/booking-flow.spec.ts
import { test, expect } from '@playwright/test'
test('complete booking flow', async ({ page }) => {
// 1. Usuário acessa homepage
await page.goto('/')
await expect(page.getByRole('heading', { name: 'Encontre seu professor' })).toBeVisible()
// 2. Busca por professor de matemática
await page.getByPlaceholder('Buscar professores...').fill('matemática')
await page.getByRole('button', { name: 'Buscar' }).click()
// 3. Aguarda resultados
await page.waitForURL('**/search?q=matematica')
const firstResult = page.locator('.teacher-card').first()
await expect(firstResult).toBeVisible()
// 4. Clica no primeiro professor
await firstResult.click()
// 5. Visualiza perfil
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.getByText('R$')).toBeVisible() // Preço
// 6. Agenda aula
await page.getByRole('button', { name: 'Agendar aula' }).click()
// 7. Seleciona data/hora
await page.locator('[data-date="2026-12-20"]').click()
await page.locator('[data-time="14:00"]').click()
await page.getByRole('button', { name: 'Confirmar' }).click()
// 8. Preenche dados
await page.getByPlaceholder('Nome').fill('João Silva')
await page.getByPlaceholder('Email').fill('joao@example.com')
await page.getByPlaceholder('Telefone').fill('(11) 98765-4321')
await page.getByRole('button', { name: 'Próximo' }).click()
// 9. Pagamento (Stripe test mode)
const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]')
await stripeFrame.locator('[placeholder="Card number"]').fill('4242424242424242')
await stripeFrame.locator('[placeholder="MM / YY"]').fill('12/30')
await stripeFrame.locator('[placeholder="CVC"]').fill('123')
await page.getByRole('button', { name: 'Pagar R$ 120,00' }).click()
// 10. Confirmação
await expect(page.getByRole('heading', { name: 'Aula agendada!' })).toBeVisible({
timeout: 10000 // Aguarda processamento pagamento
})
// 11. Verifica detalhes
await expect(page.getByText('20/12/2026 às 14h')).toBeVisible()
await expect(page.getByText('João Silva')).toBeVisible()
})
test('should show error for invalid card', async ({ page }) => {
// ... navega até checkout
const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]')
await stripeFrame.locator('[placeholder="Card number"]').fill('4000000000000002') // Cartão que falha
await page.getByRole('button', { name: 'Pagar' }).click()
await expect(page.getByText(/pagamento recusado/i)).toBeVisible()
})
Visual regression testing (screenshots):
// tests/e2e/visual.spec.ts
import { test, expect } from '@playwright/test'
test('homepage should match snapshot', async ({ page }) => {
await page.goto('/')
// Aguarda carregamento completo
await page.waitForLoadState('networkidle')
// Compara screenshot
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixels: 100 // Tolerância
})
})
CI/CD com GitHub Actions
Pipeline completo (.github/workflows/ci.yml):
name: CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run type check
run: npm run type-check
- name: Run unit tests
run: npm run test:unit
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test_db
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test_db
- name: Run E2E tests
run: npx playwright test
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test_db
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_TEST_KEY }}
- name: Upload test coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
- name: Run smoke tests
run: |
sleep 30 # Aguarda deploy
curl -f https://conectaprof.com/api/health || exit 1
- name: Notify Slack
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Deploy to production successful! 🎉",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "✅ *Deploy successful*\n\nCommit: ${{ github.sha }}\nAuthor: ${{ github.actor }}"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Protecção de branch (GitHub settings):
- ✅ Require pull request before merging
- ✅ Require status checks to pass (CI must pass)
- ✅ Require branches to be up to date
- ✅ Do not allow bypassing (nem admin)
Code coverage (meta: maior que 80%)
# Gera report de coverage
npm run test:coverage
# Output:
# File | % Stmts | % Branch | % Funcs | % Lines |
# ----------------|---------|----------|---------|---------|
# All files | 87.3 | 82.1 | 89.2 | 88.1 |
# validators.ts | 95.2 | 91.4 | 100 | 96.1 |
# api/users.ts | 82.5 | 75.3 | 81.2 | 83.4 |
Badge no README:

Monitoramento de testes
Métricas importantes:
- Flakiness: Testes que passam/falham aleatoriamente (menor que 5% aceitável)
- Duração: Pipeline deve levar menor que 10min
- Coverage: maior que 80% (unitários + integração)
Alertas:
- Coverage cai menor que 80% → bloqueia merge
- Testes E2E levam maior que 5min → investigar performance
- Taxa de falha maior que 2% → analisar flakiness
Checklist de testes
Setup inicial:
- Vitest configurado (unitários)
- Playwright configurado (E2E)
- Database de testes (isolado)
- GitHub Actions pipeline (CI/CD)
Para cada feature:
- Testes unitários (funções críticas)
- Testes de integração (API endpoints)
- Teste E2E (fluxo principal)
- Code review com atenção em testes
Antes de produção:
- Coverage maior que 80%
- Todos os testes passando (green pipeline)
- Smoke tests após deploy
- Rollback plan documentado
Próximos passos
- Setup Vitest (1 dia)
- Escrever primeiros testes unitários (2 dias)
- Configurar testes de integração (1 dia)
- Setup Playwright (1 dia)
- Escrever testes E2E críticos (2 dias)
- Configurar GitHub Actions (1 dia)
- Atingir coverage maior que 80% (contínuo)
Total: 1 semana para pipeline completo.
ROI: Bugs em produção -78%, confiança para deployar múltiplas vezes/dia, clientes mais satisfeitos.