🧪 Guía de Testing
Visión General
PuntoHack utiliza Vitest como framework de testing, con cobertura de código y tests organizados por módulos.
Configuración
Vitest Config
Archivo: vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./tests/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'.next/',
'**/*.config.*',
'**/types.ts',
'**/route.ts', // API routes no necesitan coverage en tests unitarios
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
Setup Global
Archivo: tests/setup.ts
import { beforeAll, afterAll, afterEach } from 'vitest';
import { db } from '@/core/db';
// Limpiar base de datos después de cada test (si es necesario)
afterEach(async () => {
// Aquí puedes agregar lógica de limpieza si usas una DB de test
});
// Cerrar conexión de Prisma después de todos los tests
afterAll(async () => {
await db.$disconnect();
});
Estructura de Tests
tests/
├── setup.ts # Configuración global
├── core/ # Tests de infraestructura
│ ├── rbac.test.ts
│ └── validations.test.ts
├── modules/ # Tests por módulo
│ ├── users/
│ │ ├── actions.test.ts
│ │ └── queries.test.ts
│ ├── hackathons/
│ │ ├── actions.test.ts
│ │ └── queries.test.ts
│ ├── teams/
│ │ ├── actions.test.ts
│ │ └── queries.test.ts
│ ├── submissions/
│ │ └── actions.test.ts
│ ├── evaluation/
│ │ ├── actions.test.ts
│ │ └── queries.test.ts
│ └── sponsors/
│ ├── actions.test.ts
│ └── queries.test.ts
└── components/ # Tests de componentes (futuro)
└── toast/
└── toast.test.tsx
Comandos de Testing
Ejecutar Todos los Tests
pnpm test
Ejecutar con Coverage
pnpm test:coverage
Ejecutar en Modo Watch
pnpm test --watch
Ejecutar Tests Específicos
pnpm test tests/modules/hackathons/actions.test.ts
Ejecutar con Filtro
pnpm test --grep "createHackathon"
Patrones de Testing
1. Tests de Queries
Estructura estándar:
import { describe, it, expect, beforeEach } from 'vitest';
import { db } from '@/core/db';
import { getHackathonById } from '@/modules/hackathons/queries';
describe('Hackathons Queries', () => {
beforeEach(async () => {
// Limpiar datos de prueba
await db.hackathon.deleteMany();
});
describe('getHackathonById', () => {
it('should return hackathon if exists', async () => {
// Arrange
const hackathon = await db.hackathon.create({
data: {
name: 'Test Hackathon',
slug: 'test-hackathon',
// ... otros campos
},
});
// Act
const result = await getHackathonById(hackathon.id);
// Assert
expect(result).not.toBeNull();
expect(result?.id).toBe(hackathon.id);
expect(result?.name).toBe('Test Hackathon');
});
it('should return null if not exists', async () => {
// Act
const result = await getHackathonById('non-existent-id');
// Assert
expect(result).toBeNull();
});
});
});
2. Tests de Actions (Server Actions)
Estructura con mocks:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Role } from '@prisma/client';
// Mocks
vi.mock('@/core/auth', () => ({
getCurrentUser: vi.fn(),
}));
vi.mock('@/core/rbac', () => ({
requireRole: vi.fn(),
}));
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}));
// Importar después de los mocks
import { createHackathon } from '@/modules/hackathons/actions';
import { getCurrentUser } from '@/core/auth';
describe('Hackathons Actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockAdminProfile = {
id: 'profile-admin',
userId: 'user_admin',
name: 'Admin User',
email: 'admin@example.com',
role: Role.ADMIN,
createdAt: new Date(),
updatedAt: new Date(),
};
describe('createHackathon', () => {
it('should create hackathon with valid data', async () => {
// Arrange
vi.mocked(getCurrentUser).mockResolvedValue({
id: 'user-1',
profile: mockAdminProfile,
});
const formData = new FormData();
formData.set('name', 'Test Hackathon');
formData.set('slug', 'test-hackathon');
// ... otros campos
// Act
const result = await createHackathon(formData);
// Assert
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(result.data.name).toBe('Test Hackathon');
});
it('should fail with invalid data', async () => {
// Arrange
vi.mocked(getCurrentUser).mockResolvedValue({
id: 'user-1',
profile: mockAdminProfile,
});
const formData = new FormData();
formData.set('name', ''); // ❌ Inválido
// Act
const result = await createHackathon(formData);
// Assert
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
it('should fail if user is not authorized', async () => {
// Arrange
vi.mocked(getCurrentUser).mockResolvedValue(null);
const formData = new FormData();
formData.set('name', 'Test Hackathon');
// Act
const result = await createHackathon(formData);
// Assert
expect(result.success).toBe(false);
expect(result.error).toContain('Unauthorized');
});
});
});
3. Tests de Validaciones
Estructura simple:
import { describe, it, expect } from 'vitest';
import { validateHackathonDates } from '@/core/validations';
describe('validateHackathonDates', () => {
const validDates = {
registrationOpensAt: new Date('2025-01-01'),
registrationClosesAt: new Date('2025-01-15'),
startsAt: new Date('2025-02-01'),
endsAt: new Date('2025-02-03'),
submissionDeadline: new Date('2025-02-02'),
judgingStartsAt: new Date('2025-02-04'),
judgingEndsAt: new Date('2025-02-10'),
};
it('should not throw for valid dates', () => {
expect(() => validateHackathonDates(validDates)).not.toThrow();
});
it('should throw if registrationOpensAt >= registrationClosesAt', () => {
const invalidDates = {
...validDates,
registrationOpensAt: new Date('2025-01-15'), // ❌ Invertido
};
expect(() => validateHackathonDates(invalidDates)).toThrow();
});
it('should throw if submissionDeadline >= judgingStartsAt', () => {
const invalidDates = {
...validDates,
submissionDeadline: new Date('2025-02-05'), // ❌ Después de judgingStartsAt
};
expect(() => validateHackathonDates(invalidDates)).toThrow();
});
});
4. Tests de RBAC
Estructura con tipos:
import { describe, it, expect } from 'vitest';
import { hasRole, requireRole, type User } from '@/core/rbac';
import { Role } from '@prisma/client';
describe('RBAC', () => {
const mockUser: User = {
id: 'user-1',
profile: {
id: 'profile-1',
role: Role.ADMIN,
},
};
describe('hasRole', () => {
it('should return true if user has the required role', () => {
expect(hasRole(mockUser, [Role.ADMIN])).toBe(true);
expect(hasRole(mockUser, [Role.ADMIN, Role.ORGANIZER])).toBe(true);
});
it('should return false if user does not have the required role', () => {
expect(hasRole(mockUser, [Role.PARTICIPANT])).toBe(false);
expect(hasRole(mockUser, [Role.JUDGE, Role.SPONSOR])).toBe(false);
});
});
describe('requireRole', () => {
it('should not throw if user has the required role', () => {
expect(() => requireRole(mockUser, [Role.ADMIN])).not.toThrow();
});
it('should throw if user does not have the required role', () => {
expect(() => requireRole(mockUser, [Role.PARTICIPANT])).toThrow('Unauthorized');
});
});
});
Mocks Comunes
Mock de Auth
vi.mock('@/core/auth', () => ({
getCurrentUser: vi.fn(),
getClerkUserId: vi.fn(),
}));
Mock de RBAC
vi.mock('@/core/rbac', () => ({
hasRole: vi.fn(),
requireRole: vi.fn(),
}));
Mock de DB
vi.mock('@/core/db', () => ({
db: {
profile: {
findUnique: vi.fn(),
create: vi.fn(),
},
hackathon: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
// ... otros modelos
},
}));
Mock de Next.js Cache
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
Mock de Errors
vi.mock('@/core/errors', () => ({
captureError: vi.fn(),
}));
Cobertura de Código
Objetivo
- Core: 100% (rbac, validations, errors)
- Módulos: 80%+ (queries, actions)
- UI: 50%+ (componentes críticos)
Ver Cobertura
pnpm test:coverage
Esto genera:
- Reporte en consola
coverage/coverage-final.jsoncoverage/index.html(abrir en navegador)
Excluir de Cobertura
Archivos excluidos:
node_modules/tests/.next/**/*.config.***/types.ts**/route.ts
Mejores Prácticas
1. Nombrar Tests Descriptivamente
// ✅ Bueno
it('should return null if hackathon does not exist', () => {});
// ❌ Malo
it('test 1', () => {});
2. Usar Arrange-Act-Assert
it('should create hackathon', async () => {
// Arrange
const formData = new FormData();
formData.set('name', 'Test');
// Act
const result = await createHackathon(formData);
// Assert
expect(result.success).toBe(true);
});
3. Limpiar Datos Entre Tests
beforeEach(async () => {
await db.hackathon.deleteMany();
await db.profile.deleteMany();
});
4. Mockear Dependencias Externas
// ✅ Mockear auth, cache, etc.
vi.mock('@/core/auth');
vi.mock('next/cache');
5. Testear Casos Límite
it('should handle empty array', () => {});
it('should handle null values', () => {});
it('should handle invalid dates', () => {});
6. Testear Errores
it('should throw error if unauthorized', () => {
expect(() => requireRole(user, [Role.PARTICIPANT])).toThrow();
});
Ejemplo Completo: Test de Módulo
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { db } from '@/core/db';
import { Role, HackathonStatus } from '@prisma/client';
// Mocks
vi.mock('@/core/auth');
vi.mock('@/core/rbac');
vi.mock('next/cache');
vi.mock('@/core/errors');
import { createHackathon, publishHackathon } from '@/modules/hackathons/actions';
import { getCurrentUser } from '@/core/auth';
import { requireRole } from '@/core/rbac';
describe('Hackathons Module - Actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockOrganizer = {
id: 'user-1',
profile: {
id: 'profile-1',
role: Role.ORGANIZER,
},
};
describe('createHackathon', () => {
it('should create hackathon with valid data', async () => {
// Arrange
vi.mocked(getCurrentUser).mockResolvedValue(mockOrganizer);
vi.mocked(requireRole).mockReturnValue(undefined);
const formData = new FormData();
formData.set('name', 'Test Hackathon');
formData.set('slug', 'test-hackathon');
formData.set('description', 'Test description');
formData.set('registrationOpensAt', '2025-01-01T09:00:00Z');
formData.set('registrationClosesAt', '2025-01-05T18:00:00Z');
formData.set('startsAt', '2025-01-10T09:00:00Z');
formData.set('endsAt', '2025-01-12T18:00:00Z');
formData.set('submissionDeadline', '2025-01-12T16:00:00Z');
formData.set('judgingStartsAt', '2025-01-13T09:00:00Z');
formData.set('judgingEndsAt', '2025-01-15T18:00:00Z');
formData.set('maxTeamSize', '5');
formData.set('minTeamSize', '1');
// Act
const result = await createHackathon(formData);
// Assert
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(result.data.name).toBe('Test Hackathon');
expect(result.data.status).toBe(HackathonStatus.DRAFT);
});
it('should fail if user is not authorized', async () => {
// Arrange
vi.mocked(getCurrentUser).mockResolvedValue(null);
const formData = new FormData();
formData.set('name', 'Test Hackathon');
// Act
const result = await createHackathon(formData);
// Assert
expect(result.success).toBe(false);
expect(result.error).toContain('Unauthorized');
});
});
describe('publishHackathon', () => {
it('should publish hackathon with criteria and judges', async () => {
// Arrange
vi.mocked(getCurrentUser).mockResolvedValue(mockOrganizer);
const hackathon = await db.hackathon.create({
data: {
name: 'Test Hackathon',
slug: 'test-hackathon',
status: HackathonStatus.DRAFT,
// ... fechas válidas
},
});
await db.criterion.create({
data: {
hackathonId: hackathon.id,
name: 'Stack Tecnológico',
weight: 2,
maxScore: 10,
},
});
await db.hackathonJudge.create({
data: {
hackathonId: hackathon.id,
profileId: 'judge-profile-id',
},
});
// Act
const result = await publishHackathon(hackathon.id);
// Assert
expect(result.success).toBe(true);
const updated = await db.hackathon.findUnique({
where: { id: hackathon.id },
});
expect(updated?.status).toBe(HackathonStatus.REGISTRATION);
});
});
});
Troubleshooting
Error: "Cannot find module '@/core/db'"
Solución: Verificar que el alias @ esté configurado en vitest.config.ts:
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
}
Error: "Prisma Client not generated"
Solución: Ejecutar:
pnpm db:generate
Tests Lentos
Solución:
- Usar mocks en lugar de DB real
- Limpiar datos eficientemente
- Usar
beforeAllpara setup costoso
Próximos Pasos
- Module Guide - Cómo crear módulos con tests
- Coding Standards - Estándares de código
- Troubleshooting - Solución de problemas
Siguiente: Module Guide