Node.js Testing: Jest, Mocha, and Supertest

Node.js Testing: Jest, Mocha, and Supertest

이 글의 핵심

Hands-on Node.js testing: Jest for unit and async tests, Supertest for HTTP APIs, mocking axios and nodemailer, MongoDB in-memory setup, integration flows for auth, coverage gates, and Mocha with Chai.

Introduction

Why test?

Testing verifies that code behaves as intended.

Benefits:

  • Catch bugs early: Before production
  • Safer refactors: Regression safety net
  • Documentation: Examples of usage
  • Confidence: Ship changes with less fear
  • Maintenance: Saves time over the long run

Kinds of tests:

  • Unit: Single functions or classes
  • Integration: Multiple modules together
  • E2E: Full system paths

1. Jest

Install

npm install --save-dev jest

package.json:

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

First tests

// math.js
function add(a, b) {
    return a + b;
}

function subtract(a, b) {
    return a - b;
}

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

describe('Math', () => {
    test('add sums two numbers', () => {
        expect(add(2, 3)).toBe(5);
        expect(add(-1, 1)).toBe(0);
        expect(add(0, 0)).toBe(0);
    });
    
    test('subtract subtracts two numbers', () => {
        expect(subtract(5, 3)).toBe(2);
        expect(subtract(1, 1)).toBe(0);
        expect(subtract(0, 5)).toBe(-5);
    });
});
npm test

Matchers

describe('Matcher examples', () => {
    test('equality', () => {
        expect(2 + 2).toBe(4);
        expect({ name: 'Alice' }).toEqual({ name: 'Alice' });
    });
    
    test('truthiness', () => {
        expect(true).toBeTruthy();
        expect(false).toBeFalsy();
        expect(null).toBeNull();
        expect(undefined).toBeUndefined();
        expect('hello').toBeDefined();
    });
    
    test('numbers', () => {
        expect(10).toBeGreaterThan(5);
        expect(10).toBeGreaterThanOrEqual(10);
        expect(5).toBeLessThan(10);
        expect(0.1 + 0.2).toBeCloseTo(0.3);
    });
    
    test('strings', () => {
        expect('hello world').toMatch(/world/);
        expect('hello').toContain('ell');
    });
    
    test('arrays and objects', () => {
        const arr = ['apple', 'banana', 'cherry'];
        expect(arr).toContain('banana');
        expect(arr).toHaveLength(3);
        const obj = { name: 'Alice', age: 25 };
        expect(obj).toHaveProperty('name');
        expect(obj).toHaveProperty('age', 25);
    });
    
    test('exceptions', () => {
        expect(() => {
            throw new Error('oops');
        }).toThrow('oops');
    });
});

2. Async tests

Promises

// api.js
async function fetchUser(id) {
    const response = await fetch(`https://api.example.com/users/${id}`);
    return response.json();
}

module.exports = { fetchUser };
// api.test.js
const { fetchUser } = require('./api');

describe('API', () => {
    test('fetches a user', async () => {
        const user = await fetchUser(1);
        expect(user).toHaveProperty('id', 1);
        expect(user).toHaveProperty('name');
    });
    
    test('missing user rejects', async () => {
        await expect(fetchUser(999)).rejects.toThrow();
    });
});

Setup and teardown

describe('Database tests', () => {
    beforeAll(async () => {
        await connectDatabase();
    });
    
    beforeEach(async () => {
        await clearDatabase();
    });
    
    afterEach(async () => {
        /* cleanup */
    });
    
    afterAll(async () => {
        await closeDatabase();
    });
    
    test('creates a user', async () => {
        const user = await User.create({ name: 'Alice' });
        expect(user).toHaveProperty('_id');
    });
});

3. Mocks and spies

Mock functions

