Just added: Algorithms you can run and practice
Updated Jul 10, 2026 Edit on GitHub

Testing & Production

The mental model that makes Express testing click: a test calls your app in memory - no real network, no open port. Hand supertest your Express app object; it sends a fake request straight into the middleware chain, your routes run exactly as they would in production, and you get back a response to assert on. Nothing is "running" in the background.

This is the entire payoff of the app / app.listen split from Phase 7. Because app.js exports the configured app without starting a listener, a test file can require('../app') and exercise it directly. If app.js called app.listen() at the bottom, importing it would try to bind a port every time you ran the suite - slow, flaky, and a mess when ten test files all want port 3000.

💡 Supertest is a fake browser that lives inside your test process. It speaks HTTP to your app object, not over the wire. That's why tests are fast and need no server.

Testing the tasks API

You need a test runner (finds tests, runs them, reports pass/fail - jest, vitest, or Node's built-in node:test) and supertest (drives HTTP requests at your app). Install the pair:

npm install --save-dev supertest jest

The one line that matters is the import - the app without listen:

const request = require('supertest');
const app = require('../app');   // the configured app, NOT a running server

test('GET /api/tasks returns 200', async () => {
  const res = await request(app).get('/api/tasks');
  expect(res.statusCode).toBe(200);
});

What just happened: request(app) wraps your app in a test client. .get('/api/tasks') builds a fake request and pushes it through the middleware chain - routers, parsers, your handler, all of it. await resolves once your route sends a response; res holds the status, headers, and body. No port opened; this test passes in milliseconds.

Writes work the same way - chain .send() for a JSON body, and .expect() asserts status inline:

test('POST /api/tasks creates a task', async () => {
  const res = await request(app)
    .post('/api/tasks')
    .send({ title: 'write tests' })
    .expect(201);

  expect(res.body.title).toBe('write tests');
});

What just happened: .send({ title: 'write tests' }) sets the request body and the Content-Type: application/json header, so your express.json() parser populates req.body like a real client would. .expect(201) fails the test if the status isn't 201. Then we assert on res.body

  • the parsed JSON your route returned.

📝 Test the unhappy paths too. A POST with a missing title should return 400; a GET /api/tasks/9999 should return 404. Those are where your Phase 4/6 validation and error handling earn their keep - and where regressions hide.

Run the suite with your runner (npx jest, or a "test": "jest" script). These request(app) tests become your regression net in CI - see Testing in CI for wiring that up.

⚠️ Shared mutable state will bite you. If tasks live in an in-memory array, one test's POST leaks into the next test's GET. Reset state between tests (jest's beforeEach) or your suite passes alone and fails together - a classic, maddening flake.

Getting ready for production

A server that runs on your laptop isn't ready for the open internet. Three concerns separate the two: config that changes per environment, middleware that protects and speeds up the app, and shutdown behavior that doesn't drop requests.

Config comes from the environment, never hardcoded. Port, NODE_ENV, database URL - all in environment variables so the same code runs unchanged on your machine, in CI, and in production.

const PORT = process.env.PORT || 3000;
const isProd = process.env.NODE_ENV === 'production';

What just happened: process.env.PORT reads the port your host assigns; || 3000 is a local fallback. NODE_ENV === 'production' becomes a switch for behaviors - verbose logging off, stack traces hidden. Setting NODE_ENV=production also makes Express itself skip dev-only work and cache views, so it's not optional cosmetics.

Then the production middleware. Express's tiny core means security and performance are opt-in packages you stack at the top of the chain:

const helmet = require('helmet');
const cors = require('cors');
const compression = require('compression');
const rateLimit = require('express-rate-limit');

app.use(helmet());          // sets safe HTTP security headers
app.use(cors());            // controls who may call your API from a browser
app.use(compression());     // gzips responses to shrink payloads
app.use(rateLimit({ windowMs: 60_000, limit: 100 }));  // caps requests per IP

What just happened: each app.use adds one more (req, res, next) function to the chain - the same shape since Phase 3, just from npm. helmet sets defensive headers (hiding X-Powered-By, content-type protections). cors decides which origins may call your API. compression gzips bodies. rateLimit rejects an IP past 100 requests a minute. Put these before your routes so every request passes through.

Shutting down without dropping requests

When a platform redeploys or scales down, it sends SIGTERM and gives your process a few seconds to exit. Ignore it and in-flight requests get cut off mid-response. Graceful shutdown means: stop accepting new connections, let current ones finish, then exit - the reason you keep the return value of app.listen.

const server = app.listen(PORT, () => {
  console.log(`listening on ${PORT}`);
});

function shutdown() {
  console.log('shutting down...');
  server.close(() => process.exit(0));   // drains in-flight requests, then exits
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);          // Ctrl-C in your terminal

What just happened: app.listen returns the underlying Node HTTP server, kept in server. On SIGTERM (deploy) or SIGINT (Ctrl-C), server.close() stops accepting new connections but lets in-flight requests finish; once done, the callback fires and we exit cleanly. No half-written responses, no abandoned clients.

A few more production realities, briefly:

  • Run it under a supervisor. A process manager like PM2, or a container orchestrator, restarts your app if it crashes and runs multiple instances across CPU cores. Don't rely on node app.js in a terminal staying alive.
  • Sit behind a reverse proxy. In production your app almost always runs behind nginx (or your platform's load balancer), which terminates TLS and forwards requests. Tell Express to trust it so req.ip and req.protocol reflect the real client, not the proxy:
app.set('trust proxy', 1);   // honor X-Forwarded-* headers from one proxy hop

What just happened: without this, every request looks like it came from the proxy's address (often 127.0.0.1), quietly breaking rate limiting and IP-based logic. trust proxy tells Express to read X-Forwarded-For / X-Forwarded-Proto, so req.ip is the real visitor and req.secure is true for HTTPS.

⚠️ Don't leak stack traces to clients in production. Your Phase 6 error handler should log the full error server-side (with pino or winston) but send the client only a generic message and status in prod - a stack trace exposes file paths, dependency versions, sometimes secrets. That's what the isProd switch is for.

When you're ready to take the whole thing live - domains, TLS, environment secrets, the deploy itself

Recap

  • Tests run in memory: request(app) from supertest pushes fake HTTP requests through your middleware chain with no port and no network - fast and isolated.
  • The app/listen split makes this possible: a test imports the configured app; only the entry point calls app.listen. Test happy and unhappy paths, and reset shared state between tests.
  • Production config comes from the environment (process.env.PORT, NODE_ENV=production), never hardcoded.
  • Stack hardening middleware before your routes: helmet, cors, compression, and a rate limiter.
  • Shut down gracefully: capture const server = app.listen(...) and call server.close() on SIGTERM/SIGINT so in-flight requests drain.
  • Behind a proxy, set app.set('trust proxy', 1), run under PM2 or a container, and never send stack traces to clients in prod.

Quick check

[
  {
    "q": "Why can supertest test your Express app without opening a network port?",
    "choices": ["It mocks every route by hand", "It pushes fake requests directly through the imported app's middleware chain in memory", "It starts a hidden server on a random port", "It only works against a deployed URL"],
    "answer": 1,
    "explain": "supertest takes the app object and runs requests through its middleware chain in-process - no socket, no port. This is why the Phase 7 app/listen split matters: you import the app, not a running server."
  },
  {
    "q": "On SIGTERM during a deploy, what does server.close() do?",
    "choices": ["Kills all connections instantly", "Stops accepting new connections but lets in-flight requests finish, then exits", "Restarts the server", "Closes the database only"],
    "answer": 1,
    "explain": "server.close() stops accepting new connections and waits for current requests to complete before its callback fires - so you can exit cleanly without dropping in-flight responses."
  },
  {
    "q": "Why set app.set('trust proxy', 1) when running behind nginx?",
    "choices": ["It enables HTTPS", "It compresses responses", "So req.ip and req.protocol reflect the real client via X-Forwarded-* headers, not the proxy", "It installs helmet automatically"],
    "answer": 2,
    "explain": "Behind a proxy, requests appear to come from the proxy's address. trust proxy tells Express to read the X-Forwarded-* headers, restoring the real client IP and protocol - which rate limiting and IP logic depend on."
  }
]

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. Why can supertest test your Express app without opening a network port?

2. On SIGTERM during a deploy, what does server.close() do?

3. Why set app.set('trust proxy', 1) when running behind nginx?