Skip to main content

JavaScript Unit Testing

Introduction

Unit testing is a fundamental practice in software development where individual units or components of code are tested in isolation. In JavaScript, a "unit" typically refers to a function, method, or class. The primary goal of unit testing is to validate that each unit of code performs as expected.

Unit tests help developers:

  • Identify bugs early in the development process
  • Ensure code changes don't break existing functionality
  • Document how code should behave
  • Enable safer refactoring

In this tutorial, we'll explore how to implement unit tests in JavaScript using popular testing frameworks, understand testing concepts, and apply best practices.

Understanding Unit Testing Fundamentals

What Makes a Good Unit Test?

A good unit test should be:

  1. Fast - Tests should run quickly to provide immediate feedback
  2. Isolated - Tests should not depend on external systems or other tests
  3. Repeatable - Tests should produce the same result each time they run
  4. Self-validating - Tests should automatically determine if they pass or fail
  5. Thorough - Tests should cover both normal and edge cases

The AAA Pattern

Most unit tests follow the AAA (Arrange-Act-Assert) pattern:

  1. Arrange - Set up the test data and conditions
  2. Act - Execute the code being tested
  3. Assert - Verify the results meet expectations

Several testing frameworks can help you write effective unit tests in JavaScript:

  1. Jest - Created by Facebook, comprehensive and requires minimal configuration
  2. Mocha - Flexible framework often paired with Chai for assertions
  3. Jasmine - Behavior-driven development framework with built-in assertion library
  4. AVA - Minimalist test runner focused on concurrency

Let's focus on Jest as it's widely used and beginner-friendly.

Setting Up Jest

First, you need to install Jest in your project:

bash
npm install --save-dev jest

Update your package.json to include a test script:

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

Now you can run your tests with:

bash
npm test

Writing Your First Unit Test

Let's start with a simple function and create a test for it:

Example 1: Testing a Sum Function

First, create a file named math.js:

javascript
function sum(a, b) {
return a + b;
}

module.exports = { sum };

Then, create a test file named math.test.js:

javascript
const { sum } = require('./math');

test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});

When you run npm test, Jest will find all files ending with .test.js and execute them.

Output:

