jsguides

JavaScript Testing: Getting Started

JavaScript testing is an essential skill for any developer working in the language. Whether you’re building a simple website or a complex web application, tests ensure your code works correctly and continues to work as you make changes. This guide introduces you to the fundamentals of testing in JavaScript and shows how a test suite can catch regressions early.

Why testing matters

When you write code, you probably test it manually—refreshing the page, clicking buttons, checking console outputs. Manual testing works for small projects but becomes impractical as your application grows. Tests automate this process, running hundreds of checks in seconds.

Good tests catch bugs before they reach production, document how your code behaves, and give you confidence when refactoring. When you have comprehensive tests, you can change implementation details without fear of breaking functionality.

Consider a simple function that calculates a discount:

function calculateDiscount(price, discountPercent) {
  return price - (price * discountPercent / 100);
}

Without tests, you’d manually verify different inputs every time you change the code. With tests, you write assertions once and run them automatically. Each assertion checks one specific behavior and reports a clear pass or fail result. The test runner gathers these results so you can see at a glance whether the module still works:

test('calculates 10% discount correctly', () => {
  expect(calculateDiscount(100, 10)).toBe(90);
});

test('returns full price when discount is 0', () => {
  expect(calculateDiscount(50, 0)).toBe(50);
});

Types of tests

JavaScript tests generally fall into three categories based on scope.

Unit tests verify individual functions or components in isolation. They mock external dependencies like APIs or databases. Unit tests are fast and numerous—you might have hundreds covering a single module.

Integration tests check how multiple units work together. An integration test might verify that a form submission triggers the correct API call and updates the UI.

End-to-end (E2E) tests simulate real user interactions in a browser. Tools like Playwright or Cypress click through your application exactly like a human would. E2E tests are slower but catch issues unit tests miss.

Most projects benefit from a testing pyramid: many unit tests at the base, fewer integration tests in the middle, and few E2E tests at the top.

Your first test with Jest

Jest is the most popular testing framework for JavaScript. Created by Meta (formerly Facebook), it ships with zero configuration and includes built-in mocking, coverage reporting, and parallel execution.

Install Jest in your project:

npm install --save-dev jest

The --save-dev flag records Jest as a development dependency so it stays out of your production bundle. After installing, open package.json and add a test script entry. This tells npm what to run when you type npm test, wiring the command to Jest’s test runner:

{
  "scripts": {
    "test": "jest"
  }
}

The test script tells npm what command to run when you invoke the shortcut. Next, create a test file so Jest has something to execute — it automatically finds files matching *.test.js or *.spec.js anywhere in your project. Start by importing the module you want to test and wrapping your assertions in describe and test blocks:

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

describe('calculateDiscount', () => {
  test('applies percentage discount correctly', () => {
    expect(calculateDiscount(200, 25)).toBe(150);
  });

  test('handles 100% discount', () => {
    expect(calculateDiscount(99, 100)).toBe(0);
  });

  test('throws error for negative discount', () => {
    expect(() => calculateDiscount(100, -10)).toThrow();
  });
});

This test file uses describe to group related tests and test to define individual assertions. The expect function checks that each call to calculateDiscount produces the expected numeric result, while the third test verifies that invalid input throws an error. Once your test file is ready, run the suite:

npm test

Jest outputs a clean summary showing which tests passed and which failed.

Your first test with Vitest

Vitest is a modern alternative to Jest, built by the Vite team. It shares Jest’s API but offers faster startup times and native Vite integration. Many new projects choose Vitest for its improved developer experience.

Install Vitest:

npm install --save-dev vitest

Vitest installs alongside your existing Jest setup without conflicts, so you can try both in the same project. Next, add the test scripts to package.json so npm test and npm run test:run invoke Vitest instead of Jest. This dual-script setup lets you run tests in watch mode during development and in single-shot mode during CI:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

The vitest command starts in watch mode by default, re-running tests when files change. The vitest run variant executes once and exits, which is what you want in CI pipelines. The test API mirrors Jest closely, so porting between the two is straightforward. Write a test using Vitest’s ESM imports:

