Skip to main content

Testing with Jest

Testing is non-negotiable for professional software development. In this chapter, you’ll learn how to write comprehensive tests for your Node.js applications using Jest and Supertest.

Why Testing Matters

BenefitDescription
ConfidenceDeploy knowing your code works
DocumentationTests describe expected behavior
RefactoringChange code safely with tests as a safety net
Bug PreventionCatch issues before production
DesignTDD leads to better architecture

Testing Pyramid

        /\
       /  \      E2E Tests (10%)
      /----\     Slow, expensive, fewer
     /      \
    /--------\   Integration Tests (20%)
   /          \  Test component interactions
  /------------\
 /              \ Unit Tests (70%)
/__________________\ Fast, isolated, many

Setup

npm install --save-dev jest @types/jest supertest
// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node",
    "coverageDirectory": "coverage",
    "collectCoverageFrom": [
      "src/**/*.js",
      "!src/**/*.test.js"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    }
  }
}

Writing Your First Test

// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => {
  if (b === 0) throw new Error('Cannot divide by zero');
  return a / b;
};

module.exports = { add, subtract, multiply, divide };
// math.test.js
const { add, subtract, multiply, divide } = require('./math');

describe('Math functions', () => {
  describe('add', () => {
    test('should add two positive numbers', () => {
      expect(add(2, 3)).toBe(5);
    });

    test('should add negative numbers', () => {
      expect(add(-2, -3)).toBe(-5);
    });

    test('should add zero', () => {
      expect(add(5, 0)).toBe(5);
    });
  });

  describe('divide', () => {
    test('should divide two numbers', () => {
      expect(divide(10, 2)).toBe(5);
    });

    test('should throw error when dividing by zero', () => {
      expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
    });
  });
});

Jest Matchers

describe('Jest Matchers', () => {
  // Equality
  test('exact equality', () => {
    expect(2 + 2).toBe(4);
  });

  test('object equality', () => {
    expect({ name: 'John' }).toEqual({ name: 'John' });
  });

  // Truthiness
  test('truthy/falsy', () => {
    expect(null).toBeNull();
    expect(undefined).toBeUndefined();
    expect('value').toBeDefined();
    expect(true).toBeTruthy();
    expect(false).toBeFalsy();
  });

  // Numbers
  test('number comparisons', () => {
    expect(10).toBeGreaterThan(5);
    expect(10).toBeGreaterThanOrEqual(10);
    expect(5).toBeLessThan(10);
    expect(0.1 + 0.2).toBeCloseTo(0.3); // Floating point
  });

  // Strings
  test('string matching', () => {
    expect('Hello World').toMatch(/World/);
    expect('Hello World').toContain('World');
  });

  // Arrays
  test('array matching', () => {
    expect([1, 2, 3]).toContain(2);
    expect(['a', 'b', 'c']).toHaveLength(3);
    expect([{ id: 1 }, { id: 2 }]).toContainEqual({ id: 1 });
  });

  // Objects
  test('object matching', () => {
    expect({ a: 1, b: 2 }).toHaveProperty('a');
    expect({ a: 1, b: 2 }).toHaveProperty('a', 1);
    expect({ a: 1, b: 2 }).toMatchObject({ a: 1 });
  });

  // Exceptions
  test('exception matching', () => {
    expect(() => { throw new Error('fail'); }).toThrow();
    expect(() => { throw new Error('fail'); }).toThrow('fail');
    expect(() => { throw new Error('fail'); }).toThrow(Error);
  });
});

Testing Async Code

// userService.js
const fetchUser = async (id) => {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error('User not found');
  return response.json();
};

const fetchUserCallback = (id, callback) => {
  setTimeout(() => {
    if (id === 0) callback(new Error('User not found'), null);
    else callback(null, { id, name: 'John' });
  }, 100);
};
// userService.test.js
describe('Async Testing', () => {
  // Async/await
  test('async function success', async () => {
    const user = await fetchUser(1);
    expect(user).toHaveProperty('name');
  });

  // Async/await with error
  test('async function error', async () => {
    await expect(fetchUser(999)).rejects.toThrow('User not found');
  });

  // Promises
  test('promise resolves', () => {
    return expect(fetchUser(1)).resolves.toHaveProperty('name');
  });

  // Callbacks
  test('callback success', (done) => {
    fetchUserCallback(1, (err, user) => {
      expect(err).toBeNull();
      expect(user.name).toBe('John');
      done();
    });
  });
});

Mocking

Function Mocks

// Mock a function
const mockFn = jest.fn();

mockFn('hello');
mockFn('world');

expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith('world');

// Mock return values
const mockAdd = jest.fn()
  .mockReturnValue(10)
  .mockReturnValueOnce(5);

expect(mockAdd()).toBe(5);  // First call
expect(mockAdd()).toBe(10); // Subsequent calls

// Mock implementation
const mockMultiply = jest.fn((a, b) => a * b);
expect(mockMultiply(3, 4)).toBe(12);

Module Mocks

// emailService.js
const sendEmail = async (to, subject, body) => {
  // Real implementation sends email
};

module.exports = { sendEmail };
// user.test.js
jest.mock('./emailService');
const { sendEmail } = require('./emailService');
const { registerUser } = require('./userService');

