Cómo mockear astro:content en Vitest
Si construiste helpers que llaman a getCollection de astro:content, probablemente notaste que hacer unit testing de ellos no es trivial. El módulo es virtual — no existe en disco — así que Vitest no puede resolverlo como un import normal. Y si tu helper lee import.meta.env.DEV para decidir si incluye drafts, tenés un segundo problema encima del primero.
Acá está exactamente cómo lo hice funcionar.
El Setup
Supongamos que tenés un helper así:
// src/helpers/collections.ts
import { getCollection } from 'astro:content'
export async function getPublishedNotes(lang: 'en' | 'es') {
const name = lang === 'es' ? 'notas' : 'notes'
const entries = await getCollection(name, ({ data }) => {
if (import.meta.env.DEV) return true
return !data.draft && data.publishDate < new Date()
})
return entries.sort((a, b) =>
b.data.publishDate.valueOf() - a.data.publishDate.valueOf()
)
}
Dos dependencias para controlar: getCollection y import.meta.env.DEV.
Mockeando astro:content
Vitest soporta mockeo de módulos virtuales con vi.mock. La clave es que los llamados a vi.mock son hoisted — se mueven al inicio del archivo antes de cualquier import. Eso es lo que hace que funcione aunque el import del helper aparezca después del mock.
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Esto se eleva automáticamente — corre antes del import de abajo
vi.mock('astro:content', () => ({
getCollection: vi.fn(),
}))
import { getCollection } from 'astro:content'
import { getPublishedNotes } from '@helpers/collections'
const mockGetCollection = vi.mocked(getCollection)
Listo. mockGetCollection es ahora un mock tipado de Vitest que podés controlar por test.
Controlando el Filter
La parte complicada: getCollection recibe una función de filtro como segundo argumento. Tu helper define la lógica de filtrado dentro de ese callback — así que si solo hacés mockResolvedValue([entry]), el filtro nunca se aplica y cualquier test que verifique el filtrado en producción va a pasar cuando no debería.
Tenés que llamar al filtro explícitamente:
it('excluye drafts en producción', async () => {
import.meta.env.DEV = false
const draft = { id: 'test', data: { draft: true, publishDate: new Date('2025-01-01') } }
mockGetCollection.mockImplementation(async (_name, filter) => {
const pass = (filter as (e: unknown) => boolean)(draft)
return pass ? [draft] : []
})
const result = await getPublishedNotes('en')
expect(result).toHaveLength(0)
})
El Problema con import.meta.env.DEV
Puede que tientes usar vi.stubEnv('DEV', 'false') para cambiar el flag de entorno. No lo hagas. vi.stubEnv trabaja con strings, pero el DEV de Vite es un boolean — así que el string 'false' es truthy y tus tests de modo producción siempre se van a comportar como si DEV estuviera activo.
La asignación directa es el enfoque correcto:
import.meta.env.DEV = false // modo producción
import.meta.env.DEV = true // modo dev
Esto funciona en Vitest porque import.meta.env es un objeto mutable en tiempo de test — Vite reemplaza los booleans en build time, pero en el test runner de Vitest son simplemente propiedades escribibles.
Un Ejemplo Completo
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('astro:content', () => ({
getCollection: vi.fn(),
}))
import { getCollection } from 'astro:content'
import { getPublishedNotes } from '@helpers/collections'
const mockGetCollection = vi.mocked(getCollection)
function makeEntry(overrides: { draft?: boolean; publishDate?: Date } = {}) {
return {
id: 'test-note',
data: {
draft: false,
publishDate: new Date('2025-01-01'),
...overrides,
},
}
}
describe('getPublishedNotes', () => {
beforeEach(() => {
mockGetCollection.mockReset()
})
it('llama a la colección notes para lang=en', async () => {
mockGetCollection.mockResolvedValue([])
await getPublishedNotes('en')
expect(mockGetCollection).toHaveBeenCalledWith('notes', expect.any(Function))
})
it('excluye drafts en producción', async () => {
import.meta.env.DEV = false
const draft = makeEntry({ draft: true })
mockGetCollection.mockImplementation(async (_name, filter) => {
return (filter as (e: unknown) => boolean)(draft) ? [draft] : []
})
const result = await getPublishedNotes('en')
expect(result).toHaveLength(0)
})
it('incluye drafts en modo dev', async () => {
import.meta.env.DEV = true
const draft = makeEntry({ draft: true })
mockGetCollection.mockImplementation(async (_name, filter) => {
return (filter as (e: unknown) => boolean)(draft) ? [draft] : []
})
const result = await getPublishedNotes('en')
expect(result).toHaveLength(1)
})
it('ordena por publishDate descendente', async () => {
const older = makeEntry({ publishDate: new Date('2024-01-01') })
const newer = makeEntry({ publishDate: new Date('2025-06-01') })
mockGetCollection.mockResolvedValue([older, newer])
const result = await getPublishedNotes('en')
expect(result[0].data.publishDate.valueOf()).toBeGreaterThan(
result[1].data.publishDate.valueOf()
)
})
})
Por Qué vi.mock Tiene Que Ir Primero
Si intentás mover el llamado a vi.mock debajo de los imports, el módulo ya está cargado y el mock no tiene efecto. Vitest usa Babel o esbuild para elevar físicamente los llamados a vi.mock antes de procesar los imports — no es solo orden de ejecución, es una transformación. Es el mismo comportamiento que el jest.mock de Jest.
El patrón funciona siempre que vitest.config.ts use getViteConfig de astro/config, que propaga automáticamente la resolución del path alias @helpers/*. No necesitás config de alias extra.




Comments for hwtmck