Saltar al contenido

Vitest en un proyecto Astro: getViteConfig, módulos TS y mocks

Vitest es el corredor de tests natural de un proyecto Astro porque comparte motor con Vite. Cómo cablearlo con getViteConfig para que los tests vean los mismos alias, plugins y módulos virtuales astro: que la aplicación; cómo escribir el bucle rojo-verde sobre una utilidad de TypeScript; y cómo aislar lo impuro con los mocks de vi —vi.mock para sustituir un módulo entero, vi.spyOn para vigilar una función real, vi.stubEnv para fijar variables de entorno— eligiendo bien el entorno de ejecución entre node y jsdom.

getViteConfig: un solo Vite para la app y los tests

El error inicial más común es escribir un vitest.config.ts a mano y descubrir que los tests no resuelven un alias, no encuentran un módulo virtual astro:* o transforman el TypeScript de otra manera que la aplicación. La causa es la divergencia: dos configuraciones que debían ser una. getViteConfig, importado de astro/config, elimina el problema de raíz al construir la configuración de Vitest a partir de la de tu proyecto Astro.

TypeScript
// vitest.config.tsimport { getViteConfig } from 'astro/config';export default getViteConfig({  test: {    environment: 'node',    globals: true,    include: ['src/**/*.test.ts'],  },});

El objeto test son las opciones de vitest; todo lo demás lo aporta Astro por debajo. Así, cuando un test importa ../lib/precio a través de un alias, o cuando el módulo bajo prueba lee import.meta.env, la resolución es idéntica a la de producción. Esta unidad no es un lujo: es lo que evita la clase de bug más frustrante, el que solo aparece en el test o solo en la app porque cada uno vivía en un universo de configuración distinto.

Testear una utilidad: el bucle rojo-verde

Con la configuración en su sitio, probar lógica pura es directo. Un fichero de test declara casos con describe e it, y afirma con expect. La disciplina clásica es escribir primero la afirmación que aún falla —el rojo—, después el código mínimo que la satisface —el verde— y, por último, refactorizar con la red puesta.

TypeScript
// src/lib/precio.test.tsimport { describe, it, expect } from 'vitest';import { conIva } from './precio';describe('conIva', () => {  it('aplica el 21 por ciento por defecto', () => {    expect(conIva(100)).toBe(121);  });  it('respeta un tipo explicito', () => {    expect(conIva(100, 0.1)).toBe(110);  });  it('rechaza una base negativa', () => {    expect(() => conIva(-1)).toThrow(RangeError);  });});

Fíjate en la naturaleza de estas afirmaciones: cubren el camino feliz, un caso alternativo y el borde que lanza. No prueban que TypeScript compile —eso lo garantiza el compilador— sino que la aritmética del negocio es la esperada. Esa es la lógica que un refactor puede romper sin que los tipos se enteren, y por tanto la que merece cada línea de test que le dedicas.

Mocks: sustituir lo que no quieres ejecutar

Un test unitario debe ser rápido y determinista, y nada lo arruina más que una dependencia real: una base de datos que hay que levantar, una red que a veces cae, un reloj que nunca da dos veces la misma hora. Los mocks sustituyen esas dependencias por dobles controlados. vi.mock reemplaza un módulo entero por una versión de fábrica; vi.spyOn envuelve una función real para vigilar sus llamadas o cambiar su retorno; vi.fn crea una función espía desde cero.

TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';// sustituye el modulo de datos entero antes de importar lo que lo usavi.mock('./db', () => ({  guardarUsuario: vi.fn(async (u) => ({ id: 'u1', ...u })),}));import { registrar } from './usuarios';import { guardarUsuario } from './db';beforeEach(() => vi.clearAllMocks());it('persiste y devuelve el usuario con id', async () => {  const u = await registrar({ email: 'a@b.co' });  expect(guardarUsuario).toHaveBeenCalledOnce();  expect(u.id).toBe('u1');});

Para la red, el patrón es espiar fetch global y resolverlo con una Response de mentira; para el tiempo, vi.useFakeTimers congela el reloj y vi.setSystemTime lo fija a un instante conocido, de modo que una lógica de caducidad se prueba sin esperar de verdad.

TypeScript
import { vi, afterEach, it, expect } from 'vitest';import { haCaducado } from './sesion';afterEach(() => vi.useRealTimers());it('marca la sesion como caducada tras una hora', () => {  vi.useFakeTimers();  vi.setSystemTime(new Date('2026-01-01T10:00:00Z'));  const creada = Date.now();  vi.setSystemTime(new Date('2026-01-01T11:01:00Z'));  expect(haCaducado(creada)).toBe(true);});

La regla que ordena todo esto es la del límite: mockeas en la frontera de tu unidad —lo que sale hacia el mundo— y dejas intacto lo que quieres probar. Mockear de más equivale a probar tus propios mocks; mockear de menos, a depender de un mundo que no controlas.

Entorno, variables y módulos virtuales

vitest ejecuta cada test en un entorno, y elegirlo bien evita horas de desconcierto. Para lógica pura y de servidor, node es lo correcto y lo más veloz. Solo cuando el código toca el DOM —document, window, una API del navegador— necesitas jsdom, que simula esas globales en Node. Poner jsdom por defecto en todo ralentiza la suite y esconde acoplamientos al navegador que preferirías ver.

TypeScript
// fijar una variable publica solo para este testimport { vi, afterEach, it, expect } from 'vitest';import { urlApi } from './config';afterEach(() => vi.unstubAllEnvs());it('usa la variable de entorno inyectada', () => {  vi.stubEnv('PUBLIC_API_URL', 'https://test.local');  expect(urlApi()).toBe('https://test.local/v1');});

vi.stubEnv fija una entrada de import.meta.env durante el test y se deshace con vi.unstubAllEnvs, de modo que ningún caso filtra su entorno al siguiente. Y como la configuración vino de getViteConfig, los módulos virtuales de Astro —los que empiezan por astro:— se resuelven igual que en la app; cuando alguno no tiene sentido bajo test, lo sustituyes con vi.mock como a cualquier otro módulo. El hilo conductor es siempre el mismo: el test debe ver el mundo tal como lo ve la aplicación, salvo en los puntos exactos donde tú, deliberadamente, decides mentirle.