Module 3: Testing with Jest

Learning Objectives

By the end of this module, you should be able to:

  • Understand the importance of testing in software development
  • Set up and configure Jest for a JavaScript project
  • Write unit tests using Jest's testing framework
  • Implement test-driven development (TDD) practices
  • Use Jest matchers and assertions effectively
  • Create test suites and organize tests properly

Video Lesson

Introduction to Testing

Testing is a critical part of software development that ensures your code functions as expected and helps prevent bugs. Automated testing allows you to verify your code's correctness continuously and catch issues early.

Types of Tests

  • Unit Tests: Test individual functions or components in isolation
  • Integration Tests: Test how multiple components work together
  • End-to-End Tests: Test the entire application workflow

Why Jest?

Jest is a popular JavaScript testing framework maintained by Facebook. It's designed to be simple to set up with zero configuration and includes built-in functionality for:

  • Test runners
  • Assertion libraries
  • Mocking capabilities
  • Coverage reports
  • Snapshot testing

Testing may seem like extra work initially, but it saves time in the long run by catching bugs early and making your code more maintainable.

Setting Up Jest

Let's start by setting up Jest in a Node.js project:

Installation

npm install --save-dev jest

Configuration

Add Jest to your package.json:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

Create a jest.config.js file for advanced configuration (optional):

// jest.config.js
module.exports = {
  testEnvironment: 'node',
  verbose: true,
  collectCoverage: true,
  collectCoverageFrom: [
    '**/*.{js,jsx}',
    '!**/node_modules/**',
    '!**/vendor/**'
  ]
};

Writing Your First Jest Test

Let's write some basic tests to understand how Jest works.

Test File Naming Conventions

Jest automatically finds tests in files that match these patterns:

  • Files with .test.js suffix
  • Files with .spec.js suffix
  • Files inside a __tests__ directory

Basic Test Structure

// sum.js
function sum(a, b) {
  return a + b;
}
module.exports = sum;

// sum.test.js
const sum = require('./sum');

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

Running Tests

npm test

Test output should look like:

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

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.789s
Ran all tests.

Jest Matchers and Assertions

Jest provides a variety of matchers to help you verify different types of conditions:

Common Matchers

// Equality
expect(value).toBe(exactValue);           // exact equality (===)
expect(object).toEqual(anotherObject);    // deep equality

// Truthiness
expect(value).toBeTruthy();               // truthy value
expect(value).toBeFalsy();                // falsy value
expect(value).toBeNull();                 // null
expect(value).toBeUndefined();            // undefined
expect(value).toBeDefined();              // not undefined

// Numbers
expect(value).toBeGreaterThan(number);
expect(value).toBeGreaterThanOrEqual(number);
expect(value).toBeLessThan(number);
expect(value).toBeLessThanOrEqual(number);
expect(value).toBeCloseTo(number, numDigits); // for floating-point equality

// Strings
expect(string).toMatch(/regex/);          // matches regex

// Arrays and iterables
expect(array).toContain(item);            // contains item
expect(array).toHaveLength(number);       // has length

// Objects
expect(object).toHaveProperty(keyPath);   // has property
expect(function).toThrow(error);          // throws error

Example with Different Matchers

test('demonstrates different matchers', () => {
  // Primitive equality
  expect(2 + 2).toBe(4);
  
  // Object equality
  expect({ name: 'Alice', age: 30 }).toEqual({ name: 'Alice', age: 30 });
  
  // Truthiness
  expect(true).toBeTruthy();
  expect(false).toBeFalsy();
  expect(null).toBeNull();
  
  // Numbers
  expect(10).toBeGreaterThan(5);
  expect(5).toBeLessThanOrEqual(5);
  
  // Strings
  expect('Hello World').toMatch(/World/);
  
  // Arrays
  expect(['apple', 'banana', 'orange']).toContain('banana');
  expect(['apple', 'banana', 'orange']).toHaveLength(3);
  
  // Exceptions
  const throwError = () => { throw new Error('This is an error'); };
  expect(throwError).toThrow('This is an error');
});

Organizing Tests with Describe and Test Blocks

Jest provides ways to organize your tests into logical groups:

Describe and Test (or it)

// userService.test.js
const userService = require('./userService');

