API Testing & Quality Engineering
Hands-onBeginner 35 min· Automated tests · 7 of 19

Unit testing in JavaScript with Vitest and Jest

Write fast unit tests for JavaScript code with Vitest: describe and it blocks, expect matchers, error tests, test.each, beforeEach and mock functions. See how the same skills carry over to Jest.

In this tutorial you will write unit tests for Brew & Bean's JavaScript price and cart logic using Vitest, a modern test runner that works well with Vite and React projects. You will learn the basic structure of a test file, common expect matchers, testing errors, running one test with many inputs, shared set-up with beforeEach, and mock functions.

Prerequisites: Node.js 18 or newer with npm, and basic JavaScript (functions, arrays, objects, ES modules with import and export). The ideas match the pytest lesson, so if you did that one, you will see the same patterns in a different language.

Vitest and Jest

Jest is the long-standing JavaScript test framework, created at Facebook, and is found in many existing codebases (including Create React App projects). Vitest is newer, designed to work with Vite and ES modules out of the box, and deliberately copies Jest's API. If you can write Vitest tests, you can write Jest tests, and the reverse is also true.

Which should you pick? For a new Vite project (such as the Brew & Bean React site), use Vitest. If your team already uses Jest, stay with Jest. The differences are small, and are listed near the end.

Step 1: set up

Terminal
mkdir brew-tests
cd brew-tests
npm init -y
npm install --save-dev vitest

Creates a project and installs Vitest as a development dependency.

Open package.json. Add "type": "module" so Node treats .js files as ES modules, and replace the test script. A minimal file looks like this:

package.json
{
  "name": "brew-tests",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "vitest run"
  }
}

Keep the devDependencies entry that npm added for vitest (not shown here). vitest run runs once and exits. Plain vitest stays running and re-runs tests when you save.

Create the code to test:

pricing.js
export const MENU = {
  espresso: 120,
  cappuccino: 180,
  "masala-chai": 90,
  croissant: 150,
};

export function formatPrice(amount) {
  return `₹${amount}`;
}

export function calculateTotal(items) {
  if (items.length === 0) {
    throw new Error("An order needs at least one item");
  }
  let total = 0;
  for (const { itemId, qty } of items) {
    if (!(itemId in MENU)) {
      throw new Error(`Unknown item: ${itemId}`);
    }
    if (qty < 1 || qty > 10) {
      throw new Error("Quantity must be between 1 and 10");
    }
    total += MENU[itemId] * qty;
  }
  return total;
}

A small JavaScript twin of the Python pricing module, with four menu items to keep the example short.

Step 2: your first test

Vitest finds files ending in .test.js (or .spec.js). A test file imports the code, groups related tests with describe, and defines each test with it (or test, which is the same thing).

pricing.test.js
import { describe, it, expect, test } from "vitest";
import { calculateTotal, formatPrice } from "./pricing.js";

describe("calculateTotal", () => {
  it("adds up several items", () => {
    const items = [
      { itemId: "espresso", qty: 2 },
      { itemId: "croissant", qty: 1 },
    ];
    expect(calculateTotal(items)).toBe(390);
  });

  it("rejects an empty order", () => {
    expect(() => calculateTotal([])).toThrow("at least one item");
  });

  test.each([0, 11])("rejects quantity %i", (qty) => {
    expect(() => calculateTotal([{ itemId: "espresso", qty }])).toThrow(
      "between 1 and 10"
    );
  });
});

describe("formatPrice", () => {
  it("adds the rupee sign", () => {
    expect(formatPrice(120)).toBe("₹120");
  });
});

describe groups tests, it defines one test, expect(...).toBe(...) is an assertion. test.each runs one test for several inputs.

Terminal
npm test
Output
 RUN  v... /path/to/brew-tests

 Test Files  1 passed (1)
      Tests  5 passed (5)
   Duration  ...

Let's read the file. `describe("calculateTotal", ...)` names a group. `it("adds up several items", ...)` is one test, and its name should read like a sentence: 'calculateTotal adds up several items'. Inside, `expect(actual).matcher(expected)` is the assertion. If a matcher fails, it throws and the test fails.

Common matchers

The matchers you will use daily

