Un test limpio cumple con tres principios elementales: es determinista, está aislado de efectos secundarios externos y evalúa el comportamiento observable en lugar de detalles internos de implementación. La columna vertebral organizativa de cualquier test automatizado confiable es el patrón AAA (Arrange, Act, Assert).
Cuando probamos módulos que interactúan con sistemas periféricos (bases de datos, sockets de red, hardware criptográfico, proveedores de identidad), el aislamiento resulta imprescindible. En la ingeniería de software se adopta el concepto general de Test Double (Doble de Prueba).
La taxonomía clásica de Gerard Meszaros (xUnit Test Patterns) define cinco categorías inequívocas de dobles de prueba:
InMemoryRepository basado en Map en lugar de una base de datos PostgreSQL real).+----------------------------------------+
| Test Double (Meszaros) |
+----------------------------------------+
|
+-------------+-----------+-----------+-------------+
| | | | |
v v v v v
+---------+ +---------+ +---------+ +---------+ +---------+
| Dummy | | Stub | | Spy | | Mock | | Fake |
| (Tipos) | |(Respuestas)|(Auditoría)|(Expectativas)|(En memoria)|
+---------+ +---------+ +---------+ +---------+ +---------+crypto.randomUUID, Date.now, performance.now) mediante espías vi.spyOn().vi.hoisted(): Configuración previa de mocks antes de evaluar módulos con efectos colaterales en sus importaciones estáticas.vi.fn() sin emitir tráfico HTTP auténtico.Analicemos un servicio de transferencia bancaria TransferService. Implementaremos los cinco tipos de dobles con tipado 100% estricto sin recurrir a conversiones arbitrarias con any.
import { describe, it, expect, vi } from 'vitest';
export interface UserAccount {
id: string;
email: string;
balanceCents: number;
}
export interface AccountRepository {
findById(id: string): Promise<UserAccount | null>;
save(account: UserAccount): Promise<void>;
}
export interface AuditLogger {
logTransfer(fromId: string, toId: string, amount: number): void;
}
export interface NotificationService {
notifyUser(email: string, message: string): Promise<void>;
}
// Lógica de dominio del servicio
export class TransferService {
constructor(
private readonly accountRepo: AccountRepository,
private readonly logger: AuditLogger,
private readonly notifier: NotificationService
) {}
async execute(fromId: string, toId: string, amountCents: number): Promise<void> {
if (amountCents <= 0) {
throw new Error('El importe de la transferencia debe ser estrictamente positivo');
}
const sender = await this.accountRepo.findById(fromId);
const recipient = await this.accountRepo.findById(toId);
if (!sender || !recipient) {
throw new Error('Una o ambas cuentas no existen');
}
if (sender.balanceCents < amountCents) {
throw new Error('Saldo insuficiente en la cuenta de origen');
}
sender.balanceCents -= amountCents;
recipient.balanceCents += amountCents;
await this.accountRepo.save(sender);
await this.accountRepo.save(recipient);
this.logger.logTransfer(fromId, toId, amountCents);
}
}
// 1. FAKE: Implementación funcional en memoria del repositorio
class InMemoryAccountRepository implements AccountRepository {
private readonly store = new Map<string, UserAccount>();
seed(account: UserAccount): void {
this.store.set(account.id, { ...account });
}
async findById(id: string): Promise<UserAccount | null> {
const acc = this.store.get(id);
return acc ? { ...acc } : null;
}
async save(account: UserAccount): Promise<void> {
this.store.set(account.id, { ...account });
}
}
describe('TransferService (Taxonomía de Dobles y Patrón AAA)', () => {
it('transfiere fondos con éxito siguiendo Arrange-Act-Assert', async () => {
// ---------------- Arrange ----------------
const fakeRepo = new InMemoryAccountRepository();
fakeRepo.seed({ id: 'acc-1', email: '[email protected]', balanceCents: 10_000 });
fakeRepo.seed({ id: 'acc-2', email: '[email protected]', balanceCents: 2_000 });
// 2. SPY: Colector pasivo de llamadas para telemetría
const spyLogger: AuditLogger = {
logTransfer: vi.fn(),
};
// 3. DUMMY: Requerido por el constructor, pero no invocado en este flujo
const dummyNotifier: NotificationService = {
notifyUser: vi.fn(),
};
const service = new TransferService(fakeRepo, spyLogger, dummyNotifier);
// ---------------- Act ----------------
await service.execute('acc-1', 'acc-2', 3_000);
// ---------------- Assert ----------------
// 1. State Verification (comprobamos el estado persistido en el repositorio)
const updatedSender = await fakeRepo.findById('acc-1');
const updatedRecipient = await fakeRepo.findById('acc-2');
expect(updatedSender?.balanceCents).toBe(7_000);
expect(updatedRecipient?.balanceCents).toBe(5_000);
// 2. Behavior Verification (comprobamos el efecto terminal mediante el Spy)
expect(spyLogger.logTransfer).toHaveBeenCalledTimes(1);
expect(spyLogger.logTransfer).toHaveBeenCalledWith('acc-1', 'acc-2', 3_000);
// 3. Verificamos que el Dummy jamás fue invocado
expect(dummyNotifier.notifyUser).not.toHaveBeenCalled();
});
it('aborta la transferencia y preserva el saldo ante fondos insuficientes', async () => {
// Arrange
const fakeRepo = new InMemoryAccountRepository();
fakeRepo.seed({ id: 'acc-1', email: '[email protected]', balanceCents: 1_000 });
fakeRepo.seed({ id: 'acc-2', email: '[email protected]', balanceCents: 500 });
const dummyLogger: AuditLogger = { logTransfer: vi.fn() };
const dummyNotifier: NotificationService = { notifyUser: vi.fn() };
const service = new TransferService(fakeRepo, dummyLogger, dummyNotifier);
// Act & Assert
await expect(service.execute('acc-1', 'acc-2', 5_000)).rejects.toThrow(
'Saldo insuficiente en la cuenta de origen'
);
// Verificación de estado: el saldo debe permanecer inalterado
const sender = await fakeRepo.findById('acc-1');
expect(sender?.balanceCents).toBe(1_000);
});
});vi.hoisted()#En la especificación de ECMAScript, las sentencias estáticas import se resuelven antes de la ejecución del código sincrónico. Vitest eleva automáticamente las llamadas a vi.mock() a la cabecera del archivo. Si la factoría del mock intenta capturar variables locales convencionales (const mockFn = vi.fn()), el motor lanzará un ReferenceError. Vitest provee vi.hoisted() para resolver esta limitación.
import { describe, it, expect, vi } from 'vitest';
// Inicialización de variables elevadas sobre las importaciones estáticas
const { mockedPost, fakeAuthHeader } = vi.hoisted(() => {
return {
mockedPost: vi.fn(),
fakeAuthHeader: 'Bearer hoisted-token-xyz',
};
});
// vi.mock se eleva automáticamente por encima del import de axios
vi.mock('axios', () => {
return {
default: {
post: mockedPost,
defaults: {
headers: {
common: { Authorization: fakeAuthHeader },
},
},
},
};
});
import axios from 'axios';
import { dispatchSecurityAlert } from './alertService';
describe('dispatchSecurityAlert con mocks elevados (hoisted)', () => {
it('envía la notificación a través de la instancia mockeada de axios', async () => {
mockedPost.mockResolvedValueOnce({ status: 200, data: { status: 'acknowledged' } });
const result = await dispatchSecurityAlert('UNAUTHORIZED_ACCESS_ATTEMPT');
expect(mockedPost).toHaveBeenCalledWith(
'/api/v1/security/incidents',
expect.objectContaining({ incidentType: 'UNAUTHORIZED_ACCESS_ATTEMPT' })
);
expect(result).toBe(true);
});
});vi.spyOn() y saneamiento seguro#Los espías (vi.spyOn) envuelven métodos preexistentes para monitorizar su interacción sin sustituir el objeto completo, permitiendo alterar su comportamiento (mockImplementation) o restaurarlo a su estado original (mockRestore).
import { describe, it, expect, vi, afterEach } from 'vitest';
function getSessionExpiryIsoString(durationMinutes: number): string {
const expiryTime = Date.now() + durationMinutes * 60 * 1000;
return new Date(expiryTime).toISOString();
}
describe('getSessionExpiryIsoString', () => {
afterEach(() => {
// Restauramos las implementaciones nativas tras cada prueba
vi.restoreAllMocks();
});
it('calcula la expiración relativa respecto a una marca temporal fija', () => {
// Congelamos Date.now usando vi.spyOn
const fixedNow = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
vi.spyOn(Date, 'now').mockReturnValue(fixedNow);
const result = getSessionExpiryIsoString(30);
// 1_700_000_000_000 + 30 * 60 * 1000 = 1_700_001_800_000
expect(result).toBe(new Date(1_700_001_800_000).toISOString());
});
});Cuando una prueba requiere 15 líneas de configuración de mocks para servicios auxiliares y bases de datos, está verificando las presunciones del desarrollador y no la realidad del software. Si la interfaz cambia, el test continuará pasando en verde falsamente (Falso Negativo).
// ❌ Antipatrón: el test evalúa el cableado entre mocks ficticios
it('procesa el usuario', async () => {
const db = { query: vi.fn().mockReturnValue([{ id: 1 }]) };
const validator = { validate: vi.fn().mockReturnValue(true) };
const logger = { log: vi.fn() };
// Desconectado de la ejecución real en producción
});
// ✅ Solución recomendada: emplear implementaciones Fake y límites de integración
const fakeDb = new InMemoryDatabase();
const service = new UserService(fakeDb); // Valida la lógica de negocio auténticaAsignar un valor sincrónico mediante mockReturnValue(data) en un método asíncrono provocará que el código que espera una promesa falle con TypeError: Cannot read properties of undefined (reading 'then').
// ❌ Error: método asíncrono configurado con retorno sincrónico
const fetchUser = vi.fn().mockReturnValue({ id: 'u1' });
// En ejecución: fetchUser().then(...) provocará una excepción no controlada
// ✅ Solución recomendada: declarar explícitamente la resolución de la Promesa
const fetchUser = vi.fn().mockResolvedValue({ id: 'u1' });Declarar una instancia de vi.fn() en el alcance del módulo sin limpiar el historial antes de cada it(...) provocará que el contador de toHaveBeenCalledTimes se acumule entre pruebas consecutivas.
// ❌ Error: acumulación de llamadas entre diferentes casos de prueba
const globalReporter = vi.fn();
it('primera invocación', () => {
globalReporter();
expect(globalReporter).toHaveBeenCalledTimes(1); // Pasa
});
it('segunda invocación', () => {
globalReporter();
expect(globalReporter).toHaveBeenCalledTimes(1); // ¡Falla! El contador es 2
});
// ✅ Solución recomendada: saneamiento automático en afterEach
afterEach(() => {
vi.clearAllMocks(); // Limpia historiales sin destruir la implementación configurada
});Pasar referencias locales a la factoría de vi.mock arroja ReferenceError: Cannot access 'mockService' before initialization, dado que el compilador de Vitest eleva físicamente el mock por encima de la declaración de dicha variable.
// ❌ Error de compilación: mockService no está inicializado cuando se procesa vi.mock
const mockService = vi.fn();
vi.mock('./myModule', () => ({ service: mockService }));
// ✅ Solución recomendada: encapsular con vi.hoisted()
const { mockService } = vi.hoisted(() => ({ mockService: vi.fn() }));
vi.mock('./myModule', () => ({ service: mockService }));Si el objeto es requerido por el contrato de la interfaz pero nunca se invoca en este flujo, suministrar un Dummy.
Si el módulo solo lee información sin alterar el estado del sistema, utilizar un Stub (vi.fn().mockReturnValue).
Si es indispensable verificar el envío de métricas o registros a un servicio externo, acoplar un Spy (vi.spyOn).
Si la dependencia gestiona el ciclo de vida CRUD de entidades, implementar una clase Fake ligera en memoria.