Testing JavaScript

JavaScript · Lesson 58 of 61 10 min read Updated

Every time you change code, you click around the page to check that it still works. That is testing, but it is slow, boring and easy to skip, and it misses things you forgot to click. Automated tests are small programs that check your code for you, in seconds, every time you change something.

In this lesson you will see the idea behind tests with a tiny test helper you can run in the browser, then write real tests with the test runner built into Node.js, read a failing test, test asynchronous code with a fake fetch, and meet the popular tools Vitest, Jest and Playwright.

Why write tests?

  • Catch bugs early. A test that fails today is far cheaper than a customer finding the bug next month.
  • Change code without fear. When you clean up or speed up a function, the tests tell you at once if you broke something. A bug that returns after being fixed is called a regression, and tests stop regressions.
  • Document behaviour. A test named "10% off ₹1,299 rounds to ₹1,169" explains what the code should do better than a comment.
  • Better design. Code that is easy to test is usually split into small functions with clear inputs and outputs.

Tests come in three common sizes:

KindWhat it checksSpeedExample
Unit testOne function or module on its ownMillisecondsfinalPrice(1299, 10) returns 1169
Integration testSeveral parts working togetherSecondsThe cart module and the coupon module give the right total
End-to-end (E2E) testThe whole app in a real browserSeconds to minutesA user can search, add to cart and reach the payment page

Most projects have many unit tests, fewer integration tests and a handful of end-to-end tests for the most important journeys.

The idea in a few lines

A test runs some code and compares the result with what you expect. If they differ, it reports a failure. You can build this yourself in a few lines, which shows there is no magic in test tools:

JavaScriptRun it yourself
// The code we want to test
function splitBill(total, people) {
  return Math.round(total / people);
}

// A tiny test helper
let passed = 0;
let failed = 0;
function test(name, fn) {
  try {
    fn();
    passed++;
    console.log(`PASS  ${name}`);
  } catch (error) {
    failed++;
    console.log(`FAIL  ${name}: ${error.message}`);
  }
}
function assertEqual(actual, expected) {
  if (actual !== expected) {
    throw new Error(`expected ${expected}, got ${actual}`);
  }
}

test("₹900 between 3 friends is ₹300 each", () => {
  assertEqual(splitBill(900, 3), 300);
});
test("₹1,000 between 3 friends rounds to ₹333", () => {
  assertEqual(splitBill(1000, 3), 333);
});
test("splitting between 0 people is an error", () => {
  assertEqual(splitBill(500, 0), "error");
});

console.log(`${passed} passed, ${failed} failed`);
Output
PASS  ₹900 between 3 friends is ₹300 each
PASS  ₹1,000 between 3 friends rounds to ₹333
FAIL  splitting between 0 people is an error: expected error, got Infinity
2 passed, 1 failed

The third test found a real bug: dividing by zero returns Infinity instead of reporting a problem. Real test tools work the same way, with better reports, many ready-made checks (called assertions) and a command that finds and runs all your test files.

Node's built-in test runner

Node.js has a complete test runner built in: the node:test module provides test(), and node:assert provides the assertions. Nothing needs installing. (You need Node.js installed; see npm, Packages & Build Tools.)

Put the code in one module and the tests in a file next to it whose name ends in .test.js:

JavaScript
// price.js
export function finalPrice(price, discountPercent = 0) {
  if (typeof price !== "number" || price < 0) {
    throw new RangeError("price must be a positive number");
  }
  const discounted = price - (price * discountPercent) / 100;
  return Math.round(discounted);
}

export function addGst(amount, rate = 18) {
  return Math.round(amount * (1 + rate / 100));
}
JavaScript
// price.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { finalPrice, addGst } from "./price.js";

test("no discount returns the same price", () => {
  assert.equal(finalPrice(1299), 1299);
});

test("10% off ₹1,299 rounds to ₹1,169", () => {
  assert.equal(finalPrice(1299, 10), 1169);
});

test("negative prices are rejected", () => {
  assert.throws(() => finalPrice(-5), RangeError);
});

test("GST at 18% on ₹1,000", () => {
  assert.equal(addGst(1000), 1180);
});

