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
fetchso each check is short, andsignUp()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
- Add
MONGODB_URI_TESTto.envand runnpm test. Read the report. - Write a test that registering with a 73-character password answers 400.
- Write a test that
GET /api/jobs?limit=9999returns at most 50 items. - Break something on purpose (for example remove
{ user: req.user._id }fromlistJobs) and watch which test catches it.
How to check it worked
npm testends with# fail 0.- Breaking the owner filter makes “keeps people apart” fail.
Summary
- Export the app from
app.js; start it fromindex.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.