MatcherChecksExample
toBe(x)Exactly equal (primitives, same reference)expect(total).toBe(390)
toEqual(x)Deep equality for objects and arraysexpect(order).toEqual({ id: 1, qty: 2 })
toBeTruthy() / toBeFalsy()Truthy or falsy valueexpect(isValid).toBeTruthy()
toContain(x)Array has item, or string has textexpect(names).toContain("Espresso")
toHaveLength(n)Length of array or stringexpect(menu).toHaveLength(8)
toBeGreaterThan(n) / toBeLessThan(n)Number comparisonsexpect(price).toBeGreaterThan(0)
toBeCloseTo(x, digits)Decimals, avoiding floating point errorsexpect(0.1 + 0.2).toBeCloseTo(0.3)
toThrow(text)The function throws an errorexpect(() => f()).toThrow("Unknown item")
notReverses any matcherexpect(total).not.toBe(0)

Wrapping the call in an arrow function

To test that a function throws, pass a function to expect, not the result of calling it: expect(() => calculateTotal([])).toThrow(). If you write expect(calculateTotal([])).toThrow(), the error is thrown before expect runs and the test crashes.

toBe compares with Object.is, so expect({ a: 1 }).toBe({ a: 1 }) fails because those are two different objects. Use toEqual for objects and arrays.

Step 3: boundary tests with test.each

test.each is Vitest's version of pytest's parametrize. In the file above, test.each([0, 11])("rejects quantity %i", ...) runs the test twice, once for each boundary just outside the allowed range. %i is replaced with the value in the test name, so a failure tells you which input broke.

You can also give rows of values. Add this to pricing.test.js:

pricing.test.js (add inside the file)
test.each([
  [1, 120],
  [10, 1200],
])("accepts quantity %i for a total of %i", (qty, expected) => {
  expect(calculateTotal([{ itemId: "espresso", qty }])).toBe(expected);
});

Boundary values 1 and 10 are valid. Each inner array is one case: [qty, expected total].

Step 4: shared set-up with beforeEach

When several tests need a fresh object, create it in beforeEach, which runs before every test. Here is a small shopping cart that uses calculateTotal:

cart.js
import { calculateTotal } from "./pricing.js";

export function createCart() {
  const lines = [];
  return {
    add(itemId, qty = 1) {
      const existing = lines.find((line) => line.itemId === itemId);
      if (existing) {
        existing.qty += qty;
      } else {
        lines.push({ itemId, qty });
      }
    },
    lines() {
      return lines.map((line) => ({ ...line }));
    },
    total() {
      return calculateTotal(lines);
    },
  };
}

createCart returns an object with add, lines and total. add merges the same item into one line.

cart.test.js
import { describe, it, expect, beforeEach, vi } from "vitest";
import { createCart } from "./cart.js";

describe("cart", () => {
  let cart;

  beforeEach(() => {
    cart = createCart();
  });

  it("starts empty", () => {
    expect(cart.lines()).toEqual([]);
  });

  it("merges the same item into one line", () => {
    cart.add("espresso");
    cart.add("espresso", 2);
    expect(cart.lines()).toEqual([{ itemId: "espresso", qty: 3 }]);
  });

  it("totals the cart", () => {
    cart.add("masala-chai", 2);
    cart.add("croissant");
    expect(cart.total()).toBe(330);
  });

  it("refuses to total an empty cart", () => {
    expect(() => cart.total()).toThrow("at least one item");
  });
});

describe("a mock function", () => {
  it("records how it was called", () => {
    const onOrder = vi.fn();
    onOrder("ord-1001");
    expect(onOrder).toHaveBeenCalledTimes(1);
    expect(onOrder).toHaveBeenCalledWith("ord-1001");
  });
});

beforeEach builds a new cart before every test, so tests cannot affect each other. vi.fn() creates a mock function that records its calls.

Each test starts with a clean cart. If cart were created once outside beforeEach, the second test would see items added by the first, and results would depend on the order of tests. That kind of hidden dependency is a classic source of flaky tests.

Step 5: a first look at mock functions

The last test in cart.test.js uses vi.fn(), a mock function. It does nothing by default, but it remembers how it was called. That lets you check that your code called a callback, without needing the real thing. For example, a checkout function that takes an onOrder callback and calls it with the new order id. The checks toHaveBeenCalledTimes(1) and toHaveBeenCalledWith("ord-1001") verify that call.