// math.test.js
import { describe, test, expect } from 'vitest';
import { calculateDiscount } from './math.js';

describe('calculateDiscount', () => {
  test('applies percentage discount correctly', () => {
    expect(calculateDiscount(200, 25)).toBe(150);
  });
});

Run with npm test for watch mode during development, or npm run test:run for a single execution.

Writing good tests

Effective tests share several characteristics.

Test behavior, not implementation. Your tests should verify what your code does, not how it does it. This allows refactoring without breaking tests.

// Good: tests the outcome
test('sorts users by name', () => {
  const result = sortUsers(users);
  expect(result[0].name).toBe('Alice');
});

// Avoid: tests implementation details
test('uses quicksort algorithm', () => {
  // This breaks if you switch to mergesort
});

The first test is resilient—it only checks that the first user is named Alice, so if you swap the sorting algorithm nothing breaks. The second test would fail immediately after such a change even though the functionality is still correct. Use descriptive names so your test file reads like a specification. A name like 'returns empty array when input is empty' tells the next developer what the contract is without opening the test body:

// Clear name explains the requirement
test('returns empty array when input is empty', () => {});

// Vague name requires reading the test body
test('handles edge case', () => {});

The first name is self-documenting; the second tells you nothing about what edge case is involved. Good names become a table of contents for your module’s behavior. Follow the Arrange-Act-Assert pattern so every test has the same recognizable shape. Set up your data, perform the action, then verify the result. This structure makes it easy to spot what each test is checking:

test('adds item to cart', () => {
  // Arrange
  const cart = new Cart();
  const item = { id: 1, name: 'Book', price: 15 };

  // Act
  cart.add(item);

  // Assert
  expect(cart.items).toHaveLength(1);
  expect(cart.items[0]).toEqual(item);
});

Test edge cases. Don’t just test the happy path—verify your code handles null values, empty arrays, and invalid inputs gracefully.

What comes next

This introduction covers why testing matters and how to write basic unit tests. The rest of this series dives deeper into specific testing scenarios.

The next tutorial explores unit testing with Jest in detail, covering matchers, mocking, and test organization. Later tutorials cover Vitest, testing asynchronous code, mocking dependencies, DOM testing with jsdom, and end-to-end testing with Playwright.

Testing is a skill that improves with practice. Start writing tests for new code, and gradually add tests to existing codebases. Your future self will thank you.

Keep the habit alive

The next step after this introduction is not to memorize more framework features. It is to keep testing small pieces of code until the workflow feels normal. A short test that proves one behavior is more valuable than a large test file that nobody wants to touch. If you can keep the examples simple, you can build confidence without turning the first pass into a project of its own.

As you continue, try to pair each new test with a change you already need to make. That keeps the learning practical and helps the test suite grow alongside the code. The habit is easier to keep when the test is part of the task instead of a separate chore. Over time, that routine makes it much easier to trust the code you ship.

Starting small and staying consistent

The easiest way to build a testing habit is to make the first test almost boring. Pick a function with a clear input and output, write one or two cases, and run them locally until the flow feels natural. That small win teaches the tools without overwhelming you with setup. Once the routine is familiar, it becomes much easier to add tests as part of regular development rather than as an afterthought.

It also helps to think in terms of behavior rather than coverage alone. A test should describe a useful promise about the code, not just fill a slot on a report. If the code is a calculator, the test should prove the calculation. If the code validates form input, the test should show which input is accepted or rejected. That keeps the suite readable to anyone who opens it later.

Another good habit is to keep test names plain and specific. A name that describes the expected behavior makes the file easier to scan and the failure easier to understand. You do not need clever wording. You need enough clarity that a teammate can tell what changed just from the failing test name and the assertion output.

As your test suite grows, pay attention to setup patterns. Shared helper functions, reusable data builders, and clear fixture creation can save time without hiding what the test is doing. The goal is not to make every test identical. The goal is to make the repeated parts easy to read so the meaningful parts stand out.

Finally, remember that testing is a tool for confidence, not a badge. A small suite that covers the important behavior is much better than a huge pile of brittle checks. If the tests help you change the code with less fear, they are doing their job well.

See Also