Skip to main content

Testing & Deployment

A production API needs automated tests, containerized deployment, and monitoring. In this lesson we'll cover all three.

Testing with Vitest and Supertest

Vitest is a fast test runner that works seamlessly with TypeScript. Supertest lets you make HTTP requests to your Express app without starting a server.

npm install -D vitest supertest @types/supertest

Add a test script to package.json:

// package.json (partial)
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Exporting the App

Make sure your Express app is exported so tests can import it without starting the server:

// src/index.ts
import express from "express";
import usersRouter from "./routes/users.js";
import { errorHandler } from "./middleware/error-handler.js";

export const app = express();
app.use(express.json());
app.use("/api/users", usersRouter);
app.use(errorHandler);

// Only start the server when running directly, not when imported in tests
if (process.env.NODE_ENV !== "test") {
  app.listen(3000, () => console.log("Server running on port 3000"));
}

Writing Tests

// src/routes/__tests__/users.test.ts
import { describe, it, expect } from "vitest";
import request from "supertest";
import { app } from "../../index.js";

describe("GET /api/users", () => {
  it("returns a list of users", async () => {
    const res = await request(app).get("/api/users");

    expect(res.status).toBe(200);
    expect(res.body.data).toBeInstanceOf(Array);
  });
});

describe("POST /api/users", () => {
  it("creates a new user with valid data", async () => {
    const newUser = { name: "Charlie", email: "charlie@example.com" };
    const res = await request(app)
      .post("/api/users")
      .send(newUser)
      .set("Content-Type", "application/json");

    expect(res.status).toBe(201);
    expect(res.body.data).toMatchObject({
      name: "Charlie",
      email: "charlie@example.com",
    });
    expect(res.body.data.id).toBeDefined();
  });

  it("returns 400 when required fields are missing", async () => {
    const res = await request(app)
      .post("/api/users")
      .send({ name: "Charlie" });

    expect(res.status).toBe(400);
    expect(res.body.error).toBeDefined();
  });
});

describe("GET /api/users/:id", () => {
  it("returns 404 for non-existent user", async () => {
    const res = await request(app).get("/api/users/99999");

    expect(res.status).toBe(404);
    expect(res.body.error).toBeDefined();
  });
});

describe("DELETE /api/users/:id", () => {
  it("deletes an existing user", async () => {
    // Create a user first
    const createRes = await request(app)
      .post("/api/users")
      .send({ name: "ToDelete", email: "delete@example.com" });

    const userId = createRes.body.data.id;
    const res = await request(app).delete(`/api/users/${userId}`);

    expect(res.status).toBe(204);
  });
});

Testing Authentication

// src/routes/__tests__/auth.test.ts
import { describe, it, expect } from "vitest";
import request from "supertest";
import { app } from "../../index.js";

describe("Protected routes", () => {
  it("returns 401 without a token", async () => {
    const res = await request(app).get("/api/users");
    expect(res.status).toBe(401);
  });

  it("returns 401 with an invalid token", async () => {
    const res = await request(app)
      .get("/api/users")
      .set("Authorization", "Bearer invalid-token");

    expect(res.status).toBe(401);
  });

  it("succeeds with a valid token", async () => {
    // Log in to get a token
    const loginRes = await request(app)
      .post("/api/auth/login")
      .send({ email: "alice@example.com", password: "Password1" });

    const { token } = loginRes.body.data;

    const res = await request(app)
      .get("/api/users")
      .set("Authorization", `Bearer ${token}`);

    expect(res.status).toBe(200);
  });
});

Docker Containerization

Package your API into a Docker container for consistent deployments.

Dockerfile

# Dockerfile
FROM node:22-slim AS base
WORKDIR /app

# Install dependencies
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# Build TypeScript
FROM base AS build
COPY package.json package-lock.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build

# Production image
FROM base AS production
ENV NODE_ENV=production

# Copy only what's needed
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./

# Run as non-root user
RUN addgroup --system app && adduser --system --ingroup app app
USER app

EXPOSE 3000
CMD ["node", "dist/index.js"]

Docker Compose

# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - JWT_SECRET=${JWT_SECRET}
      - DATABASE_URL=${DATABASE_URL}
    depends_on:
      - db
    restart: unless-stopped

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: myapi
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  pgdata:

Build and run:

docker compose up --build -d

CI/CD with GitHub Actions

Automate testing and deployment on every push:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm run build
      - run: npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build and push Docker image
        run: |
          docker build -t myapi:${{ github.sha }} .
          # Push to your container registry

Health Check Endpoint

Every production API needs a health check for load balancers and monitoring:

// src/routes/health.ts
import { Router } from "express";

const router = Router();

router.get("/health", (_req, res) => {
  res.json({
    status: "healthy",
    uptime: process.uptime(),
    timestamp: new Date().toISOString(),
    version: process.env.npm_package_version || "unknown",
  });
});

// Deep health check — verifies database connectivity
router.get("/health/ready", async (_req, res) => {
  try {
    // Check database connection
    // await db.query("SELECT 1");
    res.json({ status: "ready" });
  } catch {
    res.status(503).json({ status: "not ready" });
  }
});

export default router;

Graceful Shutdown

Handle termination signals properly so in-flight requests complete:

// src/index.ts
const server = app.listen(3000, () => {
  console.log("Server running on port 3000");
});

function shutdown(signal: string) {
  console.log(`${signal} received. Shutting down gracefully...`);
  server.close(() => {
    console.log("HTTP server closed");
    // Close database connections, flush logs, etc.
    process.exit(0);
  });

  // Force shutdown after 10 seconds
  setTimeout(() => {
    console.error("Forced shutdown");
    process.exit(1);
  }, 10_000);
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

Production Monitoring

Structured Logging with Pino

npm install pino pino-http
import pino from "pino";
import pinoHttp from "pino-http";

const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  transport:
    process.env.NODE_ENV === "development"
      ? { target: "pino-pretty" }
      : undefined,
});

app.use(
  pinoHttp({
    logger,
    serializers: {
      req: (req) => ({
        method: req.method,
        url: req.url,
        id: req.id,
      }),
      res: (res) => ({
        statusCode: res.statusCode,
      }),
    },
  })
);

Production Checklist

Before deploying, make sure you have:

  • Validation on all inputs (body, query, params)
  • Authentication on protected routes
  • Rate limiting to prevent abuse
  • CORS configured for your frontend domains
  • Helmet for security headers
  • Health checks for load balancers
  • Graceful shutdown for zero-downtime deploys
  • Structured logging for debugging in production
  • Error handling that never leaks stack traces to clients
  • Environment variables for all secrets and config