A Complete Test Suite
Objectives
By the end of this lesson, you should be able to:
- Write unit tests for the Notes API’s Zod schemas
- Write a full integration test suite covering every route’s success and failure paths
- Confirm rate limiting with an isolated, strictly-configured app instance
๐ก Why this matters: The last lesson built the whole API. This lesson proves it, with a real, passing test suite, unit tests (Module 7) for the validation logic, integration tests (Module 8) for every route, including every meaningful failure case this course has covered.
โ ๏ธ A note on verification: every snippet and every output in this lesson was actually run.
Unit Tests for the Schemas
// schemas.test.js
const { registerSchema, noteSchema } = require('./schemas');
describe('registerSchema', () => {
it('accepts a valid email and password', () => {
const result = registerSchema.safeParse({ email: 'erin@example.com', password: 'sunshine123' });
expect(result.success).toBe(true);
});
it('rejects a short password', () => {
const result = registerSchema.safeParse({ email: 'erin@example.com', password: 'short' });
expect(result.success).toBe(false);
});
it('strips an injected role field', () => {
const result = registerSchema.safeParse({ email: 'erin@example.com', password: 'sunshine123', role: 'admin' });
expect(result.data).toEqual({ email: 'erin@example.com', password: 'sunshine123' });
});
});
describe('noteSchema', () => {
it('rejects a title under 3 characters', () => {
const result = noteSchema.safeParse({ title: 'Hi', body: 'A note' });
expect(result.success).toBe(false);
});
it('accepts a valid note', () => {
const result = noteSchema.safeParse({ title: 'Grocery List', body: 'Milk, eggs, bread' });
expect(result.success).toBe(true);
});
});
No Express, no HTTP, exactly the Module 7 pattern, testing the schemas in complete isolation. The mass-assignment test (strips an injected role field) is worth keeping here specifically, this is the one property that, if it silently broke, would be a real security regression, not just an inconvenience.
Integration Tests: Setup
// app.test.js
const request = require('supertest');
const { createApp } = require('./app');
async function registerAndLogin(app, email, password = 'sunshine123') {
await request(app).post('/api/v1/auth/register').send({ email, password });
const res = await request(app).post('/api/v1/auth/login').send({ email, password });
return res.body.token;
}
let app;
beforeEach(() => {
app = createApp();
});
beforeEach creates a fresh app before every single test, no leftover users, notes, or rate-limit quota from any previous test, registerAndLogin is a small helper, reused across most of the suite, avoiding repeating the same two-request setup in every test.
Registration and Login
describe('registration', () => {
it('registers a new user', async () => {
const res = await request(app).post('/api/v1/auth/register').send({ email: 'erin@example.com', password: 'sunshine123' });
expect(res.status).toBe(201);
expect(res.body.role).toBe('member');
});
it('rejects a duplicate email with 409', async () => {
await request(app).post('/api/v1/auth/register').send({ email: 'erin@example.com', password: 'sunshine123' });
const res = await request(app).post('/api/v1/auth/register').send({ email: 'erin@example.com', password: 'sunshine123' });
expect(res.status).toBe(409);
});
it('rejects invalid input with 400 and field details', async () => {
const res = await request(app).post('/api/v1/auth/register').send({ email: 'not-an-email', password: 'short' });
expect(res.status).toBe(400);
expect(res.body.details.length).toBe(2);
});
});
describe('login', () => {
it('returns a token for correct credentials', async () => {
await request(app).post('/api/v1/auth/register').send({ email: 'erin@example.com', password: 'sunshine123' });
const res = await request(app).post('/api/v1/auth/login').send({ email: 'erin@example.com', password: 'sunshine123' });
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
});
it('returns 401 for a wrong password', async () => {
await request(app).post('/api/v1/auth/register').send({ email: 'erin@example.com', password: 'sunshine123' });
const res = await request(app).post('/api/v1/auth/login').send({ email: 'erin@example.com', password: 'wrong' });
expect(res.status).toBe(401);
});
it('returns the same 401 message for a nonexistent user', async () => {
const res = await request(app).post('/api/v1/auth/login').send({ email: 'nobody@example.com', password: 'sunshine123' });
expect(res.status).toBe(401);
expect(res.body.error).toBe('Incorrect email or password');
});
});
That last test is the user-enumeration check from Module 1, still holding, now enforced automatically instead of relying on someone remembering to check it by hand.
Notes: Ownership
describe('notes ownership', () => {
it('lets an owner create and read their own note', async () => {
const token = await registerAndLogin(app, 'erin@example.com');
const createRes = await request(app).post('/api/v1/notes').set('Authorization', `Bearer ${token}`).send({ title: 'Grocery List', body: 'Milk, eggs' });
expect(createRes.status).toBe(201);
const readRes = await request(app).get(`/api/v1/notes/${createRes.body.id}`).set('Authorization', `Bearer ${token}`);
expect(readRes.status).toBe(200);
});
it('returns 403 when a different user tries to read the note', async () => {
const ownerToken = await registerAndLogin(app, 'erin@example.com');
const otherToken = await registerAndLogin(app, 'jordan@example.com');
const createRes = await request(app).post('/api/v1/notes').set('Authorization', `Bearer ${ownerToken}`).send({ title: 'Private Note', body: 'Secret' });
const readRes = await request(app).get(`/api/v1/notes/${createRes.body.id}`).set('Authorization', `Bearer ${otherToken}`);
expect(readRes.status).toBe(403);
});
it('returns 401 with no token at all', async () => {
const res = await request(app).post('/api/v1/notes').send({ title: 'No Auth', body: 'Should fail' });
expect(res.status).toBe(401);
});
it('returns 400 for a note that fails validation, even when authenticated', async () => {
const token = await registerAndLogin(app, 'erin@example.com');
const res = await request(app).post('/api/v1/notes').set('Authorization', `Bearer ${token}`).send({ title: 'Hi', body: 'Too short a title' });
expect(res.status).toBe(400);
});
it('lets an owner delete their own note', async () => {
const token = await registerAndLogin(app, 'erin@example.com');
const createRes = await request(app).post('/api/v1/notes').set('Authorization', `Bearer ${token}`).send({ title: 'Delete Me', body: 'Bye' });
const deleteRes = await request(app).delete(`/api/v1/notes/${createRes.body.id}`).set('Authorization', `Bearer ${token}`);
expect(deleteRes.status).toBe(204);
});
});
Two different users, erin and jordan, confirm ownership is checked by identity, not just by “any authenticated user”, exactly the property Module 4’s ownership-based authorization was built around.
Admin Access
describe('admin routes', () => {
it('returns 403 for a non-admin member', async () => {
const token = await registerAndLogin(app, 'erin@example.com');
const res = await request(app).get('/api/v1/admin/users').set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
});
it('returns 200 for an admin, and lets an admin read any note', async () => {
const ownerToken = await registerAndLogin(app, 'erin@example.com');
const createRes = await request(app).post('/api/v1/notes').set('Authorization', `Bearer ${ownerToken}`).send({ title: 'Owned Note', body: 'By erin' });
await request(app).post('/api/v1/auth/register').send({ email: 'admin@example.com', password: 'sunshine123' });
app.locals.testHelpers.promoteToAdmin('admin@example.com');
const adminLogin = await request(app).post('/api/v1/auth/login').send({ email: 'admin@example.com', password: 'sunshine123' });
const adminToken = adminLogin.body.token;
const usersRes = await request(app).get('/api/v1/admin/users').set('Authorization', `Bearer ${adminToken}`);
expect(usersRes.status).toBe(200);
const noteRes = await request(app).get(`/api/v1/notes/${createRes.body.id}`).set('Authorization', `Bearer ${adminToken}`);
expect(noteRes.status).toBe(200);
});
});
app.locals.testHelpers.promoteToAdmin(...) is the last lesson’s testing hook, used directly, no HTTP request needed for it, since the test already has the app object in scope.
Rate Limiting, with an Isolated, Strict App
describe('rate limiting', () => {
it('blocks after the configured number of rapid login attempts', async () => {
const strictApp = createApp({ authLimiterMax: 3 });
await request(strictApp).post('/api/v1/auth/register').send({ email: 'erin@example.com', password: 'sunshine123' });
const statuses = [];
for (let i = 0; i < 4; i++) {
const res = await request(strictApp).post('/api/v1/auth/login').send({ email: 'erin@example.com', password: 'wrong' });
statuses.push(res.status);
}
expect(statuses).toEqual([401, 401, 429, 429]);
});
});
This test builds its own strictApp, with authLimiterMax: 3, separate from the shared app every other test uses, this is exactly why the factory pattern from the last lesson matters, this one test needs an unusually strict limit to reliably trigger blocking, without lowering the limit for every other test in the suite.
Running Everything
PASS app.test.js
registration
โ registers a new user (164 ms)
โ rejects a duplicate email with 409 (84 ms)
โ rejects invalid input with 400 and field details (7 ms)
login
โ returns a token for correct credentials (154 ms)
โ returns 401 for a wrong password (149 ms)
โ returns the same 401 message for a nonexistent user (8 ms)
notes ownership
โ lets an owner create and read their own note (157 ms)
โ returns 403 when a different user tries to read the note (300 ms)
โ returns 401 with no token at all (6 ms)
โ returns 400 for a note that fails validation, even when authenticated (151 ms)
โ lets an owner delete their own note (151 ms)
admin routes
โ returns 403 for a non-admin member (151 ms)
โ returns 200 for an admin, and lets an admin read any note (301 ms)
rate limiting
โ blocks after the configured number of rapid login attempts (222 ms)
PASS schemas.test.js
registerSchema
โ accepts a valid email and password (1 ms)
โ rejects a short password
โ strips an injected role field (1 ms)
noteSchema
โ rejects a title under 3 characters
โ accepts a valid note
Test Suites: 2 passed, 2 total
Tests: 19 passed, 19 total
Nineteen tests, covering every route’s success path, every meaningful failure path, ownership by identity, role-gating, mass-assignment protection, and rate limiting, all passing, all real requests against a real app.
Try It
- Build both test files, and confirm all 19 tests pass with
npx jest. - Add a test confirming that deleting a note that doesn’t exist returns
404. - Add a test confirming a member (not an admin) gets
403attempting to delete a note they don’t own, distinct from the404case above. - Deliberately reintroduce a bug, remove the
isOwner && !isAdmincheck fromDELETE /api/v1/notes/:identirely, rerun the suite, and confirm which test(s) fail, then fix it back.
Recap
- Nineteen tests, five unit tests for schemas, fourteen integration tests for the full API, cover this capstone’s entire behavior, not just its happy path.
- The factory pattern from the last lesson pays off directly here,
beforeEach(() => { app = createApp(); })keeps every test independent, and a one-offcreateApp({ authLimiterMax: 3 })isolates the rate-limiting test specifically. - Every property this course built individually, hashed passwords, no user enumeration, ownership by identity, role-gating, mass-assignment protection, rate limiting, is now enforced by an automated suite, not just correct the one time it was checked by hand.
Next lesson: a final security review, and where this course leaves off.