Saltar al contenido principal

🧪 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.json
  • coverage/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 beforeAll para setup costoso

Próximos Pasos


Siguiente: Module Guide