🧪 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
# 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:
// ❌ 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:
// ✅ 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)
// ❌ Limitações do mock manual
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => mockData
});
Estratégia Recomendada (MSW)
// 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
// ❌ 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
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
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
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
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)
// 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)
// 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)
// 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
# 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
# .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)
Fase 2: Unit Tests Críticos (Semana 2)
Fase 3: Integration Tests (Semana 3)
Fase 4: E2E Tests (Semana 4)
Fase 5: Edge Cases (Semana 5)
📚 10. Referências
Nota: Este documento deve ser atualizado conforme a cobertura de testes evolui. Execute npm run test -- --coverage regularmente para acompanhar métricas.