Testing JavaScript

Why should you care about testing JavaScript?

Tests check that your code still works after you change it. They help you find bugs early and show what each function is meant to do.

Testing means checking that your code does what you expect it to do. A test gives your code some input and checks the result.

Tests are useful because they find mistakes early. They also make changes safer: after you update your code, you can run the tests to check that you did not break something.

You can start with a simple check before using a testing library:

javascript

// A simple function
function add(a, b) {
  return a + b;
}

// A simple test (without any library)
console.assert(add(2, 3) === 5, "add(2, 3) should return 5");
console.assert(add(-1, 1) === 0, "add(-1, 1) should return 0");
console.log("All tests passed!");
Note: console.assert() is useful for simple checks and debugging. If the condition is false, it logs an error message to the console. It is a good starting point, but it is not a replacement for a proper testing framework.

Each type of test checks a different amount of your application.

Unit testing checks one small part of your code, usually one function. It is useful for checking a function quickly and clearly.

Integration testing checks whether multiple parts of your application work correctly together. It is useful when a problem may be caused by the way two parts communicate.

End-to-end (E2E) testing checks the complete application from a user's point of view. It may click buttons, fill in forms, and check the result. It is useful for checking a complete user journey, but it usually takes longer to run.

For beginners, unit testing is the best place to start because the tests are small and fast.

Testing pyramid with unit tests at the base, integration tests in the middle, and end-to-end tests at the top
  • Unit tests - check one function or small part
  • Integration tests - check multiple parts working together
  • E2E tests - check the full user flow

Popular tools: Jest is used for unit and integration testing. Cypress and Playwright are popular for E2E testing.

A project often has many unit tests, fewer integration tests, and a small number of E2E tests.

A unit test checks one function or one small piece of behaviour. Unit tests are useful because they are usually fast and easy to understand. They normally avoid databases, network calls, and running servers.

A simple unit test often follows the Arrange → Act → Assert pattern:

  • Arrange - set up the data and variables you need
  • Act - call the function you are testing
  • Assert - check that the result is what you expected
Arrange Act Assert diagram for the three steps of a unit test

javascript

// Function to test
function multiply(a, b) {
  return a * b;
}

// Unit test (manual, no library)
function testMultiply() {
  // Arrange
  const a = 4;
  const b = 5;

  // Act
  const result = multiply(a, b);

  // Assert
  if (result === 20) {
    console.log("✅ multiply(4, 5) passed");
  } else {
    console.error("❌ multiply(4, 5) failed - expected 20, got " + result);
  }
}

testMultiply();

Arrange prepares the example, Act runs the function, and Assert checks the result. This pattern works with or without a testing library.

Jest is a JavaScript testing framework. A framework gives you tools for writing and running tests.

Jest includes a test runner, which finds your test files and runs them. It also includes assertions, which are checks that compare the result with the value you expect.

Jest workflow from finding test files through running and comparing results to a pass or fail report

First, install Jest in your project:

bash

npm install --save-dev jest

Test files often end in .test.js, so Jest knows where to look. Start with one small test:

javascript

function add(a, b) {
  return a + b;
}

test('adds two numbers', () => {
  expect(add(2, 3)).toBe(5);
});

Here is what each part means:

  • test() creates one test with a name and a function to run
  • expect() selects the value you want to check
  • .toBe() checks whether the result matches the expected value using strict equality
  • .toEqual() compares the contents of objects and arrays
  • .toBeTruthy() and .toBeFalsy() check whether a value is truthy or falsy

Run your tests with:

bash

npx jest

Jest shows which tests passed and which tests failed. A failed test tells you the expected value and the value that your code returned.

A mock is a replacement for a real dependency, such as a network service. A spy watches a function call so you can check whether it was called. These tools let you test your code without relying on a real service.

Jest can also test asynchronous code. Asynchronous code finishes later and often returns a Promise. Use async and await to wait for the result:

javascript

test('loads user data', async () => {
  const user = await fetchUser();
  expect(user.name).toBe('Sam');
});

For DOM or component testing, tools such as Testing Library and jsdom can check what a user would see without opening a full browser.

Test coverage tells you how much of your code ran during testing. High coverage can be useful, but it does not prove that your tests check the right things.

Follow these steps in a new folder. This walkthrough uses Jest so every file and command can be copied as-is.

  1. Create a project: open a terminal, make a folder, and create a default package.json.

bash

mkdir first-test
cd first-test
npm init -y
npm install --save-dev jest
  1. Add a test script: update package.json so npm test runs Jest.

json

{
  "scripts": {
    "test": "jest"
  }
}
  1. Write the code: create add.js in the project folder.

javascript

function add(first, second) {
  return first + second;
}

module.exports = { add };
  1. Write the test: create add.test.js. The .test.js suffix tells Jest to find this file.

javascript

const { add } = require("./add");