describe('Mock functions', () => {
    test('tracks calls', () => {
        const mockFn = jest.fn();
        mockFn('hello');
        mockFn('world');
        expect(mockFn).toHaveBeenCalledTimes(2);
        expect(mockFn).toHaveBeenCalledWith('hello');
        expect(mockFn).toHaveBeenLastCalledWith('world');
    });
    
    test('return values', () => {
        const mockFn = jest.fn();
        mockFn.mockReturnValue(42);
        expect(mockFn()).toBe(42);
        mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(3);
        expect(mockFn()).toBe(1);
        expect(mockFn()).toBe(2);
        expect(mockFn()).toBe(3);
        expect(mockFn()).toBe(3);
    });
});

Module mocks

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

async function getUser(id) {
    const response = await axios.get(`https://api.example.com/users/${id}`);
    return response.data;
}

module.exports = { getUser };
// user.test.js
const axios = require('axios');
const { getUser } = require('./user');

jest.mock('axios');

describe('getUser', () => {
    test('returns user data', async () => {
        const mockUser = { id: 1, name: 'Alice' };
        axios.get.mockResolvedValue({ data: mockUser });
        const user = await getUser(1);
        expect(axios.get).toHaveBeenCalledWith('https://api.example.com/users/1');
        expect(user).toEqual(mockUser);
    });
    
    test('propagates errors', async () => {
        axios.get.mockRejectedValue(new Error('Network Error'));
        await expect(getUser(1)).rejects.toThrow('Network Error');
    });
});

Spies

describe('Spies', () => {
    test('wraps a method', () => {
        const obj = { method: () => 'original' };
        const spy = jest.spyOn(obj, 'method');
        obj.method();
        expect(spy).toHaveBeenCalled();
        spy.mockRestore();
    });
});

4. API tests with Supertest

npm install --save-dev supertest
// app.js
const express = require('express');
const app = express();
app.use(express.json());

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

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

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

describe('API', () => {
    describe('GET /api/users', () => {
        test('returns users', async () => {
            const response = await request(app)
                .get('/api/users')
                .expect(200)
                .expect('Content-Type', /json/);
            expect(response.body).toHaveProperty('users');
            expect(Array.isArray(response.body.users)).toBe(true);
        });
    });
    
    describe('POST /api/users', () => {
        test('creates a user', async () => {
            const newUser = { name: 'Alice', email: '[email protected]' };
            const response = await request(app)
                .post('/api/users')
                .send(newUser)
                .expect(201)
                .expect('Content-Type', /json/);
            expect(response.body).toMatchObject(newUser);
            expect(response.body).toHaveProperty('id');
        });
        
        test('returns 400 when invalid', async () => {
            const response = await request(app)
                .post('/api/users')
                .send({ name: 'Alice' })
                .expect(400);
            expect(response.body).toHaveProperty('error');
        });
    });
});

5. Database tests

MongoDB setup

// test/setup.js
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();
});

afterEach(async () => {
    const collections = mongoose.connection.collections;
    for (const key in collections) {
        await collections[key].deleteMany();
    }
});

jest.config.js:

module.exports = {
    testEnvironment: 'node',
    setupFilesAfterEnv: ['<rootDir>/test/setup.js'],
    coveragePathIgnorePatterns: ['/node_modules/']
};

Model tests

// models/User.test.js
const User = require('../models/User');

describe('User model', () => {
    test('creates a user', async () => {
        const userData = {
            name: 'Alice',
            email: '[email protected]',
            password: 'password123'
        };
        const user = await User.create(userData);
        expect(user).toHaveProperty('_id');
        expect(user.name).toBe(userData.name);
        expect(user.email).toBe(userData.email);
    });
    
    test('duplicate email fails', async () => {
        await User.create({
            name: 'Alice',
            email: '[email protected]',
            password: 'password123'
        });
        await expect(User.create({
            name: 'Bob',
            email: '[email protected]',
            password: 'password456'
        })).rejects.toThrow();
    });
    
    test('required fields enforced', async () => {
        await expect(User.create({ name: 'Alice' })).rejects.toThrow();
    });
});

6. Integration tests

// test/integration/users.test.js
const request = require('supertest');
const app = require('../../app');
const User = require('../../models/User');

