- Configure workspace Git repository with comprehensive .gitignore - Add Odoo as submodule for ERP reference code - Include documentation: SETUP.md, GIT-STRUCTURE.md - Add gitignore templates for projects (backend, frontend, database) - Structure supports independent repos per project/subproject level Workspace includes: - core/ - Reusable patterns, modules, orchestration system - projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.) - knowledge-base/ - Reference code and patterns (includes Odoo submodule) - devtools/ - Development tools and templates - customers/ - Client implementations template 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
20 KiB
20 KiB
ESTÁNDARES DE TESTING PARA APIs
Versión: 1.0 Fecha: 2025-11-23 Autor: Architecture-Analyst Motivación: Garantizar que bugs de configuración de rutas API se detecten en fase de testing
PROBLEMA IDENTIFICADO
El bug de duplicación de rutas (/api/api/) no fue detectado porque:
- Falta de tests de integración que validen URLs completas
- Tests unitarios insuficientes que no validaban endpoints
- Ausencia de tests E2E que ejecuten requests HTTP reales
- Falta de validación en Network tab durante testing manual
- Mocks incorrectos que ocultaban problemas de configuración
ESTÁNDARES Y MEJORES PRÁCTICAS
1. PIRÁMIDE DE TESTING PARA APIs
/\
/ \ E2E Tests (10%)
/____\ - Requests HTTP reales
/ \ - Validación de URLs completas
/ \ - Flujos end-to-end
/----------\
/ \ Integration Tests (30%)
/ \ - Servicios + API Client
/________________\ - Controladores + Servicios
Unit Tests (60%)
- Validación de endpoints
- Lógica de negocio
- DTOs y validaciones
2. UNIT TESTS
2.1. Tests de Servicios (Frontend)
Archivo de test: apps/frontend/web/src/services/healthService.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { apiClient } from '@/lib/apiClient';
import { healthService } from './healthService';
describe('healthService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('checkHealth', () => {
it('should call correct endpoint without /api prefix', async () => {
const mockResponse = {
data: { status: 'ok', timestamp: new Date().toISOString() },
};
const getSpy = vi.spyOn(apiClient, 'get').mockResolvedValue(mockResponse);
await healthService.checkHealth();
// ✅ CRÍTICO: Validar que endpoint NO incluye /api
expect(getSpy).toHaveBeenCalledWith('/health');
expect(getSpy).not.toHaveBeenCalledWith('/api/health');
});
it('should return health status', async () => {
const mockData = { status: 'ok', timestamp: '2025-11-23T00:00:00Z' };
vi.spyOn(apiClient, 'get').mockResolvedValue({ data: mockData });
const result = await healthService.checkHealth();
expect(result).toEqual(mockData);
expect(result.status).toBe('ok');
});
it('should handle errors', async () => {
vi.spyOn(apiClient, 'get').mockRejectedValue(
new Error('Network error')
);
await expect(healthService.checkHealth()).rejects.toThrow();
});
});
describe('checkDatabase', () => {
it('should call correct endpoint', async () => {
const getSpy = vi.spyOn(apiClient, 'get').mockResolvedValue({
data: { database: 'connected' },
});
await healthService.checkDatabase();
expect(getSpy).toHaveBeenCalledWith('/health/database');
});
});
});
2.2. Tests de Servicios con Parámetros
describe('userService', () => {
describe('findById', () => {
it('should call endpoint with correct ID parameter', async () => {
const userId = '123';
const mockUser = { id: userId, name: 'John Doe' };
const getSpy = vi.spyOn(apiClient, 'get').mockResolvedValue({
data: mockUser,
});
await userService.findById(userId);
// ✅ Validar template literal correcto
expect(getSpy).toHaveBeenCalledWith(`/users/${userId}`);
expect(getSpy).not.toHaveBeenCalledWith('/users/123');
});
});
describe('searchUsers', () => {
it('should call endpoint with query parameters', async () => {
const getSpy = vi.spyOn(apiClient, 'get').mockResolvedValue({
data: [],
});
await userService.searchUsers('john', 2);
// ✅ Validar que query params se pasan correctamente
expect(getSpy).toHaveBeenCalledWith('/users', {
params: { q: 'john', page: 2 },
});
});
});
});
2.3. Tests de Controladores (Backend)
Archivo de test: apps/backend/src/modules/health/health.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
describe('HealthController', () => {
let controller: HealthController;
let service: HealthService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController],
providers: [
{
provide: HealthService,
useValue: {
checkHealth: jest.fn(),
checkDatabase: jest.fn(),
},
},
],
}).compile();
controller = module.get<HealthController>(HealthController);
service = module.get<HealthService>(HealthService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('checkHealth', () => {
it('should return health status', async () => {
const mockResult = { status: 'ok', timestamp: new Date() };
jest.spyOn(service, 'checkHealth').mockResolvedValue(mockResult);
const result = await controller.checkHealth();
expect(result).toEqual(mockResult);
expect(service.checkHealth).toHaveBeenCalled();
});
});
describe('checkDatabase', () => {
it('should return database status', async () => {
const mockResult = { database: 'connected' };
jest.spyOn(service, 'checkDatabase').mockResolvedValue(mockResult);
const result = await controller.checkDatabase();
expect(result).toEqual(mockResult);
});
});
});
3. INTEGRATION TESTS
3.1. Tests de Servicios con API Client Real
// apps/frontend/web/src/services/__integration__/healthService.integration.spec.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import axios from 'axios';
import { healthService } from '../healthService';
// Mock server para tests de integración
let mockServer: any;
beforeAll(async () => {
// Setup mock server (usando MSW o similar)
mockServer = setupMockServer();
mockServer.listen();
});
afterAll(() => {
mockServer.close();
});
describe('healthService Integration Tests', () => {
it('should make actual HTTP request to correct URL', async () => {
// Mock de respuesta del servidor
mockServer.use(
rest.get('http://localhost:3000/api/health', (req, res, ctx) => {
return res(ctx.json({ status: 'ok' }));
})
);
const result = await healthService.checkHealth();
expect(result.status).toBe('ok');
});
it('should NOT call /api/api/health', async () => {
// Este endpoint NO debe ser llamado
mockServer.use(
rest.get('http://localhost:3000/api/api/health', (req, res, ctx) => {
// Si este endpoint es llamado, el test debe fallar
throw new Error('Invalid endpoint /api/api/health was called');
})
);
// No debe lanzar error
await expect(healthService.checkHealth()).resolves.toBeDefined();
});
});
3.2. Tests de Controladores (Backend E2E)
Archivo de test: apps/backend/test/health.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('HealthController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
// ✅ CRÍTICO: Configurar prefijo global igual que en main.ts
app.setGlobalPrefix('api');
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('/api/health (GET)', () => {
it('should return health status', () => {
return request(app.getHttpServer())
.get('/api/health') // ✅ Ruta completa con /api
.expect(200)
.expect((res) => {
expect(res.body).toHaveProperty('status');
expect(res.body.status).toBe('ok');
});
});
it('should have correct content-type', () => {
return request(app.getHttpServer())
.get('/api/health')
.expect('Content-Type', /json/)
.expect(200);
});
});
describe('/api/health/database (GET)', () => {
it('should return database status', () => {
return request(app.getHttpServer())
.get('/api/health/database')
.expect(200)
.expect((res) => {
expect(res.body).toHaveProperty('database');
});
});
});
describe('Invalid routes', () => {
it('should NOT respond to /api/api/health', () => {
// ✅ Validar que ruta duplicada NO existe
return request(app.getHttpServer())
.get('/api/api/health')
.expect(404);
});
it('should NOT respond to /health without /api prefix', () => {
return request(app.getHttpServer())
.get('/health')
.expect(404);
});
});
});
4. E2E TESTS
4.1. Tests con Playwright
// apps/frontend/web/e2e/health.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Health Check Page', () => {
test.beforeEach(async ({ page }) => {
// Interceptar requests para validar URLs
page.on('request', (request) => {
const url = request.url();
// ✅ CRÍTICO: Validar que NO hay /api/api/
if (url.includes('/api/api/')) {
throw new Error(`Invalid URL detected: ${url}`);
}
console.log(`Request: ${request.method()} ${url}`);
});
await page.goto('http://localhost:5173/health');
});
test('should load health status', async ({ page }) => {
// Esperar que el request se complete
const response = await page.waitForResponse(
(response) => response.url().includes('/api/health') && response.status() === 200
);
expect(response.ok()).toBeTruthy();
// Validar que la URL es correcta
expect(response.url()).toBe('http://localhost:3000/api/health');
expect(response.url()).not.toContain('/api/api/');
});
test('should display health status on page', async ({ page }) => {
await page.waitForSelector('[data-testid="health-status"]');
const status = await page.textContent('[data-testid="health-status"]');
expect(status).toBe('ok');
});
test('should handle API errors', async ({ page }) => {
// Mock error response
await page.route('**/api/health', (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Internal server error' }),
});
});
await page.goto('http://localhost:5173/health');
await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
});
});
4.2. Tests con Cypress
// cypress/e2e/health.cy.ts
describe('Health Check API', () => {
beforeEach(() => {
// Interceptar requests
cy.intercept('GET', '/api/health').as('healthCheck');
});
it('should call correct API endpoint', () => {
cy.visit('/health');
cy.wait('@healthCheck').then((interception) => {
// ✅ Validar URL correcta
expect(interception.request.url).to.include('/api/health');
expect(interception.request.url).to.not.include('/api/api/');
// ✅ Validar respuesta
expect(interception.response.statusCode).to.eq(200);
expect(interception.response.body).to.have.property('status', 'ok');
});
});
it('should display health status', () => {
cy.visit('/health');
cy.get('[data-testid="health-status"]')
.should('be.visible')
.and('contain', 'ok');
});
});
5. NETWORK TAB VALIDATION
5.1. Manual Testing Checklist
Antes de marcar feature como completa:
-
Abrir DevTools
Chrome/Edge: F12 o Ctrl+Shift+I Firefox: F12 o Ctrl+Shift+K Safari: Cmd+Option+I -
Ir a Network Tab
- Activar "Preserve log"
- Filtrar por "Fetch/XHR"
- Limpiar logs existentes
-
Ejecutar la acción que hace el request
- Click en botón
- Navegar a página
- Hacer submit de form
-
Validar en Network Tab:
✅ Checklist de validación: Request URL: [ ] Debe ser: http://localhost:3000/api/health [ ] NO debe ser: http://localhost:3000/api/api/health [ ] NO debe ser: http://localhost:3000/health Request Method: [ ] GET, POST, PUT, DELETE, etc. (correcto para la acción) Status Code: [ ] 200 OK (para GET exitoso) [ ] 201 Created (para POST exitoso) [ ] 204 No Content (para DELETE exitoso) [ ] NO 404 Not Found [ ] NO 500 Internal Server Error Request Headers: [ ] Content-Type: application/json [ ] Authorization: Bearer ... (si requiere auth) Response: [ ] Body tiene estructura esperada [ ] NO hay errores en response -
Tomar Screenshot
- Guardar screenshot del Network tab
- Adjuntar al PR como evidencia
5.2. Automated Network Validation
// apps/frontend/web/src/tests/helpers/networkValidator.ts
/**
* Validates that a URL does not have duplicate /api/ prefixes
*/
export function validateApiUrl(url: string): void {
if (url.includes('/api/api/')) {
throw new Error(
`Invalid API URL detected: ${url}\n` +
`URL contains duplicate /api/ prefix.\n` +
`This indicates incorrect endpoint configuration.`
);
}
if (!url.includes('/api/')) {
console.warn(
`Warning: URL does not contain /api/ prefix: ${url}\n` +
`This might be a non-API request or incorrect configuration.`
);
}
}
/**
* Setup global request interceptor for validation
*/
export function setupNetworkValidation(): void {
const originalFetch = window.fetch;
window.fetch = function(...args) {
const url = args[0] as string;
if (url.startsWith('http://') || url.startsWith('https://')) {
validateApiUrl(url);
}
return originalFetch.apply(this, args);
};
}
// Usar en tests
beforeAll(() => {
setupNetworkValidation();
});
6. MOCK SERVER TESTING
6.1. MSW (Mock Service Worker)
// apps/frontend/web/src/mocks/handlers.ts
import { rest } from 'msw';
export const handlers = [
rest.get('http://localhost:3000/api/health', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
status: 'ok',
timestamp: new Date().toISOString(),
})
);
}),
rest.get('http://localhost:3000/api/health/database', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
database: 'connected',
tables: 42,
})
);
}),
// ✅ Handler para detectar requests inválidos
rest.get('http://localhost:3000/api/api/*', (req, res, ctx) => {
console.error(`[MSW] Invalid request to /api/api/: ${req.url}`);
return res(
ctx.status(404),
ctx.json({
error: 'Invalid endpoint',
message: 'Endpoint contains duplicate /api/ prefix',
})
);
}),
];
// apps/frontend/web/src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// Setup en tests
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
6.2. Tests con MSW
// apps/frontend/web/src/services/healthService.msw.spec.ts
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '@/mocks/server';
import { rest } from 'msw';
import { healthService } from './healthService';
describe('healthService with MSW', () => {
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('should call correct endpoint', async () => {
const result = await healthService.checkHealth();
expect(result).toEqual({
status: 'ok',
timestamp: expect.any(String),
});
});
it('should handle server errors', async () => {
server.use(
rest.get('http://localhost:3000/api/health', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Server error' }));
})
);
await expect(healthService.checkHealth()).rejects.toThrow();
});
it('should fail if calling /api/api/health', async () => {
// Modificar temporalmente el servicio para probar endpoint incorrecto
const originalGet = apiClient.get;
apiClient.get = vi.fn().mockImplementation((url) => {
return originalGet(`/api${url}`); // Simular bug de duplicación
});
await expect(healthService.checkHealth()).rejects.toThrow();
// Restaurar
apiClient.get = originalGet;
});
});
7. COVERAGE REQUIREMENTS
7.1. Minimum Coverage
Requisitos mínimos de cobertura:
Unit Tests:
- Servicios (Frontend): 90%
- Controladores (Backend): 85%
- Lógica de negocio: 95%
Integration Tests:
- Flujos críticos: 80%
- Endpoints principales: 100%
E2E Tests:
- User flows críticos: 100%
- Happy paths: 100%
- Error handling: 70%
7.2. Coverage Configuration
// vitest.config.ts (Frontend)
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts', 'src/**/*.tsx'],
exclude: [
'src/**/*.spec.ts',
'src/**/*.test.ts',
'src/mocks/**',
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
});
// jest.config.js (Backend)
module.exports = {
coverageThreshold: {
global: {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.spec.ts',
'!src/**/*.e2e-spec.ts',
],
};
CHECKLIST DE VALIDACIÓN
Pre-Commit Checklist (Developer)
- Unit tests escritos y pasando
- Integration tests para endpoints críticos
- Tests validan endpoints correctos (sin
/apien services) - Coverage mínimo alcanzado
- Probado en Network tab del navegador
- No hay errores en consola
- Todos los tests pasan localmente
Pre-Merge Checklist (Reviewer)
- Revisar que tests existen para nuevos endpoints
- Revisar que tests validan URLs correctas
- Revisar que hay tests E2E si es feature crítica
- Ejecutar tests localmente
- Validar coverage report
- Probar manualmente en navegador
Pre-Deploy Checklist (QA)
- Smoke tests en staging
- Validar endpoints en Network tab
- Tests E2E en staging
- Performance tests
- Security tests
- Regression tests
HERRAMIENTAS DE AUTOMATIZACIÓN
1. CI/CD Pipeline
# .github/workflows/test.yml
name: Test Suite
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: npm install
- name: Run linter
run: npm run lint
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
- name: Run E2E tests
run: npm run test:e2e
- name: Check coverage
run: npm run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
files: ./coverage/coverage-final.json
2. Pre-commit Hook
#!/bin/bash
# .git/hooks/pre-commit
echo "Running tests before commit..."
# Run unit tests
npm run test:unit
if [ $? -ne 0 ]; then
echo "Unit tests failed. Commit aborted."
exit 1
fi
# Run linter
npm run lint
if [ $? -ne 0 ]; then
echo "Linter failed. Commit aborted."
exit 1
fi
echo "Tests passed. Proceeding with commit."
REFERENCIAS
- ESTANDARES-API-ROUTES.md - Estándares de rutas API
- CHECKLIST-CODE-REVIEW-API.md - Checklist de code review
- PITFALLS-API-ROUTES.md - Errores comunes
- Vitest Documentation
- Jest Documentation
- Playwright Documentation
Uso: Referencia obligatoria para escribir tests de APIs Validación: Obligatoria antes de merge y deploy Actualización: Mantener sincronizado con cambios en arquitectura