π°οΈ Testing API Endpoints
Your API is the contract between the front end, other services, and the outside world. In this lesson you'll drive that contract with supertest β sending real HTTP requests straight into your Express app, checking the status codes and JSON that come back, and confirming the database really changed.
Week 12 · Day 2 (Tuesday: Integration Testing) · Lecture 2
π― Learning Objectives
By the end of this lesson, you will be able to:
- Structure an app so the Express instance can be tested without starting a server
- Send GET, POST, PUT, and DELETE requests with supertest and assert on status and body
- Write a full CRUD test suite for a resource against a real test database
- Test authentication and authorization with JWT bearer tokens
- Cover the unhappy paths: validation errors, 401, 403, 404, and 409
- Verify persistence β that a request truly changed the database, not just the response
Estimated Time: 70 minutes
Practice: Build a CRUD test suite for a products API, including an admin-only create and a validation failure.
In This Lesson
Why Test at the HTTP Layer?
Think of your API as a vending machine. You could unit-test the internal coil-release motor in isolation β but what customers actually care about is: put in the right code, get the right snack; put in a bad code, get a clear error and your money back. Endpoint tests check the machine through the slot, exactly the way a real caller uses it.
An endpoint test sends a genuine HTTP request β method, URL, headers, body β through your entire Express stack (routing, middleware, controller, database) and asserts on the response. That single test exercises more wiring than a dozen isolated unit tests, which is why it gives such high confidence per line.
π₯ When an untested endpoint bites
A financial services company once shipped a "small" schema change that had never been exercised through the API. In development the validation behaved one way; in production, subtly another. The endpoints returned inconsistent data for hours, costing real money and trust. One endpoint test asserting on the response shape would have caught the mismatch before release.
Making the App Testable
The single most important setup step is to separate building the app from starting it. If your file both defines routes and calls app.listen(), then importing it in a test would bind a real network port β slow, and a nightmare for parallel runs.
Split into two files. app.js builds and exports the Express instance. server.js imports it and listens. Tests import app and never touch a port.
// src/app.js β builds the app, exports it, does NOT listen
const express = require('express');
const usersRouter = require('./routes/users');
const errorHandler = require('./middleware/errorHandler');
const app = express();
app.use(express.json()); // parse JSON request bodies
app.use('/api/users', usersRouter);
app.use(errorHandler); // centralized error-to-response mapping
module.exports = app;
// src/server.js β the only place a real port is opened
const app = require('./app');
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Server running on port ${port}`));
server.js runs it for real, the test hands it to supertest, which manages an ephemeral port for you.supertest in 90 Seconds
supertest wraps your Express app, boots it on a throwaway port for the duration of one request, sends the request, and gives you a fluent API to assert on the response. You chain .expect() calls for quick checks and drop into Jest's expect() for deeper assertions on the body.
const request = require('supertest');
const app = require('../src/app');
test('GET /api/users returns an empty list initially', async () => {
const res = await request(app)
.get('/api/users') // method + path
.expect('Content-Type', /json/) // assert a header
.expect(200); // assert the status code
expect(res.body).toEqual([]); // assert on the parsed JSON body
});
The anatomy of a request builder:
| Call | What it does |
|---|---|
.get(path) / .post(path) | Sets the HTTP method and URL |
.send(body) | Attaches a JSON request body |
.set(header, value) | Adds a header (e.g. Authorization) |
.query(obj) | Adds a query string (e.g. ?page=2) |
.expect(status) | Asserts the response status code |
res.body | The parsed JSON response, ready for expect() |
Here is the full round trip a single supertest call makes through your stack:
A Full CRUD Suite
Let's test a products resource end to end. The suite connects once, resets before each test, and covers create, read, update, and delete. Notice that every mutating test asserts on both the response and the database β the response could lie, the database cannot.
const request = require('supertest');
const app = require('../src/app');
const db = require('../src/db');
beforeAll(async () => {
await db.connect();
await db.migrate();
});
afterAll(async () => {
await db.disconnect();
});
beforeEach(async () => {
await db.truncateAll(); // isolation: every test starts empty
});
// A factory keeps test data valid and unique
function makeProduct(overrides = {}) {
const n = Math.random().toString(36).slice(2, 8);
return { name: `Widget ${n}`, price: 19.99, sku: `SKU-${n}`, ...overrides };
}
describe('Products API', () => {
test('POST creates a product and persists it', async () => {
const res = await request(app)
.post('/api/products')
.send(makeProduct({ name: 'Test Widget' }))
.expect('Content-Type', /json/)
.expect(201);
expect(res.body.id).toBeDefined();
expect(res.body.name).toBe('Test Widget');
// Verify persistence directly
const saved = await db.findProductById(res.body.id);
expect(saved).toBeTruthy();
expect(saved.name).toBe('Test Widget');
});
test('GET returns all products', async () => {
await db.createProduct(makeProduct());
await db.createProduct(makeProduct());
const res = await request(app).get('/api/products').expect(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body).toHaveLength(2);
});
test('GET /:id returns one product', async () => {
const created = await db.createProduct(makeProduct({ name: 'Findable' }));
const res = await request(app)
.get(`/api/products/${created.id}`)
.expect(200);
expect(res.body.id).toBe(created.id);
expect(res.body.name).toBe('Findable');
});
test('PUT updates a product', async () => {
const created = await db.createProduct(makeProduct({ price: 10 }));
const res = await request(app)
.put(`/api/products/${created.id}`)
.send({ price: 42 })
.expect(200);
expect(res.body.price).toBe(42);
const saved = await db.findProductById(created.id);
expect(saved.price).toBe(42); // change really persisted
});
test('DELETE removes a product', async () => {
const created = await db.createProduct(makeProduct());
await request(app)
.delete(`/api/products/${created.id}`)
.expect(204); // No Content
const saved = await db.findProductById(created.id);
expect(saved).toBeNull(); // gone from the database
});
});
π‘ Query strings for filtering & pagination
Use .query() to test list features cleanly. For example, await request(app).get('/api/products').query({ page: 1, limit: 5 }).expect(200) and then assert res.body has length 5. Seed enough rows in the test's own setup so the assertion is meaningful.
Testing Authentication
Most real endpoints are protected. To test them you generate a valid JWT in the test, attach it as a bearer token, and assert the endpoint lets you through. You also test the opposite: no token gets a 401, and a valid-but-insufficient token gets a 403.
const jwt = require('jsonwebtoken');
const request = require('supertest');
const app = require('../src/app');
const db = require('../src/db');
// Helper: create a user in the DB and mint a token for them
async function makeAuthedUser(role = 'user') {
const user = await db.createUser({
name: 'Auth User',
email: `auth-${Date.now()}@example.com`,
role,
});
const token = jwt.sign({ id: user.id, role: user.role }, process.env.JWT_SECRET);
return { user, token };
}
describe('Protected routes', () => {
test('rejects a request with no token', async () => {
await request(app).get('/api/products/secret').expect(401);
});
test('rejects a request with a bad token', async () => {
await request(app)
.get('/api/products/secret')
.set('Authorization', 'Bearer not.a.real.token')
.expect(401);
});
test('allows an authenticated request', async () => {
const { token } = await makeAuthedUser();
const res = await request(app)
.get('/api/products/secret')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(res.body).toBeDefined();
});
test('forbids a regular user from an admin action', async () => {
const { token } = await makeAuthedUser('user'); // NOT an admin
await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Nope', price: 1, sku: 'X' })
.expect(403); // Forbidden
});
});
β 401 vs 403 β know the difference
401 Unauthorized means "I don't know who you are" β the token is missing or invalid. 403 Forbidden means "I know who you are, and you're not allowed" β a valid user without the required role. Test both; they're guarded by different middleware and both are easy to get wrong.
For a registration/login flow, also assert on the security-critical details: the password is hashed (never returned), and a login with the correct password issues a working token.
const bcrypt = require('bcrypt');
test('register hashes the password and returns a token', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({ name: 'Ada', email: 'ada@example.com', password: 'Str0ng!pass' })
.expect(201);
expect(res.body.token).toBeDefined();
expect(res.body.user.password).toBeUndefined(); // never leak the password
const saved = await db.findUserByEmail('ada@example.com');
expect(saved.password).not.toBe('Str0ng!pass'); // stored value is hashed
expect(await bcrypt.compare('Str0ng!pass', saved.password)).toBe(true);
});
Testing the Unhappy Paths
Beginners test the happy path and stop. Robust APIs are defined by how they fail. For every endpoint, ask: what does a caller see when the input is wrong, the resource is missing, or a uniqueness rule is broken? Each answer is a test.
| Status | Meaning | Trigger to test |
|---|---|---|
400 | Bad Request | Missing or wrong-typed fields |
401 | Unauthorized | No / invalid token |
403 | Forbidden | Valid user, insufficient role |
404 | Not Found | ID that doesn't exist |
409 | Conflict | Duplicate unique value (email, SKU) |
describe('Error handling', () => {
test('400 when a required field is missing', async () => {
const res = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: 'No price or sku' }) // invalid
.expect(400);
expect(res.body.error).toBeDefined();
});
test('404 for a product that does not exist', async () => {
await request(app).get('/api/products/999999').expect(404);
});
test('409 when creating a duplicate SKU', async () => {
await db.createProduct(makeProduct({ sku: 'DUP-1' }));
const res = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send(makeProduct({ sku: 'DUP-1' })) // same SKU
.expect(409);
expect(res.body.error).toMatch(/sku/i);
});
});
β οΈ Don't leak internals in error responses
When testing a 500, assert that the response gives a friendly message and does not expose stack traces, SQL, or "Database connection lost". A good error test checks both: the right status and the absence of sensitive detail: expect(res.body.error).not.toContain('at Object').
Practice & Quiz
ποΈ Exercise 1: The create-then-read test
Goal: Write a supertest test that POSTs a new product, expects 201, then GETs it back by the returned id and confirms the name matches.
π‘ Hint
Capture the create response, read res.body.id, and use it in the follow-up GET path. Assert on the second response's body.
β Solution
test('create then read a product', async () => {
const created = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: 'Round Trip', price: 5, sku: 'RT-1' })
.expect(201);
const res = await request(app)
.get(`/api/products/${created.body.id}`)
.expect(200);
expect(res.body.name).toBe('Round Trip');
});
ποΈ Exercise 2: Guard an admin-only route
Goal: Write two tests for DELETE /api/products/:id: an admin token succeeds with 204, a regular user token is rejected with 403 and the product still exists afterward.
β Solution
test('admin can delete', async () => {
const p = await db.createProduct(makeProduct());
await request(app)
.delete(`/api/products/${p.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(204);
expect(await db.findProductById(p.id)).toBeNull();
});
test('regular user cannot delete', async () => {
const p = await db.createProduct(makeProduct());
await request(app)
.delete(`/api/products/${p.id}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(403);
expect(await db.findProductById(p.id)).toBeTruthy(); // still there
});
π― Quick Quiz
Question 1: Why do you pass app (not a running server) to supertest?
Question 2: A valid, logged-in user hits an admin-only endpoint. What status should you assert?
Question 3: After a POST returns 201, why also query the database directly?
Best Practices & Pitfalls
β Do
- Split
app.jsfromserver.jsand hand the app to supertest - Assert on the status code and the JSON body, not just one
- Verify persistence by querying the test database after a mutation
- Cover the unhappy paths: 400, 401, 403, 404, 409
- Generate auth tokens in a small helper so token setup stays DRY
- Reset the database in
beforeEachso tests are order-independent
β Don't
- Call
app.listen()in code you import into tests - Only test the happy path β the failures are where bugs hide
- Trust the response body alone as proof the write happened
- Hard-code a token string that expires or leaks a real secret
- Return stack traces or SQL in error responses (test that you don't)
π Keep tests readable with ArrangeβActβAssert
Structure each test in three visual blocks: Arrange the data (seed the DB, mint a token), Act by sending one request, Assert on status, body, and persistence. When every test follows the same shape, a reviewer can scan the suite in seconds.
Summary
π Key Takeaways
- Split app from server so supertest can drive the app with no live port
- supertest sends real HTTP through the whole Express stack and lets you assert on status, headers, and body
- A CRUD suite asserts on the response and verifies the database changed
- Test auth with real JWTs: 401 for no/invalid token, 403 for the wrong role
- Cover the unhappy paths β 400, 404, 409 β deliberately
- Reset between tests so the suite is order-independent
π Additional Resources
- supertest β official README and API
- Jest β the expect assertion reference
- Express β error handling middleware
- MDN β HTTP response status codes
π What's Next?
Your endpoint tests only tell the truth if the database underneath them is real, fast, and isolated. Next up: Database Testing β how to stand up a genuine test database with Docker or Testcontainers, seed fixtures, and roll back between tests so every run starts clean.
π Well done!
You can now hold your API to its contract β every status code, every body, every failure mode, proven by a test.