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.
Astro se construye sobre Vite, y esa herencia tiene una consecuencia afortunada para las pruebas: vitest, el corredor de tests que comparte el mismo motor de transformación que Vite, entiende tu proyecto casi sin configuración. Los mismos alias, los mismos plugins, la misma resolución de TypeScript que sirven a la aplicación pueden servir a los tests. La pieza que sella esa continuidad es getViteConfig, un ayudante que Astro exporta para que tu configuración de test herede la de la app en lugar de duplicarla y divergir. Con ese cimiento, probar una utilidad se vuelve el gesto clásico de escribir una afirmación, verla fallar y hacerla pasar; y aislar lo que no quieres ejecutar —una base de datos, la red, el reloj— se resuelve con los mocks que vitest trae de serie.
- Cablear
vitestcongetViteConfigpara heredar la configuración de Vite del proyecto. - Escribir el bucle rojo-verde sobre una utilidad o módulo de TypeScript.
- Sustituir dependencias impuras con
vi.mock,vi.spyOnyvi.stubEnv. - Elegir el entorno de ejecución adecuado entre
nodeyjsdom.
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.
// vitest.config.ts
import { 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.
flowchart LR T[fichero de test] --> V[vitest] V --> G[getViteConfig hereda de Astro] G --> A[alias plugins y astro virtual modules] A --> M[modulo bajo prueba] style T fill:#89b4fa,color:#11111b style G fill:#cba6f7,color:#11111b style M fill:#a6e3a1,color:#11111b
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.
// src/lib/precio.test.ts
import { 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.
import { describe, it, expect, vi, beforeEach } from 'vitest';
// sustituye el modulo de datos entero antes de importar lo que lo usa
vi.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.
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.
vi.mock no se ejecuta donde lo escribes: vitest lo eleva por encima de los import para que el doble esté en su sitio antes de que se cargue el módulo bajo prueba. Esa magia tiene dos consecuencias que debes interiorizar. Primera: la fábrica no puede depender de variables definidas más abajo, porque corre antes. Segunda: los espías acumulan estado entre tests, así que sin un vi.clearAllMocks en beforeEach un test heredará las llamadas del anterior y verás fallos fantasma que dependen del orden. Un mock sin higiene no aísla, contamina.
vi.mock
Sustituye un modulo entero por una fabrica. Se eleva sobre los import; limpia con clearAllMocks.
vi.spyOn
Envuelve una funcion real para vigilar sus llamadas o cambiar su retorno sin reemplazar el modulo.
Fake timers
useFakeTimers y setSystemTime congelan el reloj para probar caducidades sin latencia real.
vi.stubEnv
Fija una entrada de import.meta.env durante el test y la deshace con unstubAllEnvs.
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.
// fijar una variable publica solo para este test
import { 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.
Cada vez que sustituyes una dependencia por un doble, estás firmando un contrato implícito: afirmas que la pieza real se comporta así. El mock no es un truco para que el test pase; es la codificación de una creencia sobre cómo responde la base de datos, qué devuelve la red, qué forma tiene la respuesta del servidor. Y ahí anida el peligro más sutil del testing con mocks: el día que la realidad cambia —la API devuelve un campo nuevo, el error viaja con otro código, la latencia altera un orden— tus tests siguen verdes porque prueban tu hipótesis congelada, no el mundo. Por eso el buen mock es minimalista y fiel: mockea solo la frontera, reproduce con honestidad los contratos que documentaste, y desconfía del mock que crece hasta reimplementar media dependencia, porque a esas alturas ya no pruebas tu código sino tu imitación de otro. La contrapartida saludable es reservar un puñado de pruebas de integración o de extremo a extremo que sí toquen lo real, aunque sean lentas, para que actúen de aduana: si tus mocks mienten, que al menos algo, en algún nivel de la pirámide, atrape la mentira. Vitest sobre getViteConfig te da la parte barata y veloz de esa ecuación —miles de afirmaciones sobre tu lógica en segundos—; tu criterio decide dónde termina lo que puedes simular con confianza y empieza lo que exige verlo funcionar de verdad. Probar bien no es aislar por aislar, sino saber exactamente de qué mundo te estás fiando en cada línea.
- Crea un
vitest.config.tscongetViteConfigy comprueba que un test resuelve un alias del proyecto sin configuración extra. - Escribe el bucle rojo-verde de una utilidad tuya: una afirmación del camino feliz, una de un caso alternativo y una del borde que lanza.
- Aísla una dependencia impura con
vi.mocky verifica contoHaveBeenCalledWithque tu código la invoca con los argumentos correctos; añadevi.clearAllMocksenbeforeEach. - Fija una variable con
vi.stubEnv, deshazla enafterEachy razona por qué el test debería correr ennodey no enjsdom.