Testing Backend Code

The difference between checking one small piece of logic in isolation and checking that a real request flows correctly through the whole app.

What is it?

As a backend grows, manually clicking through the app (or firing off requests by hand) to check that everything still works becomes slower and less reliable with every new feature. Automated tests exist to do that checking for you, quickly and repeatedly — but not all tests check the same thing, or at the same scope.

A unit test checks one small, isolated piece of logic — usually a single function — on its own, often replacing its dependencies with fakes (see dependency injection) so the test focuses purely on that one function's behavior. An integration test goes further: it sends a real request through the actual running app — through routing, middleware, and often a real (or realistic test) database — and checks that the whole chain produces the right result together.

Explain like I'm 10

A unit test is like testing a single car part on a bench — does this brake pad grip correctly under pressure — in isolation from the rest of the car. An integration test is like taking the whole assembled car out for a test drive: you're no longer checking one part alone, you're checking that the brakes, engine, and steering all work together correctly as a system.

Examples

A unit test — one function, in isolation

function calculateDiscount(price, percentOff) {
  if (percentOff < 0 || percentOff > 100) {
    throw new Error("Invalid discount percentage");
  }
  return price - (price * percentOff) / 100;
}

test("applies a 20% discount correctly", () => {
  expect(calculateDiscount(100, 20)).toBe(80);
});

test("rejects an invalid discount percentage", () => {
  expect(() => calculateDiscount(100, 150)).toThrow();
});

This test never starts a server or touches a network — it calls the function directly and checks its return value, making it extremely fast and focused.

An integration test — a real request through the app

const request = require("supertest");
const app = require("../app");

test("GET /products/:id returns the requested product", async () => {
  const res = await request(app).get("/products/42");

  expect(res.status).toBe(200);
  expect(res.body).toMatchObject({ id: "42" });
});

test("GET /products/:id returns 404 for a missing product", async () => {
  const res = await request(app).get("/products/does-not-exist");
  expect(res.status).toBe(404);
});

This test sends an actual HTTP request through the app's real routing and middleware, checking the full response the way a real client would see it — not just one internal function.

The same integration test, in FastAPI with pytest

from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_get_existing_product():
    response = client.get("/products/42")
    assert response.status_code == 200
    assert response.json()["id"] == 42

def test_get_missing_product_returns_404():
    response = client.get("/products/does-not-exist")
    assert response.status_code == 404

TestClient wraps the actual FastAPI app the same way supertest wraps an Express app — it sends real requests through routing, dependencies, and validation, and lets you assert on the real response, without needing a live server running somewhere.

How it works

Unit tests call a function or method directly, typically supplying fake versions of any dependencies (see dependency injection) so the test stays fast, isolated, and unaffected by anything outside that one function. Integration tests instead run through the app as a whole — usually by simulating an actual HTTP request against the app's real routing and middleware, often against a real (but test-only) database — verifying that all the pieces genuinely work together, not just in isolation.

Why does it exist?

Unit tests alone can all pass while the app as a whole is still broken — if two individually-correct functions are wired together incorrectly, no unit test would catch that. Integration tests alone, meanwhile, are slower and harder to pinpoint failures in — a single failing integration test might not tell you exactly which function is at fault. Having both kinds of tests exists because they catch different classes of problems, at different speeds, and a healthy test suite typically leans on many fast unit tests plus a smaller number of integration tests for the critical paths.

When to use it

Write unit tests for individual pieces of business logic, especially ones with several branches or edge cases (like a discount calculation or validation rule). Write integration tests for the critical paths a real user or client actually exercises end-to-end, like signing up, logging in, or placing an order.

When not to use it

Don't write an integration test for something a unit test could check just as well, more quickly and reliably — spinning up the whole app to test that a pure calculation function returns the right number is unnecessary overhead.