describe('Users API integration', () => {
    describe('POST /api/users', () => {
        test('creates a user', async () => {
            const userData = {
                name: 'Alice',
                email: '[email protected]',
                password: 'password123'
            };
            const response = await request(app)
                .post('/api/users')
                .send(userData)
                .expect(201);
            expect(response.body).toHaveProperty('id');
            expect(response.body.name).toBe(userData.name);
            const user = await User.findById(response.body.id);
            expect(user).toBeTruthy();
            expect(user.email).toBe(userData.email);
        });
    });
    
    describe('GET /api/users/:id', () => {
        test('returns a user', async () => {
            const user = await User.create({
                name: 'Alice',
                email: '[email protected]',
                password: 'password123'
            });
            const response = await request(app)
                .get(`/api/users/${user._id}`)
                .expect(200);
            expect(response.body.name).toBe(user.name);
            expect(response.body.email).toBe(user.email);
        });
        
        test('404 when missing', async () => {
            const response = await request(app)
                .get('/api/users/507f1f77bcf86cd799439011')
                .expect(404);
            expect(response.body).toHaveProperty('error');
        });
    });
});

Auth integration

const request = require('supertest');
const app = require('../../app');
const User = require('../../models/User');
const bcrypt = require('bcrypt');

describe('Auth API', () => {
    describe('POST /auth/login', () => {
        test('logs in with valid credentials', async () => {
            const password = 'password123';
            await User.create({
                name: 'Alice',
                email: '[email protected]',
                password: await bcrypt.hash(password, 10)
            });
            const response = await request(app)
                .post('/auth/login')
                .send({ email: '[email protected]', password })
                .expect(200);
            expect(response.body).toHaveProperty('token');
            expect(response.body.user.email).toBe('[email protected]');
        });
        
        test('401 on wrong password', async () => {
            await User.create({
                name: 'Alice',
                email: '[email protected]',
                password: await bcrypt.hash('password123', 10)
            });
            await request(app)
                .post('/auth/login')
                .send({ email: '[email protected]', password: 'wrong' })
                .expect(401);
        });
    });
    
    describe('GET /api/profile', () => {
        test('returns profile with token', async () => {
            await User.create({
                name: 'Alice',
                email: '[email protected]',
                password: await bcrypt.hash('password123', 10)
            });
            const loginResponse = await request(app)
                .post('/auth/login')
                .send({ email: '[email protected]', password: 'password123' });
            const token = loginResponse.body.token;
            const response = await request(app)
                .get('/api/profile')
                .set('Authorization', `Bearer ${token}`)
                .expect(200);
            expect(response.body.user.email).toBe('[email protected]');
        });
        
        test('401 without token', async () => {
            await request(app).get('/api/profile').expect(401);
        });
    });
});

7. Mocha + Chai

npm install --save-dev mocha chai
{ "scripts": { "test": "mocha" } }
const { expect } = require('chai');
const { add, subtract } = require('../math');

describe('Math', () => {
    describe('add()', () => {
        it('adds two numbers', () => {
            expect(add(2, 3)).to.equal(5);
        });
    });
    describe('subtract()', () => {
        it('subtracts two numbers', () => {
            expect(subtract(5, 3)).to.equal(2);
        });
    });
});

Chai

const { expect } = require('chai');

describe('Chai', () => {
    it('equality', () => {
        expect(2 + 2).to.equal(4);
        expect({ name: 'Alice' }).to.deep.equal({ name: 'Alice' });
    });
    it('types', () => {
        expect('hello').to.be.a('string');
        expect(123).to.be.a('number');
        expect([]).to.be.an('array');
    });
});

8. Coverage

npm test -- --coverage

jest.config.js:

module.exports = {
    collectCoverageFrom: [
        'src/**/*.js',
        '!src/**/*.test.js',
        '!src/index.js'
    ],
    coverageThreshold: {
        global: {
            branches: 80,
            functions: 80,
            lines: 80,
            statements: 80
        }
    }
};

9. Practical examples

User service

// services/userService.js
const User = require('../models/User');
const bcrypt = require('bcrypt');

