Sistema NEXUS v3.4 migrado con: Estructura principal: - core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles) - core/catalog: Catalogo de funcionalidades reutilizables - shared/knowledge-base: Base de conocimiento compartida - devtools/scripts: Herramientas de desarrollo - control-plane/registries: Control de servicios y CI/CD - orchestration/: Configuracion de orquestacion de agentes Proyectos incluidos (11): - gamilit (submodule -> GitHub) - trading-platform (OrbiquanTIA) - erp-suite con 5 verticales: - erp-core, construccion, vidrio-templado - mecanicas-diesel, retail, clinicas - betting-analytics - inmobiliaria-analytics - platform_marketing_content - pos-micro, erp-basico Configuracion: - .gitignore completo para Node.js/Python/Docker - gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git) - Sistema de puertos estandarizado (3005-3199) Generated with NEXUS v3.4 Migration System EPIC-010: Configuracion Git y Repositorios
728 lines
20 KiB
Markdown
728 lines
20 KiB
Markdown
# PATRÓN: TESTING
|
|
|
|
**Versión:** 1.0.0
|
|
**Fecha:** 2025-12-08
|
|
**Aplica a:** Backend (NestJS), Frontend (React)
|
|
**Prioridad:** RECOMENDADA
|
|
|
|
---
|
|
|
|
## PROPÓSITO
|
|
|
|
Definir patrones estándar de testing para garantizar código de calidad.
|
|
|
|
---
|
|
|
|
## 1. TIPOS DE TESTS
|
|
|
|
| Tipo | Qué Testea | Herramienta | Cobertura Objetivo |
|
|
|------|------------|-------------|-------------------|
|
|
| **Unit** | Funciones/Clases aisladas | Jest | 70%+ |
|
|
| **Integration** | Módulos integrados | Jest + Supertest | 50%+ |
|
|
| **E2E** | Flujos completos | Jest + Supertest | Críticos |
|
|
| **Component** | Componentes React | React Testing Library | 60%+ |
|
|
|
|
---
|
|
|
|
## 2. BACKEND: TEST DE SERVICE
|
|
|
|
### Template
|
|
|
|
```typescript
|
|
// user.service.spec.ts
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { UserService } from './user.service';
|
|
import { UserEntity } from '../entities/user.entity';
|
|
import { CreateUserDto } from '../dto/create-user.dto';
|
|
import { NotFoundException, ConflictException } from '@nestjs/common';
|
|
|
|
describe('UserService', () => {
|
|
let service: UserService;
|
|
let repository: jest.Mocked<Repository<UserEntity>>;
|
|
|
|
// Mock del repositorio
|
|
const mockRepository = {
|
|
find: jest.fn(),
|
|
findOne: jest.fn(),
|
|
create: jest.fn(),
|
|
save: jest.fn(),
|
|
remove: jest.fn(),
|
|
count: jest.fn(),
|
|
};
|
|
|
|
// Fixtures
|
|
const mockUser: UserEntity = {
|
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
status: 'active',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const createUserDto: CreateUserDto = {
|
|
email: 'new@example.com',
|
|
name: 'New User',
|
|
password: 'SecurePass123!',
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
UserService,
|
|
{
|
|
provide: getRepositoryToken(UserEntity),
|
|
useValue: mockRepository,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<UserService>(UserService);
|
|
repository = module.get(getRepositoryToken(UserEntity));
|
|
|
|
// Reset mocks
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('findOne', () => {
|
|
it('should return user when found', async () => {
|
|
// Arrange
|
|
mockRepository.findOne.mockResolvedValue(mockUser);
|
|
|
|
// Act
|
|
const result = await service.findOne(mockUser.id);
|
|
|
|
// Assert
|
|
expect(result).toEqual(mockUser);
|
|
expect(mockRepository.findOne).toHaveBeenCalledWith({
|
|
where: { id: mockUser.id },
|
|
});
|
|
});
|
|
|
|
it('should throw NotFoundException when user not found', async () => {
|
|
// Arrange
|
|
mockRepository.findOne.mockResolvedValue(null);
|
|
|
|
// Act & Assert
|
|
await expect(service.findOne('non-existent-id'))
|
|
.rejects
|
|
.toThrow(NotFoundException);
|
|
});
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('should create user successfully', async () => {
|
|
// Arrange
|
|
mockRepository.findOne.mockResolvedValue(null); // No existe
|
|
mockRepository.create.mockReturnValue(mockUser);
|
|
mockRepository.save.mockResolvedValue(mockUser);
|
|
|
|
// Act
|
|
const result = await service.create(createUserDto);
|
|
|
|
// Assert
|
|
expect(result).toEqual(mockUser);
|
|
expect(mockRepository.findOne).toHaveBeenCalled();
|
|
expect(mockRepository.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
email: createUserDto.email,
|
|
name: createUserDto.name,
|
|
})
|
|
);
|
|
expect(mockRepository.save).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should throw ConflictException when email exists', async () => {
|
|
// Arrange
|
|
mockRepository.findOne.mockResolvedValue(mockUser); // Ya existe
|
|
|
|
// Act & Assert
|
|
await expect(service.create(createUserDto))
|
|
.rejects
|
|
.toThrow(ConflictException);
|
|
|
|
expect(mockRepository.save).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('findAll', () => {
|
|
it('should return array of users', async () => {
|
|
// Arrange
|
|
const users = [mockUser, { ...mockUser, id: '2' }];
|
|
mockRepository.find.mockResolvedValue(users);
|
|
|
|
// Act
|
|
const result = await service.findAll();
|
|
|
|
// Assert
|
|
expect(result).toHaveLength(2);
|
|
expect(mockRepository.find).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return empty array when no users', async () => {
|
|
// Arrange
|
|
mockRepository.find.mockResolvedValue([]);
|
|
|
|
// Act
|
|
const result = await service.findAll();
|
|
|
|
// Assert
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('should update user successfully', async () => {
|
|
// Arrange
|
|
const updateDto = { name: 'Updated Name' };
|
|
const updatedUser = { ...mockUser, ...updateDto };
|
|
|
|
mockRepository.findOne.mockResolvedValue(mockUser);
|
|
mockRepository.save.mockResolvedValue(updatedUser);
|
|
|
|
// Act
|
|
const result = await service.update(mockUser.id, updateDto);
|
|
|
|
// Assert
|
|
expect(result.name).toBe('Updated Name');
|
|
expect(mockRepository.save).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should throw NotFoundException when user not found', async () => {
|
|
// Arrange
|
|
mockRepository.findOne.mockResolvedValue(null);
|
|
|
|
// Act & Assert
|
|
await expect(service.update('non-existent', { name: 'Test' }))
|
|
.rejects
|
|
.toThrow(NotFoundException);
|
|
});
|
|
});
|
|
|
|
describe('remove', () => {
|
|
it('should remove user successfully', async () => {
|
|
// Arrange
|
|
mockRepository.findOne.mockResolvedValue(mockUser);
|
|
mockRepository.remove.mockResolvedValue(mockUser);
|
|
|
|
// Act
|
|
await service.remove(mockUser.id);
|
|
|
|
// Assert
|
|
expect(mockRepository.remove).toHaveBeenCalledWith(mockUser);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 3. BACKEND: TEST DE CONTROLLER
|
|
|
|
### Template
|
|
|
|
```typescript
|
|
// user.controller.spec.ts
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { UserController } from './user.controller';
|
|
import { UserService } from '../services/user.service';
|
|
import { CreateUserDto } from '../dto/create-user.dto';
|
|
import { UserEntity } from '../entities/user.entity';
|
|
import { NotFoundException } from '@nestjs/common';
|
|
|
|
describe('UserController', () => {
|
|
let controller: UserController;
|
|
let service: jest.Mocked<UserService>;
|
|
|
|
const mockService = {
|
|
findAll: jest.fn(),
|
|
findOne: jest.fn(),
|
|
create: jest.fn(),
|
|
update: jest.fn(),
|
|
remove: jest.fn(),
|
|
};
|
|
|
|
const mockUser: UserEntity = {
|
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
status: 'active',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
controllers: [UserController],
|
|
providers: [
|
|
{
|
|
provide: UserService,
|
|
useValue: mockService,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
controller = module.get<UserController>(UserController);
|
|
service = module.get(UserService);
|
|
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('findAll', () => {
|
|
it('should return array of users', async () => {
|
|
// Arrange
|
|
mockService.findAll.mockResolvedValue([mockUser]);
|
|
|
|
// Act
|
|
const result = await controller.findAll();
|
|
|
|
// Assert
|
|
expect(result).toHaveLength(1);
|
|
expect(service.findAll).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('findOne', () => {
|
|
it('should return user by id', async () => {
|
|
// Arrange
|
|
mockService.findOne.mockResolvedValue(mockUser);
|
|
|
|
// Act
|
|
const result = await controller.findOne(mockUser.id);
|
|
|
|
// Assert
|
|
expect(result).toEqual(mockUser);
|
|
expect(service.findOne).toHaveBeenCalledWith(mockUser.id);
|
|
});
|
|
|
|
it('should propagate NotFoundException', async () => {
|
|
// Arrange
|
|
mockService.findOne.mockRejectedValue(new NotFoundException());
|
|
|
|
// Act & Assert
|
|
await expect(controller.findOne('non-existent'))
|
|
.rejects
|
|
.toThrow(NotFoundException);
|
|
});
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('should create and return user', async () => {
|
|
// Arrange
|
|
const createDto: CreateUserDto = {
|
|
email: 'new@example.com',
|
|
name: 'New User',
|
|
password: 'Pass123!',
|
|
};
|
|
mockService.create.mockResolvedValue(mockUser);
|
|
|
|
// Act
|
|
const result = await controller.create(createDto);
|
|
|
|
// Assert
|
|
expect(result).toEqual(mockUser);
|
|
expect(service.create).toHaveBeenCalledWith(createDto);
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('should update and return user', async () => {
|
|
// Arrange
|
|
const updateDto = { name: 'Updated' };
|
|
const updated = { ...mockUser, ...updateDto };
|
|
mockService.update.mockResolvedValue(updated);
|
|
|
|
// Act
|
|
const result = await controller.update(mockUser.id, updateDto);
|
|
|
|
// Assert
|
|
expect(result.name).toBe('Updated');
|
|
expect(service.update).toHaveBeenCalledWith(mockUser.id, updateDto);
|
|
});
|
|
});
|
|
|
|
describe('remove', () => {
|
|
it('should remove user', async () => {
|
|
// Arrange
|
|
mockService.remove.mockResolvedValue(undefined);
|
|
|
|
// Act
|
|
await controller.remove(mockUser.id);
|
|
|
|
// Assert
|
|
expect(service.remove).toHaveBeenCalledWith(mockUser.id);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 4. BACKEND: TEST E2E
|
|
|
|
### Template
|
|
|
|
```typescript
|
|
// user.e2e-spec.ts
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
import * as request from 'supertest';
|
|
import { AppModule } from '../src/app.module';
|
|
|
|
describe('UserController (e2e)', () => {
|
|
let app: INestApplication;
|
|
let createdUserId: string;
|
|
|
|
beforeAll(async () => {
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
app.useGlobalPipes(new ValidationPipe());
|
|
await app.init();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
describe('/users (POST)', () => {
|
|
it('should create user', () => {
|
|
return request(app.getHttpServer())
|
|
.post('/users')
|
|
.send({
|
|
email: 'e2e@test.com',
|
|
name: 'E2E User',
|
|
password: 'SecurePass123!',
|
|
})
|
|
.expect(201)
|
|
.expect((res) => {
|
|
expect(res.body.id).toBeDefined();
|
|
expect(res.body.email).toBe('e2e@test.com');
|
|
createdUserId = res.body.id;
|
|
});
|
|
});
|
|
|
|
it('should reject invalid email', () => {
|
|
return request(app.getHttpServer())
|
|
.post('/users')
|
|
.send({
|
|
email: 'invalid-email',
|
|
name: 'Test',
|
|
password: 'Pass123!',
|
|
})
|
|
.expect(400);
|
|
});
|
|
|
|
it('should reject duplicate email', () => {
|
|
return request(app.getHttpServer())
|
|
.post('/users')
|
|
.send({
|
|
email: 'e2e@test.com', // Ya existe
|
|
name: 'Duplicate',
|
|
password: 'Pass123!',
|
|
})
|
|
.expect(409);
|
|
});
|
|
});
|
|
|
|
describe('/users (GET)', () => {
|
|
it('should return users list', () => {
|
|
return request(app.getHttpServer())
|
|
.get('/users')
|
|
.expect(200)
|
|
.expect((res) => {
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('/users/:id (GET)', () => {
|
|
it('should return user by id', () => {
|
|
return request(app.getHttpServer())
|
|
.get(`/users/${createdUserId}`)
|
|
.expect(200)
|
|
.expect((res) => {
|
|
expect(res.body.id).toBe(createdUserId);
|
|
});
|
|
});
|
|
|
|
it('should return 404 for non-existent user', () => {
|
|
return request(app.getHttpServer())
|
|
.get('/users/550e8400-e29b-41d4-a716-446655440000')
|
|
.expect(404);
|
|
});
|
|
});
|
|
|
|
describe('/users/:id (PUT)', () => {
|
|
it('should update user', () => {
|
|
return request(app.getHttpServer())
|
|
.put(`/users/${createdUserId}`)
|
|
.send({ name: 'Updated Name' })
|
|
.expect(200)
|
|
.expect((res) => {
|
|
expect(res.body.name).toBe('Updated Name');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('/users/:id (DELETE)', () => {
|
|
it('should delete user', () => {
|
|
return request(app.getHttpServer())
|
|
.delete(`/users/${createdUserId}`)
|
|
.expect(204);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 5. FRONTEND: TEST DE COMPONENTE
|
|
|
|
### Template con React Testing Library
|
|
|
|
```typescript
|
|
// UserCard.test.tsx
|
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
import { UserCard } from './UserCard';
|
|
import { User } from '@/types/user.types';
|
|
|
|
describe('UserCard', () => {
|
|
const mockUser: User = {
|
|
id: '1',
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
status: 'active',
|
|
};
|
|
|
|
const mockOnEdit = jest.fn();
|
|
const mockOnDelete = jest.fn();
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('should render user information', () => {
|
|
render(<UserCard user={mockUser} />);
|
|
|
|
expect(screen.getByText('Test User')).toBeInTheDocument();
|
|
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should call onEdit when edit button clicked', () => {
|
|
render(
|
|
<UserCard
|
|
user={mockUser}
|
|
onEdit={mockOnEdit}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
|
|
|
expect(mockOnEdit).toHaveBeenCalledWith(mockUser);
|
|
});
|
|
|
|
it('should call onDelete when delete button clicked', () => {
|
|
render(
|
|
<UserCard
|
|
user={mockUser}
|
|
onDelete={mockOnDelete}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
|
|
|
|
expect(mockOnDelete).toHaveBeenCalledWith(mockUser.id);
|
|
});
|
|
|
|
it('should show loading state', () => {
|
|
render(<UserCard user={mockUser} isLoading />);
|
|
|
|
expect(screen.getByTestId('loading-skeleton')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should hide actions when not provided', () => {
|
|
render(<UserCard user={mockUser} />);
|
|
|
|
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 6. FRONTEND: TEST DE HOOK
|
|
|
|
### Template
|
|
|
|
```typescript
|
|
// useUsers.test.tsx
|
|
import { renderHook, waitFor } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { useUsers, useCreateUser } from './useUsers';
|
|
import { userService } from '@/services/user.service';
|
|
|
|
// Mock del servicio
|
|
jest.mock('@/services/user.service');
|
|
const mockUserService = userService as jest.Mocked<typeof userService>;
|
|
|
|
describe('useUsers', () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
const wrapper = ({ children }) => (
|
|
<QueryClientProvider client={queryClient}>
|
|
{children}
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
beforeEach(() => {
|
|
queryClient.clear();
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('useUsers', () => {
|
|
it('should fetch users', async () => {
|
|
// Arrange
|
|
const mockUsers = [
|
|
{ id: '1', name: 'User 1', email: 'u1@test.com' },
|
|
{ id: '2', name: 'User 2', email: 'u2@test.com' },
|
|
];
|
|
mockUserService.getAll.mockResolvedValue(mockUsers);
|
|
|
|
// Act
|
|
const { result } = renderHook(() => useUsers(), { wrapper });
|
|
|
|
// Assert - Initially loading
|
|
expect(result.current.isLoading).toBe(true);
|
|
|
|
// Wait for data
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.data).toEqual(mockUsers);
|
|
});
|
|
|
|
it('should handle error', async () => {
|
|
// Arrange
|
|
mockUserService.getAll.mockRejectedValue(new Error('Network error'));
|
|
|
|
// Act
|
|
const { result } = renderHook(() => useUsers(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
|
|
expect(result.current.error).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('useCreateUser', () => {
|
|
it('should create user and invalidate cache', async () => {
|
|
// Arrange
|
|
const newUser = { id: '3', name: 'New', email: 'new@test.com' };
|
|
mockUserService.create.mockResolvedValue(newUser);
|
|
|
|
// Act
|
|
const { result } = renderHook(() => useCreateUser(), { wrapper });
|
|
|
|
await result.current.mutateAsync({
|
|
name: 'New',
|
|
email: 'new@test.com',
|
|
password: 'Pass123!',
|
|
});
|
|
|
|
// Assert
|
|
expect(mockUserService.create).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 7. CONVENCIONES DE NOMBRES
|
|
|
|
```typescript
|
|
// Archivos de test
|
|
user.service.spec.ts // Unit test backend
|
|
user.controller.spec.ts // Unit test controller
|
|
user.e2e-spec.ts // E2E test
|
|
UserCard.test.tsx // Component test
|
|
useUsers.test.tsx // Hook test
|
|
|
|
// Describe blocks
|
|
describe('UserService', () => { ... });
|
|
describe('findOne', () => { ... });
|
|
|
|
// Test cases
|
|
it('should return user when found', () => { ... });
|
|
it('should throw NotFoundException when user not found', () => { ... });
|
|
```
|
|
|
|
---
|
|
|
|
## 8. CHECKLIST DE TESTING
|
|
|
|
```
|
|
Por Service:
|
|
[ ] Test de cada método público
|
|
[ ] Test de casos de éxito
|
|
[ ] Test de casos de error (NotFoundException, etc.)
|
|
[ ] Test de validaciones de negocio
|
|
|
|
Por Controller:
|
|
[ ] Test de cada endpoint
|
|
[ ] Test de status codes correctos
|
|
[ ] Test de propagación de errores
|
|
|
|
Por Componente:
|
|
[ ] Test de render correcto
|
|
[ ] Test de eventos (click, change)
|
|
[ ] Test de estados (loading, error)
|
|
[ ] Test de props opcionales
|
|
|
|
Por Hook:
|
|
[ ] Test de fetch exitoso
|
|
[ ] Test de manejo de error
|
|
[ ] Test de mutaciones
|
|
|
|
Cobertura:
|
|
[ ] npm run test:cov muestra 70%+ en services
|
|
[ ] npm run test:cov muestra 60%+ en components
|
|
[ ] Tests críticos e2e pasan
|
|
```
|
|
|
|
---
|
|
|
|
## 9. COMANDOS
|
|
|
|
```bash
|
|
# Backend
|
|
npm run test # Unit tests
|
|
npm run test:watch # Watch mode
|
|
npm run test:cov # Con cobertura
|
|
npm run test:e2e # E2E tests
|
|
|
|
# Frontend
|
|
npm run test # All tests
|
|
npm run test -- --watch # Watch mode
|
|
npm run test -- --coverage # Con cobertura
|
|
npm run test -- UserCard # Test específico
|
|
```
|
|
|
|
---
|
|
|
|
**Versión:** 1.0.0 | **Sistema:** SIMCO | **Tipo:** Patrón de Testing
|