Testing From Zero

Integration and API Tests

Testing an Express API in-process, test database strategies, contracts, webhooks and queues.

Lesson 0315 min read#testing#api#integration

On this page

  1. Words used in this lesson
  2. What "integration" means
  3. The app under test
  4. Calling the app in-process with supertest
  5. A test-database strategy
  6. Seeding data
  7. Testing auth
  8. Contract testing, explained simply
  9. Webhooks and queues: test at the boundary
  10. Running the whole suite
  11. Speed tips
  12. Try it
  13. Interview questions
  14. Key takeaways
  15. What's next

In one line: an integration test checks that your pieces work together (routes, validation, auth, the database, the queue boundary) by calling the real app in the same process. It is slower than a unit test, but it catches the bugs that unit tests cannot see: wrong SQL, wrong status codes, missing auth checks.

Lesson 1 taught unit tests and test doubles. Lesson 2 tested UI components. Now we test the part in the middle: an HTTP API with a real database. We build a small notes API with Express, test it with supertest, and cover a test-database strategy, seeding, auth, contracts, webhooks, queues and speed.

This lesson does not repeat what other pages already cover. For the "unit, integration, contract" decision in a backend see Backend, Lesson 11. For Prisma-based database tests and tool reference see Full-Stack Lesson 21. For where integration tests sit in a frontend plan see Frontend Depth, Lesson 5. For the team-level test plan see Team Lead, Lesson 6. The next lesson, Lesson 4, moves to a real browser.

What I ran / checked. Everything below was run on Node v22.22.0 with the built-in test runner (node --test), express 5.3.0, supertest 7.3.1 and ajv 8.20.0. The database is SQLite 3.50.4 through node:sqlite, which is built into Node 22. It prints an ExperimentalWarning, so the API may change. I did not run PostgreSQL: this machine has the psql client but no server, so every Postgres sentence in this lesson is "not run here". I read the supertest README on GitHub (ladjs/supertest) for the ephemeral-port rule and the Node test runner doc (nodejs/node, doc/api/test.md) for the runner flags. Timings are from this one machine and only show a rough size. The output has the experimental warning removed (NODE_NO_WARNINGS=1), nothing else.

Words used in this lesson

  • Integration test: a test that runs two or more real parts of your system together, for example the route handler plus the database. It does not need to cover the whole system.
  • In-process: the app and the test run inside the same Node process. No separate server to start.
  • Ephemeral port: a free port that the operating system picks for you when you ask for port 0.
  • supertest: a library that sends HTTP requests to your app and gives you the response to check.
  • Transaction rollback: a transaction is a group of database steps that succeed or fail together. A rollback undoes all of them. We can use it to wipe a test's changes.
  • Contract: a written agreement about what a request and a response look like (names, types, required fields).
  • Consumer-driven contract: a contract written by the caller (the consumer) of an API. It lists only the fields the caller really uses.
  • OpenAPI: a standard file format (JSON or YAML) that describes the paths, inputs and outputs of an HTTP API.
  • Webhook: an HTTP request that another service sends to you when something happens (for example "payment done").
  • Idempotent: doing it twice has the same result as doing it once.
  • Queue: a place where one part of the system puts messages and another picks them up later.

1. What "integration" means

The word is vague, so teams argue about it. Here is a working definition:

A test is an integration test when it uses two or more of your own real parts that a unit test would have replaced with a fake.

Three levels are common:

LevelReal partsFaked partsTypical speed
Unitone functioneverything elseunder 1 ms
Integration (this lesson)route + validation + auth + real databasethe outside world (payment provider, email, queue broker)5 to 50 ms
End-to-end (Lesson 4)browser + server + databasealmost nothing1 to 10 seconds

The rule I use: fake only what you do not own and cannot run cheaply (a payment provider, an email service). Keep real what you own and can run cheaply (your routes, your SQL, a local database). A fake database tells you nothing about your SQL. A real one tells you a lot.

2. The app under test

I wrote a small notes API. It has four things worth testing: bearer-token auth, validation, a per-user rule ("you only see your own notes"), and a webhook with a signature. It publishes a message to a queue when a note is created.

The key design choice is that createApp takes its dependencies as arguments (database, queue, clock, secret) and returns an Express app. It does not call listen. That one choice makes in-process testing easy.

