DEV Community

Cover image for Unit Testing in JavaScript: A Practical Guide with Jest
Alok Kumar
Alok Kumar

Posted on

Unit Testing in JavaScript: A Practical Guide with Jest

Think of Unit Testing as a software development technique where you break your software up into small, isolated units and write automated tests that ensure each unit works as expected.

In simpler terms, unit testing is about verifying that the smallest pieces of your application — usually functions — behave correctly under different conditions.

A unit can be:

  • A function
  • A method
  • A component
  • A utility
  • A hook
  • A service

The key idea is isolation.
You test one unit at a time, without depending on other parts of the system.

For example, instead of testing an entire application flow, you test a single function like:

function calculateTotal(price, quantity) {
  return price * quantity;
}
Enter fullscreen mode Exit fullscreen mode

Why Unit Testing is Required?

Unit testing is not just a best practice — it is a core part of professional software development, especially as applications grow in size and complexity.

Without tests, every change introduces risk.
With tests, you gain confidence.

Here are the real reasons why unit testing is required -

1) To Catch Bugs Early

Finding bugs early is significantly cheaper and easier than fixing them later in production.

Unit tests act as an automated safety net that quickly detects issues when code changes.

Instead of discovering a bug:

  • During manual testing
  • In production
  • From a customer

You discover it immediately after running tests.

2) To Enable Safe Refactoring

As developers, we constantly refactor code to:

  • Improve readability
  • Optimize performance
  • Reduce duplication
  • Add new features

Without tests, refactoring is risky.

You don't know if something broke.

With tests, you can confidently modify code because the tests verify that behavior remains correct.

3) To Prevent Regression Bugs

A regression bug happens when a new change breaks existing functionality.

Unit tests prevent this.

Every time you run tests, you verify that previously working features still work.

This is critical in large applications where multiple developers are making changes simultaneously.

4) To Support Continuous Integration and Deployment (CI/CD)

Modern development workflows rely on automated testing.

Before code is deployed:

  • Tests run automatically
  • Failures block deployment
  • Only stable code is released

Code That Feels Simple at the Start Doesn't Stay Simple

What begins as a small function or a quick feature often grows as new requirements are introduced. Edge cases appear, validations are added, and business logic evolves. Over time, multiple developers may contribute to the same code, increasing its complexity.

A function that once handled a single scenario may eventually need to support:

  • Input validation
  • Error handling
  • Business rules
  • Performance optimizations
  • Backward compatibility

As complexity increases, the risk of introducing bugs also increases.
This is one of the key reasons unit testing becomes essential — it ensures that as code evolves, existing functionality continues to work as expected.


Unit Test Using Pure JavaScript

Before using testing frameworks like Jest or Vitest, it's important to understand that unit testing is simply verifying behavior, and this can be done using plain JavaScript.

Let's take a simple example -

function calculateTotal(price, quantity) {
  return price * quantity;
}

function runTests() {
  const tests = [
    { price: 100, quantity: 2, expected: 200 },
    { price: 50, quantity: 3, expected: 150 },
    { price: 0, quantity: 5, expected: 0 }
  ];

  tests.forEach((test, index) => {
    const result = calculateTotal(test.price, test.quantity);

    if (result === test.expected) {
      console.log(`✅ Test ${index + 1} Passed`);
    } else {
      console.log(`❌ Test ${index + 1} Failed`);
      console.log("Expected:", test.expected);
      console.log("Received:", result);
    }
  });
}

runTests();
Enter fullscreen mode Exit fullscreen mode

What This Demonstrates

This is still a unit test, even though no library is used.

Because:

  • We executed a function
  • We compared the result with an expected value
  • We reported whether the behavior was correct

Testing frameworks like Jest simply automate and organize this process, but the core idea remains the same.


Unit Test Using Jest

Jest is a popular JavaScript testing framework used to write and run unit tests in a structured and automated way. It provides built-in features such as test runners, assertions, mocking, and reporting, making it easier to verify that individual units of code behave as expected.

Instead of manually checking results using if statements and console.log, Jest allows developers to define tests using readable functions like test() and validate outcomes using assertions like expect().