Run every test file in the project with one command. (The folder's package.json contains "type": "module" so that Node treats .js files as modules.)

Terminal
node --test

In a terminal, Node.js 22 prints:

Output
✔ no discount returns the same price (2.1599ms)
✔ 10% off ₹1,299 rounds to ₹1,169 (0.3792ms)
✔ negative prices are rejected (1.1583ms)
✔ GST at 18% on ₹1,000 (2.2091ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 970.3196

node --test finds files named like *.test.js, *_test.js or files inside a test folder. The times in brackets will differ on your computer. Always import from node:assert/strict, which compares with === rules instead of the loose == rules of the older non-strict mode.

AssertionPasses when
assert.equal(actual, expected)The values are strictly equal
assert.deepEqual(actual, expected)Objects or arrays have the same contents
assert.ok(value)The value is truthy
assert.match(text, /regex/)The text matches the pattern
assert.throws(fn, ErrorType)Calling fn throws that kind of error
await assert.rejects(promise, ...)The promise rejects

Use deepEqual for arrays and objects: assert.equal([1, 2], [1, 2]) fails, because two separate arrays are never ===.

Reading a failing test

A good test has three parts, often called Arrange, Act, Assert: set up the data, call the code, check the result. Group related tests with describe() and name each case with it(), which is another name for test():

JavaScript
// cart.test.js
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { cartTotal, applyCoupon } from "./cart.js";

describe("cartTotal", () => {
  it("adds price times quantity", () => {
    const items = [
      { name: "Notebook", price: 60, qty: 3 },
      { name: "Pen", price: 15, qty: 4 },
    ];                                    // Arrange
    const total = cartTotal(items);       // Act
    assert.equal(total, 240);             // Assert
  });
});

describe("applyCoupon", () => {
  it("accepts coupon codes in lower case", () => {
    assert.equal(applyCoupon(500, "diwali10"), 450);
  });
});

Suppose applyCoupon only recognises "DIWALI10" in capitals. The output (with the stack trace shortened to its first line) points straight at the problem:

Output
▶ cartTotal
  ✔ adds price times quantity (1.6913ms)
✔ cartTotal (4.4952ms)
▶ applyCoupon
  ✖ accepts coupon codes in lower case (3.7332ms)
✖ applyCoupon (5.7229ms)
ℹ tests 2
ℹ suites 2
ℹ pass 1
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 334.5887

✖ failing tests:

test at cart.test.js:18:3
✖ accepts coupon codes in lower case (3.7332ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  500 !== 450

      at TestContext.<anonymous> (file:///.../cart.test.js:19:12)

Read it from the bottom up: the test expected 450, the code returned 500, so the coupon was ignored. The file and line number tell you which test to open. When a test fails, first decide which is wrong, the code or the test, and only then change one of them. The command also exits with an error code, which is how automatic checks on GitHub or other build servers know to stop a broken release.

Async code and mocks

Tests can be async. To test code that calls fetch, you do not want a real network call: it is slow, needs the internet, and the answer changes. Replace fetch with a mock, a fake version that returns exactly what you choose:

JavaScript
// weather.js
export async function temperatureIn(city) {
  const response = await fetch(`https://api.example.com/weather?city=${encodeURIComponent(city)}`);
  if (!response.ok) throw new Error(`Weather service failed: ${response.status}`);
  const data = await response.json();
  return `${city}: ${Math.round(data.tempC)}°C`;
}
JavaScript
// weather.test.js
import { test, mock, afterEach } from "node:test";
import assert from "node:assert/strict";
import { temperatureIn } from "./weather.js";

afterEach(() => mock.restoreAll()); // put the real fetch back after each test

test("formats the temperature", async () => {
  mock.method(globalThis, "fetch", async () => Response.json({ tempC: 31.6 }));

  assert.equal(await temperatureIn("Jaipur"), "Jaipur: 32°C");
  assert.equal(globalThis.fetch.mock.callCount(), 1);
});

test("reports a server error", async () => {
  mock.method(globalThis, "fetch", async () => new Response("", { status: 503 }));

  await assert.rejects(temperatureIn("Kochi"), /failed: 503/);
});

Both tests pass in a few milliseconds, with no network. Notice that the error case is tested too: tests for failures are often more valuable than tests for the happy path.

Other useful features: hooks such as beforeEach and afterEach for shared setup, test.skip() and test.todo() for tests you are not running yet, node --test --watch to rerun tests whenever you save, and node --test --experimental-test-coverage to see which lines your tests never reached. Add "test": "node --test" to the scripts in package.json so everyone can run npm test.

Vitest and Jest

Many projects use a dedicated test framework instead of the built-in runner:

  • Vitest is the usual choice for modern projects, especially those built with Vite. It understands ES modules and TypeScript with no extra setup, reruns only the affected tests when you save, and can run tests in a simulated or real browser.
  • Jest is older and still extremely common in existing projects, including many React apps. It was designed in the CommonJS era, so ES module support needs extra configuration.

Both use the same expect style, so tests look almost identical:

JavaScript
// price.test.js with Vitest
import { describe, it, expect } from "vitest";
import { finalPrice } from "./price.js";

describe("finalPrice", () => {
  it("applies a discount", () => {
    expect(finalPrice(1299, 10)).toBe(1169);
  });

  it("rejects negative prices", () => {
    expect(() => finalPrice(-5)).toThrow(RangeError);
  });
});

Install it with npm install --save-dev vitest and run npx vitest. Matchers such as toBe, toEqual (deep equality), toContain and toThrow read almost like English.

Testing in the browser

Code that uses the DOM needs a document. There are three common approaches:

  • A simulated DOM. Libraries such as jsdom and happy-dom implement document in Node.js. Vitest and Jest can use them, so you can test that clicking a button adds an item to a list, quickly and without a real browser.
  • Vitest browser mode runs your unit tests inside a real browser for maximum accuracy.
  • End-to-end tests with Playwright (or the older Cypress) start real browsers such as Chrome, Firefox and Safari's engine, open your site, and act like a user:
JavaScript
// checkout.spec.js (Playwright)
import { test, expect } from "@playwright/test";

test("a student can add a course to the cart", async ({ page }) => {
  await page.goto("http://localhost:5173/");
  await page.getByRole("button", { name: "Add JavaScript course" }).click();
  await expect(page.getByTestId("cart-count")).toHaveText("1");
});

E2E tests catch problems that unit tests cannot, such as a broken layout hiding a button, but they are slower and break more easily, so keep them for the journeys that matter most: sign-up, search and payment.

Common mistakes

  • Testing only the happy path. Add tests for empty input, zero, negative numbers and server errors.
  • Using equal for objects or arrays. Use deepEqual (or toEqual in Vitest and Jest).
  • Forgetting await in async tests. The test finishes before the check runs and passes by mistake.
  • Calling real APIs in unit tests. Mock fetch so tests are fast and repeatable.
  • Tests that depend on each other or on the current date. Each test should set up its own data; pass dates in as parameters.
  • Changing the test to match buggy code. Decide what the correct behaviour is first.

Key points

  • Automated tests check your code in seconds and stop old bugs from coming back.
  • Unit tests check single functions, integration tests check parts together, E2E tests drive a real browser.
  • Node.js has a built-in runner: import { test } from "node:test", import assert from "node:assert/strict", then run node --test.
  • Structure each test as Arrange, Act, Assert, and read failures from the expected and actual values.
  • Mock fetch and other slow or unpredictable dependencies.
  • Vitest is the common modern framework, Jest is widespread in existing projects, and Playwright handles browser end-to-end tests.

Exercise

Fix splitBill(total, people) from the lesson so that it throws a RangeError when people is not a whole number greater than 0. Then write a test file split.test.js using node:test and node:assert/strict with at least three tests: an even split, a split that needs rounding, and the error case. Run it with node --test.

Show solution
JavaScript
// split.js
export function splitBill(total, people) {
  if (!Number.isInteger(people) || people < 1) {
    throw new RangeError("people must be a whole number above 0");
  }
  return Math.round(total / people);
}
JavaScript
// split.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { splitBill } from "./split.js";

test("₹900 between 3 friends is ₹300 each", () => {
  assert.equal(splitBill(900, 3), 300);
});

test("₹1,000 between 3 friends rounds to ₹333", () => {
  assert.equal(splitBill(1000, 3), 333);
});

test("0 people or 2.5 people is an error", () => {
  assert.throws(() => splitBill(500, 0), RangeError);
  assert.throws(() => splitBill(500, 2.5), RangeError);
});
Output
✔ ₹900 between 3 friends is ₹300 each (3.5652ms)
✔ ₹1,000 between 3 friends rounds to ₹333 (0.549ms)
✔ 0 people or 2.5 people is an error (1.9072ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 642.7256

The guard clause runs before the division, so bad input never produces Infinity or NaN. One test may contain several assertions when they check the same rule.

Quick Quiz

1. Which import gives you assertions that use strict (===) comparison?

2. Why does assert.equal([1, 2], [1, 2]) fail?

3. Why do unit tests usually replace fetch with a mock?

4. Which kind of test opens the real site in a browser and acts like a user?

Learn JavaScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call