TypeScript Testing with Vitest: Unit Tests, Mocks (vi.fn) and expectTypeOf
November 6, 2026
Welcome to episode 47 of TypeScript from Zero, a series for complete beginners (no JavaScript needed). Welcome back to TypeScript from zero! This is Part 6, the last one: production. Code that nobody tests is code that nobody dares to change. Today we write tests with Vitest, a fast test runner that understands TypeScript. We start with a function to test, in a new file called summary.ts. Every code block below is the exact code and output from the video, ready to copy.
- All episodes are in the TypeScript from Zero playlist.
- Versions used: Node.js 26.13.0, npm 11.20.0, TypeScript 7.0.2.
Setup
We start in the terminal and open the project in VS Code:
cd tasks
touch src/summary.ts
code .1. Install
We install Vitest as a development dependency, and add a script called test to the package file, which runs vitest run. Run means: once, and then stop.
$ npm install --save-dev vitest && npm pkg set scripts.test='vitest run'
added 35 packages, and audited 45 packages in 1s
13 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities2. Function
The function under test counts the tasks per status, like in episode thirty one. It takes a list and returns a record. A pure function: the same input always gives the same output. These are the easiest to test.
3. First test
A test file ends with .test.ts. We import describe, it and expect from Vitest. Describe groups tests, and it is one test, with a name. Expect takes a value, and to equal compares it with what we expect. Here we say, on purpose wrongly, that three tasks are todo. npm test runs it, and the test fails. Vitest shows the difference: expected three, received two. That is exactly what a test is for.
src/summary.test.ts:
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { countByStatus } from "./summary.ts";
import type { Task } from "./task.ts";
const tasks: Task[] = [
{ id: 1, title: "a", status: "todo" },
{ id: 2, title: "b", status: "done", finishedAt: "2026-11-01" },
{ id: 3, title: "c", status: "todo" },
];
describe("countByStatus", () => {
it("counts every status", () => {
expect(countByStatus(tasks)).toEqual({ todo: 3, doing: 0, done: 1 });
});
});src/summary.ts:
import type { Task } from "./task.ts";
export type Status = Task["status"];
export function countByStatus(tasks: Task[]): Record<Status, number> {
const counts: Record<Status, number> = { todo: 0, doing: 0, done: 0 };
for (const t of tasks) {
counts[t.status] += 1;
}
return counts;
}$ npm test
> tasks@1.0.0 test
> vitest run
RUN v5.1.0 /home/mah/tasks
❯ src/summary.test.ts (1 test | 1 failed) 9ms
❯ countByStatus (1)
× counts every status 8ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/summary.test.ts > countByStatus > counts every status
AssertionError: expected { todo: 2, doing: +0, done: 1 } to deeply equal { todo: 3, doing: +0, done: 1 }
- Expected
+ Received
{
"doing": 0,
"done": 1,4. Pass
We correct the number to two, and the test passes. Green.
src/summary.test.ts:
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { countByStatus } from "./summary.ts";
import type { Task } from "./task.ts";
const tasks: Task[] = [
{ id: 1, title: "a", status: "todo" },
{ id: 2, title: "b", status: "done", finishedAt: "2026-11-01" },
{ id: 3, title: "c", status: "todo" },
];
describe("countByStatus", () => {
it("counts every status", () => {
expect(countByStatus(tasks)).toEqual({ todo: 2, doing: 0, done: 1 });
});
});$ npm test
> tasks@1.0.0 test
> vitest run
RUN v5.1.0 /home/mah/tasks
✓ src/summary.test.ts (1 test) 2ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 16:13:50
Duration 98ms (transform 55%, import 26%, worker 11%, tests 7%)5. Mocks
A mock replaces a real function while the test runs. Vi spy on watches console dot log, and mock implementation silences it. After the call, we can ask: was it called once? And at the end, we restore the original. Mocks let us test code that has side effects, like printing, sending, or saving.
src/summary.test.ts:
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { countByStatus } from "./summary.ts";
import type { Task } from "./task.ts";
const tasks: Task[] = [
{ id: 1, title: "a", status: "todo" },
{ id: 2, title: "b", status: "done", finishedAt: "2026-11-01" },
{ id: 3, title: "c", status: "todo" },
];
describe("countByStatus", () => {
it("counts every status", () => {
expect(countByStatus(tasks)).toEqual({ todo: 2, doing: 0, done: 1 });
});
it("logs through console.log", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
console.log(countByStatus(tasks));
expect(log).toHaveBeenCalledOnce();
log.mockRestore();
});
});$ npm test
> tasks@1.0.0 test
> vitest run
RUN v5.1.0 /home/mah/tasks
✓ src/summary.test.ts (2 tests) 4ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 16:13:51
Duration 112ms (transform 53%, import 27%, tests 11%, worker 8%)6. Type tests
Even types can be tested. Expect type of takes a value, and to equal type of compares types. Here we claim that the function returns a record of string and number. At runtime this does nothing, so Vitest says all is fine. But tsc checks it: the claim is wrong, and tsc says so. TS2344.
src/summary.test.ts:
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { countByStatus } from "./summary.ts";
import type { Task } from "./task.ts";
const tasks: Task[] = [
{ id: 1, title: "a", status: "todo" },
{ id: 2, title: "b", status: "done", finishedAt: "2026-11-01" },
{ id: 3, title: "c", status: "todo" },
];
describe("countByStatus", () => {
it("counts every status", () => {
expect(countByStatus(tasks)).toEqual({ todo: 2, doing: 0, done: 1 });
});
it("logs through console.log", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
console.log(countByStatus(tasks));
expect(log).toHaveBeenCalledOnce();
log.mockRestore();
});
it("has the right type", () => {
expectTypeOf(countByStatus).returns.toEqualTypeOf<Record<string, number>>();
});
});$ npx tsc --noEmit
src/summary.test.ts(22,55): error TS2344: Type 'Record<string, number>' does not satisfy the constraint '{ [x: string]: "Expected: number, Actual: never" | undefined; }'.
'string' index signatures are incompatible.
Type 'number' is not assignable to type '"Expected: number, Actual: never"'.7. Fix
We write the real type, and tsc is quiet. Type tests keep the types of a library honest, and we do not need any extra tool for that.
src/summary.test.ts:
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { countByStatus } from "./summary.ts";
import type { Task } from "./task.ts";
const tasks: Task[] = [
{ id: 1, title: "a", status: "todo" },
{ id: 2, title: "b", status: "done", finishedAt: "2026-11-01" },
{ id: 3, title: "c", status: "todo" },
];
describe("countByStatus", () => {
it("counts every status", () => {
expect(countByStatus(tasks)).toEqual({ todo: 2, doing: 0, done: 1 });
});
it("logs through console.log", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
console.log(countByStatus(tasks));
expect(log).toHaveBeenCalledOnce();
log.mockRestore();
});
it("has the right type", () => {
expectTypeOf(countByStatus).returns.toEqualTypeOf<Record<"todo" | "doing" | "done", number>>();
});
});$ npx tsc --noEmit$ npm test
> tasks@1.0.0 test
> vitest run
RUN v5.1.0 /home/mah/tasks
✓ src/summary.test.ts (3 tests) 3ms
Test Files 1 passed (1)
Tests 3 passed (3)
Start at 16:13:53
Duration 104ms (transform 55%, import 25%, tests 10%, worker 10%)Recap
Today: Vitest, describe, it and expect, a failing test and its difference, a mock with spy on, and a type test with expect type of. Next episode: linting.