JavaScript Tests, Jest & Vitest
Websites, Node.js servers and many mobile apps are written in JavaScript, so a large share of automated tests are JavaScript tests too. The two most popular test runners are Jest and Vitest. Both use the same style: describe groups tests, test (or it) defines one test, and expect(value).toBe(...) checks a result.
In this lesson you will first build a tiny runner yourself, so nothing about these tools feels like magic. Then you will test asynchronous code and set up real tests with Jest 30 and Vitest 5.
How expect works: build it yourself
A matcher such as toBe is just a function that throws an error when the check fails. A test runner calls each test inside try/catch and records which ones threw. Run this complete mini framework:
const results = [];
function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) throw new Error(`expected ${expected}, received ${actual}`);
},
toEqual(expected) {
const a = JSON.stringify(actual);
const b = JSON.stringify(expected);
if (a !== b) throw new Error(`expected ${b}, received ${a}`);
},
toThrow(message) {
try {
actual();
} catch (error) {
if (message && !error.message.includes(message)) {
throw new Error(`threw "${error.message}", expected "${message}"`);
}
return;
}
throw new Error("expected function to throw");
},
};
}
function test(name, fn) {
try {
fn();
results.push(` ✓ ${name}`);
} catch (error) {
results.push(` ✗ ${name}: ${error.message}`);
}
}
// Code under test
function cartTotal(items, couponPercent = 0) {
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
return subtotal - (subtotal * couponPercent) / 100;
}
const items = [
{ name: "Notebook", price: 60, qty: 2 },
{ name: "Pen", price: 15, qty: 4 },
];
test("adds price times quantity", () => expect(cartTotal(items)).toBe(180));
test("applies a 10% coupon", () => expect(cartTotal(items, 10)).toBe(162));
test("empty cart costs nothing", () => expect(cartTotal([])).toBe(0));
test("3% coupon on 3 pens", () => expect(cartTotal([{ price: 9.99, qty: 3 }], 3)).toBe(29.07));
test("arrays compare by content", () => expect(items.map((i) => i.name)).toEqual(["Notebook", "Pen"]));
test("rejects bad JSON", () => expect(() => JSON.parse("{bad")).toThrow("JSON"));
console.log("cartTotal");
for (const line of results) console.log(line);
const failed = results.filter((r) => r.includes("✗")).length;
console.log(`Tests: ${failed} failed, ${results.length - failed} passed, ${results.length} total`);cartTotal ✓ adds price times quantity ✓ applies a 10% coupon ✓ empty cart costs nothing ✗ 3% coupon on 3 pens: expected 29.07, received 29.070899999999998 ✓ arrays compare by content ✓ rejects bad JSON Tests: 1 failed, 5 passed, 6 total
The failing test found a real defect: money calculated with floating-point numbers is not rounded, so a price shows as 29.070899999999998. The fix is to round to paise, for example Math.round(value * 100) / 100. Notice two details that real frameworks share:
toBecompares with===, so two different arrays with the same items are nottoBeeach other. UsetoEqualto compare contents.toThrowneeds a function (() => JSON.parse(...)), so the matcher can call it inside its owntry. PassingJSON.parse("{bad")directly would throw beforeexpecteven runs.
Testing asynchronous code
Most JavaScript talks to servers, so functions return promises. A test of async code must await the result, or the test finishes before the answer arrives. The third test below forgets await:
// A fake API call that answers after a short delay
function fetchOrderStatus(orderId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (orderId.startsWith("ORD-")) resolve({ id: orderId, status: "SHIPPED" });
else reject(new Error(`Invalid order id ${orderId}`));
}, 50);
});
}
async function test(name, fn) {
try {
await fn();
console.log(`✓ ${name}`);
} catch (error) {
console.log(`✗ ${name}: ${error.message}`);
}
}
await test("returns the status of a real order", async () => {
const order = await fetchOrderStatus("ORD-1042");
if (order.status !== "SHIPPED") throw new Error(`got ${order.status}`);
});
await test("rejects a malformed id", async () => {
try {
await fetchOrderStatus("1042");
throw new Error("expected a rejection");
} catch (error) {
if (!error.message.includes("Invalid order id")) throw error;
}
});
await test("forgetting await hides the result", async () => {
const order = fetchOrderStatus("ORD-7"); // missing await: this is a Promise
if (order.status !== "SHIPPED") throw new Error(`status is ${order.status}`);
});✓ returns the status of a real order ✓ rejects a malformed id ✗ forgetting await hides the result: status is undefined
Without await, order is a pending promise, which has no status property. Jest and Vitest have the same rule: make the test function async and await the code, or use await expect(promise).resolves.toEqual(...) and await expect(promise).rejects.toThrow(...).
Jest
Jest (version 30 at the time of writing) is a long-established, all-in-one test runner used widely with React and Node.js. Install it as a development dependency and add a script to package.json:
npm install --save-dev jest{
"scripts": {
"test": "jest"
}
}Jest finds files ending in .test.js or .spec.js, and files inside a __tests__ folder. Here is a test file for a CommonJS module cart.js that exports cartTotal and applyCoupon:
// cart.test.js
const { cartTotal, applyCoupon } = require("./cart");
describe("cartTotal", () => {
const items = [
{ name: "Notebook", price: 60, qty: 2 },
{ name: "Pen", price: 15, qty: 4 },
];
test("adds price times quantity", () => {
expect(cartTotal(items)).toBe(180);
});
test("applies a percentage coupon", () => {
expect(cartTotal(items, 10)).toBe(162);
});
test("empty cart costs nothing", () => {
expect(cartTotal([])).toBe(0);
});
});
describe("applyCoupon", () => {
test("knows FEST10", () => {
expect(applyCoupon("FEST10")).toBe(10);
});
test("rejects unknown codes", () => {
expect(() => applyCoupon("FREE100")).toThrow("Unknown coupon FREE100");
});
});Run npm test. The summary at the end looks like this (the time will differ):
Test Suites: 1 passed, 1 total Tests: 5 passed, 5 total Snapshots: 0 total Time: 1.204 s Ran all test suites.
Jest's support for native ES modules (import/export) still needs extra configuration in many projects, which is one reason new projects often choose Vitest.
Vitest
Vitest (version 5 at the time of writing) comes from the Vite build tool team. It understands ES modules and TypeScript out of the box, offers a Jest-compatible API, and re-runs affected tests instantly in watch mode. Install it with npm install --save-dev vitest and run npx vitest (watch mode) or npx vitest run (run once, as in CI).
// cart.test.js (ES module project)
import { describe, it, expect, vi } from "vitest";
import { cartTotal } from "./cart.js";
describe("cartTotal", () => {
it("adds price times quantity", () => {
expect(cartTotal([{ price: 60, qty: 2 }])).toBe(120);
});
it("returns a new order object", () => {
const order = { items: 2, total: 120 };
expect({ ...order }).toEqual({ items: 2, total: 120 });
});
it("calls the notifier once", () => {
const notify = vi.fn();
notify("Order placed");
expect(notify).toHaveBeenCalledWith("Order placed");
expect(notify).toHaveBeenCalledTimes(1);
});
});vi.fn() creates a mock function that records every call. Jest's equivalent is jest.fn(). Mocks are the subject of the next lesson, Mocks, Stubs & Fakes.
Matchers you will use most
| Matcher | Passes when |
|---|---|
toBe(x) | === equal (numbers, strings, same object) |
toEqual(x) | same contents, compared deeply |
toBeCloseTo(x, 2) | floats equal to 2 decimal places |
toContain(x) | array or string contains x |
toHaveLength(n) | .length is n |
toBeTruthy() / toBeNull() | truthy / exactly null |
toThrow("text") | the function throws an error containing the text |
toHaveBeenCalledWith(...) | a mock was called with these arguments |
Every matcher can be negated with .not, for example expect(cart).not.toContain("Pen").
Common mistakes
- Using
toBefor objects or arrays. Two separate objects are never===; usetoEqual. - Forgetting
awaitin async tests. The test passes or fails for the wrong reason. - Calling the function inside
expectfortoThrow. Wrap it:expect(() => fn()).toThrow(). - Comparing money as floats. Round to paise, or use
toBeCloseTo.
Key points
describegroups tests,test/itdefines one, andexpect(value).matcher()checks it.- A matcher is a function that throws when the check fails; the runner catches and reports it.
- Async tests must be
asyncand mustawaitthe code under test. - Jest is a mature all-in-one runner; Vitest is a fast, ES-module-native runner with a Jest-like API.
- Use
toEqualfor contents,toBeCloseTofor floats andtoThrowwith a wrapping function.
Exercise
Add a toBeCloseTo(expected, digits = 2) matcher to the mini framework from the first example. It should pass when Math.abs(actual - expected) < 10 ** -digits / 2. Then change the failing "3% coupon" test to use it and check that all six tests pass.
Show solution
// Add inside the object returned by expect(actual):
toBeCloseTo(expected, digits = 2) {
if (!(Math.abs(actual - expected) < 10 ** -digits / 2)) {
throw new Error(`expected about ${expected}, received ${actual}`);
}
},
// And change the test:
test("3% coupon on 3 pens", () =>
expect(cartTotal([{ price: 9.99, qty: 3 }], 3)).toBeCloseTo(29.07));The difference between 29.070899999999998 and 29.07 is about 0.0009, which is less than 0.005, so the test now passes and the summary reports 6 passed. In a real shop you would still fix cartTotal to round to paise, because customers should never see the long number.
Quick Quiz
1. Which matcher should compare two arrays with the same items?
toBe uses ===, which is false for two different arrays; toEqual compares contents.
2. Why must toThrow receive a function, as in expect(() => fn()).toThrow()?
If fn() were called directly, it would throw before expect could catch it.
3. An async test forgets await. What usually happens?
Without await, the variable holds a Promise, not the resolved value.
4. Which tool creates a mock function in Vitest?
Vitest exposes mocks through vi; Jest uses jest.fn().
Learn Software Testing with a mentor
Free lessons get you started. Our Software Testing training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
