TypeScript/JavaScript testing patterns — Vitest (preferred) and Jest, with test organization, mocking, snapshot testing, coverage, fixtures, and integration with Playwright. Use when writing or reviewing TypeScript/JavaScript unit and integration tests.
Testing patterns for TypeScript/JavaScript, covering Vitest (preferred) and Jest, with integration for E2E tests via Playwright (separate skill).
| Aspect | Vitest | Jest |
|---|---|---|
| ESM support | Native (Vite-based) | Via Babel or experimental |
| TypeScript | Native via Vite | Via ts-jest or Babel |
| Speed | Faster (HMR) | Slower |
| Config | vitest.config.ts (shareable with Vite) | jest.config.ts |
| Ecosystem |
| New, growing fast |
| Mature, many plugins |
| API | Jest-compatible | — |
Default: Vitest for new projects. Jest if already present in the project.
bun add -d vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node", // or "jsdom" for components
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
setupFiles: ["./tests/setup.ts"],
},
});
// package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}
src/
├── users/
│ ├── users.service.ts
│ └── users.service.test.ts # colocate (preferred)
└── utils/
└── date.ts
tests/
├── setup.ts # global mocks
├── fixtures/ # shared data
└── integration/ # tests hitting real DB, HTTP
Pattern: unit tests colocated (next to the code); integration tests in a separate directory.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { UsersService } from "./users.service";
describe("UsersService", () => {
let service: UsersService;
beforeEach(() => {
service = new UsersService(mockRepo());
});
describe("create", () => {
it("should hash the password before storing", async () => {
const user = await service.create({ email: "[email protected]", password: "secret123456" });
expect(user.passwordHash).not.toBe("secret123456");
expect(user.passwordHash).toMatch(/^\$2[aby]\$/); // bcrypt
});
it("should throw on duplicate email", async () => {
await service.create({ email: "[email protected]", password: "secret123456" });
await expect(
service.create({ email: "[email protected]", password: "different12345" })
).rejects.toThrow(/already exists/);
});
});
});
Rules:
describe per class/module, nested for methodsit describes behavior (not implementation): "should hash the password" not "calls bcrypt"beforeEach creates a fresh instance; prevents leaks between testsexpect(...).rejects.toThrow() for async errorsimport { vi } from "vitest";
// Whole-module mock
vi.mock("./email-service", () => ({
sendEmail: vi.fn().mockResolvedValue({ id: "fake" }),
}));
// Partial mock
vi.mock("./config", async (importOriginal) => {
const actual = await importOriginal<typeof import("./config")>();
return { ...actual, API_KEY: "test-key" };
});
// Spy on existing method
const spy = vi.spyOn(repo, "findByEmail").mockResolvedValue(null);
// Fake timers
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01"));
// ... test code
vi.useRealTimers();
Rules:
afterEach with vi.restoreAllMocks()msw/node) instead of mocking fetchit.each([
{ input: "[email protected]", expected: true },
{ input: "invalid", expected: false },
{ input: "", expected: false },
{ input: "a@b", expected: false },
])("isValidEmail($input) → $expected", ({ input, expected }) => {
expect(isValidEmail(input)).toBe(expected);
});
Use sparingly — only for stable, reviewable output:
it("renders the user card correctly", () => {
const { container } = render(<UserCard user={mockUser} />);
expect(container).toMatchSnapshot();
});
Avoid inline snapshots for large objects — they grow out of control.
For tests that hit a real DB:
// tests/integration/users.test.ts
import { beforeAll, afterAll, beforeEach, describe, it, expect } from "vitest";
import { Pool } from "pg";
import { migrate } from "../../src/db/migrate";
let pool: Pool;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
await migrate(pool);
});
afterAll(async () => {
await pool.end();
});
beforeEach(async () => {
await pool.query("TRUNCATE users CASCADE");
});
describe("Users repository (integration)", () => {
it("persists and retrieves a user", async () => {
const repo = new UsersRepository(pool);
const created = await repo.insert({ email: "[email protected]" });
const loaded = await repo.findById(created.id);
expect(loaded?.email).toBe("[email protected]");
});
});
Pattern: truncate between tests instead of rollback — simpler and works with any driver.
Set explicit thresholds (see vitest.config.ts above). Exclude: