workspace/projects/gamilit/orchestration/directivas/ESTANDARES-TESTING-API.md
rckrdmrd ea1879f4ad feat: Initial workspace structure with multi-level Git configuration
- 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>
2025-12-08 10:44:23 -06:00

845 lines
20 KiB
Markdown

# 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:
1. **Falta de tests de integración** que validen URLs completas
2. **Tests unitarios insuficientes** que no validaban endpoints
3. **Ausencia de tests E2E** que ejecuten requests HTTP reales
4. **Falta de validación en Network tab** durante testing manual
5. **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`
```typescript
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
```typescript
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`
```typescript
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
```typescript
// 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`
```typescript
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
```typescript
// 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
```typescript
// 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:**
1. **Abrir DevTools**
```
Chrome/Edge: F12 o Ctrl+Shift+I
Firefox: F12 o Ctrl+Shift+K
Safari: Cmd+Option+I
```
2. **Ir a Network Tab**
- Activar "Preserve log"
- Filtrar por "Fetch/XHR"
- Limpiar logs existentes
3. **Ejecutar la acción que hace el request**
- Click en botón
- Navegar a página
- Hacer submit de form
4. **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
```
5. **Tomar Screenshot**
- Guardar screenshot del Network tab
- Adjuntar al PR como evidencia
#### 5.2. Automated Network Validation
```typescript
// 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)
```typescript
// 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',
})
);
}),
];
```
```typescript
// 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
```typescript
// 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
```yaml
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
```typescript
// 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,
},
},
},
});
```
```json
// 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 `/api` en 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
```yaml
# .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
```bash
#!/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](./ESTANDARES-API-ROUTES.md) - Estándares de rutas API
- [CHECKLIST-CODE-REVIEW-API.md](./CHECKLIST-CODE-REVIEW-API.md) - Checklist de code review
- [PITFALLS-API-ROUTES.md](./PITFALLS-API-ROUTES.md) - Errores comunes
- [Vitest Documentation](https://vitest.dev/)
- [Jest Documentation](https://jestjs.io/)
- [Playwright Documentation](https://playwright.dev/)
---
**Uso:** Referencia obligatoria para escribir tests de APIs
**Validación:** Obligatoria antes de merge y deploy
**Actualización:** Mantener sincronizado con cambios en arquitectura