02 Integration tests 6 chapters

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.

  • 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.