“What’s the difference between a unit test and an integration test?” is one of the most common testing questions, and the textbook answer (one tests a unit in isolation, the other tests units together) doesn’t help much when you’re deciding what to write on a Tuesday afternoon. A more useful framing: a unit test checks your logic with every collaborator replaced by something you control, and an integration test checks that your code works with something real you don’t control, like a database, a message broker, the file system or another service.
Each catches bugs the other can’t. The quickest way to see it is a small example where the unit tests are good and still miss two real bugs.
The service and its unit tests
A sign-up service normalises the email, validates it, rejects duplicates and saves the user through a repository:
export class ValidationError extends Error {}
export class DuplicateEmailError extends Error {}
export function register({ email, name }, users) {
const normalized = email.trim().toLowerCase();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalized)) throw new ValidationError(`invalid email: ${email}`);
if (users.findByEmail(normalized)) throw new DuplicateEmailError(normalized);
return users.insert({ email: normalized, name });
}users is a parameter, so unit tests can pass an in-memory fake with the same two methods. These tests use Node’s built-in runner:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { register, ValidationError, DuplicateEmailError } from './users.js';
function fakeUsers() {
const rows = new Map();
return {
findByEmail: (email) => rows.get(email),
insert: (u) => {
const row = { id: rows.size + 1, ...u };
rows.set(u.email, row);
return row;
},
};
}
test('normalises the email before saving', () => {
const users = fakeUsers();
const saved = register({ email: ' Ada@Example.COM ', name: 'Ada' }, users);
assert.equal(saved.email, 'ada@example.com');
});
test('rejects an email without a domain', () => {
assert.throws(() => register({ email: 'ada@', name: 'Ada' }, fakeUsers()), ValidationError);
});
test('rejects a second account for the same address', () => {
const users = fakeUsers();
register({ email: 'ada@example.com', name: 'Ada' }, users);
assert.throws(() => register({ email: 'ADA@example.com', name: 'Ada L' }, users), DuplicateEmailError);
});All three pass in well under a millisecond each. They’re good tests: each checks one rule, failures point straight at the logic, and you could have hundreds without slowing anyone down. This is where business rules should be tested.
The real repository and what the fakes missed
Here’s the production repository, using node:sqlite (built into Node 22; it prints an ExperimentalWarning on first use):
import { DatabaseSync } from 'node:sqlite';
export function openDb(file = ':memory:') {
const db = new DatabaseSync(file);
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL
)`);
return db;
}
export class SqliteUsers {
constructor(db) {
this.db = db;
}
findByEmail(email) {
return this.db.prepare('SELECT id, email, name FROM users WHERE email = ?').get(email);
}
insert({ email, name }) {
const { lastInsertRowid } = this.db
.prepare('INSERT INTO users (name, email) VALUES (?, ?)')
.run(email, name);
return { id: Number(lastInsertRowid), email, name };
}
}Look closely at insert. The column list says (name, email) but the values are passed as (email, name). Someone reordered the columns in the SQL and forgot the arguments. Both columns are TEXT, so the database accepts it. And insert returns the object it was given, not what was stored, so the caller sees correct data.
An integration test runs the real service against the real repository and an in-memory SQLite database, then reads the row back:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { register, DuplicateEmailError } from './users.js';
import { openDb, SqliteUsers } from './sqlite-users.js';
test('a registered user can be read back from the database', () => {
const db = openDb();
const users = new SqliteUsers(db);
register({ email: 'grace@example.com', name: 'Grace' }, users);
assert.deepEqual(
{ ...users.findByEmail('grace@example.com') },
{ id: 1, email: 'grace@example.com', name: 'Grace' },
);
});Running both files together (node --test --test-reporter=spec):
✖ a registered user can be read back from the database (8.278542ms)
✔ normalises the email before saving (0.495208ms)
✔ rejects an email without a domain (0.2815ms)
✔ rejects a second account for the same address (0.080167ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
+ {}
- {
- email: 'grace@example.com',
- id: 1,
- name: 'Grace'
- }The lookup by email found nothing, because the email column contains Grace. In production this would have meant every user could register twice and nobody could log in. No unit test could catch it, because the fake is the thing that’s correct; the bug is in the code the fake replaces. Swap the arguments to .run(name, email) and the test passes.
Notice the cost difference too: about 8 ms for the integration test (most of it opening the database and creating the table) against a fraction of a millisecond for each unit test. With SQLite in memory that’s still cheap. With PostgreSQL in a container, the first test in a run may take seconds while the container starts.
The second bug: what the database says when you lose a race
The duplicate check in register is check-then-act: look the email up, and insert if it’s free. Two sign-up requests for the same address arriving at the same moment can both pass the lookup before either inserts. The UNIQUE constraint on the column is the real protection, so the question is what happens when it fires.
A unit test can’t tell you, because the fake has no constraint. An integration test can simulate the race by giving register a repository whose lookup sees nothing (as the second request would have) but whose insert hits the real table:
test('a duplicate that slips past the lookup is still a DuplicateEmailError', () => {
const db = openDb();
const users = new SqliteUsers(db);
register({ email: 'grace@example.com', name: 'Grace' }, users);
// A second request that ran its lookup before the first one committed:
const racing = { findByEmail: () => undefined, insert: (u) => users.insert(u) };
assert.throws(
() => register({ email: 'grace@example.com', name: 'Grace H' }, racing),
DuplicateEmailError,
);
});✖ a duplicate that slips past the lookup is still a DuplicateEmailError (0.809833ms)
AssertionError [ERR_ASSERTION]: The error is expected to be an instance of "DuplicateEmailError". Received "Error"
Error message:
UNIQUE constraint failed: users.emailThe raw driver error escapes. The HTTP layer, which maps DuplicateEmailError to a friendly 409, would return a 500 instead. The fix belongs in the adapter, which is the only code that knows about SQLite’s error codes:
const SQLITE_CONSTRAINT_UNIQUE = 2067;
insert({ email, name }) {
try {
const { lastInsertRowid } = this.db
.prepare('INSERT INTO users (name, email) VALUES (?, ?)')
.run(name, email);
return { id: Number(lastInsertRowid), email, name };
} catch (err) {
if (err.errcode === SQLITE_CONSTRAINT_UNIQUE) throw new DuplicateEmailError(email, { cause: err });
throw err;
}
}The 2067 came from inspecting the thrown error: node:sqlite reports code: 'ERR_SQLITE_ERROR', errcode: 2067 and errstr: 'constraint failed'. That kind of detail is exactly what you only learn by running against the real dependency. After the change, all five tests pass.
Where each kind of test belongs
The example suggests a division of labour that scales:
- Business rules go in unit tests. Validation, normalisation, pricing, state transitions. They’re fast, precise, and you want many cases.
- Adapters get integration tests. Every piece of code that talks to something external (a repository, an HTTP client, a queue consumer) gets a small number of tests against the real thing: a round trip, each constraint or error mapping, and any query with non-trivial SQL. These tests target the adapter, not every rule above it.
- A few broader tests check the wiring. One or two tests that go from the HTTP handler to the database confirm that configuration and dependency wiring are right. Those are expensive, so keep them few.
Two misconceptions come up in interviews. The first is that integration tests are just slow unit tests and should be replaced with mocks for speed. As shown above, mocking the database removes exactly the code that had the bugs. The second is the opposite: “we only trust tests that hit a real database”. That makes every validation case pay the database’s start-up cost and produces failures that are harder to read. Both kinds earn their place.
It also helps to agree on definitions inside a team. Martin Fowler notes that “integration test” is used for both narrow tests of one adapter against a real dependency and broad tests of many services together. If one person means the first and another means the second, a discussion about “more integration tests” goes nowhere.
Interview exercise: the mocked ORM
A team’s service has 900 unit tests that mock the ORM (prisma.user.findUnique returns a canned object) and no tests that touch PostgreSQL. In the last quarter there were three production incidents: a migration that dropped a default value, a query that was case-sensitive when it shouldn’t be, and a transaction that didn’t roll back on error. The team proposes adding more unit tests. What do you recommend?
Answer and reasoning
None of those three bugs can be caught with a mocked ORM, because each lives in the database or in how the code uses it: schema defaults, collation and query semantics, and transaction behaviour. More unit tests would add confidence in code that wasn’t the problem.
I’d introduce a thin layer of integration tests at the repository boundary, running against a real PostgreSQL started once per test run (for example in a container), with the schema created by the same migrations production uses. Each test runs in its own transaction or its own schema so tests stay isolated. I’d start with tests that reproduce the three incidents: insert without the defaulted column and check the stored value; look up an email with different casing; force an error mid-transaction and check nothing was written. Then add a round-trip test for each repository method. I’d also look at the 900 unit tests: those that only assert that the mock was called with particular arguments are testing the implementation, and some can be replaced by fewer, clearer tests of the service’s behaviour against an in-memory fake of the repository, now that the real repository has its own integration tests.