Testing & Production
Here's the mental model that makes Fastify testing click: a test sends a fake request through your real app, in the same process — no port, no socket, no network. You hand nothing to an external library; Fastify already ships the tool. You call app.inject(), hand it a method and a URL, and Fastify dispatches that request through the entire app — every hook, every schema, every plugin, your route handler — exactly as a real HTTP request would travel. Then it hands you back the response object to assert on.
💡 Coming from Express, you reached for
supertest. In Fastify you don't install anything:app.inject()is built in, and it's the official, recommended way to test. One less dependency, and it understands Fastify's lifecycle natively.
The reason this works cleanly is a small structural choice we make once and reuse everywhere: build the app as a function that returns a configured-but-not-started instance. Your tests build a fresh app and inject into it; your entry file builds the same app and calls listen on it. The two share one definition and never fight over a port.
Structure: a buildApp() function
Everything in this phase depends on splitting "build the app" from "start the app." Put the building in a function that registers your plugins and routes and returns the instance — but never calls listen.
// app.js
const Fastify = ;
module.exports = buildApp;
What just happened: buildApp() does all the wiring — it creates a Fastify instance, registers the books routes plugin, and returns it. The crucial thing is what it doesn't do: it never calls app.listen(), so nothing binds a port. The opts parameter lets a caller pass options through (a test might pass { logger: false } to keep output quiet; production passes { logger: true }). This one function is now the single source of truth for what your app is.
The entry file is then tiny — it builds the app and starts it:
// server.js
const buildApp = ;
const app = ;
const port = process.env. || 3000;
app.
.;
What just happened: server.js is the only place that calls listen. It builds the app with logging on, reads the port from the environment (more on that below), and starts accepting connections. If startup fails — a port already in use, a plugin that throws — we log the error and exit non-zero so a supervisor knows the process died. Tests will require('./app'), never ./server.js, so importing your app for a test never tries to open a socket.
Testing the books API with app.inject()
Now the payoff. A test builds the app, injects a request, asserts on the response, and closes the app. The runner can be jest, vitest, or Node's built-in node:test — app.inject() is identical for all of them.
const buildApp = ;
;
What just happened: buildApp() gives us a fresh instance for this test. app.inject({ method, url }) constructs a fake GET request and pushes it through the full lifecycle — onRequest hooks, schema validation, your handler — without ever touching TCP. await resolves once your route replies, and res holds the status, headers, and body. res.json() is a Fastify convenience that parses the JSON body for you, so we can assert it came back as an array. Finally await app.close() runs your onClose hooks and frees resources — important so one test's app doesn't leak into the next.
Writes look the same, with a payload for the body:
;
What just happened: payload is the request body. Hand it a plain object and Fastify serializes it to JSON and sets Content-Type: application/json for you, so your route's body schema validates it just like a real client's POST. We assert the 201 and that the created book came back with the right title. No port, no fetch, no server running in the background — just a function call that exercises your real route.
📝 Test the unhappy paths, not only the happy ones. A
POSTmissingtitleshould return400(your Phase 2 body schema rejects it), andGET /api/books/9999for a missing id should return404(your Phase 6 error handling). Those tests are where validation and error handling earn their keep — and where regressions hide. The sameapp.inject()tests become your regression net in CI: every push runs them before anything merges. See Testing in CI for wiring this into a pipeline.
⚠️ Shared mutable state will bite you. If the books API keeps books in an in-memory array, one test's
POSTleaks into the next test'sGET, and your suite passes alone but fails together. Build a fresh app per test (or reset the store in abeforeEach) so tests stay independent. Order-dependent tests are a classic, maddening flake.
Getting ready for production
A server that runs on your laptop isn't a server ready for the open internet. Three concerns separate them: structured logging, configuration that changes per environment, and shutdown behavior that doesn't drop requests. Fastify gives you strong defaults for the first.
Logging is built in — turn it on. Fastify ships with pino, a very fast JSON logger, wired straight into the instance.
const app = ;
app.;
What just happened: passing a logger object configures pino — level controls verbosity (set it from the environment so prod can be quieter or louder without a code change), and redact scrubs sensitive fields so an auth token never lands in your logs. Fastify automatically logs every request and response with timing, and request.log is a child logger that tags each line with that request's id — so when you add request.log.info(...), you get structured, correlated logs you can actually search in production. Structured JSON beats console.log the moment you ship.
Config comes from the environment, never hardcoded. Port, log level, database URL — all of it lives in environment variables so the same code runs unchanged on your machine, in CI, and in production.
const port = process.env. || 3000;
const host = process.env. || '0.0.0.0';
What just happened: process.env.PORT reads the port your platform assigns (most set it for you), with a local fallback. The host matters more than it looks: the default localhost only accepts connections from the same machine, which is invisible from outside a container. Listening on 0.0.0.0 binds all interfaces so the container's port mapping (or your orchestrator) can actually reach the app. Forgetting this is the classic "works locally, 502s in Docker" bug.
Shutting down without dropping requests
When a platform redeploys or scales down, it sends your process a SIGTERM and gives it a few seconds before it's killed. Ignore the signal and in-flight requests get cut mid-response. Graceful shutdown means: stop accepting new connections, let current ones finish, then exit. Fastify's app.close() does exactly the draining for you.
.;
.; // Ctrl-C in your terminal
What just happened: on SIGTERM (deploy/scale-down) or SIGINT (you hit Ctrl-C), we call await app.close(). Fastify stops accepting new connections, lets the requests already in flight run to completion, and runs every plugin's onClose hook (closing DB pools, flushing logs) before the promise resolves. Only then do we process.exit(0). No half-written responses, no abandoned clients, no leaked connections.
💡 If you'd rather not hand-write the signal handling, the official
@fastify/graceful-shutdownplugin registers these handlers and the drain logic for you. The small handler above is fine for most apps; reach for the plugin when you want extra timeout control.
Stack the official hardening plugins the same way you registered everything else — Fastify keeps the core lean and ships security and limits as first-party plugins:
app.; // safe HTTP security headers
app.; // controls cross-origin access
app.;
What just happened: each register adds a first-party plugin. @fastify/helmet sets defensive HTTP headers, @fastify/cors decides which browser origins may call your API, and @fastify/rate-limit rejects an IP that exceeds 100 requests a minute, blunting abuse. These are official, Fastify-maintained packages — the framework's whole philosophy is a fast core plus plugins for everything else, and production hardening is just three more registrations.
Deploying it
With logging, config, graceful shutdown, and hardening in place, deployment is mostly packaging:
- Run it in a container, behind a reverse proxy. In production your app almost always sits behind nginx or your platform's load balancer, which terminates TLS and forwards requests. Bind to
0.0.0.0(above) so the proxy can reach it, and configuretrustProxyin your Fastify options if you need the real client IP fromX-Forwarded-For. - Use
fastify-clias a convenient runner. Instead of a hand-writtenserver.js,fastify-clican start your app from the command line:fastify start app.js. It expectsapp.jsto export a plugin function and handleslisten, logging, and graceful shutdown for you — a nice option once your app is a clean exported function (whichbuildAppalready nudges you toward). - Let a supervisor restart crashes. A container orchestrator (or PM2) restarts the process if it dies and runs multiple instances across CPU cores. Don't rely on
node server.jsin a terminal staying alive.
When you're ready to take the whole thing live — a domain, TLS, environment secrets, the deploy itself — Ship Your Side Project walks the last mile.
Recap
- Testing is
app.inject()— Fastify's built-in dispatcher pushes a fake request through your real app in-process (every hook, schema, and route), with no port and no network. Nosupertestneeded. - Structure the app as
buildApp()that returns a configured-but-not-started instance; tests build and inject into it, and onlyserver.jscallslisten. That split is what keeps tests in memory. - Use
payloadfor request bodies andres.json()to parse responses; test the unhappy paths (400, 404) too, build a fresh app per test, andawait app.close()to clean up. - Turn on the built-in pino logger (
logger: { level, redact }) for fast structured JSON logs and per-request child loggers; read config likePORTfrom the environment. - Bind to
0.0.0.0so containers and proxies can reach you, and shut down gracefully withawait app.close()onSIGTERM/SIGINTso in-flight requests drain. - Harden with official plugins (
@fastify/helmet,@fastify/cors,@fastify/rate-limit), run behind a reverse proxy, and considerfastify-clias the runner.
Quick check
[
{
"q": "How do you send a test request to a Fastify app without opening a network port?",
"choices": ["Install supertest and wrap the app", "Call app.inject() with a method and url — it dispatches through the real app in-process", "Start the server on a random port", "Mock every route by hand"],
"answer": 1,
"explain": "app.inject() is built into Fastify. It runs a fake request through the full lifecycle (hooks, schemas, handler) in the same process — no socket, no port, and no extra dependency like supertest."
},
{
"q": "Why structure the app as a buildApp() function that returns the instance without calling listen?",
"choices": ["It makes Fastify faster at runtime", "So tests can build the app and inject into it while only the entry file calls listen — sharing one definition without binding a port", "Because listen is deprecated", "To enable the pino logger"],
"answer": 1,
"explain": "Building without listening means a test can require the app and inject requests in memory, while server.js is the single place that opens a port. Tests and production share one app definition and never fight over the port."
},
{
"q": "What does await app.close() do when called on SIGTERM during a deploy?",
"choices": ["Kills all connections instantly", "Stops accepting new connections, lets in-flight requests finish, and runs onClose hooks before resolving", "Restarts the process", "Closes only the logger"],
"answer": 1,
"explain": "app.close() stops new connections, drains the requests already in flight, and runs every plugin's onClose hook (closing pools, flushing logs) before its promise resolves — so you exit cleanly without dropping responses."
}
]
← Phase 6: Error Handling · Guide overview · Phase 8: Where to Go Next →
Check your understanding
1. How do you send a test request to a Fastify app without opening a network port?
2. Why structure the app as a buildApp() function that returns the instance without calling listen?
3. What does await app.close() do when called on SIGTERM during a deploy?