Testes automatizados + CI/CD: de 0 a pipeline completo em 1 semana (reduzindo bugs 78%)

Setup completo: testes unitários, integração, E2E com Playwright, CI/CD com GitHub Actions e deploy automático em produção.

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:

![Coverage](https://codecov.io/gh/username/repo/branch/main/graph/badge.svg)

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

  1. Setup Vitest (1 dia)
  2. Escrever primeiros testes unitários (2 dias)
  3. Configurar testes de integração (1 dia)
  4. Setup Playwright (1 dia)
  5. Escrever testes E2E críticos (2 dias)
  6. Configurar GitHub Actions (1 dia)
  7. 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.

Pronto para sair do manual?

Agende o diagnóstico gratuito. Vamos mapear o gargalo, estimar o impacto e definir o primeiro resultado mensurável.

Você sai com clareza — não com um pitch de vendas.