PASS  ./math.test.js
✓ adds 1 + 2 to equal 3 (2 ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.5s
Ran all test suites.

Example 2: Testing Multiple Cases

Let's test a more complex function that checks if a number is prime:

javascript
// isPrime.js
function isPrime(num) {
if (num <= 1) return false;
if (num <= 3) return true;

if (num % 2 === 0 || num % 3 === 0) return false;

let i = 5;
while (i * i <= num) {
if (num % i === 0 || num % (i + 2) === 0) return false;
i += 6;
}

return true;
}

module.exports = { isPrime };

Let's test this function with multiple test cases:

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

describe('isPrime function', () => {
test('should return false for numbers less than 2', () => {
expect(isPrime(1)).toBe(false);
expect(isPrime(0)).toBe(false);
expect(isPrime(-1)).toBe(false);
});

test('should identify prime numbers correctly', () => {
expect(isPrime(2)).toBe(true);
expect(isPrime(3)).toBe(true);
expect(isPrime(5)).toBe(true);
expect(isPrime(7)).toBe(true);
expect(isPrime(11)).toBe(true);
expect(isPrime(13)).toBe(true);
});

test('should identify non-prime numbers correctly', () => {
expect(isPrime(4)).toBe(false);
expect(isPrime(6)).toBe(false);
expect(isPrime(8)).toBe(false);
expect(isPrime(9)).toBe(false);
expect(isPrime(10)).toBe(false);
});
});

Notice that we used describe to group related tests together.

Test Matchers

Jest provides a variety of matchers to help you validate different things:

javascript
// Common matchers
test('common matchers demo', () => {
// Exact equality
expect(2 + 2).toBe(4);

// Object equality (checks the value, not the reference)
expect({ name: 'user' }).toEqual({ name: 'user' });

// Truthiness
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();

// Numbers
expect(4).toBeGreaterThan(3);
expect(4).toBeLessThan(5);

// Strings
expect('team').toMatch(/ea/);

// Arrays
expect(['apple', 'banana']).toContain('apple');
});

Testing Asynchronous Code

Modern JavaScript often involves asynchronous operations. Jest provides several ways to test async code:

Testing Promises

javascript
// fetchData.js
function fetchData() {
return new Promise(resolve => {
setTimeout(() => {
resolve('peanut butter');
}, 100);
});
}

module.exports = { fetchData };
javascript
// fetchData.test.js
const { fetchData } = require('./fetchData');

test('the data is peanut butter', () => {
return fetchData().then(data => {
expect(data).toBe('peanut butter');
});
});

Using async/await

javascript
test('the data is peanut butter (async/await)', async () => {
const data = await fetchData();
expect(data).toBe('peanut butter');
});

Mocking

Unit tests should be isolated, which means you may need to mock dependencies:

Mocking Functions

javascript
test('mock implementation of a function', () => {
const mock = jest.fn(() => 'hello');

expect(mock()).toBe('hello');
expect(mock).toHaveBeenCalledTimes(1);
});

Mocking Modules

javascript
// user.js
const axios = require('axios');

class User {
static async getUser(id) {
const response = await axios.get(`https://jsonplaceholder.typicode.com/users/${id}`);
return response.data;
}
}

module.exports = User;
javascript
// user.test.js
const axios = require('axios');
const User = require('./user');

// Mock axios
jest.mock('axios');

test('should fetch user data', async () => {
const userData = { id: 1, name: 'John Doe' };
axios.get.mockResolvedValue({ data: userData });

const user = await User.getUser(1);
expect(user).toEqual(userData);
expect(axios.get).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users/1');
});

Test-Driven Development (TDD)

Test-Driven Development is a software development approach where you:

  1. Write a failing test for a new feature
  2. Write the minimum code to make the test pass
  3. Refactor the code while ensuring tests still pass

Let's use TDD to build a simple stringCalculator function that adds numbers provided as a string:

Step 1: Write a failing test

javascript
// stringCalculator.test.js
const { add } = require('./stringCalculator');

test('should return 0 for an empty string', () => {
expect(add('')).toBe(0);
});

Step 2: Write the minimum code to make the test pass

javascript
// stringCalculator.js
function add(numbers) {
return 0;
}

module.exports = { add };

Step 3: Add another test

javascript
test('should return the number for a single number', () => {
expect(add('1')).toBe(1);
});

Step 4: Update the code to make both tests pass

javascript
function add(numbers) {
if (numbers === '') return 0;
return parseInt(numbers);
}

Step 5: Keep adding tests and updating the code

javascript
test('should add two numbers separated by a comma', () => {
expect(add('1,2')).toBe(3);
});

Updated implementation:

javascript
function add(numbers) {
if (numbers === '') return 0;

if (numbers.includes(',')) {
return numbers
.split(',')
.map(num => parseInt(num))
.reduce((sum, current) => sum + current, 0);
}

return parseInt(numbers);
}

Best Practices for Unit Testing

  1. Test one thing per test - Each test should verify a single behavior or outcome
  2. Use descriptive test names - Names should explain what's being tested and expected result
  3. Keep tests small and focused - Tests should be concise and easy to understand
  4. Don't test implementation details - Test behavior, not how it's implemented
  5. Use setup and teardown - Use beforeEach and afterEach for common setup and cleanup
  6. Don't mock everything - Only mock what's necessary for isolation
  7. Test edge cases - Consider boundary conditions and error scenarios

Real-World Example: Shopping Cart

Let's build and test a simple shopping cart:

javascript
// shoppingCart.js
class ShoppingCart {
constructor() {
this.items = [];
}

addItem(item) {
this.items.push(item);
}

removeItem(id) {
const index = this.items.findIndex(item => item.id === id);
if (index !== -1) {
this.items.splice(index, 1);
return true;
}
return false;
}

getTotal() {
return this.items.reduce((total, item) => total + item.price, 0);
}

getItemCount() {
return this.items.length;
}
}

module.exports = ShoppingCart;

And here's how we would test it:

javascript
// shoppingCart.test.js
const ShoppingCart = require('./shoppingCart');

describe('ShoppingCart', () => {
let cart;

// Setup before each test
beforeEach(() => {
cart = new ShoppingCart();
});

test('should start with an empty cart', () => {
expect(cart.getItemCount()).toBe(0);
expect(cart.getTotal()).toBe(0);
});

test('should add items to the cart', () => {
cart.addItem({ id: 1, name: 'Product 1', price: 10 });
cart.addItem({ id: 2, name: 'Product 2', price: 15 });

expect(cart.getItemCount()).toBe(2);
});

test('should calculate total correctly', () => {
cart.addItem({ id: 1, name: 'Product 1', price: 10 });
cart.addItem({ id: 2, name: 'Product 2', price: 15 });

expect(cart.getTotal()).toBe(25);
});

test('should remove items from the cart', () => {
cart.addItem({ id: 1, name: 'Product 1', price: 10 });
cart.addItem({ id: 2, name: 'Product 2', price: 15 });

const result = cart.removeItem(1);

expect(result).toBe(true);
expect(cart.getItemCount()).toBe(1);
expect(cart.getTotal()).toBe(15);
});

test('should return false when removing non-existent item', () => {
cart.addItem({ id: 1, name: 'Product 1', price: 10 });

const result = cart.removeItem(999);

expect(result).toBe(false);
expect(cart.getItemCount()).toBe(1);
});
});

Summary

Unit testing is an essential skill for JavaScript developers that helps ensure code quality and reliability. In this tutorial, we've covered:

  • The fundamentals of unit testing in JavaScript
  • Setting up and using Jest as a testing framework
  • Writing tests for synchronous and asynchronous code
  • Implementing Test-Driven Development
  • Mocking dependencies for isolated tests
  • Best practices for effective unit testing

By incorporating unit testing into your development workflow, you'll catch bugs earlier, improve code quality, and build more maintainable applications.

Additional Resources

Exercises

  1. Create a Calculator class with add, subtract, multiply, and divide methods, then write unit tests for each method including edge cases.
  2. Write tests for a function that validates email addresses.
  3. Implement a TodoList class using TDD principles. Include methods to add, remove, and mark todos as complete.
  4. Create a function that fetches data from an API and write tests using mocks.
  5. Add unit tests to an existing project you're working on.

Happy testing!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)