Mocks are a big topic, and *Mocks, stubs and test doubles* in this track covers them properly, including replacing network calls and modules.

The everyday test loop

  1. 1Pick one behaviour, such as 'quantity 11 is rejected'.
  2. 2Write the test first and run npm test. It should fail, which proves the test can fail.
  3. 3Change the code as little as needed to make it pass, and run again.
  4. 4Add the next case (a boundary or a negative input) and repeat.
  5. 5Finish by running the whole suite and reading any failure message carefully.

Running tests in watch mode

Terminal
npx vitest

Starts watch mode: tests re-run when you save a file, and only the affected ones run. Press q to quit. Press h to see keyboard options.

Watch mode is how most developers work: change a line, save, and see green or red within a second. Combine it with writing a failing test first, then the code to pass it.

What changes in Jest?

Vitest and Jest side by side

TopicVitestJest
Importsimport { describe, it, expect } from "vitest" (or turn on globals in config)describe, it and expect are global, no import needed
Mock functionvi.fn()jest.fn()
Mock a modulevi.mock("./api.js")jest.mock("./api.js")
Run oncevitest runjest
ES modulesWorks out of the boxNeeds extra configuration (Babel, or an experimental Node flag)
File names*.test.js or *.spec.js*.test.js or *.spec.js

Most tests in this lesson would run in Jest if you removed the import ... from "vitest" line, changed vi.fn() to jest.fn(), and set up your project for ES modules or CommonJS. The matchers (toBe, toEqual, toThrow) and structure (describe, it, beforeEach) are the same.

Testing React components

For React, the common pairing is Vitest (or Jest) plus React Testing Library, which renders a component and lets you find things the way a user would (by visible text or role) and click them. That is a topic for the front-end tracks. The foundation is exactly what you learned here: arrange, act, assert.

How this comes up in interviews

Typical questions: "What is the difference between toBe and toEqual?", "How do you test an async function?" (make the test function async and await the call, or use expect(promise).resolves), "What is a mock and when would you use one?", "What is the difference between Jest and Vitest?", "What makes a test flaky?" Be ready with the beforeEach example above to explain independent tests.

Prompt · Write Vitest tests for a function
Write Vitest tests (JavaScript, ES modules, importing describe, it, expect from vitest) for this function.

Function:
[paste function]

Rules it must follow:
[list the rules, e.g. qty 1 to 10, unknown items throw]

Include the happy path, boundary values using test.each, and error cases using toThrow. Use clear test names written as sentences. Do not change the function.

What to expect: Run the tests with npm test. Check that boundary values are on the exact edges, that error tests wrap the call in an arrow function, and that expected numbers are hand-calculated. Break the function on purpose to confirm a test fails.

Try it: Extend the tests

1. Add a test that calculateTotal for masala-chai x 3 equals 270. 2. Add test.each rows for four different menu items from MENU with hand-calculated totals. 3. Add a test that an unknown item throws Unknown item: pizza. 4. In cart.js, add a remove(itemId) method, write tests for it first (removing an item that is there, and removing one that is not), then implement it.

Try it: Port a pytest test

Take the test_phone parametrised test from the pytest lesson and write the same checks in JavaScript. First write an isValidPhone(phone) function in pricing.js (10 digits, first digit 6 to 9), then use test.each with the same six cases.

Key takeaways

  1. Vitest finds *.test.js files; describe groups tests, it defines one, and expect(actual).matcher(expected) asserts.
  2. Use toBe for simple values and toEqual for objects and arrays; wrap calls in an arrow function for toThrow.
  3. test.each runs the same test for many inputs, ideal for boundaries.
  4. beforeEach builds fresh objects so tests stay independent.
  5. vi.fn() creates a mock function that records its calls.
  6. Vitest copies Jest's API, so the skills carry over; the main differences are the vi and jest names and the module setup.

Check yourself

Finishing the quiz marks the lesson as done.

1. What is the difference between toBe and toEqual?

2. Which line correctly tests that calculateTotal([]) throws?

3. Why create the cart inside beforeEach and not once at the top of the file?

4. What does test.each([0, 11])("rejects quantity %i", ...) do?

5. You have an existing project that uses Jest. Which statement is true?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free