JavaScript
// db.js (excerpt)import { DatabaseSync } from 'node:sqlite';export const SCHEMA = `  CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, token TEXT UNIQUE NOT NULL);  CREATE TABLE notes (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id),                      title TEXT NOT NULL, created_at TEXT NOT NULL);  CREATE TABLE payments (event_id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, amount INTEGER NOT NULL);`;export function openDb(file = ':memory:') {  const db = new DatabaseSync(file);  db.exec('PRAGMA foreign_keys = ON');  db.exec(SCHEMA);  return db;}
JavaScript
// app.js (excerpt: the notes routes)export function createApp({ db, queue, webhookSecret, now = () => new Date() }) {  const app = express();  app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));  const auth = (req, res, next) => {    const m = /^Bearer (.+)$/.exec(req.get('authorization') ?? '');    const user = m && db.prepare('SELECT * FROM users WHERE token = ?').get(m[1]);    if (!user) return res.status(401).json({ error: 'unauthorized' });    req.user = user;    next();  };  app.post('/notes', auth, (req, res) => {    const title = req.body?.title;    if (typeof title !== 'string' || title.trim() === '') {      return res.status(400).json({ error: 'title is required' });    }    const info = db.prepare('INSERT INTO notes (user_id, title, created_at) VALUES (?, ?, ?)')      .run(req.user.id, title.trim(), now().toISOString());    queue.publish('note.created', { noteId: Number(info.lastInsertRowid), userId: req.user.id });    res.status(201).json({ id: Number(info.lastInsertRowid), title: title.trim() });  });  app.delete('/notes/:id', auth, (req, res) => {    const info = db.prepare('DELETE FROM notes WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);    res.status(info.changes ? 204 : 404).end();  });  // ... GET /notes and POST /webhooks/payment are in section 8  return app;}

The now argument is a clock you can control. A test passes () => new Date('2026-01-01T00:00:00Z') and can then assert the exact created_at. This is the cheapest fix for time-based flakiness, and Lesson 4 uses the same idea in the browser.

3. Calling the app in-process with supertest

supertest takes your app and sends a real HTTP request to it. The supertest README says that if the server is not already listening, it "is bound to an ephemeral port for you", so you do not manage ports. You write the test like this:

JavaScript
// test/helpers.js (excerpt)export function fakeQueue() {  const messages = [];  return { messages, publish: (name, payload) => messages.push({ name, payload }) };}export function seedUser(db, email = 'ada@example.com', token = 'tok-ada') {  const { lastInsertRowid } = db.prepare('INSERT INTO users (email, token) VALUES (?, ?)').run(email, token);  return { id: Number(lastInsertRowid), email, token };}export function makeWorld() {            // a fresh app + fresh database + fake queue  const db = openDb();  const queue = fakeQueue();  const secret = 'whsec_test';  const app = createApp({ db, queue, webhookSecret: secret, now: () => new Date('2026-01-01T00:00:00Z') });  return { db, queue, secret, app };}
JavaScript
// test/notes.test.jsimport { test, describe, beforeEach } from 'node:test';import assert from 'node:assert/strict';import request from 'supertest';import { makeWorld, seedUser } from './helpers.js';describe('notes API', () => {  let w, ada;  beforeEach(() => { w = makeWorld(); ada = seedUser(w.db); });   // fresh DB for every test  test('rejects a request with no token', async () => {    const res = await request(w.app).get('/notes');    assert.equal(res.status, 401);  });  test('creates a note, stores it, and publishes an event', async () => {    const res = await request(w.app).post('/notes').set('Authorization', `Bearer ${ada.token}`)      .send({ title: '  Buy milk ' });    assert.equal(res.status, 201);    assert.deepEqual(res.body, { id: 1, title: 'Buy milk' });    const row = w.db.prepare('SELECT * FROM notes WHERE id = 1').get();    assert.equal(row.created_at, '2026-01-01T00:00:00.000Z');    assert.deepEqual(w.queue.messages, [{ name: 'note.created', payload: { noteId: 1, userId: ada.id } }]);  });  test('validates the body', async () => {    const res = await request(w.app).post('/notes').set('Authorization', `Bearer ${ada.token}`).send({ title: '  ' });    assert.equal(res.status, 400);    assert.equal(w.queue.messages.length, 0);          // nothing published on failure  });});

Look at what each test checks. The "creates a note" test checks four things at once: the HTTP answer, the stored row, the clock, and the message at the queue boundary. A unit test of the handler with a mocked database could never tell you that the SQL is right.

What to assert

Check the status code, the body shape, the side effects (rows, messages) and the non-effects on failure (a rejected request must not insert or publish). Assert headers only when they matter (Location, Set-Cookie).

