Vitest Testing Patterns
This skill provides patterns for writing unit tests with Vitest in this repository.
Test Structure
-
Unit tests: app/tests/unit/** (mirror app/src/** structure)
-
Integration tests: app/tests/integration/**
-
Mocks: app/tests/mocks/**
-
Helpers: app/tests/helpers/**
Plugin Test Template
import Fastify from "fastify"; import { afterEach, describe, expect, it } from "vitest"; import pluginName from "../../../src/plugins/plugin-name.js";
describe("Plugin Name", () => { let fastify: ReturnType<typeof Fastify>;
afterEach(async () => { if (fastify) await fastify.close(); });
it("should register plugin successfully", async () => { fastify = Fastify(); await fastify.register(pluginName); await fastify.ready();
// Test expectations
expect(fastify.hasDecorator("decoratorName")).toBe(true);
}); });
Route Test Template
import Fastify from "fastify"; import { afterEach, describe, expect, it } from "vitest"; import routeName from "../../../src/routes/route-name.js";
describe("GET /endpoint", () => { let fastify: ReturnType<typeof Fastify>;
afterEach(async () => { if (fastify) await fastify.close(); });
it("should return expected response", async () => { fastify = Fastify(); await fastify.register(routeName);
const response = await fastify.inject({
method: "GET",
url: "/endpoint",
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ status: "healthy" });
});
it("should return 400 for invalid input", async () => { fastify = Fastify(); await fastify.register(routeName);
const response = await fastify.inject({
method: "POST",
url: "/endpoint",
payload: { invalid: "data" },
});
expect(response.statusCode).toBe(400);
}); });
Key Patterns
Fastify Instance
-
Use Fastify() to create test instances
-
Always call await fastify.ready() after registering plugins
-
Always call await fastify.close() after tests (use afterEach hook)
Request Simulation
Use fastify.inject() for HTTP request simulation:
const response = await fastify.inject({ method: "GET", url: "/endpoint", headers: { authorization: "Bearer token" }, query: { param: "value" }, payload: { data: "value" }, });
Response Assertions
expect(response.statusCode).toBe(200); expect(response.headers["content-type"]).toContain("application/json"); expect(response.json()).toEqual({ expected: "data" });
Testing Decorators
it("should decorate fastify instance", async () => { const fastify = Fastify(); await fastify.register(plugin); await fastify.ready();
expect(fastify.hasDecorator("myDecorator")).toBe(true); expect(fastify.myDecorator).toBeDefined();
await fastify.close(); });
Coverage Requirements
-
Provider: V8
-
Minimum thresholds: 70% (lines, functions, branches, statements)
-
Target: 90%+ overall, 100% for critical business logic
-
Scope: Only tests/unit/** affects coverage metrics
V8 Coverage Ignore
Use /* v8 ignore next -- @preserve */ to exclude untestable code:
// ignore single statement /* v8 ignore next -- @preserve */ process.on("SIGTERM", () => gracefulShutdown());
// ignore block /* v8 ignore start -- @preserve / const handler = setupHandler(); process.on("SIGINT", handler); / v8 ignore stop -- @preserve */
The -- @preserve suffix prevents esbuild from stripping comments.
Import Conventions
-
Use .js extensions for relative imports
-
Use import type { ... } from "pkg" for type-only imports
-
Vitest globals are enabled but explicit imports are preferred for better IntelliSense, type checking, and refactoring support: import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Commands
cd app npm run test # Run all tests npm run test:watch # Run tests in watch mode npm run test:coverage # Run tests with coverage report
Boundaries
-
No real network, filesystem, or database calls in unit tests
-
Each source file should have a matching test file
-
Do not skip tests without documenting the reason
-
Do not add inline Vitest env comments