In simple terms, Jest standardizes and automates the unit testing process.

Example: Unit Test Using Jest

Function Under Test

function calculateTotal(price, quantity) {
  return price * quantity;
}

module.exports = calculateTotal;
Enter fullscreen mode Exit fullscreen mode

Jest Test

const calculateTotal = require("./calculateTotal");

describe("calculateTotal", () => {
  test("returns correct total for valid inputs", () => {
    const result = calculateTotal(100, 2);
    expect(result).toBe(200);
  });

  test("returns correct total when price is zero", () => {
    const result = calculateTotal(0, 5);
    expect(result).toBe(0);
  });

  test("returns correct total for another valid case", () => {
    const result = calculateTotal(50, 3);
    expect(result).toBe(150);
  });
});
Enter fullscreen mode Exit fullscreen mode

Unit testing with Jest follows a simple pattern:

Arrange → Act → Assert

  • Arrange — Set up input values
  • Act — Call the function
  • Assert — Verify the result

What we did:

// 1) Importing the Function to Test
const calculateTotal = require("./calculateTotal");

// 2) Using describe() to Group Tests
describe("calculateTotal", () => {

// 3) Writing Individual Test Cases with test()
test("returns correct total for valid inputs", () => {

// 4) Executing the Function (Act Step)
const result = calculateTotal(100, 2);

// 5) Verifying the Result with expect()
expect(result).toBe(200);
Enter fullscreen mode Exit fullscreen mode

How to Run Jest

Running Jest involves installing the package, writing tests, and executing them using a command. Once configured, Jest automatically discovers and runs all test files in your project.

Step 1: Install Jest

First, install Jest as a development dependency.

npm install --save-dev jest
Enter fullscreen mode Exit fullscreen mode

Step 2: Add a Test Script

Update the scripts section in your package.json file.

{
  "scripts": {
    "test": "jest"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Create a Test File

Jest automatically looks for files with names like:

  • *.test.js
  • *.spec.js

Example -

// calculateTotal.test.js

const calculateTotal = require("./calculateTotal");

test("calculates total correctly", () => {
  expect(calculateTotal(100, 2)).toBe(200);
});
Enter fullscreen mode Exit fullscreen mode

Step 4: Run Jest

Now execute the test runner using:

npm test
Enter fullscreen mode Exit fullscreen mode

Jest will:

  • Find all test files
  • Run the tests
  • Display results

Example output:

PASS  calculateTotal.test.js
✓ calculates total correctly (2 ms)
Enter fullscreen mode Exit fullscreen mode

Mocking Network Calls in Jest

In real-world applications, functions often depend on external services such as APIs, databases, or third-party systems. These dependencies can make tests slow, unreliable, or difficult to control.

Mocking solves this problem.

Mocking is the process of replacing real dependencies with controlled, simulated versions during testing. Instead of making an actual network request, we provide a fake response and verify how our code behaves.

This ensures that unit tests remain:

  • Fast
  • Predictable
  • Independent
  • Isolated from external systems

Example:

// priceService.js

async function fetchPrice() {
  const response = await fetch("https://api.example.com/price");
  const data = await response.json();
  return data.price;
}

async function calculateTotal(quantity) {
  const price = await fetchPrice();
  return price * quantity;
}

module.exports = {
  fetchPrice,
  calculateTotal
};
Enter fullscreen mode Exit fullscreen mode

Jest Test with the Mock:

const { calculateTotal, fetchPrice } = require("./priceService");

jest.mock("./priceService", () => ({
  fetchPrice: jest.fn()
}));

describe("calculateTotal", () => {
  test("returns correct total using mocked price", async () => {
    // Arrange
    fetchPrice.mockResolvedValue(100);

    // Act
    const result = await calculateTotal(2);

    // Assert
    expect(fetchPrice).toHaveBeenCalled();
    expect(result).toBe(200);
  });
});
Enter fullscreen mode Exit fullscreen mode

What it does:

// 1) jest.mock() Replaces the Real Function
jest.mock("./priceService", () => ({
  fetchPrice: jest.fn()
}));

// 2) mockResolvedValue() Controls the Response
fetchPrice.mockResolvedValue(100);

// above simulates:
return Promise.resolve(100);

// 3) Testing Behavior, Not the Network
expect(fetchPrice).toHaveBeenCalled();
expect(result).toBe(200);
Enter fullscreen mode Exit fullscreen mode

Test Driven Development (TDD)

Test Driven Development (TDD) is a software development approach where tests are written before the actual implementation code. Instead of writing functionality first and testing later, developers define the expected behavior through tests and then write code to satisfy those tests.

The primary goal of TDD is to ensure correctness, improve design, and maintain confidence in the codebase as it evolves.

At the heart of TDD lies a simple, repeatable cycle known as:

Red → Green → Refactor

The Red, Green, Refactor Cycle

TDD follows a disciplined three-step workflow that guides development in small, incremental steps.

1) Red — Write a Failing Test 🔴

The first step in TDD is to write a test that defines the desired behavior of a feature or function. Since the functionality has not been implemented yet, the test will fail.

This failure is intentional and confirms that the test is valid and capable of detecting missing or incorrect behavior.

Example:

test("calculates total price correctly", () => {
  expect(calculateTotal(100, 2)).toBe(200);
});
Enter fullscreen mode Exit fullscreen mode

At this stage:

  • The function may not exist yet
  • The implementation is incomplete
  • The test fails

This is the Red phase.

2) Green — Write the Minimum Code to Pass the Test 🟢

Next, we implement just enough code to make the test pass.
The focus here is correctness, not optimization or perfection.

Example:

function calculateTotal(price, quantity) {
  return price * quantity;
}
Enter fullscreen mode Exit fullscreen mode

Once the implementation satisfies the test, the test runner shows a successful result.

This is the Green phase.

3) Refactor — Improve the Code Safely 🔵

After the test passes, we improve the code while ensuring that behavior remains unchanged. This step focuses on code quality, readability, and maintainability.

function calculateTotal(price, quantity) {
  if (price < 0 || quantity < 0) {
    throw new Error("Invalid input");
  }

  return price * quantity;
}
Enter fullscreen mode Exit fullscreen mode

Because tests already exist, we can refactor confidently.
If something breaks, the tests will immediately detect it.

This is the Refactor phase.


Test Coverage

Test coverage is a metric that measures how much of your application's code is executed when your tests run. It helps developers understand which parts of the codebase are tested and which parts are not.

In simple terms, test coverage answers the question:

"How much of my code is actually being tested?"

Test coverage does not guarantee that your code is bug-free, but it provides visibility into the areas that may need additional testing.

Why Test Coverage Matters

Test coverage helps teams maintain confidence in their codebase, especially as applications grow in size and complexity.

It is useful because it:

  • Identifies untested code
  • Reduces the risk of hidden bugs
  • Improves code reliability
  • Supports safe refactoring
  • Provides measurable testing goals

For example, if a critical function is never executed during testing, test coverage will highlight it immediately.

How to Run Test Coverage in Jest

Jest provides built-in support for generating coverage reports.

Run Coverage

npm test -- --coverage
Enter fullscreen mode Exit fullscreen mode

Example Coverage Output

File % Stmts % Branch % Funcs % Lines
calculateTotal 100 100 100 100

Where Coverage Reports Are Stored

After running coverage, Jest creates a folder:

coverage/
Enter fullscreen mode Exit fullscreen mode

Inside it, you will find:

coverage/
  └── lcov-report/
        └── index.html
Enter fullscreen mode Exit fullscreen mode

Final Note on Unit Testing in JavaScript

Unit testing is not just about writing tests — it's about building confidence in your code.

As applications grow, code changes frequently. New features are added, bugs are fixed, and logic evolves. Without tests, every change introduces uncertainty. With unit tests in place, developers gain a reliable safety net that ensures existing functionality continues to work as expected.

Unit testing also encourages better design. It pushes developers to write smaller, more focused functions, reduce unnecessary dependencies, and think clearly about expected behavior.

In the long run, unit testing saves time, reduces bugs, and improves code quality.

Write tests not because you have to — write tests because future you (and your team) will depend on them.

Top comments (0)