docs: add comprehensive frontend testing strategy
- Add test coverage strategy for Next.js 15 App Router - Document Server vs Client Components testing approach - Include MSW mock strategy for API testing - Add edge cases often forgotten (auth, forms, pagination, uploads) - Provide templates for unit, integration, and E2E tests - Include Zustand store testing patterns
This commit is contained in:
parent
fe731e83c6
commit
4a096ff903
1 changed files with 923 additions and 0 deletions
923
docs/FRONTEND_TESTING_STRATEGY.md
Normal file
923
docs/FRONTEND_TESTING_STRATEGY.md
Normal file
|
|
@ -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(<LoginForm onSubmit={mockLogin} />);
|
||||
|
||||
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(<ProtectedPage />);
|
||||
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(<LoginForm />);
|
||||
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(<ProtectedPage />);
|
||||
|
||||
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(<LoginForm />);
|
||||
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(<JobApplicationForm />);
|
||||
|
||||
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(<MultiStepForm />);
|
||||
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(<JobsList />);
|
||||
|
||||
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(<JobsList />);
|
||||
|
||||
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(<JobsList />);
|
||||
|
||||
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(<JobsList />);
|
||||
|
||||
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(<JobsList />);
|
||||
|
||||
// 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<script>.pdf', {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
|
||||
await user.upload(fileInput, specialFile);
|
||||
|
||||
// Nome deve ser sanitizado
|
||||
expect(screen.queryByText(/<script>/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle network interruption during upload', async () => {
|
||||
mockUpload.mockImplementation(() =>
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Network error')), 100)
|
||||
)
|
||||
);
|
||||
|
||||
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
|
||||
await user.upload(fileInput, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/upload failed.*retry/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 5.5 Real-time / WebSockets
|
||||
|
||||
```typescript
|
||||
describe('Real-time Edge Cases', () => {
|
||||
it('should handle WebSocket disconnection', async () => {
|
||||
render(<NotificationsProvider />);
|
||||
|
||||
// Simula desconexão
|
||||
act(() => {
|
||||
wsClient.simulateDisconnect();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/reconnecting/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle out-of-order messages', async () => {
|
||||
// Mensagens chegando fora de ordem
|
||||
render(<ChatWindow />);
|
||||
|
||||
act(() => {
|
||||
wsClient.receiveMessage({ id: '3', text: 'Third' });
|
||||
wsClient.receiveMessage({ id: '1', text: 'First' });
|
||||
wsClient.receiveMessage({ id: '2', text: 'Second' });
|
||||
});
|
||||
|
||||
const messages = screen.getAllByRole('listitem');
|
||||
expect(messages[0]).toHaveTextContent('First');
|
||||
expect(messages[1]).toHaveTextContent('Second');
|
||||
expect(messages[2]).toHaveTextContent('Third');
|
||||
});
|
||||
|
||||
it('should handle duplicate messages', async () => {
|
||||
render(<ChatWindow />);
|
||||
|
||||
act(() => {
|
||||
wsClient.receiveMessage({ id: '1', text: 'Hello' });
|
||||
wsClient.receiveMessage({ id: '1', text: 'Hello' }); // Duplicado
|
||||
});
|
||||
|
||||
const messages = screen.getAllByRole('listitem');
|
||||
expect(messages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 6. Estrutura de Testes
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── __tests__/ # Testes globais
|
||||
│ └── setup/
|
||||
│ └── test-utils.tsx # Custom render com providers
|
||||
├── e2e/ # Playwright E2E
|
||||
│ ├── auth.spec.ts # Fluxos de autenticação
|
||||
│ ├── jobs.spec.ts # Fluxos de vagas
|
||||
│ ├── payment.spec.ts # Fluxos de pagamento
|
||||
│ └── fixtures/ # Dados de teste
|
||||
│ └── test-data.ts
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── login/
|
||||
│ │ │ └── page.test.tsx # Testes da página
|
||||
│ │ └── dashboard/
|
||||
│ │ └── candidates/
|
||||
│ │ └── page.test.tsx
|
||||
│ ├── components/
|
||||
│ │ └── ui/
|
||||
│ │ └── __tests__/
|
||||
│ │ └── job-card.test.tsx
|
||||
│ ├── lib/
|
||||
│ │ ├── __tests__/
|
||||
│ │ │ ├── api.test.ts # API client
|
||||
│ │ │ ├── auth.test.ts # Auth utils
|
||||
│ │ │ └── validation.test.ts # Zod schemas
|
||||
│ │ └── store/
|
||||
│ │ └── __tests__/
|
||||
│ │ └── notifications-store.test.ts
|
||||
│ └── hooks/
|
||||
│ └── __tests__/
|
||||
│ └── use-auth.test.ts
|
||||
└── msw/
|
||||
└── handlers.ts # API mocks globais
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 7. Templates de Teste
|
||||
|
||||
### Template: Teste de Página (Client Component)
|
||||
|
||||
```typescript
|
||||
// src/app/dashboard/settings/page.test.tsx
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import SettingsPage from './page';
|
||||
|
||||
// Mocks
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/api', () => ({
|
||||
settingsApi: {
|
||||
get: jest.fn().mockResolvedValue({ logoUrl: '', primaryColor: '#000' }),
|
||||
save: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
credentialsApi: {
|
||||
list: jest.fn().mockResolvedValue({ services: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('SettingsPage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should show loading spinner initially', () => {
|
||||
render(<SettingsPage />);
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Theme Settings', () => {
|
||||
it('should save theme changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/company name/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByLabelText(/company name/i), 'Test Company');
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/saved successfully/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should display error message on API failure', async () => {
|
||||
const { settingsApi } = require('@/lib/api');
|
||||
settingsApi.save.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/company name/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to save/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Template: Teste de Store (Zustand)
|
||||
|
||||
```typescript
|
||||
// src/lib/store/__tests__/notifications-store.test.ts
|
||||
import { act } from '@testing-library/react';
|
||||
import { useNotificationsStore } from '../notifications-store';
|
||||
|
||||
// Mock da API
|
||||
jest.mock('@/lib/api', () => ({
|
||||
notificationsApi: {
|
||||
list: jest.fn(),
|
||||
markAsRead: jest.fn(),
|
||||
markAllAsRead: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useNotificationsStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state
|
||||
useNotificationsStore.setState({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
loading: false,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchNotifications', () => {
|
||||
it('should fetch and set notifications', async () => {
|
||||
const { notificationsApi } = require('@/lib/api');
|
||||
notificationsApi.list.mockResolvedValue([
|
||||
{ id: '1', message: 'Test', read: false },
|
||||
{ id: '2', message: 'Test 2', read: true },
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
await useNotificationsStore.getState().fetchNotifications();
|
||||
});
|
||||
|
||||
const state = useNotificationsStore.getState();
|
||||
expect(state.notifications).toHaveLength(2);
|
||||
expect(state.unreadCount).toBe(1);
|
||||
expect(state.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const { notificationsApi } = require('@/lib/api');
|
||||
notificationsApi.list.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await act(async () => {
|
||||
await useNotificationsStore.getState().fetchNotifications();
|
||||
});
|
||||
|
||||
const state = useNotificationsStore.getState();
|
||||
expect(state.notifications).toEqual([]);
|
||||
expect(state.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsRead (Optimistic Update)', () => {
|
||||
it('should optimistically mark notification as read', async () => {
|
||||
// Setup initial state
|
||||
useNotificationsStore.setState({
|
||||
notifications: [
|
||||
{ id: '1', message: 'Test', read: false },
|
||||
],
|
||||
unreadCount: 1,
|
||||
});
|
||||
|
||||
const { notificationsApi } = require('@/lib/api');
|
||||
notificationsApi.markAsRead.mockResolvedValue({});
|
||||
|
||||
await act(async () => {
|
||||
await useNotificationsStore.getState().markAsRead('1');
|
||||
});
|
||||
|
||||
const state = useNotificationsStore.getState();
|
||||
expect(state.notifications[0].read).toBe(true);
|
||||
expect(state.unreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should revert optimistic update on API failure', async () => {
|
||||
useNotificationsStore.setState({
|
||||
notifications: [
|
||||
{ id: '1', message: 'Test', read: false },
|
||||
],
|
||||
unreadCount: 1,
|
||||
});
|
||||
|
||||
const { notificationsApi } = require('@/lib/api');
|
||||
notificationsApi.markAsRead.mockRejectedValue(new Error('Failed'));
|
||||
|
||||
await act(async () => {
|
||||
await useNotificationsStore.getState().markAsRead('1');
|
||||
});
|
||||
|
||||
// TODO: Implementar rollback no store
|
||||
// const state = useNotificationsStore.getState();
|
||||
// expect(state.notifications[0].read).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Template: Teste E2E (Playwright)
|
||||
|
||||
```typescript
|
||||
// e2e/candidate-application.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Candidate Job Application Flow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login como candidato
|
||||
await page.goto('/login');
|
||||
await page.fill('[name="email"]', 'candidate@example.com');
|
||||
await page.fill('[name="password"]', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/\/dashboard/);
|
||||
});
|
||||
|
||||
test('should apply to a job successfully', async ({ page }) => {
|
||||
// Navegar para listagem de vagas
|
||||
await page.goto('/jobs');
|
||||
|
||||
// Aguardar vagas carregarem
|
||||
await expect(page.getByTestId('job-card').first()).toBeVisible();
|
||||
|
||||
// Clicar em uma vaga
|
||||
await page.getByTestId('job-card').first().click();
|
||||
|
||||
// Verificar detalhes da vaga
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
|
||||
// Clicar em candidatar-se
|
||||
await page.click('button:has-text("Apply")');
|
||||
|
||||
// Preencher formulário de candidatura
|
||||
await page.fill('[name="coverLetter"]', 'I am very interested...');
|
||||
await page.click('button:has-text("Submit Application")');
|
||||
|
||||
// Verificar sucesso
|
||||
await expect(page.getByText(/application submitted/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show validation errors for incomplete application', async ({ page }) => {
|
||||
await page.goto('/jobs/1/apply');
|
||||
|
||||
// Tentar submeter sem preencher
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Verificar mensagens de erro
|
||||
await expect(page.getByText(/cover letter is required/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should prevent duplicate applications', async ({ page }) => {
|
||||
// Candidatar a uma vaga que já se candidatou
|
||||
await page.goto('/jobs/already-applied-job/apply');
|
||||
|
||||
await expect(page.getByText(/already applied/i)).toBeVisible();
|
||||
|
||||
// Botão de apply deve estar desabilitado
|
||||
await expect(page.getByRole('button', { name: /apply/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 8. Métricas e Cobertura
|
||||
|
||||
### Comandos de Cobertura
|
||||
|
||||
```bash
|
||||
# Unit tests com coverage
|
||||
npm run test -- --coverage --coverageReporters=text --coverageReporters=lcov
|
||||
|
||||
# E2E tests com trace
|
||||
npx playwright test --trace on
|
||||
|
||||
# Coverage por diretório
|
||||
npm run test -- --coverage --testPathPattern="src/lib"
|
||||
```
|
||||
|
||||
### Metas por Módulo
|
||||
|
||||
| Módulo | Cobertura Atual | Meta |
|
||||
|--------|-----------------|------|
|
||||
| `lib/auth.ts` | ? | 100% |
|
||||
| `lib/api.ts` | 85% | 95% |
|
||||
| `lib/validation.ts` | ? | 100% |
|
||||
| `lib/store/*` | ? | 90% |
|
||||
| `components/ui/*` | ? | 70% |
|
||||
| `app/login/*` | 70% | 90% |
|
||||
| `app/dashboard/*` | ? | 80% |
|
||||
|
||||
### CI Integration
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- run: npm ci
|
||||
- run: npm run test -- --coverage
|
||||
- uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npm run build
|
||||
- run: npx playwright test
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 9. Checklist de Implementação
|
||||
|
||||
### Fase 1: Infraestrutura (Semana 1)
|
||||
|
||||
- [ ] Instalar MSW para mocks de API
|
||||
- [ ] Configurar custom render com providers
|
||||
- [ ] Setup test fixtures e factories
|
||||
- [ ] Configurar CI com coverage
|
||||
|
||||
### Fase 2: Unit Tests Críticos (Semana 2)
|
||||
|
||||
- [ ] Testes de autenticação (auth.ts)
|
||||
- [ ] Testes de validação (Zod schemas)
|
||||
- [ ] Testes de stores (Zustand)
|
||||
- [ ] Testes de hooks customizados
|
||||
|
||||
### Fase 3: Integration Tests (Semana 3)
|
||||
|
||||
- [ ] Formulários de login/registro
|
||||
- [ ] Formulários de candidatura
|
||||
- [ ] Listagem de vagas com filtros
|
||||
- [ ] Dashboard com dados mocked
|
||||
|
||||
### Fase 4: E2E Tests (Semana 4)
|
||||
|
||||
- [ ] Fluxo completo de registro
|
||||
- [ ] Fluxo completo de candidatura
|
||||
- [ ] Fluxo de pagamento (Stripe)
|
||||
- [ ] Fluxo de recuperação de senha
|
||||
|
||||
### Fase 5: Edge Cases (Semana 5)
|
||||
|
||||
- [ ] Implementar edge cases documentados
|
||||
- [ ] Testes de acessibilidade (jest-axe)
|
||||
- [ ] Testes de performance (Lighthouse CI)
|
||||
|
||||
---
|
||||
|
||||
## 📚 10. Referências
|
||||
|
||||
- [Next.js Testing Docs](https://nextjs.org/docs/testing)
|
||||
- [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/)
|
||||
- [Playwright Best Practices](https://playwright.dev/docs/best-practices)
|
||||
- [MSW Documentation](https://mswjs.io/docs/)
|
||||
- [Testing Trophy](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications)
|
||||
|
||||
---
|
||||
|
||||
> **Nota:** Este documento deve ser atualizado conforme a cobertura de testes evolui. Execute `npm run test -- --coverage` regularmente para acompanhar métricas.
|
||||
Loading…
Reference in a new issue