Testing Fundamentals with Vitest
Advanced · 18 min read · ▶ live playground · ✦ checkpoint
Vitest is a JavaScript test runner that fits TypeScript projects without a special "test TypeScript" ceremony. You write .test.ts files, import test and expect, and keep one non-negotiable rule: Vitest runs tests, but tsc --noEmit remains the project-wide type gate.
This is a tool-shaped lesson. The playground on this page cannot install packages, so the Vitest examples are shown as read-only reference code for a real project.
Set up Vitest in a TypeScript project
In a project that already has TypeScript, add Vitest as a development dependency. Add the coverage provider now if you want the coverage command in the last section:
npm install -D vitest @vitest/coverage-v8Then give the project separate scripts for the two jobs:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit"
}
}The split matters. vitest watches files and runs matching tests. vitest run runs once for CI. tsc --noEmit asks the TypeScript compiler to check the whole project and write no JavaScript.
Why does TypeScript feel like it just works here?
- Vitest can execute
.tstest files by transforming TypeScript syntax before Node runs them. - Vitest ships TypeScript declarations for its APIs, so importing
test,describe, andexpectgives your editor andtsctyped names. - Your own source imports keep their normal TypeScript shapes, so test fixtures are checked against the same types as production code.
That is not the same as saying Vitest replaces the compiler. A test runner can prove behavior for examples you execute. The compiler checks every included file for type coherence, including code paths your tests did not hit. In a typed project, the normal local loop is:
npm run typecheck
npm run test:runRun both in CI. If the tests pass but tsc --noEmit fails, the project is not ready.
Arrange, act, assert with full type support
The basic test shape is still ordinary testing:
- Arrange the values needed for the behavior.
- Act by calling the function under test.
- Assert the result.
Here is a tiny module under test, shown as read-only reference code because this page does not execute module files:
export type Plan = "free" | "pro" | "enterprise";
export type Coupon = "launch" | "none";
export type Invoice = {
plan: Plan;
seats: number;
coupon?: Coupon;
};
const pricePerSeat: Record<Plan, number> = {
free: 0,
pro: 20,
enterprise: 45,
};
export function monthlyTotal(invoice: Invoice): number {
if (invoice.seats <= 0) {
return 0;
}
const subtotal = pricePerSeat[invoice.plan] * invoice.seats;
if (invoice.coupon === "launch") {
return Math.round(subtotal * 0.8);
}
return subtotal;
}And here is the matching Vitest file, also read-only reference code:
import { describe, expect, test } from "vitest";
import { monthlyTotal, type Invoice } from "./pricing";
describe("monthlyTotal", () => {
test("applies the launch coupon to pro seats", () => {
const invoice: Invoice = {
plan: "pro",
seats: 3,
coupon: "launch",
};
const total = monthlyTotal(invoice);
expect(total).toBe(48);
});
test("charges enterprise seats without a coupon", () => {
const invoice: Invoice = {
plan: "enterprise",
seats: 2,
coupon: "none",
};
const total = monthlyTotal(invoice);
expect(total).toBe(90);
});
});TypeScript participates in the arrange step. If you mistype the plan, forget seats, or pass text where monthlyTotal expects an Invoice, the editor and tsc --noEmit can reject the test file before Vitest runs it. The assertion is still runtime work: expect(total).toBe(48) checks the behavior that actually happened.
That pairing is the point. Types keep test data honest. Tests prove runtime behavior.
Typed fixtures and factories
Repeated test data rots quickly when every test builds a full object by hand. A typed factory keeps the default shape in one place, while still letting each test override the detail it cares about.
Read-only reference code:
import { describe, expect, test } from "vitest";
import { monthlyTotal, type Invoice } from "./pricing";
const baseInvoice = {
plan: "pro",
seats: 2,
coupon: "none",
} satisfies Invoice;
function makeInvoice(overrides: Partial<Invoice> = {}): Invoice {
return {
...baseInvoice,
...overrides,
};
}
const pricingCases = [
{
name: "free plan stays free",
invoice: makeInvoice({ plan: "free", seats: 100 }),
total: 0,
},
{
name: "enterprise plan bills every seat",
invoice: makeInvoice({ plan: "enterprise", seats: 2 }),
total: 90,
},
{
name: "zero seats bill zero",
invoice: makeInvoice({ seats: 0 }),
total: 0,
},
{
name: "missing coupon uses full price",
invoice: { plan: "pro", seats: 2 },
total: 40,
},
{
name: "launch coupon discounts pro seats",
invoice: makeInvoice({ coupon: "launch", seats: 5 }),
total: 80,
},
] satisfies Array<{ name: string; invoice: Invoice; total: number }>;
describe("monthlyTotal edge cases", () => {
test.each(pricingCases)("$name", ({ invoice, total }) => {
expect(monthlyTotal(invoice)).toBe(total);
});
});There are two useful checks hiding in that setup.
baseInvoice satisfies Invoice checks the fixture without widening away its precise values. If the production type changes, the base fixture is one of the first places to tell you.
pricingCases satisfies Array<{ name: string; invoice: Invoice; total: number }> checks the table itself. A typo like seatz, an impossible plan, or a missing expected total is no longer just test data. It is TypeScript data, so the checker can help maintain it.
Keep factories boring. They should build valid defaults and accept small overrides. When a factory starts encoding business rules, your tests become hard to read because the important arrange step is hidden inside a helper.
Coverage should include your type edge cases
Coverage reports measure executed JavaScript: lines, branches, functions, and statements. They do not see erased type aliases. A union like "free" | "pro" | "enterprise" leaves no runtime object called Plan for coverage to inspect.
That means type-informed coverage is your responsibility. Look at the type model and ask which values deserve runtime examples:
- Every member of a discriminated or literal union that changes behavior.
- Nullish or optional cases such as a missing coupon.
- Boundary values such as zero seats.
- Failure branches from
Result<T, E>values and exhaustive switches.
Then run the same pair of gates:
npm run typecheck
npm run test:coverageDo not chase 100% as a trophy. A codebase can report high coverage while never testing the "enterprise" branch or the undefined path that caused last week's bug. Good typed tests use the types as a checklist for representative runtime cases, then let coverage show whether those cases actually executed the code you think they did.
The next two lessons go deeper into testing TypeScript specifically: first typed mocks and test doubles, then tests that assert public types themselves.
Checkpoint
Answer all three to mark this lesson complete