Testing TypeScript
Reviewed & published by Brayan K
Automated tests prove your code does what you think — and keep proving it as you change things. You'll learn the describe / it / expect pattern with Vitest and Jest, the everyday matchers, how to mock and type dependencies, and how to test async code and read coverage.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Structure tests with describe, it/test, and expect
- Pick the right matcher: toBe, toEqual, toThrow, toContain
- Run TypeScript tests with Vitest (or ts-jest for Jest)
- Create and type mocks with vi.fn() / jest.fn()
- Assert calls with toHaveBeenCalledWith
- Test async code and read a coverage report
💡 Real-World Analogy
Tests are the smoke detectors of your codebase. You don't notice them while everything works, but the moment a change starts a fire — a function quietly returning the wrong value — they go off immediately, pointing at the exact room. Without them you only find out when the whole house is ablaze in production.
1. What a TypeScript Test File Looks Like
A test file imports the runner's helpers, imports the code under test, and asserts on its behaviour. With Vitest this is a plain .test.ts file that runs directly — esbuild transforms the TypeScript for you. Read the real version here:
// math.ts
export function sum(a: number, b: number): number {
return a + b;
}
// math.test.ts — Vitest runs this TypeScript file directly.
import { describe, it, expect } from "vitest";
import { sum } from "./math";
describe("sum", () => {
it("adds two numbers", () => {
expect(sum(2, 3)).toBe(5); // strict equality
});
it("handles negatives", () => {
expect(sum(-1, 1)).toBe(0);
});
});
// Run with: npx vitest (or "npm test" once it's wired up)2. describe / it / expect
describe groups related tests, it (or test) declares one case, and expect(value) wraps the value you want to assert on. The box below provides tiny stand-ins so you can see the whole flow run.
// A test runner gives you describe(), it(), and expect().
// (We define tiny stand-ins so the box runs anywhere; real runners
// like Vitest or Jest provide these globally.)
const results = [];
function describe(label, fn) { console.log("describe:", label); fn(); }
function it(name, fn) {
try { fn(); results.push("✅ " + name); }
catch (e) { results.push("❌ " + name + " — " + e.message); }
}
function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) throw new Error("expected " + expected + " but got " + actual);
},
};
}
// The code under test:
function sum(a, b) { return a + b; }
describe("sum", () => {
it("adds two numbers", () => {
expect(sum(2, 3)).toBe(5);
});
it("adds negatives", () => {
expect(sum(-1, -1)).toBe(-2);
});
});
console.log(results.join("\n"));
// ✅ adds two numbers
// ✅ adds negatives3. Matchers: toBe vs toEqual
A matcher says how to compare. Use .toBe() for primitives (strict equality) and .toEqual() for objects and arrays (deep, value-based equality). .toThrow() asserts a function throws. Run the example to see why objects need toEqual:
// MATCHERS describe HOW to compare. The two most important:
// .toBe() -> strict reference equality (Object.is) — good for primitives
// .toEqual() -> deep, value-based equality — needed for objects and arrays
function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) throw new Error("toBe failed");
console.log("toBe ok:", actual);
},
toEqual(expected) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("toEqual failed");
console.log("toEqual ok");
},
toThrow() {
try { actual(); } catch (e) { console.log("threw as expected:", e.message); return; }
throw new Error("expected a throw, but none happened");
},
};
}
expect(2 + 2).toBe(4); // primitives: toBe is fine
expect({ a: 1, b: [2, 3] }).toEqual({ a: 1, b: [2, 3] }); // objects need toEqual
expect(() => { throw new Error("boom"); }).toThrow(); // assert it throws
// Why not toBe for objects? Two equal-looking objects are DIFFERENT references,
// so .toBe() would fail. .toEqual() compares the contents instead.4. Mocks & Async Tests
A mock is a fake function that records its calls so you can assert on them — vi.fn() in Vitest, jest.fn() in Jest. Typing the mock lets the compiler check it matches the real signature. For async code, make the test async and await the result so the runner waits.
// A MOCK is a fake function that RECORDS how it was called, so you can
// assert on the calls. In Vitest you'd write vi.fn(); in Jest, jest.fn().
// Here is a hand-rolled equivalent to show what it does:
function fn(impl) {
const mock = (...args) => { mock.calls.push(args); return impl ? impl(...args) : undefined; };
mock.calls = [];
return mock;
}
// Suppose our code calls a "logger" we don't want to really run:
function processOrder(id, logger) {
logger("processing " + id);
return { id, status: "done" };
}
const logger = fn(); // vi.fn() in real tests
const result = processOrder(7, logger);
console.log(result); // { id: 7, status: 'done' }
console.log("called once?", logger.calls.length === 1); // true
console.log("called with:", logger.calls[0]); // [ 'processing 7' ]
// Testing ASYNC code: make the test async and AWAIT the result.
async function fetchUser(id) { return { id, name: "Ada" }; }
(async () => {
const user = await fetchUser(1); // await the promise
console.log("async result:", user); // { id: 1, name: 'Ada' }
})();Your turn. Fill in the two blanks marked ___ to complete the assertions, then run it.
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// Finish the test for a 'double' function.
function expect(actual) {
return {
toBe(expected) {
if (actual === expected) console.log("✅ pass");
else console.log("❌ fail: got " + actual);
},
};
}
function double(n) { return n * 2; }
// 1) double(4) should equal 8
expect(double(4)).toBe(___); // 👉 the number 8
// 2) double(0) should equal 0
expect(double(0)).toBe(___); // 👉 the number 0
// ✅ Expected output:
// ✅ pass
// ✅ passCommon Errors (and the fix)
- ❌ Using .toBe() on an object — it compares references and fails. ✅ Use .toEqual() for objects and arrays.
- ❌ Forgetting to await an async test — the test finishes before the assertion runs and falsely passes. ✅ Mark the callback async and await the result.
- ❌ Asserting a throw by calling the function directly — the error escapes the test. ✅ Pass a function: expect(() => risky()).toThrow().
- ❌ Untyped mocks that return the wrong shape — bugs slip through. ✅ Type the mock so the compiler checks it against the real signature.
Pro Tips
- 💡 Name tests by behaviour — "adds two numbers" reads better in output than "test1".
- 💡 Vitest needs no TS setup — it transforms .ts tests with esbuild; reach for ts-jest only on Jest.
- 💡 Assert calls, not just results — toHaveBeenCalledWith catches the wrong arguments being passed.
- 💡 Watch coverage, not chase 100% — use it to find untested branches, not as a vanity number.
Mini-Challenge: Test a Cart Total
No blanks this time — write the three assertions yourself using the provided expect, run it, and check all three print pass.
// 🎯 MINI-CHALLENGE: test a small cart module
//
// Given this code under test:
// function total(items) { return items.reduce((s, i) => s + i.price, 0); }
//
// Write three assertions using the provided expect():
// 1. total([]) is 0 -> expect(...).toBe(0)
// 2. total([{ price: 5 }]) is 5
// 3. total([{ price: 2 }, { price: 3 }]) is 5
// Print "✅ pass" for each one that succeeds.
//
// ✅ Expected output:
// ✅ pass
// ✅ pass
// ✅ pass
function expect(actual) {
return { toBe(expected) { console.log(actual === expected ? "✅ pass" : "❌ fail"); } };
}
function total(items) { return items.reduce((s, i) => s + i.price, 0); }
// your assertions here🎉 Lesson Complete
- ✅ describe groups, it/test declares a case, expect asserts
- ✅ .toBe() for primitives, .toEqual() for objects and arrays
- ✅ Vitest runs TypeScript tests directly; Jest usually adds ts-jest
- ✅ vi.fn()/jest.fn() create recording mocks you can type and assert on
- ✅ Test async code by making the case async and awaiting the result
Practice quiz
What is the role of describe() in a test file?
- It groups related tests under a shared label
- It asserts a value
- It mocks a function
- It runs the production code
Answer: It groups related tests under a shared label. describe(label, fn) groups related test cases so output is organised and shared setup can live in one place.
Which function defines a single test case?
- assert
- describe
- it (or test)
- expect
Answer: it (or test). it(name, fn), with its alias test(name, fn), declares one individual test case.
What does expect(sum(2, 3)).toBe(5) check?
- That the test is async
- That sum(2, 3) is strictly equal to 5
- That sum throws
- That sum is mocked
Answer: That sum(2, 3) is strictly equal to 5. expect(value) wraps the actual value and .toBe(5) asserts strict (Object.is) equality with 5.
Which matcher compares objects and arrays by value (deep equality)?
- .toContain()
- .toThrow()
- .toBe()
- .toEqual()
Answer: .toEqual(). .toEqual() recursively compares structure and values; .toBe() would fail for two different object references.
In Vitest, how do you create a mock function?
- vi.fn()
- expect.fn()
- mock.create()
- new Mock()
Answer: vi.fn(). vi.fn() creates a mock function in Vitest (jest.fn() is the Jest equivalent) that records its calls.
How do you assert a mock was called with specific arguments?
- expect(fn).toBe(arg)
- expect(fn).toHaveBeenCalledWith(arg)
- expect(fn).called(arg)
- fn.assert(arg)
Answer: expect(fn).toHaveBeenCalledWith(arg). .toHaveBeenCalledWith(...) checks the mock received exactly those arguments on at least one call.
What is the correct way to test code that returns a Promise?
- Wrap it in describe
- Ignore the Promise
- Use a setTimeout
- Make the test async and await the result
Answer: Make the test async and await the result. Declare the test callback async and await the promise, then assert on the resolved value so the runner waits for it.
How does Vitest support TypeScript test files out of the box?
- It compiles with the C++ compiler
- It requires Babel only
- It uses esbuild to transform TS, so .ts/.tsx tests run with no extra config
- It cannot run TypeScript
Answer: It uses esbuild to transform TS, so .ts/.tsx tests run with no extra config. Vitest transforms TypeScript with esbuild internally, so .test.ts files run directly. With Jest you typically add ts-jest.
What does code coverage measure?
- How fast tests run
- How many lines/branches of code the tests exercised
- How many files exist
- The TypeScript version
Answer: How many lines/branches of code the tests exercised. Coverage reports the percentage of statements, branches, and functions executed while the tests ran.
Why does typing a mock (for example a typed vi.fn()) help?
- It lets the compiler check that the mock matches the real function's signature
- It disables type checking
- It makes tests run faster
- It changes the runtime behaviour
Answer: It lets the compiler check that the mock matches the real function's signature. A typed mock must match the real signature, so the compiler catches a mock that returns the wrong shape or takes wrong args.
Continue this course
- Previous: Runtime Validation with Zod
- Next: TypeScript on Node.js — Configure tsconfig for Node, @types/node, ESM vs CommonJS, and ts-node/tsx
- Quick reference: TypeScript cheat sheet
Frequently asked questions
Should I use Vitest or Jest for TypeScript?
Both are excellent. Vitest is built on Vite/esbuild and runs TypeScript test files (.test.ts) with essentially zero configuration, sharing your Vite config, which makes it the smoother choice for modern Vite projects. Jest is the long-standing standard; with TypeScript you usually add ts-jest (or a Babel transform) to compile tests. The test API — describe, it/test, expect, and the matchers — is nearly identical, so skills transfer directly.
What's the difference between .toBe() and .toEqual()?
.toBe() checks strict reference equality (Object.is), which is correct for primitives like numbers, strings, and booleans. .toEqual() performs a deep, value-based comparison, which is what you need for objects and arrays, because two structurally identical objects are still different references and would fail .toBe(). Rule of thumb: primitives use toBe, structures use toEqual.
Why mock a function instead of calling the real one?
Mocks let you isolate the unit under test from slow, unpredictable, or side-effecting dependencies — network calls, databases, loggers, timers. A mock created with vi.fn() (or jest.fn()) records every call so you can assert it was invoked the right number of times with the right arguments, without actually performing the real work. Typing the mock also lets the compiler verify it matches the real signature.
How do I test asynchronous code?
Make the test callback async and await the value you care about, then assert on the resolved result — the runner will wait for the promise to settle. For rejections, assert that the call rejects (for example with expect(fn()).rejects.toThrow()). The key is to return or await the promise so the test does not finish before the async work completes.