# 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