describe('User registration', () => {
  beforeEach(() => {
    sendEmail.mockClear();
    sendEmail.mockResolvedValue({ success: true });
  });

  test('should send welcome email on registration', async () => {
    await registerUser({ email: 'test@example.com', name: 'Test' });
    
    expect(sendEmail).toHaveBeenCalledTimes(1);
    expect(sendEmail).toHaveBeenCalledWith(
      'test@example.com',
      expect.stringContaining('Welcome'),
      expect.any(String)
    );
  });
});

Mocking Modules

// Automatically mock all module functions
jest.mock('axios');
const axios = require('axios');

test('fetch data', async () => {
  axios.get.mockResolvedValue({ data: { id: 1, name: 'John' } });
  
  const user = await getUser(1);
  expect(user.name).toBe('John');
  expect(axios.get).toHaveBeenCalledWith('/api/users/1');
});

// Manual mock implementation
jest.mock('axios', () => ({
  get: jest.fn(() => Promise.resolve({ data: {} })),
  post: jest.fn(() => Promise.resolve({ data: {} }))
}));

Testing Express APIs with Supertest

npm install --save-dev supertest
// app.js (export app without listening)
const express = require('express');
const app = express();

app.use(express.json());

app.get('/api/users', (req, res) => {
  res.json([{ id: 1, name: 'John' }]);
});

app.get('/api/users/:id', (req, res) => {
  const id = parseInt(req.params.id);
  if (id === 999) return res.status(404).json({ error: 'Not found' });
  res.json({ id, name: 'John' });
});

app.post('/api/users', (req, res) => {
  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({ error: 'Name and email required' });
  }
  res.status(201).json({ id: 1, name, email });
});

module.exports = app;
// app.test.js
const request = require('supertest');
const app = require('./app');

describe('User API', () => {
  describe('GET /api/users', () => {
    test('should return all users', async () => {
      const response = await request(app)
        .get('/api/users')
        .expect('Content-Type', /json/)
        .expect(200);

      expect(response.body).toBeInstanceOf(Array);
      expect(response.body.length).toBeGreaterThan(0);
    });
  });

  describe('GET /api/users/:id', () => {
    test('should return user by id', async () => {
      const response = await request(app)
        .get('/api/users/1')
        .expect(200);

      expect(response.body).toHaveProperty('id', 1);
      expect(response.body).toHaveProperty('name');
    });

    test('should return 404 for non-existent user', async () => {
      await request(app)
        .get('/api/users/999')
        .expect(404);
    });
  });

  describe('POST /api/users', () => {
    test('should create new user', async () => {
      const newUser = { name: 'Jane', email: 'jane@example.com' };
      
      const response = await request(app)
        .post('/api/users')
        .send(newUser)
        .expect(201);

      expect(response.body).toMatchObject(newUser);
      expect(response.body).toHaveProperty('id');
    });

    test('should return 400 for invalid data', async () => {
      await request(app)
        .post('/api/users')
        .send({ name: 'Jane' }) // Missing email
        .expect(400);
    });
  });
});

Database Testing

// Setup test database
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');

let mongoServer;

beforeAll(async () => {
  mongoServer = await MongoMemoryServer.create();
  await mongoose.connect(mongoServer.getUri());
});

afterAll(async () => {
  await mongoose.disconnect();
  await mongoServer.stop();
});

beforeEach(async () => {
  // Clear all collections before each test
  const collections = mongoose.connection.collections;
  for (const key in collections) {
    await collections[key].deleteMany({});
  }
});

describe('User Model', () => {
  test('should create user', async () => {
    const user = await User.create({
      name: 'John',
      email: 'john@example.com',
      password: 'password123'
    });

    expect(user._id).toBeDefined();
    expect(user.name).toBe('John');
    expect(user.password).not.toBe('password123'); // Should be hashed
  });

  test('should not create user without email', async () => {
    await expect(
      User.create({ name: 'John', password: 'password123' })
    ).rejects.toThrow();
  });
});

Test Coverage

# Generate coverage report
npm test -- --coverage

# Output:
# ----------|---------|----------|---------|---------|-------------------
# File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
# ----------|---------|----------|---------|---------|-------------------
# All files |   85.71 |    83.33 |     100 |   85.71 |
#  math.js  |     100 |      100 |     100 |     100 |
#  user.js  |   71.43 |    66.67 |     100 |   71.43 | 15-17
# ----------|---------|----------|---------|---------|-------------------

Best Practices

  1. Name tests clearly: Describe what is being tested and expected outcome
  2. One assertion per test: Keep tests focused
  3. Use beforeEach/afterEach: Clean state between tests
  4. Don’t test implementation: Test behavior, not internals
  5. Mock external services: Don’t make real API calls in tests
  6. Aim for 80%+ coverage: But 100% isn’t always necessary
  7. Run tests in CI/CD: Every push should run tests

Test Organization

src/
├── services/
│   ├── userService.js
│   └── userService.test.js    # Co-located tests
├── models/
│   └── User.js
└── __tests__/                  # Or separate test directory
    ├── integration/
    │   └── userApi.test.js
    └── unit/
        └── userModel.test.js

Summary

  • Jest is the most popular testing framework for Node.js
  • Use describe/test to organize tests hierarchically
  • Matchers like toBe, toEqual, toThrow validate expectations
  • Mock external dependencies to isolate tests
  • Supertest makes API testing straightforward
  • Use in-memory databases for database tests
  • Aim for high coverage but prioritize critical paths
  • Write tests before or alongside code, not as an afterthought