Unit Testing with Vitest
Vitest is the recommended unit-test runner for Astro projects: it shares Vite’s transform pipeline, so it resolves the same aliases, TypeScript, and plugins your app already uses with almost no extra configuration. The key to wiring it up correctly is Astro’s getViteConfig helper, which hands Vitest a fully-resolved Astro Vite config — including content collection support and integration transforms. In practice you’ll use Vitest to test the logic of your site (utility functions, content loaders, schema transforms, and the JavaScript in component frontmatter) while leaving full-page and browser behavior to end-to-end tools.
Installing and configuring Vitest
Install Vitest as a dev dependency, then create a vitest.config.ts that wraps your config with getViteConfig. The helper accepts your normal Vitest/Vite options and merges them with Astro’s resolved config, so things like ~/ path aliases and import.meta.env work exactly as they do in your app.
npm install -D vitest
// vitest.config.ts
import { getViteConfig } from "astro/config";
export default getViteConfig({
test: {
// Use jsdom when a test touches the DOM; "node" is faster otherwise.
environment: "node",
globals: true,
include: ["src/**/*.{test,spec}.{ts,js}"],
},
});
Add a script to package.json so the suite is one command away:
// package.json (scripts)
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
getViteConfigis asynchronous internally but you export it synchronously — Astro resolves integrations and config for you. Do not hand-roll a plain Vite config for Astro projects; you’ll lose alias resolution and content-collection virtual modules, and imports will fail in confusing ways.
Testing utility functions
The simplest and highest-value tests cover pure helpers — formatting, slugging, filtering. Keep this logic in plain .ts files (not inside .astro frontmatter) so it’s trivially importable.
// src/utils/posts.ts
export interface Post {
title: string;
draft: boolean;
date: Date;
}
export function publishedPosts(posts: Post[]): Post[] {
return posts
.filter((p) => !p.draft)
.sort((a, b) => b.date.getTime() - a.date.getTime());
}
// src/utils/posts.test.ts
import { describe, it, expect } from "vitest";
import { publishedPosts, type Post } from "./posts";
const make = (title: string, draft: boolean, day: number): Post => ({
title,
draft,
date: new Date(2026, 0, day),
});
describe("publishedPosts", () => {
it("drops drafts and sorts newest first", () => {
const result = publishedPosts([
make("old", false, 1),
make("draft", true, 5),
make("new", false, 10),
]);
expect(result.map((p) => p.title)).toEqual(["new", "old"]);
});
});
Output:
✓ src/utils/posts.test.ts (1)
✓ publishedPosts > drops drafts and sorts newest first
Test Files 1 passed (1)
Tests 1 passed (1)
Testing content loaders with mocks
Custom content loaders fetch remote data, so you mock fetch (or the SDK) to assert the loader normalizes entries correctly. Vitest’s vi.fn() and vi.stubGlobal() make this clean, and beforeEach keeps tests isolated.
// src/loaders/articles-loader.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { articlesLoader } from "./articles-loader";
beforeEach(() => {
vi.restoreAllMocks();
});
describe("articlesLoader", () => {
it("stores each article keyed by slug", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json([{ slug: "hello", title: "Hello", body: "Hi" }])
)
);
const store = new Map<string, unknown>();
const loader = articlesLoader({ endpoint: "https://api.test/articles" });
await loader.load({
store: {
set: (entry: { id: string; data: unknown }) =>
store.set(entry.id, entry.data),
clear: () => store.clear(),
},
logger: { info: vi.fn() },
parseData: async ({ data }: { data: unknown }) => data,
generateDigest: () => "digest",
meta: new Map(),
} as never);
expect(store.get("hello")).toMatchObject({ title: "Hello" });
expect(fetch).toHaveBeenCalledOnce();
});
});
Because the loader is a plain function returning an object with a load() method, you can invoke load() directly with a stubbed context — no build required. This is exactly why loaders should live in their own importable modules.
Testing component logic
Astro components compile to server code, so you don’t render full .astro files in Vitest (use the Container API for that). Instead, extract the non-trivial logic out of the frontmatter into testable functions and unit-test those.
---
// src/components/Price.astro
import { formatPrice } from "../utils/price";
interface Props { cents: number; currency?: string }
const { cents, currency = "USD" } = Astro.props;
---
<span class="price">{formatPrice(cents, currency)}</span>
// src/utils/price.test.ts
import { it, expect } from "vitest";
import { formatPrice } from "./price";
it("formats cents as localized currency", () => {
expect(formatPrice(1999, "USD")).toBe("$19.99");
});
This keeps your zero-JS-by-default islands thin: the component is just markup plus a tested helper, and only components with a client:* directive ship any runtime JavaScript at all.
Matchers and options reference
| Item | Use |
|---|---|
expect(x).toBe(y) | Strict equality for primitives. |
expect(x).toEqual(y) | Deep structural equality for objects/arrays. |
expect(x).toMatchObject(y) | Asserts a subset of object properties. |
vi.fn() | Create a mock/spy function. |
vi.stubGlobal() | Replace a global such as fetch; reset with vi.unstubAllGlobals(). |
environment: "node" | Default, fast; no DOM. |
environment: "jsdom" | Provides document/window for DOM-touching tests. |
Best Practices
- Wrap your config with
getViteConfigso tests share the app’s aliases, env, and content-collection support. - Keep business logic in plain
.tsmodules; reserve.astrofrontmatter for wiring props to markup. - Default to the
nodeenvironment and opt intojsdomonly for the few tests that need the DOM. - Mock
fetchand external SDKs withvi.fn()/vi.stubGlobal(), and reset them inbeforeEachfor isolation. - Test loaders by calling
load()with a stubbed context — no full build needed. - Use
vitest runin CI andvitest(watch mode) locally for fast feedback.