Why test the backend
An automated test is code that runs your code and fails if the behavior is not the expected one. On the backend it protects us from breaking endpoints, business rules or data contracts when refactoring.
Two big families stand out:
- Unit tests (unit): they test a single isolated piece (a service, a pure function, a handler) without touching network, disk or database. They are fast and precise: if they fail, you know exactly where the problem is.
- Integration tests (integration): they exercise several pieces together, typically a real HTTP request that goes through routes, middleware and handlers. They are slower but check that everything fits together end to end.
Isolate with test doubles (mocks)
To test a unit without its real dependencies you use doubles: fake objects that replace a dependency (the database, an external service). A mock or spy also records how it was called, so you can assert about it afterwards.
// Service that depends on a "repository" (data access).
function registerUser(repo, name) {
return repo.save({ name, created: true });
}
// In the test, we pass it a FAKE repo that spies on the calls.
const calls = [];
const repoMock = {
save(user) { calls.push(user); return { id: 1, ...user }; },
};
const r = registerUser(repoMock, "Ana");
// We verify behavior (not internal implementation):
// - save was called exactly once
// - with the correct name
// - and we returned what the repo returned
The key to the mock: check that it was called correctly (how many times, with which arguments) without running the real dependency.
Test endpoints with supertest
For integration tests of an HTTP server, the supertest library launches the app on an ephemeral port and lets you write assertions about the response:
import request from "supertest";
import { app } from "./app.js";
await request(app)
.get("/users/1")
.expect(200)
.expect((res) => {
if (res.body.name !== "Ana") throw new Error("incorrect name");
});
supertest takes care of starting and closing the server; you only describe the
request (.get, .post, .send(...)) and what you expect (.expect(...)).
TDD (Test-Driven Development)
Test-driven development reverses the usual order into a short cycle:
- Red: first write a test that describes the desired behavior. It fails (the code does not exist yet).
- Green: write the minimum code to make the test pass.
- Refactor: clean up the code with the safety net of the green test.
TDD pushes you to design small, testable functions, and leaves a test suite as a natural byproduct.
Examples
A simple spy: record the calls in an array
function notify(service, message) {
service.send(message);
}
const calls = [];
const serviceMock = { send: (m) => calls.push(m) };
notify(serviceMock, "Hello");
notify(serviceMock, "Goodbye");
console.log(calls.length); // 2 -> it was called twice
console.log(calls[0]); // "Hello" -> with the correct argument