Common mistakes

  • Writing only unit tests and never verifying that the pieces actually work correctly wired together.

  • Writing only integration tests, resulting in a slow test suite where a single failure is hard to trace back to its root cause.

  • Letting integration tests depend on real external services (a real payment provider, a real third-party API) instead of test doubles, making the suite flaky and slow.

Practice exercises

  1. Easy:

    Write a unit test for a isValidEmail(email) function, covering both a valid and an invalid case.

  2. Medium:

    Write an integration test for a POST /login route that checks it returns a 200 with valid credentials and a 401 with invalid ones.

  3. Hard:

    Explain a scenario where all unit tests pass but an integration test still fails, and what that reveals about where the bug actually lives.

Interview questions

What's the difference between a unit test and an integration test?

A unit test checks one isolated piece of logic, usually with fake dependencies; an integration test checks that multiple real pieces of the app — routing, middleware, and often a database — work correctly together.

Why might all unit tests pass while the app is still broken?

Because unit tests check pieces in isolation — if those pieces are wired together incorrectly, no individual unit test would necessarily catch that, since it isn't a problem with any one piece in isolation.

Why do integration tests typically run slower than unit tests?

They exercise more of the real system — routing, middleware, and often a real or realistic database — rather than a single function in isolation, so there's more work happening per test.

What is a test double, and what are the main kinds?

A generic term for anything substituted for a real dependency in a test. The main kinds are stubs (return canned values), mocks (record and let you assert on calls), fakes (simplified but working implementations), and spies (wrap a real function while observing calls to it).

What's the difference between a stub and a mock?

A stub just returns preprogrammed responses with no verification of how it was called; a mock records the calls made to it so the test can assert on specific interactions, like 'was called exactly once with these arguments.'

What is a spy, as distinct from a mock?

A spy wraps a real function or method and records how it was called while optionally still letting the real implementation run, whereas a mock typically replaces the real implementation entirely with a fake one.

What does supertest actually do when you call `request(app).get(...)`?

It runs the actual Express app in-memory and fires a real HTTP request through its routing and middleware stack, then hands back the genuine response — without needing to bind to an actual network port.

In the FastAPI example, what does `TestClient(app)` let you do that hitting a live server wouldn't?

Send requests directly against the app object in-process, exercising its real routing, dependency injection, and validation, without needing a live server process or an open network port running anywhere.

In the FastAPI test, why does `test_get_missing_product_returns_404` assert on a status code instead of expecting an exception?

The route handler is expected to catch the 'not found' case internally and return a 404 response, not let an unhandled exception propagate — the test is verifying the API's documented failure-path contract.

What is the test pyramid, and what shape does it describe for a healthy test suite?

A large base of many fast, cheap unit tests, a smaller middle layer of integration tests, and very few slow end-to-end tests — reflecting that most bugs are cheaper and faster to catch the lower down that pyramid you can find them.

Why is `calculateDiscount` from the example a good candidate for a unit test specifically?

It's pure — the same input always produces the same output, with no external dependency like a network or database — so calling it directly, in isolation, fully verifies its behavior with no setup overhead.

What makes a test flaky, and why is that a serious problem?

A flaky test sometimes passes and sometimes fails for the same code, typically because it depends on something non-deterministic like real timing or network calls. It erodes trust in the suite, since failures start getting reflexively re-run instead of investigated.

Why do integration tests typically run against a real but test-only database, rather than the production one?

So tests can freely create, modify, and delete data without corrupting real user data, and so the suite can run repeatably against a known, resettable starting state each time.

What is test isolation, and why does it matter across a suite?

Each test should be independent of side effects left by any other test, so tests can run in any order, in parallel, or individually — a failure in one test shouldn't cascade into unrelated failures elsewhere in the suite.

Scenario: a signup endpoint's test suite starts failing whenever the tests run in a different order. What does that suggest?

The tests are likely sharing state — e.g. relying on a row inserted by an earlier test, or not cleaning up the database between runs — a test isolation problem, not necessarily a bug in the signup logic itself.