describe("add", () => {
  test("adds two positive numbers", () => {
    // Arrange
    const first = 2;
    const second = 3;

    // Act
    const result = add(first, second);

    // Assert
    expect(result).toBe(5);
  });

  test("handles a negative number", () => {
    expect(add(-2, 5)).toBe(3);
  });
});
  1. Run the test: a passing suite exits with status 0.

text

npm test
PASS  ./add.test.js
  add
    ✓ adds two positive numbers
    ✓ handles a negative number

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total

See a failure on purpose: temporarily change toBe(5) to toBe(6). Jest reports the expected value, the received value, and the exact test name. Restore toBe(5) and run npm test again.

Vitest equivalent

Vitest uses the same familiar describe, test, and expect style. In an ESM project, install it and use its test command:

npm install --save-dev vitest
npx vitest run
Beginner checkpoint: you have written a function, described its behavior, asserted a result, run a passing test, and watched the runner explain a failure. That loop is the foundation for larger test suites.

Now let us test a function that checks whether a number is even. The function should return true for even numbers and false for odd numbers.

javascript

// isEven.js
function isEven(number) {
  return number % 2 === 0;
}
module.exports = { isEven };

Now write a test file for it:

javascript

// isEven.test.js
const { isEven } = require('./isEven');

describe('isEven', () => {
  test('returns true for even numbers', () => {
    expect(isEven(4)).toBe(true);
    expect(isEven(0)).toBe(true);
  });

  test('returns false for odd numbers', () => {
    expect(isEven(3)).toBe(false);
    expect(isEven(7)).toBe(false);
  });

  test('handles negative numbers', () => {
    expect(isEven(-2)).toBe(true);
    expect(isEven(-3)).toBe(false);
  });
});

describe() groups related tests together - similar to a folder for your test cases. It makes the output easier to read when you have many tests.

Tips for writing good tests:

  • Test normal input that should work
  • Test edge cases, such as zero, an empty string, or a negative number
  • Test invalid input and the error your function should return or throw
  • Test boundary values, such as the smallest or largest allowed value
  • Test important business rules, such as a discount that starts at exactly $100

Start with the parts of your code that matter most. For each function, ask what should happen with normal input, unusual input, and invalid input.

  • Normal input: check the result for an expected value, such as isEven(4) returning true.
  • Edge cases: check unusual values, such as isEven(0) or a negative number.
  • Invalid input: check what happens when the input is the wrong type or format.
  • Boundary values: check values at a limit, such as an age of exactly 18 when adults must be 18 or older.
  • Error conditions: check that the function returns an error or throws an error when it should.
  • Business rules: check rules that are important to the application, such as a discount that starts at exactly $100.

For example, if a function accepts a username, test a normal username, an empty username, and a username that is too long. You do not need a separate test for every possible value. Choose examples that check the rule clearly.

Tip: A good test describes one behaviour. When a test fails, its name should help you understand what went wrong.

Test-Driven Development (TDD) is a way to write code where you write a test first, then write the code that makes it pass. It helps you decide what the function should do before you build it.

The TDD cycle has three steps:

  • 🔴 Red - write a test that fails (because the code does not exist yet)
  • 🟢 Green - write just enough code to make the test pass
  • 🔵 Refactor - clean up the code without breaking the test
Test-driven development cycle showing Red, Green, and Refactor

Here is an example using a simple capitalize function:

javascript

// Step 1: Write the test first (RED - it will fail)
test('capitalizes first letter', () => {
  expect(capitalize('hello')).toBe('Hello');
});

// Step 2: Write the function to make it pass (GREEN)
function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

// Step 3: Refactor if needed (the code looks clean, so we're done)

Benefits of TDD: it forces you to think about what your code should do before writing it, can encourage clearer design, and gives you confidence when refactoring later. It is a workflow, not a guarantee that every test or design choice will be good.

Note: TDD is not required on every project - but understanding it makes you a better developer and is a common interview topic.
  • Testing checks that your code does what you expect and helps you find mistakes early
  • Unit tests check one function, integration tests check parts working together, and E2E tests check a complete user flow
  • Unit tests are the best starting point - fast, isolated, and easy to write
  • The Arrange → Act → Assert pattern prepares data, runs the function, and checks the result
  • Jest provides a test runner, assertions, and tools such as mocks and spies
  • test() creates a test, expect() selects a value, and .toBe() checks the expected result
  • describe() groups related tests together
  • A first-test workflow is: create a project, install Jest, write a function, write a .test.js file, and run the test
  • Vitest uses a similar test and assertion style to Jest, with npx vitest run for a one-time run
  • TDD means writing the test first, then following Red → Green → Refactor
  • Good tests cover normal input, edge cases, invalid input, boundaries, errors, and important rules
What's next? Head back to the Introduction tutorial or explore other topics in the sidebar to keep building your JavaScript skills.

Reviewed by

SimplyJavaScript Editorial Team

Technical editors and JavaScript educators with hands-on experience building frontend projects, writing learning material, and reviewing tutorials for clarity, accuracy, and beginner-friendly guidance.