Welcome to the final lesson of Stabilizing Express APIs: Pagination, Versioning, and Testing—and the final lesson of the course path! Your API now has authenticated and authorized ticket workflows, paginated collections, standardized success envelopes, and canonical versioned routes with a temporary compatibility surface.
In this lesson, you’ll add database-aware readiness and verify the complete API through automated integration tests. You’ll use Supertest to exercise the real Express application and PostgreSQL sidecar, create disposable records through public HTTP routes, and remove only the rows created by each test.
Previously, the API Simulator gave you a convenient way to explore one request at a time. Automated tests build on that understanding by running the same important checks repeatedly and reporting regressions immediately.
Manual Exploration and Automated Verification
The API Simulator and Supertest serve different purposes.
The API Simulator is useful when you want to:
Experiment with a request interactively.
Inspect headers, bodies, parameters, and authorization settings.
Follow a newly introduced route by hand.
Explore unexpected behavior while developing.
Supertest is useful when you want to:
Repeat the same requests automatically.
Assert exact status codes, headers, and body fields.
Verify several roles and error branches consistently.
Prove that data was written to and read from PostgreSQL.
Run a complete regression suite after changing the application.
Both tools exercise the HTTP layer. The difference is that Supertest turns your expectations into executable checks.
Understanding Liveness and Readiness
Operational endpoints answer whether an application can receive traffic.
A liveness-style check answers:
Is the application process running and able to respond?
A readiness check answers:
Is the application ready to perform real work, including reaching required dependencies?
The project exposes two public endpoints:
text
GET /healthGET /ready
The existing /health route returns a small operational response. In this project, it also checks PostgreSQL, but its payload remains intentionally simple:
JSON
{ "status": "ok", "database": "connected"}
The /ready route makes the dependency check part of a richer readiness contract. It verifies PostgreSQL, reports process uptime, and uses the stabilized success envelope.
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal
Implementing the Readiness Route
The readiness endpoint lives in src/routes/readiness-routes.ts. It is a public operational route rather than an authenticated API resource.
import { Router } from "express";import { checkDatabase } from "../db/client.js";import { sendSuccess } from "../lib/response.js";export const readinessRoutes = Router();
checkDatabase() performs the dependency check required before the service should be considered ready.
sendSuccess() gives the successful response the same { data, meta } shape used by version discovery and ticket resources.
The router does not import authentication or authorization middleware. Infrastructure can check readiness without supplying a user token.
The success path waits for PostgreSQL and reports integer process uptime.
The route does not report readiness until checkDatabase() completes successfully. A running Node.js process is not enough if the API cannot access its required database.
process.uptime() returns the number of seconds that the current Node.js process has been running. Math.floor() converts it into an integer suitable for a simple operational payload.
sendSuccess() places the readiness information under data and adds request metadata under meta.
HTTP 503 Service Unavailable communicates that the process responded but cannot currently perform its expected work.
NOT_READY gives monitoring systems and clients a stable machine-readable error code.
The log records a safe diagnostic on the server. The public response does not expose connection strings, credentials, raw SQL, or internal PostgreSQL details.
The route does not call process.exit() or crash the server. Readiness can recover automatically if the dependency becomes available again.
To align the failure fully with the established { error, meta } contract, the same failure can be delegated as an AppError to the centralized handler. The essential boundary remains the same: log safely, return 503 NOT_READY, and do not expose sensitive database details.
Mounting Readiness Before API Routes
The application mounts the readiness router in src/app.ts.
import { apiV1Routes } from "./routes/api-v1-routes.js";import { healthRoutes } from "./routes/health-routes.js";import { legacyApiRoutes } from "./routes/legacy-api-routes.js";import { readinessRoutes } from "./routes/readiness-routes.js";
The route is registered after global middleware and before authenticated API surfaces.
The integration suite lives in tests/health.test.ts. It imports the application factory and gives the Express app directly to Supertest.
import request from "supertest";import { afterAll, afterEach, describe, expect, it,} from "vitest";import { createApp } from "../src/app.js";import { closeDatabase, query,} from "../src/db/client.js";const app = createApp();
createApp() returns the configured Express application without calling listen().
request(app) allows Supertest to send HTTP-style requests directly to that application.
The tests do not compete with the development server running on port 4000.
Although the HTTP listener is avoided, repository code still communicates with the real PostgreSQL sidecar. These are integration tests rather than database mocks.
This separation is one reason exporting an application factory is useful. Production startup can call listen(), while tests can exercise the same middleware and routes in process.
What the Integration Tests Verify
A request sent through Supertest still travels through the real application pipeline:
Supertest ↓createApp() ↓request context and parsers ↓version or legacy router ↓authentication and authorization ↓Zod validation ↓services and repositories ↓PostgreSQL sidecar ↓response envelope
The tests can therefore verify public behavior across all of these layers without asserting private implementation details.
Tracking Disposable Test Tickets
The suite keeps a set of ticket IDs created during the current test.
const createdTicketIds = new Set<string>();
A Set prevents duplicate cleanup entries.
Only dynamically created test tickets are added.
Deterministic seeded ticket IDs are never placed in this collection.
The suite does not truncate tables or rerun seed scripts between tests. Instead, it records its own changes and removes only those rows.
Creating a Ticket Through the Public API
The helper creates tickets through the canonical authenticated endpoint.
async function createTicketForTest() { const response = await request(app) .post("/api/v1/tickets") .set( "Authorization", "Bearer customer-token-1", ) .send({ title: `Integration ticket ${Date.now()}`, description: "Created by the integration test suite.", priority: "high", });
The helper uses the same /api/v1/tickets route that a real client would call.
It authenticates with the deterministic customer token, so the created ticket’s requester comes from user context.
Date.now() makes the title easy to distinguish when examining the database or a failed test.
.send() provides a JSON body, and Supertest sets an appropriate JSON content type.
The helper verifies creation before using the result.
expect(response.status).toBe(201); const id = response.body.data.id as string; createdTicketIds.add(id); return { id, response };}
The 201 assertion confirms that authentication, media-type enforcement, Zod validation, ticket creation, and response formatting all succeeded.
The generated ID is read from the standardized success envelope at response.body.data.id.
The ID is recorded immediately so cleanup can remove the row even if a later assertion in the test fails.
Returning both id and response lets each test make additional assertions without repeating creation logic.
Why Cleanup Registration Happens Early
Consider a test that successfully creates a ticket and then fails while checking its Location header.
The row already exists in PostgreSQL. If the helper waited until the end of the test to record its ID, the failed assertion could leave that row behind.
By adding the ID immediately after the successful 201, afterEach can still clean it up when the test finishes—whether the remaining assertions pass or fail.
Cleaning Up After Each Test
The suite deletes only IDs recorded by the current test.
afterEach(async () => { for (const id of createdTicketIds) { await query( "DELETE FROM tickets WHERE id = $1", [id], ); } createdTicketIds.clear();});
The cleanup query uses $1 and a separate values array. Test data follows the same parameterized SQL rule as production repositories.
Only generated IDs stored in createdTicketIds are deleted.
Seeded users, tickets, and comments remain untouched.
Clearing the set prevents one test’s identifiers from leaking into the next test.
Deleting a ticket also lets PostgreSQL apply its existing cascade behavior to comments created beneath that disposable ticket.
Avoiding Destructive Test Cleanup
A safe integration suite should not use broad cleanup operations such as:
TRUNCATE tickets CASCADE;DELETE FROM tickets;
Those statements would remove deterministic seed data required by other tests and manual API exploration.
The suite also avoids rerunning the seed script after every test. Repeated global resets are slower and can hide isolation problems.
Targeted cleanup provides a better boundary:
Create a disposable record ↓Record its generated ID ↓Exercise the API ↓Delete only that generated ID
Closing the Shared Database Pool
The suite closes the PostgreSQL pool once after all tests finish.
afterAll(async () => { await closeDatabase();});
Repository calls share a database connection pool.
Closing it after every test would interfere with later tests that use the same imported application and database client.
Closing it once at the end releases open connections and allows the test process to exit cleanly.
afterAll is the correct lifecycle hook because the pool belongs to the complete suite rather than one test.
Verifying Health and Readiness
The first test confirms that both operational routes remain public.
it("keeps health and readiness public", async () => { const health = await request(app) .get("/health"); expect(health.status).toBe(200); expect(health.body).toEqual({ status: "ok", database: "connected", }); const ready = await request(app) .get("/ready"); expect(ready.status).toBe(200); expect(ready.body.data.status).toBe("ready");});
No Authorization header is supplied to either request.
The health assertion verifies its intentionally small raw payload.
The readiness assertion reads through the standardized success envelope.
A successful readiness request proves that the Express app can reach the real PostgreSQL sidecar during the test.
Testing Version Discovery
The canonical discovery endpoint can be verified with a public request:
The response uses NOT_FOUND, which is distinct from TICKET_NOT_FOUND.
The request ID proves that global request context ran before terminal not-found handling.
A complete assertion can also confirm that durationMs is numeric.
Keeping Tests Independent
Each test should establish the state it needs.
A test that updates a ticket should create its own disposable ticket. It should not depend on a ticket created by a previous test because test order may change and failures can interrupt the suite.
A good integration test follows this pattern:
Arrange → create its own resourceAct → call the route under testAssert → verify public response and persisted behaviorCleanup → delete only the created resource
Seeded records are appropriate for stable read-only checks, such as verifying customer ownership against tkt_1002. Tests that mutate state should prefer disposable resources.
Running the Test Suite
Run the integration suite with:
npm test
Vitest executes the Supertest requests, while repository code communicates with the PostgreSQL sidecar.
After the suite passes, also verify compilation:
npm run typechecknpm run build
These commands cover different failure categories:
npm test verifies runtime behavior and PostgreSQL integration.
npm run typecheck verifies TypeScript contracts.
npm run build verifies that the deployable JavaScript output can be produced.
Verifying Seed Preservation Manually
After running the tests, return to the API Simulator.
Set the base URL to:
http://localhost:4000
Use Bearer token:
customer-token-1
Send:
GET /api/v1/tickets?pageSize=3
The deterministic seeded tickets should still be available. Disposable test-created tickets should no longer appear because afterEach removed them.
This is an important checkpoint: the suite tested real mutations without damaging the shared learning dataset.
What the Suite Covers
The complete integration suite verifies the API’s public contract across:
Health and database-aware readiness.
Public version discovery.
Canonical version headers.
Legacy deprecation headers.
Authentication.
Customer collection scoping.
Pagination and metadata.
Ticket creation and canonical Location.
PostgreSQL persistence.
Agent updates.
Admin deletion.
Validation failures.
Ownership denial.
Workflow state changes.
Repeated transition conflicts.
Unsupported-method handling.
Shared not-found responses.
Request metadata.
The tests do not need to inspect private repository arrays, middleware internals, or SQL strings. They verify the behavior visible to real API consumers.
Staying Within the Course Scope
The finished project provides a substantial Express and PostgreSQL foundation, but it is not a complete production platform.
This course does not implement:
Deployment infrastructure.
Database migrations.
Secret management.
Token hashing, expiration, or revocation.
Rate limiting.
Security headers.
Generated API documentation.
Monitoring and alerting.
Distributed tracing.
Full observability.
The testing architecture established here makes those future changes safer because important existing behavior is already protected by repeatable regression checks.
Final Recap
Congratulations—you’ve completed the course and the full API path!
You added a public readiness endpoint that verifies PostgreSQL connectivity, reports process uptime, and distinguishes dependency readiness from general API discovery. The endpoint remains outside authenticated ticket routes while still benefiting from shared request context.
You also built a Supertest integration suite around createApp() without opening another network listener. The suite exercises the real Express middleware pipeline and PostgreSQL sidecar, creates unique disposable tickets, records their IDs, removes only those rows after each test, and closes the shared database pool once at the end.
Across the path, you progressed from basic Express routing to parameterized PostgreSQL access, CRUD operations, nested resources, Zod validation, request context, centralized errors, authentication, authorization, workflow services, pagination, standardized responses, API versioning, readiness, and automated integration testing.
Arrange → create its own resourceAct → call the route under testAssert → verify public response and persisted behaviorCleanup → delete only the created resource