What is arrange-act-assert, as a structure for writing a test?

Arrange sets up the inputs, fakes, or state a test needs; act calls the function or makes the request under test; assert checks the actual outcome against what's expected — a structure that keeps individual tests readable and focused.

Why is `expect(() => calculateDiscount(100, 150)).toThrow()` written with a wrapping function instead of calling `calculateDiscount(100, 150)` directly inside `expect`?

Calling it directly would throw immediately at that line, before the test framework gets a chance to catch it. Wrapping it in a function lets the framework invoke it internally and catch the exception it expects to happen.

What is code coverage, and what's a common misconception about it?

The percentage of code actually executed by the test suite. The misconception is treating high coverage as proof of correctness — a line can run without its output ever being asserted on, so coverage measures exposure to tests, not the quality of what those tests check.

Why test a login route's 401 response, rather than only its success case?

Rejecting bad credentials is just as much a part of the endpoint's real contract as accepting good ones — untested failure paths are a common source of security and correctness bugs.

What's a mocking library's role when testing code that calls an external HTTP API?

It intercepts the outgoing HTTP call and returns a controlled, predetermined response instead of actually hitting the network, keeping the test fast, deterministic, and independent of that third-party service's availability.

Trap: a test mocks every database call so it always returns success, and the test passes — but the real integration is broken. What does this reveal?

Mocking too aggressively, even in something meant to be an integration test, can hide real wiring or contract mismatches between components — some tests need to exercise the real, or a realistic, dependency to actually verify things integrate correctly.

Why does FastAPI's `TestClient` behave 'the same way supertest wraps an Express app'?

Both let a test send a simulated HTTP request straight into the actual application object in-process — exercising the real routing, middleware, and validation stack — without starting a real network server.

What is a snapshot test, and what's a risk of relying on it too heavily?

It captures a piece of output the first time a test runs, and later runs compare against that saved snapshot. The risk is developers reflexively 'updating the snapshot' whenever it changes rather than checking the new output is actually correct, turning the test into a rubber stamp.

How would you unit test a function that checks 'has this session expired?' without it being flaky?

Inject the current time as a parameter instead of calling Date.now() internally (see dependency injection), so the test can pass a fixed, known timestamp and assert deterministic behavior around it.

What's the practical tradeoff of writing an integration test for something a unit test could verify just as well?

The integration test takes longer to run and needs more setup — like a running app and test database — and when it fails, a broken piece buried inside a longer request/response chain is usually harder to pinpoint than a focused unit test failure.

Why is a suite with many end-to-end tests and almost no unit tests usually a sign of a problem?

That shape inverts the test pyramid — the suite becomes slow to run, and a failing integration test only reveals that something in a long chain broke, not which specific piece, making failures much harder to pinpoint.

Debugging: an integration test for `GET /products/:id` passes locally but fails in CI with a connection error. What's a likely cause?

Something the test depends on isn't available in CI the way it is locally — e.g. no test database is running or seeded there, or the app under test was never actually started before the request was fired.

Beyond speed, what does letting integration tests depend on real external services cost a suite?

It makes the suite's pass/fail signal depend on that external service's uptime, rate limits, and data, introduces nondeterminism, and can cause real-world side effects — like actually charging a card or sending a real email — purely as a byproduct of running tests.

Why might you choose an in-memory fake database over a plain mock for testing a repository layer?

A fake actually implements real query and storage behavior, even if simplified, so it can catch logic bugs in how the repository queries or filters data — a mock that just returns canned values wouldn't exercise that logic at all.

Follow-up: after adding dependency injection to `chargeCustomer(order, paymentService)`, what specifically becomes testable that wasn't before?

The function's own logic — deciding what to charge, handling the service's response and errors — can now be verified with a fake payment service standing in, without ever making a real charge or depending on the real provider being reachable.