How to Mock astro:content in Vitest
If you’ve built helpers that call getCollection from astro:content, you’ve probably noticed that unit testing them is non-trivial. The module is virtual — it doesn’t exist on disk — so Vitest can’t resolve it like a normal import. And if your helper reads import.meta.env.DEV to decide whether to include drafts, you’ve got a second problem on top of that.
Here’s exactly how I got it working.
The Setup
Say you have a helper like this:
// 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()
)
}
Two dependencies to control: getCollection and import.meta.env.DEV.
Mocking astro:content
Vitest supports virtual module mocking with vi.mock. The key is that vi.mock calls are hoisted to the top of the file before any imports — that’s what makes it work even though the import of your helper appears after the mock call.
import { describe, it, expect, vi, beforeEach } from 'vitest'
// This gets hoisted — runs before the import below
vi.mock('astro:content', () => ({
getCollection: vi.fn(),
}))
import { getCollection } from 'astro:content'
import { getPublishedNotes } from '@helpers/collections'
const mockGetCollection = vi.mocked(getCollection)
That’s it. mockGetCollection is now a typed Vitest mock you can control per test.
Controlling the Filter
The tricky part: getCollection receives a filter function as its second argument. Your helper defines the filtering logic inside that callback — so if you just mockResolvedValue([entry]), the filter is never applied and every test that checks production filtering will pass when it shouldn’t.
You need to actually call the filter:
it('excludes drafts in production', 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)
})
The import.meta.env.DEV Gotcha
You might reach for vi.stubEnv('DEV', 'false') to flip the environment flag. Don’t. vi.stubEnv works with string values, but Vite’s DEV is a boolean — so the string 'false' is truthy and your production-mode tests will always behave as if DEV is on.
Direct assignment is the correct approach:
import.meta.env.DEV = false // production mode
import.meta.env.DEV = true // dev mode
This works in Vitest because import.meta.env is a plain mutable object at test time — Vite replaces the booleans at build time, but in Vitest’s test runner they’re just writable properties.
A Full Working Example
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('calls the notes collection for lang=en', async () => {
mockGetCollection.mockResolvedValue([])
await getPublishedNotes('en')
expect(mockGetCollection).toHaveBeenCalledWith('notes', expect.any(Function))
})
it('excludes drafts in production', 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('includes drafts in dev mode', 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('sorts by publishDate descending', 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()
)
})
})
Why vi.mock Has to Come First
If you try to move the vi.mock call below the imports, the module is already loaded and the mock has no effect. Vitest uses Babel or esbuild to physically hoist vi.mock calls before processing imports — it’s not just execution order, it’s a transform. This is the same behavior as Jest’s jest.mock.
The pattern works as long as vitest.config.ts uses getViteConfig from astro/config, which propagates the @helpers/* path alias resolution automatically. No extra alias config needed.




Comments for hwtmck