describe('User Service', () => {
  // You can have setup and teardown for each describe block
  beforeAll(() => {
    // Run before all tests in this describe block
    console.log('Setting up User Service tests');
  });
  
  afterAll(() => {
    // Run after all tests in this describe block
    console.log('Cleaning up after User Service tests');
  });
  
  beforeEach(() => {
    // Run before each test in this describe block
    // Great for resetting state
  });
  
  afterEach(() => {
    // Run after each test in this describe block
  });
  
  describe('createUser', () => {
    test('creates a user successfully with valid data', () => {
      const userData = { username: 'testuser', email: 'test@example.com' };
      const user = userService.createUser(userData);
      
      expect(user).toHaveProperty('id');
      expect(user.username).toBe('testuser');
      expect(user.email).toBe('test@example.com');
    });
    
    test('throws error when missing required fields', () => {
      const userData = { username: 'testuser' }; // Missing email
      
      expect(() => {
        userService.createUser(userData);
      }).toThrow('Email is required');
    });
  });
  
  describe('findUserById', () => {
    test('returns user when found', () => {
      const user = userService.findUserById(1);
      expect(user).not.toBeNull();
    });
    
    test('returns null when user not found', () => {
      const user = userService.findUserById(999);
      expect(user).toBeNull();
    });
  });
});

Testing Asynchronous Code

Jest has several ways to test asynchronous code:

Promises

// With promises, return the promise
test('fetchUserData returns user data', () => {
  return fetchUserData(1).then(data => {
    expect(data).toHaveProperty('name');
  });
});

// Using async/await
test('fetchUserData returns user data', async () => {
  const data = await fetchUserData(1);
  expect(data).toHaveProperty('name');
});

// Testing rejected promises
test('fetchUserData rejects when user does not exist', async () => {
  expect.assertions(1); // Ensure the assertion in the catch block is called
  try {
    await fetchUserData(999);
  } catch (error) {
    expect(error).toMatch('User not found');
  }
});

// Alternative way to test rejected promises
test('fetchUserData rejects when user does not exist', () => {
  return expect(fetchUserData(999)).rejects.toMatch('User not found');
});

Callbacks

// For callback APIs, use the done parameter
test('fetchUserDataCallback returns user data', done => {
  function callback(error, data) {
    if (error) {
      done(error);
      return;
    }
    
    try {
      expect(data).toHaveProperty('name');
      done();
    } catch (error) {
      done(error);
    }
  }
  
  fetchUserDataCallback(1, callback);
});

Mocking with Jest

Mocking is crucial for isolating units of code during testing. Jest provides powerful mocking capabilities:

Function Mocks

// Mock a specific function
const mockFn = jest.fn();
mockFn.mockReturnValue(42);
// or
mockFn.mockImplementation(() => 42);

// With arguments
mockFn.mockReturnValueOnce('first call')
      .mockReturnValueOnce('second call')
      .mockReturnValue('default');

// Testing a mock was called correctly
test('mock function example', () => {
  mockFn(10, 'test');
  
  expect(mockFn).toHaveBeenCalled();
  expect(mockFn).toHaveBeenCalledTimes(1);
  expect(mockFn).toHaveBeenCalledWith(10, 'test');
});

Module Mocks

// Create a manual mock by creating a __mocks__ directory
// __mocks__/axios.js
module.exports = {
  get: jest.fn(() => Promise.resolve({ data: {} }))
};

// In your test file:
jest.mock('axios');
const axios = require('axios');

test('fetches data with axios', async () => {
  // Set up the mock return value for this test
  axios.get.mockResolvedValueOnce({
    data: { id: 1, name: 'Test User' }
  });
  
  const result = await fetchUser(1);
  
  expect(axios.get).toHaveBeenCalledWith('/users/1');
  expect(result).toEqual({ id: 1, name: 'Test User' });
});

Spying on Methods

// Spy on an object method
const user = {
  setName(name) {
    this.name = name;
  }
};

test('spy on setName method', () => {
  const spy = jest.spyOn(user, 'setName');
  
  user.setName('Alice');
  
  expect(spy).toHaveBeenCalledWith('Alice');
  expect(user.name).toBe('Alice');
  
  // Restore the original implementation
  spy.mockRestore();
});

Additional Resources

Practice Assignment

Now it's time to practice writing tests with Jest! Create tests for the authentication and JWT functionality you've been working on:

  • Write unit tests for user registration functions
  • Test password hashing and verification
  • Test JWT generation and verification
  • Create mocks for database interactions
  • Aim for at least 80% test coverage

Next Steps

In the next module, we'll explore testing backend applications, focusing on API route testing, database testing, and integration testing.

Go to Module 4: Backend Testing