Automated Testing for Routes and Services
Test the pieces of a real Express API the way this curriculum's testing courses teach — separating logic from routing so most of it never needs a running server at all.
What you'll learn
- Separate business logic (services) from routing so logic is testable without an HTTP layer
- Write an isolated test for a service function using the setup/act/assert/teardown structure
- Add a real automated test suite to a local Express project and run it
Prerequisites
Explanation
A route handler that mixes HTTP concerns (req, res, status codes) directly with business logic (validating an enrollment, computing a total) forces every test of that logic through the HTTP layer, even for logic that has nothing to do with HTTP at all. Separating logic into plain functions — a "service" layer — that route handlers call, but that don't themselves know about req/res, makes the actual business logic testable in complete isolation, the same "extract the reusable, independently-testable piece" instinct from this curriculum's React course, applied to a backend.
A service function like createEnrollment(courseRepository, courseId, learnerId) can be tested directly — call it, assert on what it returns or what it did to its dependencies — with no server running, no port bound, no real HTTP request involved at all. This is dramatically faster and more reliable than spinning up the whole server for every test, and it's exactly the setup/act/assert/teardown structure and test-isolation discipline from this curriculum's API Testing and Automation course: each test creates its own fresh state, doesn't depend on another test's leftover data, and can run alone or in any order.
Testing the HTTP layer itself — does POST /enrollments actually return 201 with the right body, does an invalid request actually get a 400 — is still valuable and necessary, but it's a genuinely different, smaller layer of testing sitting on top of well-tested service logic, typically using a library like supertest to make real requests against the Express app without needing a separately-running server process.
This lesson's guided local lab adds a real, running automated test suite to the Express project built throughout this course — testing both the service logic in isolation and a couple of the actual HTTP routes.
Example
Business logic extracted as a plain, testable function -- no req/res, no HTTP layer, directly testable the way the guided local lab's real service tests will be.
function createEnrollment(existingEnrollments, courseId, learnerId) {
const alreadyEnrolled = existingEnrollments.some(
(e) => e.courseId === courseId && e.learnerId === learnerId,
);
if (alreadyEnrolled) {
throw new Error("Already enrolled in this course");
}
return { id: existingEnrollments.length + 1, courseId, learnerId, status: "active" };
}
// No req, no res, no server -- just a function, testable directly.
const enrollments = [];
const newEnrollment = createEnrollment(enrollments, 1, 42);
console.log(newEnrollment);Try it yourself
Call createEnrollment a second time with the SAME courseId and learnerId, wrapped in try/catch, to see the duplicate-enrollment guard fire.
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Guided exercise
Guided exercise
Using createEnrollment already defined, write an isolated test: setup a fresh empty enrollments array, act by creating one enrollment, assert its shape is correct. Store the assertion result in isCorrect.
Checks: correctly asserts the created enrollment's shape
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Independent exercise
Independent exercise
Write a test function testDuplicateEnrollmentRejected() using createEnrollment (already defined below): it should set up an enrollments array that ALREADY contains an enrollment for courseId 1 / learnerId 42, then assert that calling createEnrollment again with the same courseId/learnerId throws an error containing the word 'enrolled'. Return true if the test passes (correctly throws), false otherwise.
Checks: the test correctly verifies duplicate rejection
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Guided local lab
Add Automated Tests and Graceful Shutdown
Runs on your computerExtract the enrollment logic from your local Express project into a testable service layer, add a real automated test suite covering both the service and the HTTP routes, and implement graceful shutdown.
Required tools
- Node.js (20.x or 22.x LTS)
- npm (10.x (bundled with Node.js))
Setup
- Reuse the `learning-api` project from earlier in this course.
- Run `npm install --save-dev vitest supertest`.
- Add `"test": "vitest run"` to the `scripts` section of package.json.
- Add the files below, then run `npm test`.
Project structure
learning-api/
src/
server.js
errors.js
services/
enrollments.service.js (new)
routes/
courses.routes.js
enrollments.routes.js (updated: uses the service)
tests/
enrollments.service.test.js (new)
enrollments.routes.test.js (new)Starter files
src/services/enrollments.service.js
export function createEnrollment(existingEnrollments, courseId, learnerId) {
const alreadyEnrolled = existingEnrollments.some(
(e) => e.courseId === courseId && e.learnerId === learnerId,
);
if (alreadyEnrolled) {
throw new Error("Already enrolled in this course");
}
return { id: existingEnrollments.length + 1, courseId, learnerId, status: "active" };
}tests/enrollments.service.test.js
import { describe, it, expect } from "vitest";
import { createEnrollment } from "../src/services/enrollments.service.js";
describe("createEnrollment", () => {
it("creates a new enrollment with active status", () => {
// TODO: setup fresh state, act, assert -- following this lesson's pattern
});
it("rejects a duplicate enrollment for the same course and learner", () => {
// TODO
});
});tests/enrollments.routes.test.js
import { describe, it, expect } from "vitest";
// TODO: import supertest and your Express app (you may need to export
// "app" from server.js separately from the app.listen() call so tests
// can import it without actually starting a real server).
describe("POST /enrollments", () => {
it("returns 201 and the created enrollment for a valid request", async () => {
// TODO
});
it("returns 400 for an invalid request", async () => {
// TODO
});
});Requirements
- The enrollment-creation logic lives in a plain, testable service function with no req/res dependency
- At least two isolated service-level tests exist: one for successful creation, one for the duplicate-rejection case
- At least two HTTP-level tests exist using supertest: one for a valid POST (expects 201), one for an invalid POST (expects 400)
- `npm test` runs the full suite successfully with zero failures
- The server implements graceful shutdown: on SIGTERM, it stops accepting new connections and exits cleanly rather than terminating abruptly
Commands to run
Run the test suite
npm test
Expected behavior
Running npm test executes all four tests (two service-level, two HTTP-level) and reports them all passing, with no server left running afterward and no port conflicts on repeated runs.
Verify it yourself
npm testExpected: All tests pass; the test process exits cleanly (does not hang)
Run the server, then send it a SIGTERM (Ctrl+C in most terminals sends SIGINT, which behaves similarly for this purpose)Expected: The server logs a shutdown message and exits, rather than terminating instantly mid-request
Troubleshooting
- supertest tests hang and never complete — Confirm server.js exports the Express `app` object separately from calling app.listen() — tests should import and use the app directly with supertest, which manages its own ephemeral server, rather than connecting to a real running instance.
- npm test fails with 'vitest: command not found' — Confirm vitest was installed as a devDependency (npm install --save-dev vitest) and that the test script in package.json matches exactly.
Stuck? Get a hint.
Extension challenge
Add a test confirming that two DIFFERENT learners can both enroll in the SAME course without triggering the duplicate-rejection guard, exercising the boundary of what counts as a 'duplicate.'
When you've verified this locally, use the "Mark lesson complete" button below to record your progress.
Common mistakes
- Mixing business logic directly into route handlers, forcing every test of that logic through a full HTTP request/response cycle even when it isn't necessary.
- Writing tests that depend on a real, separately-running server process instead of testing the exported Express app object directly.
- Never testing the duplicate/rejection/error paths, only the happy path where everything succeeds.
Knowledge check
Takeaway
Extracting business logic into plain, testable service functions makes most of an API's real behavior verifiable without any HTTP layer at all — with a smaller set of route-level tests confirming the actual HTTP contract on top.
Summary
This lesson covered separating logic from routing for testability and the setup/act/assert structure from browser exercises, then added a real service-level and HTTP-level automated test suite (plus graceful shutdown) to a local Express project via the guided local lab.
References
Your notes
Notes save automatically.
Finished this lesson?
Mark it complete to track your progress and schedule a future review.
AI tutor
The optional AI tutor isn't enabled in this deployment. All lessons, exercises, quizzes, and search work fully without it.