Node.js

Testing

Testing Node.js apps with Jest and Supertest — unit tests, integration tests, mock functions, lifecycle hooks, and coverage reporting.

Jest Setup & Configuration

Install Jest and configure it in package.json. Use --forceExit to prevent hanging when DB connections are open during tests.

npm install --save-dev jest supertest

// package.json
{
  "scripts": {
    "test":          "jest --forceExit",
    "test:watch":    "jest --watch",
    "test:coverage": "jest --coverage --forceExit"
  },
  "jest": {
    "testEnvironment":     "node",
    "testMatch":           ["**/__tests__/**/*.test.js"],
    "coverageDirectory":   "coverage",
    "collectCoverageFrom": [
      "src/**/*.js",
      "!src/migrations/**",
      "!src/seeders/**"
    ],
    "coverageThreshold": {
      "global": {
        "branches":   70,
        "functions":  80,
        "lines":      80,
        "statements": 80
      }
    },
    "setupFilesAfterFramework": ["./jest.setup.js"]
  }
}

Unit Tests with Jest

Test pure functions in isolation. Use describe to group related tests and it/test for individual assertions. Use beforeEach/afterEach for setup and teardown.

// __tests__/userService.test.js
const userService = require('../src/services/userService');
const db          = require('../src/db');

// Mock the database module entirely
jest.mock('../src/db');

describe('UserService', () => {
    beforeEach(() => {
        jest.clearAllMocks();   // reset call counts before each test
    });

    describe('getUserById()', () => {
        it('returns the user when found', async () => {
            const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
            db.query.mockResolvedValueOnce([[mockUser]]);

            const user = await userService.getUserById(1);

            expect(db.query).toHaveBeenCalledTimes(1);
            expect(db.query).toHaveBeenCalledWith(
                expect.stringContaining('SELECT'), [1]
            );
            expect(user).toEqual(mockUser);
        });

        it('returns null when user does not exist', async () => {
            db.query.mockResolvedValueOnce([[]]); // empty result

            const user = await userService.getUserById(999);
            expect(user).toBeNull();
        });

        it('throws if DB query fails', async () => {
            db.query.mockRejectedValueOnce(new Error('DB connection lost'));

            await expect(userService.getUserById(1)).rejects.toThrow('DB connection lost');
        });
    });

    describe('createUser()', () => {
        it('hashes password and inserts user', async () => {
            db.query.mockResolvedValueOnce([{ insertId: 42 }]);

            const result = await userService.createUser({
                name: 'Bob', email: 'bob@example.com', password: 'secret',
            });

            expect(result.id).toBe(42);
            expect(db.query).toHaveBeenCalledWith(
                expect.stringContaining('INSERT'),
                expect.arrayContaining(['Bob', 'bob@example.com'])
            );
        });
    });
});

Mock Functions & Spies

Use jest.fn() for standalone mocks and jest.spyOn() to wrap existing methods. Spies can restore the original implementation after the test.

const mailer = require('../src/lib/mailer');

// ── jest.fn() – create a mock from scratch ────────────────────
const sendEmail = jest.fn().mockResolvedValue({ messageId: 'abc123' });

// Check invocations
sendEmail('a@b.com', 'Hello');
expect(sendEmail).toHaveBeenCalledWith('a@b.com', 'Hello');
expect(sendEmail).toHaveBeenCalledTimes(1);

// Return different values per call
sendEmail
    .mockResolvedValueOnce('first call result')
    .mockResolvedValueOnce('second call result');

// ── jest.spyOn() – wrap a real method ─────────────────────────
describe('with spies', () => {
    let sendSpy;

    beforeEach(() => {
        sendSpy = jest.spyOn(mailer, 'sendWelcome').mockResolvedValue(true);
    });

    afterEach(() => {
        sendSpy.mockRestore(); // restore original implementation
    });

    it('sends welcome email on registration', async () => {
        await userService.register({ email: 'x@y.com', password: 'pw' });
        expect(sendSpy).toHaveBeenCalledWith('x@y.com');
    });
});

API Integration Tests with Supertest

Supertest mounts your Express app without starting a real HTTP server. Test the full request/response cycle including middleware, validation, and JSON shape.

// __tests__/api/products.test.js
const request = require('supertest');
const app     = require('../../src/app');
const db      = require('../../src/db');

jest.mock('../../src/db');

describe('GET /api/products', () => {
    const mockProducts = [
        { id: 1, name: 'Widget', price: 9.99 },
        { id: 2, name: 'Gadget', price: 29.99 },
    ];

    beforeEach(() => {
        db.query.mockResolvedValue([mockProducts]);
    });

    it('returns 200 with product list', async () => {
        const res = await request(app).get('/api/products');

        expect(res.status).toBe(200);
        expect(res.body.data).toHaveLength(2);
        expect(res.body.data[0]).toMatchObject({ name: 'Widget' });
    });

    it('supports ?q= search', async () => {
        db.query.mockResolvedValueOnce([[mockProducts[0]]]);

        const res = await request(app).get('/api/products?q=widget');
        expect(res.status).toBe(200);
        expect(res.body.data).toHaveLength(1);
    });
});

describe('POST /api/products', () => {
    it('creates product and returns 201', async () => {
        db.query.mockResolvedValueOnce([{ insertId: 99 }]);

        const res = await request(app)
            .post('/api/products')
            .set('Authorization', 'Bearer test-token')
            .send({ name: 'New Product', price: 49.99 });

        expect(res.status).toBe(201);
        expect(res.body.id).toBe(99);
    });

    it('returns 400 for missing required fields', async () => {
        const res = await request(app)
            .post('/api/products')
            .set('Authorization', 'Bearer test-token')
            .send({});

        expect(res.status).toBe(400);
        expect(res.body.code).toBe('BAD_REQUEST');
    });
});

Test Coverage Report

Run npm run test:coverage to generate an HTML report in /coverage. The coverageThreshold in Jest config enforces minimum thresholds and fails the CI pipeline if not met.

# Generate coverage report
npm run test:coverage

# Output example:
# ----------------------|---------|----------|---------|---------|
# File                  | % Stmts | % Branch | % Funcs | % Lines |
# ----------------------|---------|----------|---------|---------|
# src/services/         |   92.31 |    85.71 |     100 |   92.31 |
#   userService.js      |   92.31 |    85.71 |     100 |   92.31 |
# src/middleware/       |     100 |      100 |     100 |     100 |
#   errorHandler.js     |     100 |      100 |     100 |     100 |
# ----------------------|---------|----------|---------|---------|

# Open HTML report
start coverage/lcov-report/index.html   # Windows
open  coverage/lcov-report/index.html   # macOS