Automated API Tests with the Built-in Node.js Test Runner

MERN Stack Full Course: MongoDB, Express, React and Node.js · Authentication and Security · 9 min read · Updated

A test run

  1. Tests are code that checks your code. node --test needs no extra package.
  2. The tests import the app without starting a real server, and send requests to it.
  3. A separate test database is cleared before the tests, so each run starts from the same state.
  4. Each test sends a request and asserts the status and the body, including the failure cases.
  5. The report shows what passed. Run it before every deploy.

A smoke script is a good start, but you have to remember to run it, and it only covers one path. Automated tests run all your checks with one command, in a clean database, and fail loudly the moment a change breaks something. They are what lets you change code next month without fear. Since Node.js 20 the test runner is built in (node:test), so you need no extra library.

Step 1: separate the app from the server

// Lesson 24: the Express app lives here, and index.js only starts it. Tests can import `app` without opening a port or a database.
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import mongoose from 'mongoose';
import { logger } from './middleware/logger.js';
import { authLimiter, rejectOperators } from './middleware/security.js';
import { notFound, errorHandler } from './middleware/error.js';
import authRouter from './routes/auth.js';
import jobsRouter from './routes/jobs.js';

export const app = express();

if (process.env.TRUST_PROXY) app.set('trust proxy', Number(process.env.TRUST_PROXY) || process.env.TRUST_PROXY);

app.use(helmet());
if (process.env.NODE_ENV !== 'test') app.use(logger);
app.use(cors({ origin: (process.env.CLIENT_ORIGIN || 'http://localhost:5173').split(',') }));
app.use(express.json({ limit: '100kb' }));
app.use(rejectOperators);

app.get('/api/health', (req, res) => res.json({ ok: true, db: mongoose.connection.readyState === 1 ? 'connected' : 'not connected' }));
app.use('/api/auth', authLimiter, authRouter);
app.use('/api/jobs', jobsRouter);

app.use(notFound);
app.use(errorHandler);
import 'dotenv/config';
import { checkEnv } from './config/env.js';
import { connectDB } from './config/db.js';
import { app } from './app.js';

const port = Number(process.env.PORT) || 5000;

try {
  checkEnv();
  await connectDB();
  app.listen(port, () => console.log(`API listening on http://localhost:${port}`));
} catch (err) {
  console.error('Could not start:', err.message);
  process.exit(1);
}

Until now index.js built the app and started it. A test must not start a server on port 5000 or connect to your real database. So the app moves into app.js, which only builds and exports it, and index.js becomes a small file that checks the config, connects and calls listen. A test can now import { app }, start it on a random free port with app.listen(0), and connect to a different database. This split is a very common structure in real projects.

Step 2: the tests

