Theme 05 · Test-Confidence Oriented
Integration tests
Explanation
Integration tests
Plain Human Explanation
An integration test proves that a user-facing path works when several pieces are connected. It is not trying to test every tiny branch. It is asking, “When this request comes in, do validation, business rules, persistence, and response behavior line up?”
For product work, this is often the test that catches the bug users would actually feel: a route accepts bad data, a database field is missing, or the response says success even though nothing useful happened.
Technical Explanation
Exercise a realistic slice of the system: handler, parser, domain logic, persistence boundary, and response. Keep setup local and deterministic. Prefer one or two important paths over a large suite that repeats every unit-test case through the full stack.
Integration tests are especially useful around routes, background jobs, migrations, webhooks, and permission checks because those paths depend on multiple modules agreeing.
Why It Matters
- User impact: the test covers the behavior a user, support person, or downstream system will see.
- Product behavior: connected pieces must agree on required fields, status codes, saved records, and failure shapes.
- Risk: isolated unit tests can pass while the actual route or job is wired incorrectly.
- Decision point: add an integration test for high-value flows where wiring mistakes are likely or expensive.
The Core Move
Test the smallest connected path that represents the product promise. Keep edge-case detail in faster tests, and use integration tests to prove the pieces work together.
Small Example
Integration tests: Small Example
Bad TypeScript Example
type UserStore = {
create(input: { email: string }): Promise<{
id: string;
email: string;
}>;
};
test("signup creates a user", async () => {
const store: UserStore = {
create: vi.fn().mockResolvedValue({ id: "user_1", email: "a@example.com" }),
};
await store.create({ email: "a@example.com" });
expect(store.create).toHaveBeenCalledWith({ email: "a@example.com" });
});
type UserStore = {
create(input: {
email: string;
}): Promise<{
id: string;
email: string;
}>;
};
test("signup creates a user", async () => {
const store: UserStore = {
create: vi.fn().mockResolvedValue({
id: "user_1",
email: "a@example.com",
}),
};
await store.create({
email: "a@example.com",
});
expect(store.create).toHaveBeenCalledWith(
{
email: "a@example.com",
},
);
});
Good TypeScript Example
type SignupResponse = {
status: number;
body: {
id?: string;
error?: string;
};
};
type UserStore = {
create(input: { email: string }): Promise<{
id: string;
email: string;
}>;
findByEmail(email: string): Promise<{ id: string; email: string } | null>;
};
async function signup(email: string, store: UserStore): Promise<SignupResponse> {
if (!email.includes("@")) return { status: 400, body: { error: "invalid-email" } };
const user = await store.create({ email });
return { status: 201, body: { id: user.id } };
}
test("signup stores the user and returns a created response", async () => {
const users = new Map<
string,
{
id: string;
email: string;
}
>();
const store: UserStore = {
async create(input) {
const user = { id: "user_1", email: input.email };
users.set(input.email, user);
return user;
},
async findByEmail(email) {
return users.get(email) ?? null;
},
};
const response = await signup("a@example.com", store);
expect(response).toEqual({ status: 201, body: { id: "user_1" } });
await expect(store.findByEmail("a@example.com")).resolves.toEqual({
id: "user_1",
email: "a@example.com",
});
});
type SignupResponse = {
status: number;
body: {
id?: string;
error?: string;
};
};
type UserStore = {
create(input: {
email: string;
}): Promise<{
id: string;
email: string;
}>;
findByEmail(email: string): Promise<{
id: string;
email: string;
} | null>;
};
async function signup(
email: string,
store: UserStore,
): Promise<SignupResponse> {
if (!email.includes("@"))
return {
status: 400,
body: {
error: "invalid-email",
},
};
const user = await store.create({
email,
});
return {
status: 201,
body: {
id: user.id,
},
};
}
test("signup stores the user and returns a created response", async () => {
const users = new Map<
string,
{
id: string;
email: string;
}
>();
const store: UserStore = {
async create(input) {
const user = {
id: "user_1",
email: input.email,
};
users.set(input.email, user);
return user;
},
async findByEmail(email) {
return users.get(email) ?? null;
},
};
const response = await signup(
"a@example.com",
store,
);
expect(response).toEqual({
status: 201,
body: {
id: "user_1",
},
});
await expect(
store.findByEmail("a@example.com"),
).resolves.toEqual({
id: "user_1",
email: "a@example.com",
});
});
What Changed
- The bad version only proves a mocked method was called.
- The good version checks the connected behavior: validation, store write, and response.
- The test still stays small because the store is local and deterministic.
Realistic Example
Integration tests: Realistic Example
Checkout behavior depends on the request parser, pricing rule, order store, and HTTP response. A useful integration test proves those pieces agree on one realistic purchase.
Bad TypeScript Example
test("checkout calls the order service", async () => {
const orders = { create: vi.fn().mockResolvedValue({ id: "ord_1" }) };
const request = { body: { sku: "pro", quantity: 2 } };
await checkoutRoute(request, { orders });
expect(orders.create).toHaveBeenCalled();
});
test("checkout calls the order service", async () => {
const orders = {
create: vi.fn().mockResolvedValue({
id: "ord_1",
}),
};
const request = {
body: {
sku: "pro",
quantity: 2,
},
};
await checkoutRoute(request, {
orders,
});
expect(orders.create).toHaveBeenCalled();
});
Good TypeScript Example
type CheckoutRequest = {
body: {
sku?: string;
quantity?: number;
};
};
type OrderRecord = {
id: string;
sku: "starter" | "pro";
quantity: number;
totalCents: number;
};
type OrderStore = {
create(order: Omit<OrderRecord, "id">): Promise<OrderRecord>;
list(): Promise<OrderRecord[]>;
};
async function checkoutRoute(request: CheckoutRequest, orders: OrderStore) {
if (request.body.sku !== "starter" && request.body.sku !== "pro") {
return { status: 400, body: { error: "invalid-sku" } };
}
if (!Number.isInteger(request.body.quantity) || request.body.quantity < 1) {
return { status: 400, body: { error: "invalid-quantity" } };
}
const price = request.body.sku === "pro" ? 2000 : 900;
const order = await orders.create({
sku: request.body.sku,
quantity: request.body.quantity,
totalCents: price * request.body.quantity,
});
return { status: 201, body: { orderId: order.id, totalCents: order.totalCents } };
}
test("checkout prices and stores a valid order", async () => {
const records: OrderRecord[] = [];
const store: OrderStore = {
async create(order) {
const record = { id: `ord_${records.length + 1}`, ...order };
records.push(record);
return record;
},
async list() {
return records;
},
};
const response = await checkoutRoute({ body: { sku: "pro", quantity: 2 } }, store);
expect(response).toEqual({ status: 201, body: { orderId: "ord_1", totalCents: 4000 } });
await expect(store.list()).resolves.toEqual([{ id: "ord_1", sku: "pro", quantity: 2, totalCents: 4000 }]);
});
type CheckoutRequest = {
body: {
sku?: string;
quantity?: number;
};
};
type OrderRecord = {
id: string;
sku: "starter" | "pro";
quantity: number;
totalCents: number;
};
type OrderStore = {
create(
order: Omit<OrderRecord, "id">,
): Promise<OrderRecord>;
list(): Promise<OrderRecord[]>;
};
async function checkoutRoute(
request: CheckoutRequest,
orders: OrderStore,
) {
if (
request.body.sku !== "starter" &&
request.body.sku !== "pro"
) {
return {
status: 400,
body: {
error: "invalid-sku",
},
};
}
if (
!Number.isInteger(
request.body.quantity,
) ||
request.body.quantity < 1
) {
return {
status: 400,
body: {
error: "invalid-quantity",
},
};
}
const price =
request.body.sku === "pro" ? 2000 : 900;
const order = await orders.create({
sku: request.body.sku,
quantity: request.body.quantity,
totalCents:
price * request.body.quantity,
});
return {
status: 201,
body: {
orderId: order.id,
totalCents: order.totalCents,
},
};
}
test("checkout prices and stores a valid order", async () => {
const records: OrderRecord[] = [];
const store: OrderStore = {
async create(order) {
const record = {
id: `ord_${records.length + 1}`,
...order,
};
records.push(record);
return record;
},
async list() {
return records;
},
};
const response = await checkoutRoute(
{
body: {
sku: "pro",
quantity: 2,
},
},
store,
);
expect(response).toEqual({
status: 201,
body: {
orderId: "ord_1",
totalCents: 4000,
},
});
await expect(
store.list(),
).resolves.toEqual([
{
id: "ord_1",
sku: "pro",
quantity: 2,
totalCents: 4000,
},
]);
});
What Changed
- The bad version does not prove pricing, persistence, or the response body.
- The good version exercises the connected route path with a realistic request.
- The test checks the user-visible response and the record the system depends on later.
System Example
Integration tests: System Example
At system scale, integration tests should protect the flows where wiring mistakes become product incidents: checkout, login, permissions, imports, exports, jobs, and webhooks.
Larger System-Level Bad TypeScript Example
test("webhook handles payment", async () => {
const billing = { markPaid: vi.fn() };
const email = { sendReceipt: vi.fn() };
await handlePaymentWebhook({ type: "invoice.paid", customerId: "cus_1" }, { billing, email });
expect(billing.markPaid).toHaveBeenCalled();
expect(email.sendReceipt).toHaveBeenCalled();
});
test("webhook handles payment", async () => {
const billing = {
markPaid: vi.fn(),
};
const email = {
sendReceipt: vi.fn(),
};
await handlePaymentWebhook(
{
type: "invoice.paid",
customerId: "cus_1",
},
{
billing,
email,
},
);
expect(
billing.markPaid,
).toHaveBeenCalled();
expect(
email.sendReceipt,
).toHaveBeenCalled();
});
Larger System-Level Good TypeScript Example
type PaymentWebhook = {
type: "invoice.paid";
customerId: string;
invoiceId: string;
};
type SubscriptionRecord = {
customerId: string;
status: "trialing" | "active";
lastInvoiceId?: string;
};
type BillingStore = {
seed(record: SubscriptionRecord): Promise<void>;
markInvoicePaid(customerId: string, invoiceId: string): Promise<void>;
findByCustomer(customerId: string): Promise<SubscriptionRecord | null>;
};
type Outbox = {
enqueue(message: { kind: "receipt"; customerId: string; invoiceId: string }): Promise<void>;
all(): Promise<
Array<{
kind: "receipt";
customerId: string;
invoiceId: string;
}>
>;
};
async function handlePaymentWebhook(event: PaymentWebhook, store: BillingStore, outbox: Outbox) {
await store.markInvoicePaid(event.customerId, event.invoiceId);
await outbox.enqueue({ kind: "receipt", customerId: event.customerId, invoiceId: event.invoiceId });
return { status: 200 };
}
test("paid invoice activates subscription and queues receipt", async () => {
const records = new Map<string, SubscriptionRecord>();
const messages: Array<{
kind: "receipt";
customerId: string;
invoiceId: string;
}> = [];
const store: BillingStore = {
async seed(record) {
records.set(record.customerId, record);
},
async markInvoicePaid(customerId, invoiceId) {
const current = records.get(customerId);
if (!current) throw new Error("subscription missing");
records.set(customerId, { ...current, status: "active", lastInvoiceId: invoiceId });
},
async findByCustomer(customerId) {
return records.get(customerId) ?? null;
},
};
const outbox: Outbox = {
async enqueue(message) {
messages.push(message);
},
async all() {
return messages;
},
};
await store.seed({ customerId: "cus_1", status: "trialing" });
const response = await handlePaymentWebhook({ type: "invoice.paid", customerId: "cus_1", invoiceId: "in_1" }, store, outbox);
expect(response).toEqual({ status: 200 });
await expect(store.findByCustomer("cus_1")).resolves.toEqual({
customerId: "cus_1",
status: "active",
lastInvoiceId: "in_1",
});
await expect(outbox.all()).resolves.toEqual([{ kind: "receipt", customerId: "cus_1", invoiceId: "in_1" }]);
});
type PaymentWebhook = {
type: "invoice.paid";
customerId: string;
invoiceId: string;
};
type SubscriptionRecord = {
customerId: string;
status: "trialing" | "active";
lastInvoiceId?: string;
};
type BillingStore = {
seed(
record: SubscriptionRecord,
): Promise<void>;
markInvoicePaid(
customerId: string,
invoiceId: string,
): Promise<void>;
findByCustomer(
customerId: string,
): Promise<SubscriptionRecord | null>;
};
type Outbox = {
enqueue(message: {
kind: "receipt";
customerId: string;
invoiceId: string;
}): Promise<void>;
all(): Promise<
Array<{
kind: "receipt";
customerId: string;
invoiceId: string;
}>
>;
};
async function handlePaymentWebhook(
event: PaymentWebhook,
store: BillingStore,
outbox: Outbox,
) {
await store.markInvoicePaid(
event.customerId,
event.invoiceId,
);
await outbox.enqueue({
kind: "receipt",
customerId: event.customerId,
invoiceId: event.invoiceId,
});
return {
status: 200,
};
}
test("paid invoice activates subscription and queues receipt", async () => {
const records = new Map<
string,
SubscriptionRecord
>();
const messages: Array<{
kind: "receipt";
customerId: string;
invoiceId: string;
}> = [];
const store: BillingStore = {
async seed(record) {
records.set(
record.customerId,
record,
);
},
async markInvoicePaid(
customerId,
invoiceId,
) {
const current =
records.get(customerId);
if (!current)
throw new Error(
"subscription missing",
);
records.set(customerId, {
...current,
status: "active",
lastInvoiceId: invoiceId,
});
},
async findByCustomer(customerId) {
return (
records.get(customerId) ?? null
);
},
};
const outbox: Outbox = {
async enqueue(message) {
messages.push(message);
},
async all() {
return messages;
},
};
await store.seed({
customerId: "cus_1",
status: "trialing",
});
const response =
await handlePaymentWebhook(
{
type: "invoice.paid",
customerId: "cus_1",
invoiceId: "in_1",
},
store,
outbox,
);
expect(response).toEqual({
status: 200,
});
await expect(
store.findByCustomer("cus_1"),
).resolves.toEqual({
customerId: "cus_1",
status: "active",
lastInvoiceId: "in_1",
});
await expect(
outbox.all(),
).resolves.toEqual([
{
kind: "receipt",
customerId: "cus_1",
invoiceId: "in_1",
},
]);
});
What Changed
- The bad version checks calls but not the final subscription state.
- The good version proves the connected webhook behavior the product relies on.
- The test covers the saved state and queued follow-up without requiring real billing or email providers.
When To Use It
Integration tests: When To Use It
Use This When
- A route, job, webhook, or command depends on several modules agreeing.
- The most likely bug is wiring: wrong field name, missing persistence, wrong status code, or skipped permission check.
- The flow is important enough that one realistic happy path and one important failure path would reduce release risk.
Avoid This When
- A pure function can prove the rule faster and more clearly.
- The test repeats every unit-test edge case through a slow full-stack setup.
- The setup is so broad that failures do not point to a clear product path.
Tradeoffs
Integration tests are slower and a little more expensive to set up than unit tests. They earn that cost when they prove connected behavior that smaller tests cannot see.
Related Concepts
- Real seams
- SQLite/local substitutes
- Observable behavior over mocks/spies
Practice Prompt
Integration tests: Practice Prompt
Beginner Exercise
Pick one important route or job. Write down the user-visible result and one saved record or emitted message that should exist afterward.
Intermediate Exercise
Write a local integration test for that path using real validation and real domain logic with a local store or in-memory substitute.
Stretch Exercise
Add one failure-path integration test for the highest-risk rejected input, missing permission, or duplicate event.
Reflection Question
Which bug would this integration test catch that your unit tests would probably miss?
Suggest an edit
Leave a private editorial note. This creates a GitHub issue for this curriculum page.