class UserService {
    async createUser(userData) {
        const { email, password, name } = userData;
        const existing = await User.findOne({ email });
        if (existing) {
            throw new Error('Email already registered');
        }
        const hashedPassword = await bcrypt.hash(password, 10);
        return User.create({ email, password: hashedPassword, name });
    }
    
    async getUserById(id) {
        const user = await User.findById(id).select('-password');
        if (!user) throw new Error('User not found');
        return user;
    }
    
    async updateUser(id, updates) {
        const user = await User.findByIdAndUpdate(id, updates, {
            new: true,
            runValidators: true
        }).select('-password');
        if (!user) throw new Error('User not found');
        return user;
    }
    
    async deleteUser(id) {
        const user = await User.findByIdAndDelete(id);
        if (!user) throw new Error('User not found');
        return user;
    }
}

module.exports = new UserService();
const userService = require('./userService');
const User = require('../models/User');

describe('UserService', () => {
    describe('createUser', () => {
        test('creates user', async () => {
            const userData = {
                name: 'Alice',
                email: '[email protected]',
                password: 'password123'
            };
            const user = await userService.createUser(userData);
            expect(user).toHaveProperty('_id');
            expect(user.password).not.toBe(userData.password);
        });
        
        test('duplicate email', async () => {
            await userService.createUser({
                name: 'Alice',
                email: '[email protected]',
                password: 'password123'
            });
            await expect(userService.createUser({
                name: 'Bob',
                email: '[email protected]',
                password: 'password456'
            })).rejects.toThrow('Email already registered');
        });
    });
});

Auth API (extended)

See integration example above; add JWT verification checks that match your auth middleware.


10. Test doubles

Mock (email)

jest.mock('nodemailer');

describe('sendEmail', () => {
    test('sends mail', async () => {
        const mockSendMail = jest.fn().mockResolvedValue({ messageId: '123' });
        nodemailer.createTransport.mockReturnValue({ sendMail: mockSendMail });
        await sendEmail('[email protected]', 'Subject', 'Body');
        expect(mockSendMail).toHaveBeenCalledWith({
            to: '[email protected]',
            subject: 'Subject',
            text: 'Body'
        });
    });
});

Stub (payment)

jest.mock('axios');
describe('processPayment', () => {
    test('returns gateway data', async () => {
        axios.post.mockResolvedValue({
            data: { success: true, transactionId: 'txn_123' }
        });
        const result = await processPayment(10000);
        expect(result.success).toBe(true);
        expect(result.transactionId).toBe('txn_123');
    });
});

11. Common issues

Async timeout

Timeout - Async callback was not invoked within the 5000 ms timeout
test('slow op', async () => {
    await slowOperation();
}, 10000);

// jest.config.js
module.exports = { testTimeout: 10000 };

Shared state

Use beforeEach to reset fixtures; avoid module-level mutable arrays unless cleared.

DB cleanup

afterEach(async () => {
    await User.deleteMany({});
    await Post.deleteMany({});
});

12. Practical tips

Layout

test/
├── unit/
├── integration/
├── e2e/
└── setup.js

Helpers

async function createTestUser(overrides = {}) {
    const defaults = {
        name: 'Test User',
        email: '[email protected]',
        password: await bcrypt.hash('password123', 10),
        role: 'user'
    };
    return User.create({ ...defaults, ...overrides });
}

TDD sketch

Write a failing test, minimal pass, refactor, add edge-case tests.


Summary

Takeaways

  1. Jest: Default all-in-one choice
  2. Mocha + Chai: Flexible stack
  3. Supertest: HTTP-level API tests
  4. Mocks: Isolate I/O and time
  5. Coverage: Enforce thresholds thoughtfully
  6. TDD: Red–green–refactor when it helps

Framework comparison

FrameworkProsCons
JestBatteries included, snapshotsHeavier
MochaMinimal coreNeeds plugins
AVAParallel by defaultSmaller ecosystem

Practices

  • Independent tests
  • One main assertion per test (rule of thumb)
  • Descriptive names
  • Arrange–Act–Assert
  • Test failure paths
  • Mock external systems

Next steps

Resources


  • Node.js Getting Started