The Aroma Store API is built: routes, representations, validation, persistence, authentication and errors. And right now the only proof that it complies with the contract is a handful of curl commands you ran by hand three lessons ago and that nobody will ever run again. That is not a guarantee: it is memory. This lesson closes the module by turning those checks into automated tests that run with npm test in two seconds and that fail the moment somebody breaks a promise of the contract —a Location that disappears, a total that starts counting only the page, a 403 that turns into a 401. We will use Node's native runner, with nothing to install, and Supertest to fire requests at the application without opening a port. At the end you will also have a contract checklist: what to check before signing off any REST API.
Contents
- What each kind of test proves
- The pyramid applied to this API
- The native runner:
node:test - A unit test of the mapper
- Test doubles and dependency injection
- A unit test of the coffee service
- Supertest: requests without a port
- Test data: an isolated database
- Integration tests of the collection
- Tests of creation, headers and validation
- Authentication and authorisation tests
- Coverage and what it really measures
- Contract tests: the idea
- Manual exploration and Postman
- Aroma Store's contract checklist
- Taking stock of module 3
- What each kind of test proves
| Kind | What it exercises | Speed | What it catches | What it misses |
|---|---|---|---|---|
| Unit | One function or service, in isolation | Milliseconds | Logic and calculation errors | Whether the pieces fit together |
| Integration | Several layers together, with a database | Tens of ms | The HTTP contract, SQL, middleware | Network or deployment failures |
| End to end | The complete deployed system | Seconds | Real configuration problems | Edge cases (there are few of them) |
The three answer different questions, and confusing them produces slow suites that catch nothing. The operational rule: the lower down, the more tests and the faster they are.
In this module we will write unit and integration tests. End-to-end ones —against a genuinely deployed environment, with its database and its gateway— are picked up in 05-04, alongside contract tests and mocks.
- The pyramid applied to this API
| Layer | What we test in Aroma Store | How many |
|---|---|---|
| Unit | centsToEuros, coffeeToRepresentation, orderLinks, coffeeService, Zod schemas |
Many |
| Integration | GET/POST/PATCH/DELETE /v1/coffees, POST /v1/sessions, permissions, errors |
Quite a few |
| End to end | Buying a coffee from start to finish against the test environment | Few |
And one judgement call that saves a lot of pointless work: we do not test Express, Zod or SQLite. Those libraries have their own tests. We test our decisions: that the price converts correctly, that the _links of a paid order includes invoice and not pay, that limit=5000 returns 400 instead of clamping, that one customer cannot see another's orders. All of that is written in the module 2 contract, which is why the tests almost write themselves: every decision in the contract is a test.
- The native runner:
node:test
node:testSince Node 18 you do not need Jest, Mocha or Vitest for the essentials: Node ships a runner and assertions.
import { describe, it, before, beforeEach, after } from 'node:test';
import assert from 'node:assert/strict';| Piece | What for |
|---|---|
describe(name, fn) |
Groups related tests |
it(name, fn) |
One specific test |
before / after |
Runs once before/after the whole group |
beforeEach / afterEach |
Before/after each test |
assert |
Assertions |
About node:assert/strict: it is the variant that uses strict comparison (===) and you must always use that one. With the lax version, assert.equal('20', 20) passes, and that is exactly the kind of bug we are hunting —remember that query parameters arrive as text.
The assertions we will use:
assert.equal(response.status, 200); // strict equality
assert.deepEqual(body, { data: [], total: 0 }); // complete structures
assert.ok(body.total > 0); // truthiness
assert.match(header, /rel="next"/); // regular expression
assert.throws(() => service.get('cof_999'), /does not exist/); // it throwsRunning them:
node --test tests/ # everything
node --test --watch tests/ # rerun on save
node --test tests/unit/mappers.test.js # a single fileNode treats as a test file anything matching *.test.js, *-test.js or living inside a test/ folder. Our convention is *.test.js inside tests/. We already had it in package.json from 03-01:
- A unit test of the mapper
We start with the simplest and at the same time most profitable thing: the money conversion, where a silent failure costs real money.
// tests/unit/mappers.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
centsToEuros,
eurosToCents,
coffeeToRepresentation,
orderToRepresentation,
} from '../../src/services/mappers.js';
describe('conversion between cents and euros', () => {
it('converts cents into euros', () => {
assert.equal(centsToEuros(1450), 14.5);
assert.equal(centsToEuros(1290), 12.9);
assert.equal(centsToEuros(0), 0);
assert.equal(centsToEuros(1), 0.01);
});
it('converts euros into cents without losing a cent to floating point', () => {
assert.equal(eurosToCents(14.5), 1450);
assert.equal(eurosToCents(16.75), 1675);
// The critical case: 16.75 * 100 gives 1674.9999999999998 in JavaScript.
// If eurosToCents used Math.floor or truncation, this would give 1674.
assert.equal(eurosToCents(0.29), 29);
assert.equal(eurosToCents(1.005), 101);
});
it('the round trip is stable', () => {
for (const cents of [1, 29, 1450, 1675, 99999]) {
assert.equal(eurosToCents(centsToEuros(cents)), cents);
}
});
});
describe('coffeeToRepresentation', () => {
const internalCoffee = {
id: 'cof_001',
name: 'Ethiopia Yirgacheffe',
origin: 'Ethiopia',
roast: 'light',
priceCents: 1450,
stock: 120,
tastingNotes: ['citrus', 'floral'],
description: null,
createdAt: '2026-01-15T09:00:00Z',
active: true,
version: 1,
};
it('exposes priceEuros and does NOT expose priceCents', () => {
const r = coffeeToRepresentation(internalCoffee);
assert.equal(r.priceEuros, 14.5);
assert.equal(r.priceCents, undefined);
});
it('does not leak internal fields', () => {
const r = coffeeToRepresentation(internalCoffee);
// 'active' and 'version' are internal: they are not part of the contract.
assert.equal(r.active, undefined);
assert.equal(r.version, undefined);
});
it('fields with no value are present as null', () => {
const r = coffeeToRepresentation(internalCoffee);
assert.ok('description' in r, 'description must be present even when null');
assert.equal(r.description, null);
});
it('empty arrays are [] and never null', () => {
const r = coffeeToRepresentation({ ...internalCoffee, tastingNotes: undefined });
assert.deepEqual(r.tastingNotes, []);
});
it('includes the self link', () => {
const r = coffeeToRepresentation(internalCoffee);
assert.equal(r._links.self.href, '/v1/coffees/cof_001');
});
});
describe("an order's action links according to its status", () => {
const base = {
id: 'ord_5001',
customerId: 'cus_842',
items: [{ coffeeId: 'cof_001', name: 'Ethiopia Yirgacheffe', quantity: 2, priceCents: 1450 }],
totalCents: 2900,
createdAt: '2026-03-14T10:30:00Z',
};
it('pending_payment offers pay and cancel, but no invoice', () => {
const { _links } = orderToRepresentation({ ...base, status: 'pending_payment' });
assert.ok(_links.pay, 'it must offer pay');
assert.ok(_links.cancel, 'it must offer cancel');
assert.equal(_links.invoice, undefined, 'there is no invoice without payment');
assert.equal(_links.return, undefined);
});
it('shipped offers return and invoice, but no longer pay', () => {
const { _links } = orderToRepresentation({ ...base, status: 'shipped' });
assert.ok(_links.return);
assert.ok(_links.invoice);
assert.equal(_links.pay, undefined, 'a shipped order cannot be paid again');
});
it('the method of the actions is POST', () => {
const { _links } = orderToRepresentation({ ...base, status: 'pending_payment' });
assert.equal(_links.pay.method, 'POST');
});
});✔ conversion between cents and euros (2.1ms) ✔ coffeeToRepresentation (1.4ms) ✔ an order's action links according to its status (0.9ms) ℹ tests 12 ℹ pass 12 ℹ fail 0
These twelve tests run in milliseconds, need neither a database nor a server, and cover decisions from the contract of 02-05 that would otherwise exist only in prose. The test on active and version is especially valuable: it is the one that will catch the day somebody "simplifies" the mapper with a {...coffee} and leaks internal fields.
- Test doubles and dependency injection
To test coffeeService without a database we need to be able to substitute its repository. And today we cannot, because the service imports it directly:
The solution is dependency injection: the service receives its repository instead of importing it. We turn the object into a factory:
// src/services/coffees.js (refactored)
import { errors } from '../errors/api-error.js';
import { eurosToCents } from './mappers.js';
import { coffeeRepository } from '../repositories/index.js';
/**
* Creates the coffee service on top of a specific repository.
* In production the SQLite one is used; in the tests, a fake in-memory one.
*/
export function createCoffeeService(repository) {
return {
list(criteria) {
return repository.findAll(criteria);
},
get(id) {
const coffee = repository.findById(id);
if (!coffee) throw errors.notFound('coffee', id);
return coffee;
},
create(data) {
return repository.create({
name: data.name.trim(),
origin: data.origin.trim(),
roast: data.roast,
priceCents: eurosToCents(data.priceEuros),
stock: data.stock,
tastingNotes: data.tastingNotes ?? [],
description: data.description ?? null,
});
},
remove(id) {
if (!repository.remove(id)) throw errors.notFound('coffee', id);
},
// ...replace and modify, just as before...
};
}
/** The default instance: the one the controllers use. */
export const coffeeService = createCoffeeService(coffeeRepository);The controllers do not change: they still import coffeeService. But now the tests can build their own instance.
About the kinds of double, because the terminology is often misused:
| Double | What it is | Example here |
|---|---|---|
| Dummy | Passed to fill a slot, never used | Any old user |
| Stub | Returns fixed answers | A repository that always returns the same coffee |
| Fake | A real but simplified implementation | Our in-memory repository |
| Mock | Also verifies it was called as expected | Checking that remove was called once |
| Spy | Records the calls without changing behaviour | node:test's mock.fn() |
We will mostly use fakes, and here comes the reward for a decision made in 03-05: we kept coffees-memory.js when migrating to SQLite. That file, which looked like dead code, is now a complete fake, already written and with the same interface.
- A unit test of the coffee service
// tests/unit/coffee-service.test.js
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { createCoffeeService } from '../../src/services/coffees.js';
/** A minimal fake with the repository's interface. */
function createFakeRepository(initialCoffees = []) {
let coffees = initialCoffees.map((c) => ({ ...c }));
let counter = coffees.length;
return {
// A record of the calls, so that interactions can be verified.
calls: [],
findAll(criteria) {
this.calls.push(['findAll', criteria]);
return { items: coffees.map((c) => ({ ...c })), total: coffees.length };
},
findById(id) {
this.calls.push(['findById', id]);
const coffee = coffees.find((c) => c.id === id);
return coffee ? { ...coffee } : undefined;
},
create(data) {
this.calls.push(['create', data]);
const coffee = {
id: `cof_${String(++counter).padStart(3, '0')}`,
...data,
createdAt: '2026-03-14T10:00:00Z',
active: true,
};
coffees.push(coffee);
return { ...coffee };
},
remove(id) {
this.calls.push(['remove', id]);
const before = coffees.length;
coffees = coffees.filter((c) => c.id !== id);
return coffees.length < before;
},
};
}
const COFFEE = {
id: 'cof_001',
name: 'Ethiopia Yirgacheffe',
origin: 'Ethiopia',
roast: 'light',
priceCents: 1450,
stock: 120,
tastingNotes: ['citrus'],
description: null,
active: true,
};
describe('coffeeService', () => {
let repository;
let service;
// beforeEach recreates the state BEFORE EACH test: that way none of them
// depends on what the previous one did and the order is irrelevant.
beforeEach(() => {
repository = createFakeRepository([COFFEE]);
service = createCoffeeService(repository);
});
it('returns an existing coffee', () => {
const coffee = service.get('cof_001');
assert.equal(coffee.name, 'Ethiopia Yirgacheffe');
});
it('throws ApiError 404 with the catalogue code when it does not exist', () => {
assert.throws(
() => service.get('cof_999'),
(error) => {
assert.equal(error.isApiError, true);
assert.equal(error.status, 404);
assert.equal(error.code, 'coffee_not_found');
assert.match(error.message, /cof_999/);
return true;
}
);
});
it("converts the client's euros into cents when creating", () => {
service.create({
name: 'Kenya Nyeri',
origin: 'Kenya',
roast: 'medium',
priceEuros: 16.75,
stock: 40,
});
const [, data] = repository.calls.find(([name]) => name === 'create');
assert.equal(data.priceCents, 1675);
assert.equal(data.priceEuros, undefined, 'the repository must never see euros');
});
it('trims the whitespace from the name before storing', () => {
service.create({
name: ' Kenya Nyeri ',
origin: 'Kenya',
roast: 'medium',
priceEuros: 16.75,
stock: 40,
});
const [, data] = repository.calls.find(([name]) => name === 'create');
assert.equal(data.name, 'Kenya Nyeri');
});
it('throws 404 when deleting a nonexistent coffee', () => {
assert.throws(() => service.remove('cof_999'), /cof_999/);
});
});These tests are fast and precise: they touch no disk, they do not depend on the state of the database and, when one fails, it points at a single function. Look at the third one: it checks that the repository never sees euros, that is, that the unit boundary we designed is respected. That is exactly what a unit test can verify and an integration test could not distinguish.
- Supertest: requests without a port
Supertest takes Express's app object and fires real HTTP requests at it… over an ephemeral server that it opens and closes itself on a random port. For us that amounts to not opening a port at all: no EADDRINUSE, nothing to start up, and several suites can run at once.
import request from 'supertest';
import { app } from '../../src/app.js';
const response = await request(app).get('/v1/coffees');
assert.equal(response.status, 200);This is where the separation of 03-02 pays its dividend. If app.js had called listen(), importing it from a test would occupy port 3000 and the second suite would fail.
Supertest's API, as a table:
| Call | What it does |
|---|---|
request(app).get(path) |
Method and path |
.set('Authorization', value) |
Request header |
.send(object) |
JSON body (it sets the Content-Type) |
.query({ limit: 5 }) |
Query parameters |
response.status |
The code |
response.body |
The already-parsed body |
response.headers['location'] |
A response header (in lowercase) |
- Test data: an isolated database
Integration tests need a database, and there are three non-negotiable rules: do not touch the development one, start every suite with known data, and make the order of the tests irrelevant.
// tests/helpers/test-environment.js
/**
* Sets up the environment BEFORE anything from src/ is loaded.
* It must be the FIRST import in every test file.
*/
process.env.NODE_ENV = 'test';
process.env.DATABASE_PATH = ':memory:'; // a SQLite database in RAM only
process.env.JWT_SECRET = 'test-only-secret-do-not-use-in-production';
process.env.JWT_EXPIRY = '1h';
process.env.PORT = '0';// tests/helpers/test-database.js
import { readFileSync } from 'node:fs';
import { database } from '../../src/config/database.js';
/** Applies the complete schema to the in-memory database. */
export function migrate() {
database.exec(readFileSync('migrations/001-initial.sql', 'utf8'));
}
/** Leaves the database with known data. Called before every test. */
export function seed() {
const clean = database.transaction(() => {
database.exec('DELETE FROM order_items; DELETE FROM orders; DELETE FROM reviews;');
database.exec('DELETE FROM coffees; DELETE FROM customers;');
});
clean();
const insertCoffee = database.prepare(`
INSERT INTO coffees (id, name, origin, roast, price_cents, stock,
tasting_notes, description, created_at, active, version)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, 1, 1)
`);
insertCoffee.run('cof_001', 'Ethiopia Yirgacheffe', 'Ethiopia', 'light', 1450, 120,
JSON.stringify(['citrus', 'floral', 'black tea']), '2026-01-15T09:00:00Z');
insertCoffee.run('cof_002', 'Colombia Huila', 'Colombia', 'medium', 1290, 80,
JSON.stringify(['chocolate', 'caramel', 'nutty']), '2026-01-20T11:15:00Z');
const insertCustomer = database.prepare(`
INSERT INTO customers (id, name, email, password_hash, role, created_at)
VALUES (?, ?, ?, ?, ?, '2026-01-10T08:00:00Z')
`);
// A real hash of 'test-password', precomputed so as not to spend 80 ms of
// bcrypt on every test. The login tests do exercise bcrypt.
const HASH = '$2b$10$K7L1OJ0/9F0iQZ8dJvKZ8eqPX5Y1cW0nR4tV6uH2sA9bC3dE4fG5i';
insertCustomer.run('cus_842', 'Marta García', '[email protected]', HASH, 'customer');
insertCustomer.run('cus_001', 'Alice Rowe', '[email protected]', HASH, 'employee');
insertCustomer.run('cus_002', 'Daniel Sands', '[email protected]', HASH, 'administrator');
database
.prepare(
`INSERT INTO orders (id, customer_id, status, total_cents, created_at, version)
VALUES ('ord_5001', 'cus_842', 'pending_payment', 2900, '2026-03-14T10:30:00Z', 1)`
)
.run();
database
.prepare(
`INSERT INTO order_items (order_id, coffee_id, coffee_name, quantity, price_cents)
VALUES ('ord_5001', 'cof_001', 'Ethiopia Yirgacheffe', 2, 1450)`
)
.run();
}And the helper for authenticating without going through the login in every test:
// tests/helpers/token.js
import { issueToken } from '../../src/services/authentication.js';
/** Generates a valid Bearer for a role. Uses the SAME issuer as the app. */
export function tokenFor(role = 'customer', id = 'cus_842') {
return `Bearer ${issueToken({ id, role })}`;
}Generating the token with the application's real function, rather than with a JWT hand-written in the test, is deliberate: if tomorrow the issuer, the algorithm or the claims change, the tests remain valid. A hand-made token becomes a copy of the code that has to be kept in sync.
About :memory:: each in-memory SQLite connection is an independent database that disappears when the process closes. It is the fastest option and the one guaranteeing complete isolation between runs. The alternative is a temporary file (data/test-${process.pid}.db), useful when you want to inspect the state after a failure.
An ESM detail that costs an afternoon.
src/config/database.jsopens the connection when it is imported, readingenvironment.databasePath. That is whytest-environment.jsmust be evaluated first. ESM modules are evaluated in the order theirimports appear, so puttingimport './helpers/test-environment.js';on the first line works… until a formatter reorders the imports alphabetically. The bulletproof version is to load the app with a dynamic import:import './helpers/test-environment.js'; const { app } = await import('../../src/app.js'); // evaluated here, not earlier
- Integration tests of the collection
// tests/integration/coffees.test.js
import './../helpers/test-environment.js';
import { describe, it, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { migrate, seed } from '../helpers/test-database.js';
import { tokenFor } from '../helpers/token.js';
const { app } = await import('../../src/app.js');
before(() => migrate()); // the schema, once
beforeEach(() => seed()); // the data, before every test
describe('GET /v1/coffees', () => {
it("returns 200 with the contract's envelope", async () => {
const r = await request(app).get('/v1/coffees');
assert.equal(r.status, 200);
assert.match(r.headers['content-type'], /application\/json/);
assert.ok(Array.isArray(r.body.data), 'data must be an array');
assert.equal(typeof r.body.total, 'number');
assert.equal(r.body.total, 2);
});
it("every item has the contract's exact shape", async () => {
const r = await request(app).get('/v1/coffees');
const coffee = r.body.data.find((c) => c.id === 'cof_001');
assert.equal(coffee.name, 'Ethiopia Yirgacheffe');
assert.equal(coffee.priceEuros, 14.5); // euros, not cents
assert.equal(coffee.priceCents, undefined);
assert.equal(coffee.description, null); // present even when null
assert.deepEqual(coffee.tastingNotes, ['citrus', 'floral', 'black tea']);
assert.equal(coffee._links.self.href, '/v1/coffees/cof_001');
});
it('filters by origin', async () => {
const r = await request(app).get('/v1/coffees').query({ origin: 'Colombia' });
assert.equal(r.body.total, 1);
assert.equal(r.body.data[0].id, 'cof_002');
});
it('total is the number of matches, not the size of the page', async () => {
const r = await request(app).get('/v1/coffees').query({ limit: 1 });
assert.equal(r.body.data.length, 1, 'the page carries one item');
assert.equal(r.body.total, 2, 'but there are two matches');
});
it('returns the Link header preserving the filters', async () => {
const r = await request(app).get('/v1/coffees').query({ limit: 1, sort: 'name' });
assert.ok(r.headers.link, 'there must be a Link header');
assert.match(r.headers.link, /rel="next"/);
assert.match(r.headers.link, /sort=name/, 'the filter must survive in the link');
});
it('rejects an excessive limit with 400 and does NOT clamp it', async () => {
const r = await request(app).get('/v1/coffees').query({ limit: 5000 });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, 'invalid_parameter');
assert.equal(r.body.error.details[0].field, 'limit');
});
it('rejects an unknown parameter (strict input)', async () => {
const r = await request(app).get('/v1/coffees').query({ pageSize: 20 });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, 'invalid_parameter');
});
});
describe('GET /v1/coffees/:id', () => {
it('returns the item WITHOUT an envelope', async () => {
const r = await request(app).get('/v1/coffees/cof_001');
assert.equal(r.status, 200);
assert.equal(r.body.id, 'cof_001');
assert.equal(r.body.data, undefined, 'a single item carries no envelope');
});
it('returns 404 with the catalogue code', async () => {
const r = await request(app).get('/v1/coffees/cof_999');
assert.equal(r.status, 404);
assert.equal(r.body.error.code, 'coffee_not_found');
assert.deepEqual(r.body.error.details, [], 'details is always present');
assert.equal(r.body.error.traceId, undefined, 'traceId only on 5xx');
});
});
describe('DELETE /v1/coffees on the collection', () => {
it('returns 405 with the Allow header', async () => {
const r = await request(app).delete('/v1/coffees').set('Authorization', tokenFor('administrator'));
assert.equal(r.status, 405);
assert.equal(r.body.error.code, 'method_not_allowed');
assert.match(r.headers.allow, /GET/);
assert.match(r.headers.allow, /POST/);
});
});Each of these tests corresponds to a specific decision from module 2. The total one guards against the most frequent error in paginated collections; the Link one against losing the filters; the limit=5000 one against silent clamping; the details: [] one against a client having to check whether the field exists.
- Tests of creation, headers and validation
// tests/integration/coffees.test.js (continued)
describe('POST /v1/coffees', () => {
const VALID_COFFEE = {
name: 'Kenya Nyeri',
origin: 'Kenya',
roast: 'medium',
priceEuros: 16.75,
stock: 40,
tastingNotes: ['blackcurrant', 'tomato'],
};
it('creates with 201, a Location header and the full representation', async () => {
const r = await request(app)
.post('/v1/coffees')
.set('Authorization', tokenFor('employee', 'cus_001'))
.send(VALID_COFFEE);
assert.equal(r.status, 201);
assert.ok(r.headers.location, 'Location is mandatory on every creation');
assert.match(r.headers.location, /^\/v1\/coffees\/cof_\d{3}$/);
assert.equal(r.body.priceEuros, 16.75);
assert.equal(r.body.description, null);
assert.equal(r.headers.location, r.body._links.self.href, 'Location and self match');
});
it('the created resource is retrievable at the Location URI', async () => {
const creation = await request(app)
.post('/v1/coffees')
.set('Authorization', tokenFor('employee', 'cus_001'))
.send(VALID_COFFEE);
const read = await request(app).get(creation.headers.location);
assert.equal(read.status, 200);
assert.equal(read.body.name, 'Kenya Nyeri');
assert.equal(read.body.priceEuros, 16.75, 'the price survives the round trip');
});
it('returns ALL the validation failures at once', async () => {
const r = await request(app)
.post('/v1/coffees')
.set('Authorization', tokenFor('employee', 'cus_001'))
.send({ name: 'K', origin: 'Kenya', roast: 'roasted', priceEuros: '16.75', stock: -3 });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, 'invalid_data');
assert.equal(r.body.error.details.length, 4, 'four failures, four details');
const fields = r.body.error.details.map((d) => d.field).sort();
assert.deepEqual(fields, ['name', 'priceEuros', 'roast', 'stock']);
// Every detail carries the contract's three fields.
for (const detail of r.body.error.details) {
assert.ok(detail.field);
assert.ok(detail.code);
assert.ok(detail.message);
}
});
it('rejects unknown fields (strict input)', async () => {
const r = await request(app)
.post('/v1/coffees')
.set('Authorization', tokenFor('employee', 'cus_001'))
.send({ ...VALID_COFFEE, colour: 'red' });
assert.equal(r.status, 400);
assert.equal(r.body.error.details[0].code, 'unknown_field');
});
it("rejects malformed JSON with the contract's 400, not with HTML", async () => {
const r = await request(app)
.post('/v1/coffees')
.set('Authorization', tokenFor('employee', 'cus_001'))
.set('Content-Type', 'application/json')
.send('{"name": "Kenya,}');
assert.equal(r.status, 400);
assert.equal(r.body.error.code, 'invalid_data');
});
});
describe('PATCH /v1/coffees/:id', () => {
it('modifies only what was sent', async () => {
const r = await request(app)
.patch('/v1/coffees/cof_001')
.set('Authorization', tokenFor('employee', 'cus_001'))
.set('Content-Type', 'application/merge-patch+json')
.send({ stock: 95 });
assert.equal(r.status, 200);
assert.equal(r.body.stock, 95);
assert.equal(r.body.priceEuros, 14.5, 'the price must not change');
assert.equal(r.body.name, 'Ethiopia Yirgacheffe');
});
it('rejects JSON Patch with 415 and Accept-Patch', async () => {
const r = await request(app)
.patch('/v1/coffees/cof_001')
.set('Authorization', tokenFor('employee', 'cus_001'))
.set('Content-Type', 'application/json-patch+json')
.send([{ op: 'replace', path: '/stock', value: 95 }]);
assert.equal(r.status, 415);
assert.equal(r.body.error.code, 'unsupported_format');
assert.equal(r.headers['accept-patch'], 'application/merge-patch+json');
});
});
describe('DELETE /v1/coffees/:id', () => {
it('returns 204 with no body and the coffee stops existing', async () => {
const deletion = await request(app)
.delete('/v1/coffees/cof_002')
.set('Authorization', tokenFor('administrator', 'cus_002'));
assert.equal(deletion.status, 204);
assert.deepEqual(deletion.body, {}, '204 carries no body');
const read = await request(app).get('/v1/coffees/cof_002');
assert.equal(read.status, 404);
});
});The second POST test is the most valuable in the whole file: it creates and then reads at the URI returned in Location. In one go it verifies that the header is correct, that the resource was persisted, that the cents survive the round trip and that the representation is the same on creation and on reading. One test, four promises of the contract.
- Authentication and authorisation tests
// tests/integration/authentication.test.js
import './../helpers/test-environment.js';
import { describe, it, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { migrate, seed } from '../helpers/test-database.js';
import { tokenFor } from '../helpers/token.js';
const { app } = await import('../../src/app.js');
before(() => migrate());
beforeEach(() => seed());
describe('authentication', () => {
it('401 with no token, carrying WWW-Authenticate', async () => {
const r = await request(app).get('/v1/orders');
assert.equal(r.status, 401);
assert.equal(r.body.error.code, 'not_authenticated');
assert.match(r.headers['www-authenticate'], /^Bearer/);
});
it('401 with a tampered token', async () => {
const valid = tokenFor('customer');
const broken = `${valid.slice(0, -4)}XXXX`; // the signature is changed
const r = await request(app).get('/v1/orders').set('Authorization', broken);
assert.equal(r.status, 401);
assert.equal(r.body.error.code, 'not_authenticated');
});
it('401 token_expired distinguishes the case of a lapsed token', async () => {
// A token that expired an hour ago is signed.
const expired = jwt.sign({ role: 'customer' }, process.env.JWT_SECRET, {
subject: 'cus_842',
issuer: 'api.aromastore.example',
expiresIn: '-1h',
});
const r = await request(app).get('/v1/orders').set('Authorization', `Bearer ${expired}`);
assert.equal(r.status, 401);
assert.equal(r.body.error.code, 'token_expired', 'the client must be able to renew');
});
it('200 with a valid token', async () => {
const r = await request(app).get('/v1/orders').set('Authorization', tokenFor('customer'));
assert.equal(r.status, 200);
});
});
describe('role-based authorisation', () => {
it('403 when creating a coffee with the customer role', async () => {
const r = await request(app)
.post('/v1/coffees')
.set('Authorization', tokenFor('customer'))
.send({ name: 'Kenya Nyeri', origin: 'Kenya', roast: 'medium', priceEuros: 16.75, stock: 40 });
assert.equal(r.status, 403, 'permission, not identity: 403 and not 401');
assert.equal(r.body.error.code, 'insufficient_permissions');
});
it('201 when creating a coffee with the employee role', async () => {
const r = await request(app)
.post('/v1/coffees')
.set('Authorization', tokenFor('employee', 'cus_001'))
.send({ name: 'Kenya Nyeri', origin: 'Kenya', roast: 'medium', priceEuros: 16.75, stock: 40 });
assert.equal(r.status, 201);
});
it('403 when deleting a coffee with the employee role (administrators only)', async () => {
const r = await request(app)
.delete('/v1/coffees/cof_001')
.set('Authorization', tokenFor('employee', 'cus_001'));
assert.equal(r.status, 403);
});
});
describe('resource-level authorisation', () => {
it("a customer does NOT see another's order, and receives 404 (not 403)", async () => {
const r = await request(app)
.get('/v1/orders/ord_5001') // it belongs to cus_842
.set('Authorization', tokenFor('customer', 'cus_999'));
assert.equal(r.status, 404, 'we do not confirm that the order exists');
assert.equal(r.body.error.code, 'order_not_found');
});
it('the owner does see it', async () => {
const r = await request(app)
.get('/v1/orders/ord_5001')
.set('Authorization', tokenFor('customer', 'cus_842'));
assert.equal(r.status, 200);
assert.equal(r.body.totalEuros, 29);
});
it("a customer cannot list another's orders with ?customerId", async () => {
const r = await request(app)
.get('/v1/orders')
.query({ customerId: 'cus_842' })
.set('Authorization', tokenFor('customer', 'cus_999'));
assert.equal(r.status, 200);
assert.equal(r.body.total, 0, "the query's customerId is ignored and the token's is forced");
});
it("an employee does see anybody's orders", async () => {
const r = await request(app)
.get('/v1/orders')
.query({ customerId: 'cus_842' })
.set('Authorization', tokenFor('employee', 'cus_001'));
assert.equal(r.body.total, 1);
});
});That second-to-last test —another customer's ?customerId— is the most important in the file. It is the only one that detects the IDOR of 03-06, and it is exactly the kind of failure that functional tests do not see: the route answers 200, the token is valid, the role is correct, and somebody else's data would still be leaking. The permission matrix of 03-06 should have one test per cell.
- Coverage and what it really measures
ℹ start of coverage report ℹ ------------------------------------------------------------ ℹ file | line % | branch % | funcs % ℹ ------------------------------------------------------------ ℹ src/services/mappers.js | 98.20 | 91.60 | 100.00 ℹ src/services/coffees.js | 92.30 | 83.30 | 100.00 ℹ src/middleware/validation.js | 88.90 | 75.00 | 100.00 ℹ src/middleware/errors.js | 71.40 | 58.30 | 80.00 ℹ src/repositories/coffees-sql | 85.00 | 70.00 | 90.00 ℹ ------------------------------------------------------------ ℹ all files | 86.10 | 74.20 | 92.30
| Metric | What it measures |
|---|---|
| line % | Lines executed |
| branch % | Branches of the if/switch statements taken |
| funcs % | Functions called at least once |
What coverage is for and what it is not. It is for finding what has not been tested: in the report above, middleware/errors.js at 58% of branches indicates that there are error translations —the SQLite ones, most likely— that no test exercises. That gap is actionable information.
What it is not is a measure of quality. This test gives 100% coverage and checks nothing:
Chasing 100% leads to writing tests like that, to testing trivial getters, and to adding assertions about internal details that turn any refactor into a day of fixing tests. A reasonable target is 80-90% on the business logic, with an eye on the branches rather than the lines, and with the rule that every error path has at least one test. Error paths are precisely the ones nobody runs by hand and the ones that fail most when the moment comes.
- Contract tests: the idea
Our tests verify what we believe the contract says. But the real contract lives in openapi.yaml (02-08), and nothing today guarantees that the two agree: if somebody adds a field to the response without touching the YAML, all our tests stay green and the documentation starts lying.
A contract test closes that gap by validating the real response against the published schema:
// The idea, not the implementation: it is developed in 05-04.
import { validateAgainstSchema } from 'some-openapi-tool';
it('the response complies with the published schema', async () => {
const r = await request(app).get('/v1/coffees/cof_001');
const result = validateAgainstSchema(r.body, 'openapi.yaml', '#/components/schemas/Coffee');
assert.equal(result.valid, true, JSON.stringify(result.errors));
});With that, the YAML stops being documentation and becomes a test: if the implementation and the specification diverge, the build fails. It is the only real defence against the drift we described in 02-08. The specific tools —OpenAPI validators, Pact for consumer-provider contracts, mocks generated from the specification— are lesson 05-04.
- Manual exploration and Postman
Automated tests check what you thought of checking. Manual exploration is for the rest: what happens if I send an array where an object is expected? What if Accept-Language is zh? What about limit=0?
# A smoke script after any significant change
API=http://localhost:3000/v1
curl -s "$API/coffees" | jq '.total'
curl -s -o /dev/null -w "%{http_code}\n" "$API/coffees/cof_999" # expects 404
curl -s -o /dev/null -w "%{http_code}\n" "$API/coffees?limit=5000" # expects 400
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "$API/coffees" # expects 405
curl -si "$API/coffees?limit=1" | grep -i "^link" # expects Link-w "%{http_code}\n" prints only the status code, which is what matters in a smoke script.
When manual exploration grows —organised collections, environments with variables, chaining the login token into the following requests, running everything at once— the right tool is Postman, and we devote the whole of lesson 05-01 to it. What matters is the order of things: whatever you discover while exploring, turn it into an automated test. A bug found by hand that does not end up as a test will come back.
- Aroma Store's contract checklist
Before signing off any version of the API:
Collections
- [ ] A collection
GETreturns{"data": [...], "total": n}, never a bare array. - [ ]
totalis the number of matches for the filter, not the size of the page. - [ ] An empty collection is
200withdata: [], never404. - [ ] Filters combine with logical AND and an unknown parameter gives
400. - [ ]
limitdefaults to 20, maximum 100, and above that400, with no clamping. - [ ] Every sort ends with a tie-break on
id. - [ ] The
Linkheader is present when there is more than one page and preserves the filters.
Items
- [ ] An item
GETreturns the bare object, with no envelope. - [ ] Fields with no value are present as
null; empty arrays are[]. - [ ] Amounts come out in euros; cents never cross the boundary.
- [ ] Enums are in
snake_caseand are never translated. - [ ] Dates are ISO-8601 in UTC with
Z. - [ ]
_links.selfis always there; actions only on orders and according to their status.
Writing
- [ ]
POSTreturns201withLocation, and that URI is retrievable withGET. - [ ]
PUTreplaces the whole resource;PATCHonly what was sent. - [ ]
PATCHwithapplication/json-patch+jsongives415withAccept-Patch. - [ ]
DELETEreturns204with no body. - [ ] Fields set by the server (
id,status, item prices) are rejected on input.
Errors
- [ ] They all have the shape
{"error": {"code", "message", "details"}}. - [ ]
detailsis always there, even when it is[]. - [ ] Validation returns all the failures at once.
- [ ]
traceIdonly on the5xx. - [ ] No error leaks a stack, SQL, versions or system paths.
- [ ] Method not allowed:
405withAllow. - [ ] Nonexistent route:
404in JSON, never Express's HTML page.
Security
- [ ]
401withWWW-Authenticate;token_expireddistinguished fromnot_authenticated. - [ ]
403when permission is missing,401when identity is missing. - [ ] Other people's resources:
404, not403. - [ ] The owner's identifier comes from the token, never from the input.
- [ ] No response includes passwords or hashes.
- [ ] There is a test for every cell of the permission matrix.
Process
- [ ]
npm testpasses green. - [ ]
openapi.yamlreflects the real endpoints, fields and errors. - [ ] Every new route has its happy-path test and its error test.
- Taking stock of module 3
Eight lessons, a single project. This is what has been built:
| Lesson | What it contributed |
|---|---|
| 03-01 | Node 20, ESM, dependencies, layered structure, validated configuration |
| 03-02 | Express, middleware, app/server separated, Router at /v1, the contract's 404 |
| 03-03 | Routes/controllers/services layers, mapper, CRUD, filters, pagination, Link |
| 03-04 | Zod schemas, strict input, validate(schema, source), all failures at once |
| 03-05 | SQLite behind the repository, migrations, prepared statements, transactions, cursor |
| 03-06 | bcrypt, JWT, authenticate, requireRole, resource-level ownership, permission matrix |
| 03-07 | ApiError, factories, a single error middleware, asyncHandler(), traceId |
| 03-08 | Unit tests with doubles, integration with Supertest, coverage, checklist |
And this is what it does not have yet, which is exactly the syllabus of module 4: there is no CORS, so a browser on another domain cannot call it; there is no request limiting, so a badly written loop can saturate it; there are no security headers and no protection against the OWASP threats; there is no HTTP caching, so every request recomputes everything; there is no delegated access for third parties; and its logs are console.log with no levels and no aggregation, impossible to query in production.
Common Mistakes and Tips
1. Tests that depend on ordering. If test B needs A to have created a coffee, any reordering breaks them. beforeEach with a full seed solves it.
2. Sharing the development database. The tests will empty it. An in-memory database or a temporary file, always.
3. Testing Express, Zod or SQLite. That is not your code. Test your decisions.
4. Asserting over the whole object. assert.deepEqual(body, {...}) with twenty fields fails every time one is added, even though that is a perfectly valid additive change. Assert about what matters.
5. Forgetting await in an async test. The test passes without having checked anything, because it finishes before the request does.
6. Chasing 100% coverage. It produces tests with no assertions and brittle tests. Look at the branches of the business logic.
7. Reading headers with capital letters. In Supertest's response they are r.headers['content-type'], in lowercase.
8. Not testing the error paths. They are the ones nobody runs by hand and the ones that break most.
9. Hand-crafting JWTs inside the test. It duplicates the issuing code. Use the application's real function.
Tip: when a bug shows up in production, write the test that reproduces it first, check that it fails, and only then fix it. That way you know the test is worth something and that particular bug never comes back.
Exercises
Exercise 1
Write the integration tests for POST /v1/orders covering: successful creation with 201 and Location, 409 insufficient_stock when requesting more units than are available, 400 idempotency_key_required without the header, and —the important one— that after a 409 the stock has not been deducted. Explain why this last test is the one that verifies the transaction of 03-05.
Exercise 2
This test always passes, even with a broken application. Find the three reasons and write it correctly.
it('creates a coffee', () => {
const response = request(app).post('/v1/coffees').send({ name: 'Kenya' });
assert.ok(response);
});Exercise 3
The coverage report shows src/middleware/errors.js at 58% of branches. Identify which specific paths are not covered according to what we have written in this lesson, decide which ones deserve a test and which do not, and write the test for the most important one: checking that an unexpected error produces 500 internal_error with a traceId and without leaking the internal message.
Solutions
Solution 1
// tests/integration/orders.test.js
import './../helpers/test-environment.js';
import { describe, it, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { migrate, seed } from '../helpers/test-database.js';
import { tokenFor } from '../helpers/token.js';
const { app } = await import('../../src/app.js');
before(() => migrate());
beforeEach(() => seed());
describe('POST /v1/orders', () => {
const headers = (key = 'unique-key-1') => ({
Authorization: tokenFor('customer', 'cus_842'),
'Idempotency-Key': key,
});
it('creates the order with 201 and Location', async () => {
const r = await request(app)
.post('/v1/orders')
.set(headers())
.send({ items: [{ coffeeId: 'cof_001', quantity: 2 }] });
assert.equal(r.status, 201);
assert.match(r.headers.location, /^\/v1\/orders\/ord_\d+$/);
assert.equal(r.body.status, 'pending_payment');
assert.equal(r.body.totalEuros, 29); // 2 × €14.50
assert.equal(r.body.items[0].priceEuros, 14.5, 'frozen price');
assert.ok(r.body._links.pay, 'a pending order offers pay');
});
it("deducts the coffee's stock", async () => {
await request(app)
.post('/v1/orders')
.set(headers())
.send({ items: [{ coffeeId: 'cof_001', quantity: 2 }] });
const coffee = await request(app).get('/v1/coffees/cof_001');
assert.equal(coffee.body.stock, 118, '120 − 2');
});
it('409 insufficient_stock when asking for more than is available', async () => {
const r = await request(app)
.post('/v1/orders')
.set(headers())
.send({ items: [{ coffeeId: 'cof_001', quantity: 99 }] });
assert.equal(r.status, 409);
assert.equal(r.body.error.code, 'insufficient_stock');
});
it('400 idempotency_key_required without the header', async () => {
const r = await request(app)
.post('/v1/orders')
.set('Authorization', tokenFor('customer', 'cus_842'))
.send({ items: [{ coffeeId: 'cof_001', quantity: 1 }] });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, 'idempotency_key_required');
});
it('after a 409, NOTHING has been modified (atomicity)', async () => {
// First item valid, second out of stock: the whole thing must fail.
const r = await request(app)
.post('/v1/orders')
.set(headers())
.send({
items: [
{ coffeeId: 'cof_001', quantity: 2 }, // there are 120: it would fit
{ coffeeId: 'cof_002', quantity: 99 }, // there are 80: it does not
],
});
assert.equal(r.status, 409);
// The FIRST coffee's stock must still be untouched.
const coffee1 = await request(app).get('/v1/coffees/cof_001');
assert.equal(coffee1.body.stock, 120, 'the ROLLBACK must undo the first deduction');
// And no half-built order must have been left behind.
const orders = await request(app)
.get('/v1/orders')
.set('Authorization', tokenFor('employee', 'cus_001'));
assert.equal(orders.body.total, 1, 'only the seeded order');
});
});Why the last test verifies the transaction: it is the only one that exercises the failure path halfway through a multi-step operation. Without BEGIN/ROLLBACK, the first item would have deducted 2 units from cof_001 and the order row would already be inserted when the second item fails; the result would be vanished stock and an incomplete order in the database. With the transaction, the exception triggers a ROLLBACK and the state goes back exactly to what it was. It is a failure that never shows up on the happy path, that does not appear in development with plenty of data, and that in production produces inventory discrepancies impossible to explain. That is why it deserves an explicit test.
Solution 2
| # | Reason | Effect |
|---|---|---|
| 1 | Missing await |
request(app).post(...) returns a thenable object that only performs the request when it is awaited. The test finishes without the request ever being sent |
| 2 | The test is not async |
Without async you cannot use await, and the runner treats the test as finished immediately |
| 3 | assert.ok(response) checks nothing |
An object is always truthy. It would pass just the same with a 500, with a 400 or with the application down. Besides, the body sent is invalid (origin, roast, priceEuros and stock are missing) and there is no token, so the real response would be 401 |
The correct version:
it('creates a coffee with valid data', async () => {
const response = await request(app)
.post('/v1/coffees')
.set('Authorization', tokenFor('employee', 'cus_001'))
.send({
name: 'Kenya Nyeri',
origin: 'Kenya',
roast: 'medium',
priceEuros: 16.75,
stock: 40,
});
assert.equal(response.status, 201);
assert.ok(response.headers.location);
assert.equal(response.body.name, 'Kenya Nyeri');
assert.equal(response.body.priceEuros, 16.75);
});The general lesson: a test with no concrete assertions is worse than no test at all, because it gives a false sense of security and it also counts towards coverage. A useful rule: if the test stays green when you deliberately break the code, the test is worthless.
Solution 3
Paths in errors.js that are probably not covered:
| Path | Does it deserve a test? |
|---|---|
translateSqlite with SQLITE_CONSTRAINT_UNIQUE |
Yes: it is a real race (two registrations with the same email) |
translateSqlite with SQLITE_CONSTRAINT_FOREIGNKEY |
Yes: an order for a nonexistent customer |
translateSqlite with SQLITE_BUSY |
No: hard to provoke and its logic is trivial |
A generic error → 500 internal_error |
Yes, the most important one |
The Accept-Patch branch on 415 |
Already covered by the PATCH test |
The Allow branch on 405 |
Already covered by the DELETE-on-collection test |
The debug field outside production |
Yes: check that it does not appear with NODE_ENV=production |
The test for the most important case:
// tests/integration/errors.test.js
import './../helpers/test-environment.js';
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import express from 'express';
import { errorHandler } from '../../src/middleware/errors.js';
import { assignTraceId } from '../../src/middleware/trace.js';
import { asyncHandler } from '../../src/middleware/async-handler.js';
/**
* A minimal application is mounted with a route that blows up on purpose.
* Provoking a real bug in the actual application would be brittle; here we
* test the MIDDLEWARE, which is what we want to verify.
*/
function createFailingApp() {
const app = express();
app.use(assignTraceId);
app.get(
'/boom',
asyncHandler(async () => {
throw new TypeError("Cannot read properties of null (reading 'map')");
})
);
app.use(errorHandler);
return app;
}
describe('unexpected errors', () => {
const app = createFailingApp();
it('returns 500 internal_error with a traceId and leaking nothing', async () => {
const r = await request(app).get('/boom');
assert.equal(r.status, 500);
assert.equal(r.body.error.code, 'internal_error');
assert.equal(r.body.error.message, 'An unexpected error has occurred.');
assert.deepEqual(r.body.error.details, []);
// traceId present and correlated with the header.
assert.match(r.body.error.traceId, /^trc_[0-9a-f]{8}$/);
assert.equal(r.headers['aroma-trace-id'], r.body.error.traceId);
// And the essential part: NOTHING from the inside has leaked.
const text = JSON.stringify(r.body);
assert.ok(!text.includes('TypeError'), 'the error type must not appear');
assert.ok(!text.includes('Cannot read properties'), 'the internal message must not appear');
assert.ok(!text.includes('.js:'), 'no stack trace must appear');
});
it('the asyncHandler wrapper catches the rejection: the request DOES respond', async () => {
// Without asyncHandler(), this request would hang and the test would time out.
const r = await request(app).get('/boom');
assert.ok(r.status, 'there must be a response, not a hang');
});
});The three negative assertions at the end are the core: they check that the consumer does not see the type of exception, nor the internal message, nor any file path. It is a security test rather than a functional one, and it is one of the few written in the negative. And the second test verifies the asyncHandler() wrapper: if somebody removed it, this test would fail with a timeout instead of passing, which is exactly the signal we want.
Conclusion
The module closes with the only guarantee that is worth anything: the implementation checks itself. You have unit tests verifying that €16.75 is 1675 cents and comes back as €16.75, that the mapper does not leak active or version, and that a shipped order offers return and no longer pay; service tests with an injected fake repository, made possible because in 03-03 we separated the layers and in 03-05 we kept the in-memory store; and integration tests with Supertest over the app object, made possible because in 03-02 we did not call listen() there, walking through status codes, the Location, Link, Allow and Accept-Patch headers, the exact shape of the body, the complete validation details and the permission matrix, including the other-customer ?customerId test that is the only one capable of detecting an IDOR. You also know what coverage measures and what it does not, that contract tests against openapi.yaml are the next rung, and you have a checklist applicable to any REST API, not just this one.
And with that, module 3 is finished. You started from the paper contract of module 2 and built a complete API: a reproducible environment with the configuration in the environment and validated at start-up; an Express server with its middleware chain properly ordered and versioning made concrete in /v1; three layers with real boundaries, a mapper concentrating the representation decisions and the complete write cycle with its codes and headers; declarative validation that rejects strict input and returns every failure at once; SQL persistence with migrations, prepared statements, atomic transactions, optimistic concurrency and cursor pagination; authentication with JWT and authorisation by role and by ownership; a single error middleware that never leaks the inside of the system; and a test suite protecting all of the above.
What you have is a correct API. What it is not yet is a production-ready API. In module 4, Best Practices and Security, it gets hardened: we will go over the design best practices that separate a decent API from an excellent one (04-01); we will cover the real threats and their defences, from the OWASP API Security Top 10 to security headers (04-02); we will implement OAuth 2.0 and OpenID Connect so that third parties can gain access without knowing the passwords (04-03); we will add request limits with 429 and Retry-After so that nobody can saturate it (04-04); we will configure CORS so the SPA can call it from another domain without opening the door to everyone (04-05); we will add HTTP caching with ETag, Cache-Control and conditional requests, which is where version_conflict will be reunited with If-Match and the 412 (04-06); and we will replace the console.log calls with real observability —structured logs, metrics and distributed traces— making use of the traceId we already emit (04-07).
REST API Course: Principles of Designing and Developing RESTful APIs
Module 1: Introduction to RESTful APIs
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
