diff --git a/docs/FRONTEND_TESTING_STRATEGY.md b/docs/FRONTEND_TESTING_STRATEGY.md new file mode 100644 index 0000000..75e778c --- /dev/null +++ b/docs/FRONTEND_TESTING_STRATEGY.md @@ -0,0 +1,923 @@ +# 🧪 Estratégia de Cobertura de Testes - Frontend Next.js + +> **Última Atualização:** 2026-02-24 +> **Projeto:** GoHorse Jobs Frontend +> **Versão Next.js:** 15.5.7 (App Router) + +--- + +## 📊 Stack Identificada + +| Componente | Tecnologia | Observações | +|------------|------------|-------------| +| **Framework** | Next.js 15.5.7 | App Router | +| **Linguagem** | TypeScript 5 | Strict mode | +| **Estado Global** | Zustand 4.5.7 | notifications-store | +| **Formulários** | React Hook Form + Zod | Validação client-side | +| **Data Fetching** | Fetch nativo | Server + Client Components | +| **Estilização** | Tailwind CSS 4 + shadcn/ui | Componentes Radix | +| **Testes Unitários** | Jest 30 + React Testing Library 16 | Configurado | +| **Testes E2E** | Playwright 1.57 | Configurado | + +--- + +## 🎯 1. Priorização de Testes + +### 🔴 Crítico (100% de cobertura almejada) + +| Área | Por quê | Tipo de Teste | +|------|--------|---------------| +| **Autenticação** | Login, registro, logout, recuperação de senha | E2E + Unit | +| **Formulários de Pagamento** | Integração Stripe, dados sensíveis | E2E + Integration | +| **Cálculos de Preço** | Planos, assinaturas, descontos | Unit (pure functions) | +| **Validação de Formulários** | Zod schemas, mensagens de erro | Unit | +| **Stores Zustand** | Estado global, optimistic updates | Unit | + +### 🟡 Importante (80% de cobertura almejada) + +| Área | Por quê | Tipo de Teste | +|------|--------|---------------| +| **Listagem de Vagas** | Filtros, paginação, busca | Integration | +| **Dashboard** | Gráficos, estatísticas, KPIs | Integration | +| **Perfil de Usuário** | Edição, upload de foto | E2E | +| **Notificações** | Real-time, marcação como lida | Unit + Integration | +| **Tickets de Suporte** | Criação, categoria, prioridade | Integration | + +### 🟢 Desejável (60% de cobertura almejada) + +| Área | Por quê | Tipo de Teste | +|------|--------|---------------| +| **UI Components** | shadcn/ui (testado pela lib) | Snapshot | +| **Páginas Estáticas** | About, Terms, Privacy | E2E smoke | +| **Animações** | Framer Motion | Visual regression | + +--- + +## 🛠️ 2. Ferramentas por Camada + +### Pirâmide de Testes + +``` + ┌─────────┐ + │ E2E │ Playwright + │ 10% │ Fluxos críticos completos + ├─────────┤ + │Integration│ Jest + RTL + │ 20% │ Componentes + API mocked + ├─────────┤ + │ Unit │ Jest + │ 70% │ Funções puras, hooks, stores + └─────────┘ +``` + +### Ferramentas Recomendadas + +| Camada | Ferramenta | Uso | +|--------|------------|-----| +| **Unit Tests** | Jest + @testing-library/react | Hooks, utils, stores, validações | +| **Integration Tests** | Jest + MSW (Mock Service Worker) | Componentes com API mocked | +| **E2E Tests** | Playwright | Fluxos completos de usuário | +| **Visual Regression** | Playwright + Percy/Chromatic | UI snapshots (opcional) | +| **A11y Tests** | jest-axe + @axe-core/playwright | Acessibilidade | + +### Setup Recomendado + +```bash +# Já instalado +npm install -D jest @testing-library/react @testing-library/jest-dom @playwright/test + +# Adicionar para melhor cobertura +npm install -D msw jest-axe @testing-library/user-event +``` + +--- + +## 🧩 3. Server Components vs Client Components + +### Server Components (Next.js 15) + +**Características:** +- Renderizados no servidor +- Sem estado local +- Sem interatividade direta +- Ideal para data fetching inicial + +**Estratégia de Teste:** + +```typescript +// ❌ NÃO testar Server Components diretamente com RTL +// Eles não têm estado client-side + +// ✅ Testar via E2E (Playwright) +test('Job listing page loads jobs from server', async ({ page }) => { + await page.goto('/jobs'); + await expect(page.getByRole('heading', { name: /jobs/i })).toBeVisible(); + await expect(page.getByTestId('job-card')).toHaveCount(10); +}); + +// ✅ Testar funções de data fetching separadamente +// lib/__tests__/api.test.ts (já existe) +describe('jobsApi', () => { + it('should fetch jobs with correct parameters', async () => { + // Testa a lógica de construção de URL e fetch + }); +}); +``` + +### Client Components + +**Características:** +- Renderizados no browser +- Podem ter estado local +- Interativos (eventos) +- Hooks (useState, useEffect) + +**Estratégia de Teste:** + +```typescript +// ✅ Testar com React Testing Library +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import LoginForm from './LoginForm'; + +describe('LoginForm', () => { + it('should submit form with valid credentials', async () => { + const user = userEvent.setup(); + const mockLogin = jest.fn().mockResolvedValue({ success: true }); + + render(); + + await user.type(screen.getByLabelText(/email/i), 'test@example.com'); + await user.type(screen.getByLabelText(/password/i), 'password123'); + await user.click(screen.getByRole('button', { name: /sign in/i })); + + expect(mockLogin).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password123' + }); + }); +}); +``` + +### Matriz de Decisão + +| Componente | Server/Client | Teste Recomendado | +|------------|---------------|-------------------| +| Página de listagem | Server | E2E + API test | +| Formulário de login | Client | RTL + Unit | +| Card de vaga | Client | RTL snapshot | +| Dashboard com gráficos | Client | RTL + MSW | +| Filtros de busca | Client | RTL + integration | +| Layout/Header | Server | E2E smoke | + +--- + +## 🎭 4. Mocks de API + +### Estratégia Atual (Jest mock) + +```typescript +// ❌ Limitações do mock manual +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockData +}); +``` + +### Estratégia Recomendada (MSW) + +```typescript +// msw/handlers.ts +import { http, HttpResponse } from 'msw'; + +export const handlers = [ + http.get('*/api/v1/jobs', () => { + return HttpResponse.json({ + data: [ + { id: '1', title: 'Software Engineer', company: 'Tech Corp' } + ], + pagination: { total: 1, page: 1, limit: 10 } + }); + }), + + http.post('*/api/v1/auth/login', async ({ request }) => { + const body = await request.json(); + if (body.email === 'error@test.com') { + return new HttpResponse(null, { status: 401 }); + } + return HttpResponse.json({ + token: 'mock-jwt-token', + user: { id: '1', email: body.email } + }); + }), +]; + +// jest.setup.js +import { setupServer } from 'msw/node'; +import { handlers } from './msw/handlers'; + +const server = setupServer(...handlers); +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); +``` + +### Vantagens do MSW + +| Aspecto | Jest Mock | MSW | +|---------|-----------|-----| +| Interceptação | Manual por teste | Automática global | +| Realismo | Baixo | Alto (rede real) | +| Manutenção | Alta | Baixa | +| Debug | Difícil | Network tab | +| Reutilização | Por teste | Global | + +--- + +## ⚠️ 5. Edge Cases Frequentemente Esquecidos + +### 5.1 Autenticação + +```typescript +// ❌ Comumente esquecido +describe('Auth Edge Cases', () => { + it('should handle expired token gracefully', async () => { + // Token expirado durante sessão + mockApi.returnError(401, 'Token expired'); + render(); + await waitFor(() => { + expect(screen.getByText(/session expired/i)).toBeInTheDocument(); + }); + }); + + it('should handle concurrent login attempts', async () => { + // Usuário clica login múltiplas vezes + const { rerender } = render(); + const submitBtn = screen.getByRole('button', { name: /login/i }); + + fireEvent.click(submitBtn); + fireEvent.click(submitBtn); + fireEvent.click(submitBtn); + + // Deve fazer apenas 1 requisição + expect(mockLogin).toHaveBeenCalledTimes(1); + }); + + it('should handle password with special characters', async () => { + // Senha com caracteres especiais: !@#$%^&*(){}[] + await user.type(passwordInput, 'P@ss!w0rd#$%'); + await user.click(submitBtn); + + expect(mockLogin).toHaveBeenCalledWith( + expect.objectContaining({ password: 'P@ss!w0rd#$%' }) + ); + }); + + it('should handle empty roles array in JWT', async () => { + // JWT válido mas sem roles + mockLogin.mockResolvedValue({ token: 'valid-token', roles: [] }); + render(); + + await waitFor(() => { + expect(screen.getByText(/unauthorized/i)).toBeInTheDocument(); + }); + }); + + it('should handle network timeout during login', async () => { + mockLogin.mockImplementation(() => + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 5000) + ) + ); + + render(); + await user.click(submitBtn); + + await waitFor(() => { + expect(screen.getByText(/connection timeout/i)).toBeInTheDocument(); + }); + }); +}); +``` + +### 5.2 Formulários + +```typescript +describe('Form Edge Cases', () => { + it('should handle paste event in confirm password', async () => { + // Usuário cola senha no campo de confirmação + const password = 'MyP@ssw0rd'; + await user.type(passwordInput, password); + + // Simula paste no confirm field + await user.click(confirmPasswordInput); + await user.paste(password); + + expect(confirmPasswordInput).toHaveValue(password); + }); + + it('should handle very long input values', async () => { + // Nome com 500 caracteres + const longName = 'A'.repeat(500); + await user.type(nameInput, longName); + + await waitFor(() => { + expect(screen.getByText(/maximum.*characters/i)).toBeInTheDocument(); + }); + }); + + it('should handle unicode characters in fields', async () => { + // Caracteres unicode: emojis, acentos, caracteres asiáticos + await user.type(nameInput, 'José 日本語 👨‍💻'); + await user.click(submitBtn); + + expect(mockSubmit).toHaveBeenCalledWith( + expect.objectContaining({ name: 'José 日本語 👨‍💻' }) + ); + }); + + it('should handle rapid consecutive form submissions', async () => { + // Submit spam - deve bloquear após primeiro + render(); + + for (let i = 0; i < 10; i++) { + await user.click(submitBtn); + } + + expect(mockSubmit).toHaveBeenCalledTimes(1); + }); + + it('should preserve form state on accidental navigation', async () => { + // Usuário preenche formulário, clica em link, volta + render(); + await user.type(input1, 'value1'); + + // Simula navegação acidental + window.history.pushState({}, '', '/other-page'); + window.history.back(); + + // Formulário deve preservar ou avisar + }); +}); +``` + +### 5.3 Paginação e Listas + +```typescript +describe('List Edge Cases', () => { + it('should handle empty results gracefully', async () => { + mockApi.returnEmptyList(); + render(); + + await waitFor(() => { + expect(screen.getByText(/no jobs found/i)).toBeInTheDocument(); + }); + }); + + it('should handle single item pagination', async () => { + // Apenas 1 item - não deve mostrar paginação + mockApi.returnSingleItem(); + render(); + + await waitFor(() => { + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + }); + }); + + it('should handle exactly N items per page', async () => { + // Exatamente 10 itens - deve mostrar paginação + mockApi.returnItems(10); + render(); + + await waitFor(() => { + expect(screen.getByRole('navigation')).toBeInTheDocument(); + }); + }); + + it('should handle invalid page number in URL', async () => { + // URL com page=999 quando só há 5 páginas + window.history.pushState({}, '', '/jobs?page=999'); + render(); + + await waitFor(() => { + expect(window.location.search).toContain('page=5'); + }); + }); + + it('should handle deleted item while on page', async () => { + // Item deletado enquanto usuário está na página + mockApi.returnItems(10); + render(); + + // Simula deleção externa + mockApi.returnItems(9); + await user.click(refreshBtn); + + expect(screen.getByText(/item removed/i)).toBeInTheDocument(); + }); +}); +``` + +### 5.4 Upload de Arquivos + +```typescript +describe('Upload Edge Cases', () => { + it('should reject files larger than limit', async () => { + const largeFile = new File(['x'.repeat(11 * 1024 * 1024)], 'large.pdf', { + type: 'application/pdf' + }); + + await user.upload(fileInput, largeFile); + + await waitFor(() => { + expect(screen.getByText(/file too large/i)).toBeInTheDocument(); + }); + }); + + it('should handle zero-byte files', async () => { + const emptyFile = new File([], 'empty.txt', { type: 'text/plain' }); + + await user.upload(fileInput, emptyFile); + + await waitFor(() => { + expect(screen.getByText(/file is empty/i)).toBeInTheDocument(); + }); + }); + + it('should handle special characters in filename', async () => { + const specialFile = new File(['content'], 'file