The same test over a real socket

Sometimes you want a real socket, for example to test with fetch, a browser, or a client library. Ask for port 0 and read the port back:

JavaScript
before(async () => {  w = makeWorld(); seedUser(w.db);  server = w.app.listen(0);                       // port 0 = "OS, pick a free port"  await new Promise((r) => server.once('listening', r));  base = `http://127.0.0.1:${server.address().port}`;});after(() => server.close());test('real HTTP over a real socket with fetch', async () => {  const res = await fetch(`${base}/notes`, { method: 'POST',    headers: { authorization: 'Bearer tok-ada', 'content-type': 'application/json' },    body: JSON.stringify({ title: 'hello' }) });  assert.equal(res.status, 201);});

A fixed port like 3000 breaks as soon as two test files run at the same time, or a dev server is open. Port 0 never collides.

4. A test-database strategy

Every integration test needs a database in a known state. There are three common ways. All three ran in test/strategies.test.js.

JavaScript
const count = (db) => db.prepare('SELECT count(*) AS n FROM users').get().n;const add = (db, e) => db.prepare('INSERT INTO users (email, token) VALUES (?, ?)').run(e, 't-' + e);test('A. transaction rollback: undo everything after the test', () => {  const db = openDb();  db.exec('BEGIN');  add(db, 'a@x.io');  assert.equal(count(db), 1);  db.exec('ROLLBACK');                       // the "cleanup": one statement, very fast  assert.equal(count(db), 0);});test('B. truncate (delete in FK order) before each test', () => {  const db = openDb();  add(db, 'a@x.io');  for (const t of ['payments', 'notes', 'users']) db.exec(`DELETE FROM ${t}`);  assert.equal(count(db), 0);});test('C. a private database per test (fresh file or schema)', () => {  const dir = mkdtempSync(join(tmpdir(), 'dbtest-'));  const a = openDb(join(dir, 'a.db')), b = openDb(join(dir, 'b.db'));  add(a, 'a@x.io');  assert.equal(count(a), 1);  assert.equal(count(b), 0);                 // b never sees a's data: safe to run in parallel});
StrategyHow it worksGoodWatch out
A. Transaction rollbackBEGIN in beforeEach, ROLLBACK in afterEachFastest cleanup. No leftover data.The code under test must use the same connection. If it opens its own, or commits, the rollback does nothing. Tests that check transaction behaviour (locks, COMMIT) cannot use it.
B. Truncate / deleteEmpty the tables before each testSimple. Works with any connection and any code.Slower on big schemas. You must list tables in foreign-key order, or use TRUNCATE ... CASCADE in Postgres.
C. Private database or schema per test (or per worker)A new in-memory DB, a new file, or in Postgres a new schema or a template database copyTotal isolation. Tests can run in parallel.Set-up cost. In Postgres, creating a schema and running migrations per test is slow. Do it per worker, not per test.

My default advice:

  1. For SQLite, use :memory: per test (what makeWorld does). Opening a fresh in-memory database costs almost nothing.
  2. For Postgres (not run here), create the schema once per test run, then clean with B or A between tests. If tests run in parallel, give each worker its own database or schema.
  3. Never point tests at a shared development or staging database. Tests delete things.

Do not replace the real database with a different one "for speed" without knowing the gap. My examples use SQLite because I could run it here. If production is Postgres, SQLite and Postgres differ in types, RETURNING, JSON operators, locking and case rules. A test that passes on SQLite can still fail on Postgres. If production is Postgres, the best test database is Postgres, in a container or a local server. Tools like Testcontainers do this; I did not run them.

5. Seeding data

Tests need rows. There are three ways to get them, from worst to best:

  1. One big shared seed file. Every test depends on it, nobody dares to change it, and tests break when someone adds a row. Avoid it.
  2. Inline SQL in each test. Clear, but noisy.
  3. Small helper functions (a "factory"). Each test builds exactly what it needs. Defaults are filled in; the test overrides only what it cares about.

seedUser(db, email, token) above is a tiny factory. A good factory has three habits:

  • It returns the created object (with its id), so the test does not guess.
  • It gives each call unique default values when the column is unique. Fixed emails collide as soon as you create two users.
  • It does not hide the thing the test is about. If the test is about "a user with no notes", it says so: seedUser(db) and no seedNote call.

Keep the data small. A test with 3 rows is easier to read and faster than a test with the whole production dump.

6. Testing auth

Auth bugs are the most expensive bugs an API can have, and unit tests rarely catch them. A short checklist, all in the same style:

CaseExpected
No Authorization header401
Bad or expired token401
Valid token, but not your resource404 (or 403), and the data is unchanged
Valid token, your resource2xx
Role-limited route with a normal user403

The most important one is the third: an authenticated user must not touch another user's data. It has a name, "broken object-level authorization", and it is common in real APIs. Here is the test:

JavaScript
test("a user cannot see or delete another user's notes", async () => {  const bob = seedUser(w.db, 'bob@example.com', 'tok-bob');  await request(w.app).post('/notes').set('Authorization', `Bearer ${ada.token}`).send({ title: 'secret' });  const list = await request(w.app).get('/notes').set('Authorization', `Bearer ${bob.token}`);  assert.deepEqual(list.body, []);  const del = await request(w.app).delete('/notes/1').set('Authorization', `Bearer ${bob.token}`);  assert.equal(del.status, 404);  assert.equal(w.db.prepare('SELECT count(*) AS n FROM notes').get().n, 1);   // still there});

Notice the last line. A test that only checks 404 would pass even if the row had been deleted. Always check the data too. For cookie sessions, supertest offers request.agent(app), which keeps cookies between requests, like a browser does. For login in a browser, see storage state in Lesson 4.

7. Contract testing, explained simply

A contract answers one question: "If the server says it returns { id, title }, can the client trust that?" Two teams, one API, and one team renames title to name. Unit tests on both sides still pass. Production breaks. A contract test catches this earlier.

There are two common ways.

Way 1: validate responses against an OpenAPI schema (provider side)

The API's owner writes a schema (usually inside an OpenAPI file). A test calls the real endpoint and checks the response against the schema. I used ajv, a JSON Schema validator:

JavaScript
const openapi = { components: { schemas: { Note: {  type: 'object', required: ['id', 'title'], additionalProperties: false,  properties: { id: { type: 'integer' }, title: { type: 'string', minLength: 1 } } } } } };const validateNote = new Ajv().compile(openapi.components.schemas.Note);test('POST /notes response matches the Note schema', async () => {  const w = makeWorld(); const u = seedUser(w.db);  const res = await request(w.app).post('/notes').set('Authorization', `Bearer ${u.token}`).send({ title: 'x' });  assert.ok(validateNote(res.body), JSON.stringify(validateNote.errors));});

When I changed the response so title became name, the validator reported:

Text
[["required","must have required property 'title'"]]

(That was a separate quick script that validated { id: 1, name: 'x' } against the same schema, not a change to the API.) The point is that the docs file and the code can no longer drift apart in silence. If you generate the OpenAPI file from the code, or the code from the file, the drift is smaller still. See tRPC, REST and OpenAPI for generating clients.

Way 2: consumer-driven contracts

Here the caller writes down what it needs: "I read id (number) and title (string). I do not care about anything else." The provider runs those expectations in its own tests. If the provider wants to remove title, a test fails before release, and it names the consumer who would break.

My toy version is only the idea:

JavaScript
const consumerExpects = { id: 'number', title: 'string' };const providerResponse = { id: 1, title: 'x', created_at: 'an extra field is fine' };for (const [k, type] of Object.entries(consumerExpects)) assert.equal(typeof providerResponse[k], type);

Real tools (the best known is Pact) store these expectations as files, share them between repositories, and check them on both sides. I did not run Pact, so I only describe it. Note the nice property: extra fields are fine, because the consumer only listed what it reads. That is why consumer-driven contracts let providers add fields safely, but warn them before they remove one.

OpenAPI validationConsumer-driven contract
Who writes itThe API ownerEach caller
Question it answers"Does my API match my docs?""Will I break any real caller?"
Best whenPublic API, many unknown callersA few known callers, often in the same company

8. Webhooks and queues: test at the boundary

Two systems talk through a boundary: an HTTP request from outside, or a message on a queue. The best place to test is exactly at that boundary, because that is where your code meets the outside.

Incoming webhook

A payment provider sends POST /webhooks/payment with a signature header. Your code must (1) check the signature, (2) handle duplicates, because providers retry. The handler:

JavaScript
app.post('/webhooks/payment', (req, res) => {  const expected = crypto.createHmac('sha256', webhookSecret).update(req.rawBody ?? '').digest('hex');  const given = req.get('x-signature') ?? '';  const ok = given.length === expected.length &&    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));  if (!ok) return res.status(401).json({ error: 'bad signature' });  const { id, userId, amount } = req.body;  const info = db.prepare('INSERT OR IGNORE INTO payments (event_id, user_id, amount) VALUES (?, ?, ?)')    .run(id, userId, amount);  res.status(200).json({ duplicate: info.changes === 0 });});

The tests sign the body the same way the provider does and send it twice:

JavaScript
test('rejects a wrong signature', async () => {  const res = await post(body, 'deadbeef');  assert.equal(res.status, 401);});test('accepts a valid signature and is idempotent', async () => {  const sig = sign(w.secret, body);  assert.deepEqual((await post(body, sig)).body, { duplicate: false });  assert.deepEqual((await post(body, sig)).body, { duplicate: true });   // provider retried  assert.equal(w.db.prepare('SELECT count(*) AS n FROM payments').get().n, 1);});

The signature is computed over the exact raw bytes of the body. If you parse JSON first and re-serialise it, the bytes can change (spaces, key order) and a valid signature fails. That is why app.js keeps req.rawBody in the express.json verify hook. Test this with the same bytes you sign.

Outgoing queue

When POST /notes succeeds it publishes note.created. In tests the queue is a fake with the same interface: it records messages in an array. We assert that the message was published (success) and was not published (validation failure). That tests our side of the boundary.

What this does not prove: that the real broker accepts the message, or that the worker handles it. Cover those with:

  • A separate unit or integration test of the worker: give it a message, check its effect. Queue messages are also a contract, so write the message shape down.
  • A few real-broker tests (a Redis or RabbitMQ container) for the glue code only. I did not run a broker in this lesson.
  • For the "write to database and publish a message" problem, which can lose messages, look up the outbox pattern. You test it by checking that the message row and the data row are saved together.

9. Running the whole suite

Text
> t03@1.0.0 test> node --test --test-reporter=spec test/*.test.js✔ POST /notes response matches the Note schema (67.597038ms)✔ consumer-driven contract: what the web client reads (0.375365ms)▶ notes API  ✔ rejects a request with no token (33.600641ms)  ✔ creates a note, stores it, and publishes an event (17.826821ms)  ✔ validates the body (8.768989ms)  ✔ a user cannot see or delete another user's notes (22.627347ms)✔ notes API (84.291922ms)port used: true | status: 201✔ real HTTP over a real socket with fetch (85.053442ms)✔ A. transaction rollback: undo everything after the test (13.195698ms)✔ B. truncate (delete in FK order) before each test (0.646898ms)✔ C. a private database per test (fresh file or schema) (55.29625ms)▶ payment webhook  ✔ rejects a wrong signature (38.38613ms)  ✔ accepts a valid signature and is idempotent (14.084848ms)✔ payment webhook (53.483506ms)ℹ tests 12ℹ pass 12ℹ fail 0ℹ duration_ms 801.919821

Twelve tests, real routes, real SQL, real signatures, in about 0.8 seconds on this machine (the whole node --test process took 0.78 s wall time, including start-up). The first test in each file is slower (module loading, first request): 30 to 70 ms. Later requests take 10 to 25 ms.

10. Speed tips

Slow integration tests get skipped, so speed is a feature.

  1. Build the app once per file, reset data per test. Creating the app is cheap here, but a real app may load config, connect to a pool and run migrations. Do that in before, not beforeEach.
  2. Run migrations once per run, not once per test. Keep the schema in a template database and copy it.
  3. Use rollback or truncate for cleanup, not drop-and-recreate.
  4. Run files in parallel, tests inside a file in series. The Node runner runs separate test files in separate processes at the same time, and the number is controlled by --test-concurrency. Parallel files need separate databases (strategy C) or separate schemas.
  5. Fake only slow, outside things: the network, the clock (use a fake you control), the email provider. Keep your own database real.
  6. Do not log in through the UI in an API test. Insert a user with a token (like seedUser) and send the header.

Try it

  1. Add a PATCH /notes/:id that changes the title. Write four tests: no token, someone else's note, empty title, success. Answer: the "someone else's note" test must also check the row is unchanged.
  2. Break the WHERE user_id = ? in DELETE /notes/:id on purpose. Which test fails? Answer: "a user cannot see or delete another user's notes". If none fails in your project, you found a missing test.
  3. Change strategy A (rollback) so the handler opens its own database connection. Why does the cleanup stop working? Answer: a rollback only undoes work done on the same connection that ran BEGIN.

Interview questions

What is an integration test, and how is it different from a unit test? (Must know)

A unit test checks one piece alone, with its neighbours replaced by fakes. An integration test runs several of my own real pieces together, for example a route, validation, auth and a real database, and fakes only the outside world. A unit test tells me my logic is right. An integration test tells me the pieces fit: the SQL runs, the status codes are right, and the auth check is really attached to the route.

How do you test an HTTP API without starting a server by hand? (Must know)

I export a function that builds the app (createApp) and does not call listen. In a test I hand that app to supertest, which binds it to an ephemeral port itself. If I need a real socket, I call listen(0), read the port from server.address(), and close the server in after. I never hard-code a port.

Follow-up: Why not port 3000? Two test files, or a dev server, would collide, and the failure looks random.

How do you give each test a clean database? (Must know)

Three ways: wrap each test in a transaction and roll back, delete or truncate the tables before each test, or give each test (or worker) its own database or schema. Rollback is fastest, but it only works if the code under test uses the same connection. Truncate is the simplest. A private database is the safest for parallel runs. I run migrations once per test run, not per test.

Should integration tests use a real database or an in-memory fake? (Senior signal)

A real one of the same kind as production. SQL, types, locking and JSON behaviour differ between engines, so a test on a different engine can pass while production fails. I use SQLite in examples when production is SQLite. If production is Postgres, I run Postgres locally or in a container. I only fake what I do not own: payment providers, email, third-party APIs.

How do you test authentication and authorization? (Must know)

Authentication: no token and bad token give 401. Authorization: a valid user who asks for another user's resource gets 404 or 403, and I check the data was not changed. I also test role-limited routes with a normal user. The cross-user test is the one that finds real bugs, so I write it for every route that takes an id.

What is contract testing, and why do we need it? (Senior signal)

Unit tests on the client and the server can both pass while the two disagree about a field name. A contract is a written agreement of the request and response shape. There are two styles: validating the real responses against an OpenAPI schema (provider checks it matches its docs), and consumer-driven contracts (each caller lists the fields it reads, and the provider runs those checks, so removing a field breaks a test before release). OpenAPI validation suits public APIs; consumer-driven suits a few known callers.

Follow-up: Can a provider add a field? Yes. Consumers only listed what they read, so extra fields are safe. Removing or renaming one is the breaking change.

How do you test a webhook handler? (Senior signal)

I test at the HTTP boundary: send a correctly signed request, and a wrongly signed one. A wrong signature gets 401 and changes nothing. Then I send the same event twice, because providers retry, and check it is stored once. I sign the exact raw body bytes, since re-serialising JSON can change the signature. I never call the real provider in a test.

How do you test code that publishes to a queue? (Senior signal)

Behind an interface (publish(name, payload)) I use a fake queue that records messages. I assert the message after success and no message after failure. That checks my side of the boundary. Then I test the worker separately by giving it a message and checking the effect, and I keep a few tests against a real broker for the glue. The message shape is itself a contract, so I write it down.

Your integration tests are slow. What do you do? (Must know)

I measure first: the runner shows each test's time. Typical fixes are building the app and running migrations once per file or run instead of per test, cleaning with rollback or truncate instead of recreating the schema, running test files in parallel with separate databases, keeping seed data small, and skipping the UI for login. I do not remove the real database to gain speed unless I accept the loss of coverage.

Why not test everything through the full stack with a browser? (Trap)

Because browser tests are slow and break for reasons unrelated to the change. An API integration test checks the same rules (validation, auth, SQL) in about 10 to 25 ms on my machine, instead of seconds. I keep browser tests for a few key user journeys (Lesson 4) and put the many edge cases at the API level.

Key takeaways

  • An integration test uses several of your own real parts together and fakes only the outside world.
  • Build the app with a function that takes its dependencies (database, queue, clock). Do not call listen in it. Then supertest or listen(0) is enough.
  • Give every test a known database state: rollback, truncate, or a private database. Use the same engine as production when you can.
  • Seed with small factory functions, not one giant file.
  • Test auth with the cross-user case, and always check the data as well as the status code.
  • Contracts stop two teams from drifting apart: OpenAPI validation for the provider, consumer-driven contracts for known callers.
  • Test webhooks and queues at the boundary: signature, duplicates, published messages. Test workers separately.
  • Speed comes from doing set-up once, cheap cleanup, parallel files and small data.

What's next

Lesson 4: End-to-End Tests with Playwright, and Fixing Flaky Tests takes the same ideas into a real browser: when it is worth the cost, how to write tests that do not flake, and how to fix the ones that do.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series