Skip to content
Astro as testing 4 min read

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"
  }
}

getViteConfig is 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

ItemUse
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 getViteConfig so tests share the app’s aliases, env, and content-collection support.
  • Keep business logic in plain .ts modules; reserve .astro frontmatter for wiring props to markup.
  • Default to the node environment and opt into jsdom only for the few tests that need the DOM.
  • Mock fetch and external SDKs with vi.fn()/vi.stubGlobal(), and reset them in beforeEach for isolation.
  • Test loaders by calling load() with a stubbed context — no full build needed.
  • Use vitest run in CI and vitest (watch mode) locally for fast feedback.
Last updated June 14, 2026
Was this helpful?