Backend in Depth
Testing an API: Unit, Integration and Contract
Where to spend your testing effort in a backend, testing against a real database, and contract tests between the frontend and the API.
On this page
- Words used in this lesson
- What to test, and at what level
- Unit tests: pure logic
- Integration tests: through HTTP, against a real database
- What to mock (and what not)
- Contract tests
- Other kinds of testing worth knowing
- Practices that keep a test suite healthy
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: test behaviour at the right level. Write many fast unit tests for pure business logic. Add a solid layer of integration tests that go through HTTP and use a real database. Add a few contract tests that stop the frontend and backend from drifting apart. Mock the edges, not your own code.
On the frontend you know the rule "test what the user sees." The backend version is "test what the API promises." That means status codes, response bodies, changes in the database, and the rules behind them. This lesson gives you a plan you can explain in an interview, and examples you can copy.
Words used in this lesson
- Test is a small program that runs your code and checks that the result is what you expect. If the result is different, the test fails.
- Unit test checks one small piece (a function) alone. Integration test checks several real pieces working together. Contract test checks that two sides agree on the shape of the data. End-to-end (E2E) test uses the whole running system like a real user. Difference in one line: the more pieces a test includes, the slower it is, but the more real problems it finds.
- Mock is a stand-in object that replaces a real part and records how it was called, so the test can check the calls. Fake is a simple working stand-in (for example a database kept in memory). Spy wraps a real function and records its calls.
- Assertion is one check inside a test, such as
expect(x).toBe(5). - Vitest is a test runner (a tool that finds and runs tests). Supertest is a library that sends HTTP requests to your app inside a test.
- Docker is a tool that runs programs in isolated containers. CI (continuous integration) is a server that runs your tests automatically on every change.
- Pure function is a function that gives the same output for the same input and changes nothing outside itself.
What to test, and at what level
| Level | What it tests | Speed | Where it helps most |
|---|---|---|---|
| Unit | One function or class alone: pricing, eligibility, state machines, parsers, policy functions | Milliseconds | Pure business logic with many edge cases |
| Integration | Several real parts together: HTTP route → validation → service → real database | Tens to hundreds of ms | The most valuable layer for APIs. It catches wiring, SQL, constraint and serialisation bugs |
| Contract | The agreement between two services or clients (the shape of requests and responses) | Fast | Stops "the backend changed a field and the frontend broke" |
| End-to-end | The deployed system, used through a browser or a full client | Seconds | Only a few important user journeys (login, checkout) |
The usual shape is a test pyramid: many unit tests for logic, a thick layer of integration tests for endpoints, and a few end-to-end tests. Some teams prefer a "trophy" shape. That means more integration tests than unit tests, instead of a strict pyramid. For CRUD-style backends (create, read, update, delete) this is usually right. Most bugs live in the seams, the places where parts connect. They do not live inside single functions.
Why can you not rely only on unit tests with a mocked database? The test can pass while the real SQL fails because of a missing column, a constraint, or how a transaction behaves. The mock simply does what you told it to do. Your assumptions are the thing that might be wrong.
Unit tests: pure logic
Clean code puts rules into pure functions. A pure function gives the same output for the same input and changes nothing outside itself. These are very easy to test:
import { describe, it, expect } from "vitest";import { calculateDiscount } from "./pricing";describe("calculateDiscount", () => { it.each([ [{ subtotal: 10_000, itemCount: 1 }, { tier: "gold" }, 1_000], // gold = 10% [{ subtotal: 10_000, itemCount: 10 }, { tier: "free" }, 500], // bulk = 5% [{ subtotal: 10_000, itemCount: 2 }, { tier: "free" }, 0], [{ subtotal: 0, itemCount: 0 }, { tier: "gold" }, 0], // edge case ])("order %j, customer %j → %i", (order, customer, expected) => { expect(calculateDiscount(order, customer)).toBe(expected); });});A good unit test has these traits:
- no network
- no database
- no real clock (pass the clock in)
- no randomness
- checks of outputs, not of which inner helper was called
Integration tests: through HTTP, against a real database
Use Supertest (or fetch against buildApp(); see Lesson 01) with a real PostgreSQL, usually in Docker. TRUNCATE is the SQL command that deletes all rows of a table quickly.
import request from "supertest";import { afterAll, beforeEach, describe, it, expect } from "vitest";import { buildApp } from "../src/app";import { prisma } from "../src/lib/prisma";import { createUser, createOrderFor, authFor } from "./helpers";const app = buildApp();beforeEach(async () => { // Reset state so tests are independent await prisma.$executeRaw`TRUNCATE TABLE "OrderItem", "Order", "User" RESTART IDENTITY CASCADE`;});afterAll(() => prisma.$disconnect());describe("POST /v1/orders", () => { it("creates an order and returns 201 with Location", async () => { const user = await createUser(); const res = await request(app) .post("/v1/orders") .set(authFor(user)) .send({ items: [{ sku: "SKU-1", qty: 2 }] }); expect(res.status).toBe(201); expect(res.headers.location).toMatch(/\/v1\/orders\/.+/); expect(res.body).toMatchObject({ status: "pending", userId: user.id }); const saved = await prisma.order.findUnique({ where: { id: res.body.id }, include: { items: true } }); expect(saved?.items).toHaveLength(1); // verify the side effect, not just the response }); it("rejects an empty cart with 422 and field errors", async () => { const res = await request(app).post("/v1/orders").set(authFor(await createUser())).send({ items: [] }); expect(res.status).toBe(422); expect(res.body.errors[0].path).toContain("items"); }); it("returns 401 without credentials", async () => { expect((await request(app).post("/v1/orders").send({})).status).toBe(401); }); it("does not let user B read user A's order", async () => { const [a, b] = [await createUser(), await createUser()]; const order = await createOrderFor(a); const res = await request(app).get(`/v1/orders/${order.id}`).set(authFor(b)); expect(res.status).toBe(404); // 404, not 403: do not reveal that the order exists });});Make the database part easy
- Run a real PostgreSQL in Docker. Use
docker composein CI and on your machine. Or use Testcontainers, a library that starts a throwaway container from the test code and removes it afterwards. - Apply the real migrations (
prisma migrate deploy, which runs saved database changes) to the test database. Then the schema looks like production. - Keep tests independent. Test files run in parallel by default in Vitest. If they all share one database, a
TRUNCATEin one file wipes the data of another. You have three options:- Truncate (empty) the tables between tests. This is simple but a little slower.
- Wrap each test in a transaction that rolls back. This is fast, but harder if your code also uses transactions.
- Give each test file its own schema or database, so files can run in parallel.
- Use factories or builders (small helper functions that create test data, such as
createUser()andcreateOrderFor(user)). Then setup is short and easy to read. - Keep tests deterministic. Deterministic means the same result every time. Control time (
vi.useFakeTimers()or a clock you pass in), randomness and ID generation.
Test the unhappy paths
The unhappy path is what happens when things go wrong. For each endpoint, test these cases:
- validation failures
- not logged in (unauthenticated: the server does not know who you are, status 401)
- not allowed (unauthorized or forbidden: the server knows you, but you may not do this, for example another user's data, or a lower role)
- not found
- conflicts (duplicates, or editing an old version of a record)
- idempotent retries (sending the same request again gives the same result)
- dependency failures, such as a payment provider that is down
Error handling is behaviour, so it needs tests too (Lesson 04).
What to mock (and what not)
Mock the boundaries that you do not own and cannot run cheaply. Do not mock the things you want to check.
| Mock / fake | Don't mock |
|---|---|
| Third-party HTTP APIs (payments, email, SMS). Use a local fake server such as MSW (Mock Service Worker) or nock (libraries that catch outgoing HTTP calls and return answers you define), or a fake that follows your gateway interface | Your own services and repositories, when you test an endpoint |
| The clock, randomness, UUID generation | The database, in integration tests |
| Slow or unpredictable resources (a large cloud service) | The framework (for example Express, the web library that handles routes) |
| Queues in unit tests (a queue is a list of jobs waiting for a worker; use a fake publisher that records messages) | The validation layer |
If you mock too much, your tests only check the mocks. Here is a warning sign: you must change tests every time you refactor, even though the behaviour did not change.
Fakes, mocks and spies:
- A fake is a simple working version, such as a repository that keeps data in memory.
- A mock checks how it was called.
- A spy wraps a real function so you can watch it.
Prefer fakes. Prefer checks on results over checks on how something was called.
// A fake payment gateway implementing the same interface (injected, see Lesson 00).// It keeps charges in memory and can be told to fail.class FakeGateway implements PaymentGateway { charges: { orderId: string; amount: number }[] = []; shouldFail = false; async charge(orderId: string, amount: number) { if (this.shouldFail) throw new PaymentUnavailableError(); this.charges.push({ orderId, amount }); return { id: "ch_fake" }; }}Contract tests
Scenario: the backend renames total to totalAmount. The backend unit and integration tests still pass, because someone updated them. The frontend still reads total, so it breaks in production. Which test was missing? A contract test. It is an automatic check that the shape both sides rely on has not changed.
Approaches:
- A shared schema package in a monorepo (one repository holding several projects; Lesson 03). The frontend and backend import the same Zod schemas (Zod is a library that describes and checks the shape of data) and types. So a rename causes a compile error on both sides. The API test also checks that responses match the schema (
orderResponseSchema.parse(res.body)). - OpenAPI as the contract. OpenAPI is a standard file that describes every endpoint of a REST API. Generate types and clients from it. In CI, run a breaking-change detector (
oasdiff,openapi-diff) on pull requests. In tests, check real responses against the spec. - Consumer-driven contracts (Pact). Pact is a tool for this. The consumer (frontend) records the requests it makes and the responses it needs. The provider (API) checks in its own CI that it can meet them. This is good when you have many independent consumers.
- A snapshot of the API schema (a saved copy of the schema; for GraphQL, a registry compares each change to it). CI fails when a change is breaking.
Also protect your database migrations. Test that the migration works on a copy of production-like data. Use backward-compatible (expand/contract) changes, so old and new code can work together during a rolling deployment (servers are replaced one by one) (Lesson 08).
Other kinds of testing worth knowing
- Load or performance tests (k6 and autocannon send many requests at once) in CI or staging for important endpoints. Set limits on p95 and p99 (the time that 95 or 99 out of 100 requests beat).
- Security tests. Test who can do what (an authorization matrix), audit your dependencies, and run an automatic scan from time to time. Add a regression test (a test that stops an old bug from coming back) for every security bug.
- Concurrency tests. Send parallel requests to endpoints like "buy the last item" or "use a coupon once". Check that the rule still holds (Lesson 07).
- Property-based tests (a tool such as fast-check) for parsers, serialisers and money math. Instead of fixed examples, you state a rule that must always be true, and the tool tries many random inputs.
- Mutation testing (a tool such as Stryker). The tool changes your code on purpose and checks whether your tests fail. This is a better signal than a coverage percentage.
Practices that keep a test suite healthy
- Fast feedback. Unit tests should take seconds. Integration tests should take a minute or two. Run them on every pull request in CI, and run them in parallel.
- One reason to fail. Each test checks one behaviour. Its name says what it checks ("returns 404 when the order belongs to another user").
- Use Arrange, Act, Assert. First set up, then do the action, then check the result. Do not put logic (loops,
if) in tests. - No shared changing state between tests. Tests must not depend on the order they run in.
- Flaky tests are bugs. A flaky test sometimes passes and sometimes fails for no clear reason. Move it aside and fix it. Do not ignore a red CI.
- Coverage is a smoke detector, not a goal. Coverage is the share of your code lines that ran during tests. 80% coverage with strong assertions is better than 100% of lines that only run code.
- Tests are code. Refactor them and keep the helpers tidy.
Common mistakes
- Mocking the database in tests that should prove the endpoint works.
- Testing inner details (which function was called) instead of results.
- Only testing the happy path.
- Shared test data that depends on order. It causes flaky failures.
- Slow suites that developers stop running.
- No contract protection between frontend and backend.
- Chasing a coverage number with tests that have no assertions.
- Real third-party calls in tests. They are flaky, they cost money, and they are unsafe.
Interview questions
What do you mock, and what don't you?
I mock or fake things I do not own, or that I cannot run cheaply and predictably. Examples are third-party HTTP APIs, email and SMS, the clock and randomness. When I test an endpoint, I do not mock my own services, the database or the validation layer. Those are exactly what the test should check. I prefer fakes and checks on results over checks on how many times something was called.
Scenario: tests pass, but production breaks on a schema change. Which test was missing?
A contract test, or a breaking-change check. The frontend relied on a response shape or a field name that the backend changed. Shared schemas, or types made from OpenAPI, would have failed the build. A breaking-change detector in CI and response checks in API tests would also have caught it. A related gap is migration testing on realistic data, and not using backward-compatible expand/contract migrations.
How do you test error paths?
Each endpoint gets tests for these cases: validation failure, not logged in, not allowed (another user and a lower role), not found, conflict or duplicate, idempotent retry, and a dependency failure (a fake gateway that throws an error). I check the status code, the shape of the error body (including the stable code), and that nothing wrong was saved to the database.
Unit vs integration tests: where do you put most of your effort in a backend?
I use unit tests for dense business logic with many edge cases. But most of the value is in integration tests that run the real HTTP stack against a real database. Most bugs are in the wiring, the SQL, the constraints and the serialisation. They are not in single functions.
How do you keep integration tests isolated and fast?
I use a real PostgreSQL in Docker or Testcontainers, with migrations applied. I reset the state for each test with truncation or a transaction that rolls back. For parallel runs, I use a schema for each file. I build data with factories. I control time and randomness. In CI, I run files in parallel.
What is a consumer-driven contract test?
The consumer of an API records the calls it depends on (the requests and the response shapes it expects). The provider checks in its own pipeline that it can meet those contracts. This finds breaking changes before they are deployed. It also shows exactly which consumer would break.
How do you test concurrency-sensitive code like "last item in stock"?
I write a test that sends many parallel requests to the endpoint while only one item is available. Then I check that exactly one request succeeds and the stock never goes below zero. This shows that the atomic update, lock or constraint really prevents overselling when many requests compete.
Is high code coverage a good goal?
It is a useful smoke detector that points to untested areas. It is not a quality target. Coverage counts lines that ran. It does not count behaviour that was checked. I focus on meaningful assertions. I cover the critical paths and error cases first, based on risk. Sometimes I use mutation testing to see if my tests would catch real changes.
Scenario: the backend test suite takes 25 minutes and developers skip it. How do you speed it up?
First I measure which tests take the most time. Then I take these steps:
- Run files in parallel with separate schemas or databases.
- Replace slow setup with transaction rollbacks or truncation.
- Share one started container across tests.
- Mock slow third parties.
- Split the unit and integration stages.
- Run only the affected tests on pull requests, and the full suite after merge.
- Fix flaky tests.
- Set a time budget for the suite.
Key takeaways
- Unit test pure logic. Integration test endpoints through HTTP with a real database. Contract test the agreement between frontend and backend. Keep E2E tests for the most important flows.
- Mock the edges, not your own code. Prefer fakes and checks on results.
- Cover unhappy paths (validation, auth, other users, conflicts, dependency failures) and concurrency rules.
- Keep tests independent, deterministic and fast. Treat flakiness as a bug.
- Protect against schema drift with shared schemas or OpenAPI breaking-change checks. Use backward-compatible migrations.
What's next
That completes Backend in Depth. Continue with Scenario Debugging to use this knowledge in "it's slow, fix it" interviews. Or try Practical Design Problems for the URL shortener, the rate limiter and the pagination designs.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Frontend Testing Strategy: Unit, Integration, E2E and What to Skip · Senior Frontend Depth
- Building an Evaluation Harness for an LLM Feature · AI Engineering
- Documenting APIs: OpenAPI, Contracts and Testing · API Design
Part of the study guide
Related series