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:
- Fast - Tests should run quickly to provide immediate feedback
- Isolated - Tests should not depend on external systems or other tests
- Repeatable - Tests should produce the same result each time they run
- Self-validating - Tests should automatically determine if they pass or fail
- Thorough - Tests should cover both normal and edge cases
The AAA Pattern
Most unit tests follow the AAA (Arrange-Act-Assert) pattern:
- Arrange - Set up the test data and conditions
- Act - Execute the code being tested
- Assert - Verify the results meet expectations
Popular JavaScript Testing Frameworks
Several testing frameworks can help you write effective unit tests in JavaScript:
- Jest - Created by Facebook, comprehensive and requires minimal configuration
- Mocha - Flexible framework often paired with Chai for assertions
- Jasmine - Behavior-driven development framework with built-in assertion library
- 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:
npm install --save-dev jest
Update your package.json
to include a test script:
{
"scripts": {
"test": "jest"
}
}
Now you can run your tests with:
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
:
function sum(a, b) {
return a + b;
}
module.exports = { sum };
Then, create a test file named math.test.js
:
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:
// 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:
// 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:
// 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
// fetchData.js
function fetchData() {
return new Promise(resolve => {
setTimeout(() => {
resolve('peanut butter');
}, 100);
});
}
module.exports = { fetchData };
// 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
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
test('mock implementation of a function', () => {
const mock = jest.fn(() => 'hello');
expect(mock()).toBe('hello');
expect(mock).toHaveBeenCalledTimes(1);
});
Mocking Modules
// 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;
// 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:
- Write a failing test for a new feature
- Write the minimum code to make the test pass
- 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
// 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
// stringCalculator.js
function add(numbers) {
return 0;
}
module.exports = { add };
Step 3: Add another test
test('should return the number for a single number', () => {
expect(add('1')).toBe(1);
});
Step 4: Update the code to make both tests pass
function add(numbers) {
if (numbers === '') return 0;
return parseInt(numbers);
}
Step 5: Keep adding tests and updating the code
test('should add two numbers separated by a comma', () => {
expect(add('1,2')).toBe(3);
});
Updated implementation:
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
- Test one thing per test - Each test should verify a single behavior or outcome
- Use descriptive test names - Names should explain what's being tested and expected result
- Keep tests small and focused - Tests should be concise and easy to understand
- Don't test implementation details - Test behavior, not how it's implemented
- Use setup and teardown - Use
beforeEach
andafterEach
for common setup and cleanup - Don't mock everything - Only mock what's necessary for isolation
- Test edge cases - Consider boundary conditions and error scenarios
Real-World Example: Shopping Cart
Let's build and test a simple shopping cart:
// 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:
// 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
- Create a
Calculator
class withadd
,subtract
,multiply
, anddivide
methods, then write unit tests for each method including edge cases. - Write tests for a function that validates email addresses.
- Implement a
TodoList
class using TDD principles. Include methods to add, remove, and mark todos as complete. - Create a function that fetches data from an API and write tests using mocks.
- 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! :)