// Lesson 24: automated tests with Node's built-in test runner. Run:  npm test
// They use a SEPARATE database, so set MONGODB_URI_TEST in .env (for example mongodb://127.0.0.1:27017/job_tracker_test).
// The whole test database is dropped at the end. Never point MONGODB_URI_TEST at real data.
import 'dotenv/config';
import { before, after, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import mongoose from 'mongoose';

const uri = process.env.MONGODB_URI_TEST;
if (!uri) {
  console.log('Skipping API tests: set MONGODB_URI_TEST in .env (a throw-away database).');
  process.exit(0);
}
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-secret-test-secret-test-secret-123';
process.env.AUTH_RATE_LIMIT = '1000';
const { app } = await import('../app.js');

let server, base;
const api = async (method, path, { body, token } = {}) => {
  const res = await fetch(base + path, {
    method,
    headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  return { status: res.status, body: text ? JSON.parse(text) : null };
};
const signUp = async (name) => (await api('POST', '/auth/register', { body: { name, email: `${name.toLowerCase()}@example.com`, password: 'Password123' } })).body.token;

before(async () => {
  await mongoose.connect(uri);
  await mongoose.connection.dropDatabase();
  // dropDatabase also drops the unique index on email, so build every index again (otherwise duplicate emails would be accepted)
  await Promise.all(Object.values(mongoose.models).map((m) => m.syncIndexes()));
  await new Promise((resolve) => { server = app.listen(0, resolve); });
  base = `http://127.0.0.1:${server.address().port}/api`;
});
after(async () => {
  await mongoose.connection.dropDatabase();
  await mongoose.disconnect();
  server.close();
});

describe('auth', () => {
  it('registers, logs in and never returns the password hash', async () => {
    const reg = await api('POST', '/auth/register', { body: { name: 'Ana', email: 'ANA@example.com', password: 'Password123' } });
    assert.equal(reg.status, 201);
    assert.equal(reg.body.user.email, 'ana@example.com'); // lower-cased
    assert.ok(!JSON.stringify(reg.body).includes('passwordHash'));
    const login = await api('POST', '/auth/login', { body: { email: 'ana@example.com', password: 'Password123' } });
    assert.equal(login.status, 200);
    assert.ok(login.body.token);
  });
  it('rejects a wrong password and a duplicate email', async () => {
    assert.equal((await api('POST', '/auth/login', { body: { email: 'ana@example.com', password: 'nope-nope-nope' } })).status, 401);
    assert.equal((await api('POST', '/auth/register', { body: { name: 'Ana', email: 'ana@example.com', password: 'Password123' } })).status, 409);
  });
  it('refuses NoSQL operator objects', async () => {
    const res = await api('POST', '/auth/login', { body: { email: { $gt: '' }, password: 'x' } });
    assert.equal(res.status, 400);
  });
});

describe('jobs', () => {
  it('needs a token', async () => {
    assert.equal((await api('GET', '/jobs')).status, 401);
    assert.equal((await api('GET', '/jobs', { token: 'garbage' })).status, 401);
  });
  it('creates, lists, updates and deletes my own jobs', async () => {
    const token = await signUp('Cara');
    const made = await api('POST', '/jobs', { token, body: { company: 'Acme', role: 'Clerk', user: 'ignored' } });
    assert.equal(made.status, 201);
    assert.equal(made.body.status, 'saved');
    const list = await api('GET', '/jobs?q=acme', { token });
    assert.equal(list.body.total, 1);
    const upd = await api('PUT', `/jobs/${made.body.id}`, { token, body: { status: 'offer' } });
    assert.equal(upd.body.status, 'offer');
    assert.equal((await api('DELETE', `/jobs/${made.body.id}`, { token })).status, 204);
  });
  it('validates input', async () => {
    const token = await signUp('Dan');
    const res = await api('POST', '/jobs', { token, body: { company: '', status: 'maybe' } });
    assert.equal(res.status, 400);
    assert.ok(res.body.details.company && res.body.details.status);
  });
  it('keeps people apart', async () => {
    const eve = await signUp('Eve');
    const fay = await signUp('Fay');
    const job = (await api('POST', '/jobs', { token: eve, body: { company: 'Secret Ltd', role: 'Spy' } })).body;
    assert.equal((await api('GET', `/jobs/${job.id}`, { token: fay })).status, 404);
    assert.equal((await api('PUT', `/jobs/${job.id}`, { token: fay, body: { status: 'offer' } })).status, 404);
    assert.equal((await api('GET', '/jobs', { token: fay })).body.total, 0);
  });
  it('paginates and counts', async () => {
    const token = await signUp('Gus');
    for (let i = 0; i < 12; i++) await api('POST', '/jobs', { token, body: { company: `Co ${i}`, role: 'Tester', status: i < 4 ? 'interview' : 'applied' } });
    const page2 = await api('GET', '/jobs?limit=5&page=2', { token });
    assert.equal(page2.body.items.length, 5);
    assert.equal(page2.body.pages, 3);
    const stats = await api('GET', '/jobs/stats', { token });
    assert.deepEqual([stats.body.total, stats.body.byStatus.interview, stats.body.byStatus.applied], [12, 4, 8]);
  });
});
  • `describe` and `it` group and name the checks. Names should read like sentences: “keeps people apart”.
  • `before` and `after` run once around the group. We connect to the test database, wipe it, build its indexes, start the app on a random port, and at the end wipe it again and stop.
  • `MONGODB_URI_TEST` is a separate address in .env. The tests drop the whole database, so the script refuses to run without it. Never point it at real data. (If the variable is missing the file says so and exits cleanly.)
  • `assert/strict` compares values with strict equality (assert.equal, assert.deepEqual, assert.ok).
  • A small `api()` helper wraps fetch so each check is short, and signUp() makes a fresh user and returns its token.

One line deserves a note: Promise.all(Object.values(mongoose.models).map((m) => m.syncIndexes())). dropDatabase() also deletes the unique index on email, and Mongoose only builds indexes when it first sees a model, not again. Without rebuilding it, the “duplicate email” test would pass or fail depending on timing. Tests that sometimes fail for no visible reason (called *flaky*) are worse than no tests, so it pays to find the cause. This one was found while writing this very course.

What to test

Look at the list of checks. The suite covers the happy path (register, log in, create, list, update, delete), the unhappy paths (wrong password, duplicate email, invalid input, no token, a garbage token), the security rules (operator objects refused, one user cannot see another's jobs), and behaviour (pagination and statistics). When you add a feature, add its tests in the same change. When you find a bug, write the test that fails first, then fix it.

Run them

Add MONGODB_URI_TEST=mongodb://127.0.0.1:27017/job_tracker_test (or a separate Atlas database) to .env, then run npm test. The script node --test --test-concurrency=1 finds files in a test folder, and the concurrency of 1 runs files one at a time so they do not wipe each other's database.

Your task

  1. Add MONGODB_URI_TEST to .env and run npm test. Read the report.
  2. Write a test that registering with a 73-character password answers 400.
  3. Write a test that GET /api/jobs?limit=9999 returns at most 50 items.
  4. Break something on purpose (for example remove { user: req.user._id } from listJobs) and watch which test catches it.

How to check it worked

  • npm test ends with # fail 0.
  • Breaking the owner filter makes “keeps people apart” fail.

Summary

  • Export the app from app.js; start it from index.js. Tests import the app.
  • Use a separate test database and rebuild its indexes after dropping it.
  • Test happy paths, unhappy paths and security rules; keep every test independent.

Download the solution (mern-lesson-24.zip)

Frequently asked questions

Do I need Jest or Mocha?

Not for this project. The built-in node:test runner has describe, it, before, after and assertions. Jest and Vitest offer extra features such as mocking and watch mode, which you can add later if you need them.

Why must tests use a separate database?

Tests create and delete data and here even drop the whole database. Running them against real data would destroy it. Always give tests their own throw-away database address.

What is a flaky test?

A test that sometimes passes and sometimes fails without any change to the code, often because of timing or leftover state. Fix the cause or delete the test, since people stop trusting a suite that cries wolf.