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

Testing & Production

You've grown the articles API the whole way - App, HttpServer, extractors, responders, shared state, middleware, and a full CRUD layer with ResponseError. Now comes the part that decides whether anyone trusts it: proving it works, and running it somewhere real without it falling over at 3am.

The mental model: testing is calling your app in memory - no ports

The fact that makes actix-web pleasant to test: you never start a real server. The actix_web::test module builds your App into an in-memory service and pushes requests straight through it. No socket opens, no port binds, no background task to remember to shut down. You hand the app a request, it produces a response, you read it back - all inside the test process, in microseconds.

💡 A test is just: build the same App your real server runs, turn it into a service with test::init_service, craft a request with test::TestRequest, and call test::call_service. The entire chain - middleware, routing, extractors, your handler - runs exactly as it would for a live request, except nothing leaves the process.

use actix_web::{test, App, web};

#[actix_web::test]
async fn list_articles_ok() {
    let app = test::init_service(
        App::new().app_data(state()).route("/articles", web::get().to(list))
    ).await;
    let req = test::TestRequest::get().uri("/articles").to_request();
    let resp = test::call_service(&app, req).await;
    assert!(resp.status().is_success());
}

What just happened: #[actix_web::test] does for main what #[actix_web::main] does - spins up the actix runtime so the async test can .await. test::init_service takes the same App builder your server uses and compiles it into an in-memory service. test::TestRequest::get().uri("/articles").to_request() builds a real Request with no connection behind it. test::call_service(&app, req) runs the whole pipeline and hands back the ServiceResponse, whose .status() we assert - under a millisecond, never touching the network.

Testing a POST with a JSON body is the same shape with two helpers - set_json to attach the body (it sets Content-Type: application/json for you) and read_body_json to deserialize the response so you can assert on its contents:

use actix_web::{test, App, web};

#[actix_web::test]
async fn create_article_returns_it() {
    let app = test::init_service(
        App::new().app_data(state()).route("/articles", web::post().to(create))
    ).await;

    let req = test::TestRequest::post()
        .uri("/articles")
        .set_json(&serde_json::json!({ "title": "write tests" }))
        .to_request();

    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), 201);

    let body: Article = test::read_body_json(resp).await;
    assert_eq!(body.title, "write tests");
}

What just happened: set_json(&body) serializes the value and sets the JSON content type, so web::Json<T> sees a properly-formed request. We assert 201, then test::read_body_json(resp).await deserializes the response into an Article to check its field. Tighter helpers exist too: test::call_and_read_body_json does call-and-deserialize in one step, and test::try_call_service returns a Result instead of panicking.

📝 For inputs you expect to fail - a missing title, malformed JSON - send the bad payload and assert the status and error body your ResponseError impl produces. That's where Phase 6's error handling pays off: tests confirm clients get a 400, not a stack trace.

This is the heart of testing a web app. The rest - table-driven cases, fixtures, running it on every push - is general Rust testing, covered in testing in CI.

Share routes between main and tests with .configure()

The two tests above share a smell: each re-declares its routes. The moment your real App and your test App describe routes differently, your tests validate wiring production doesn't use. The fix: declare routes once, in a function, and call it from both places.

actix-web's hook for this is .configure(config_fn), where config_fn takes a &mut web::ServiceConfig and registers everything on it.

use actix_web::{web, App, HttpServer};

fn config(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/articles")
            .route("", web::get().to(list))
            .route("", web::post().to(create))
            .route("/{id}", web::get().to(get_one))
            .route("/{id}", web::delete().to(delete)),
    );
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().app_data(state()).configure(config))
        .bind(("127.0.0.1", 8080))?
        .run()
        .await
}

And the test calls the very same config:

#[actix_web::test]
async fn list_articles_ok() {
    let app = test::init_service(
        App::new().app_data(state()).configure(config)
    ).await;
    let req = test::TestRequest::get().uri("/articles").to_request();
    let resp = test::call_service(&app, req).await;
    assert!(resp.status().is_success());
}

What just happened: all route registration now lives in one config function. main and the test both call the same .configure(config) - no second, slightly-different set of routes that "should match production" but quietly drifts. If handlers need state or a pool, pass it in (config(pool) returning a closure, or set app_data alongside configure) so tests can hand in a fixture. Rule of thumb: the first time you copy-paste route setup into a test, pull out a .configure() function.

Production: workers, graceful shutdown, env config

actix-web is built for production out of the box - a lot of what you'd hand-roll in other stacks is already there. Three things to know.

Workers. HttpServer runs multiple copies of your App, one per worker thread, and load-balances connections across them. By default it spawns one worker per logical CPU, which is usually what you want. You only set .workers(n) to override it - for example, pinning it lower in a small container.

HttpServer::new(|| App::new().app_data(state()).configure(config))
    .workers(4)
    .bind(("0.0.0.0", 8080))?
    .run()
    .await

What just happened: .workers(4) tells HttpServer to run four worker threads, each with its own copy of the App built by your closure - that's why the App is built in a closure, constructed once per worker. Remove .workers(4) and you get the default: one per CPU. Note 0.0.0.0 rather than 127.0.0.1 - in a container you bind all interfaces so outside traffic reaches you.

Graceful shutdown - already handled. actix-web installs signal handlers for you. On SIGINT (Ctrl+C) or SIGTERM (what your platform sends on a deploy or scale-down), it stops accepting new connections, lets in-flight requests finish, then exits - no goroutine-and-channel dance, it's built into .run(). The one knob you may tune is how long it waits for stragglers:

HttpServer::new(|| App::new().configure(config))
    .shutdown_timeout(30) // seconds to let in-flight requests drain
    .bind(("0.0.0.0", 8080))?
    .run()
    .await

What just happened: .shutdown_timeout(30) gives in-flight requests up to 30 seconds after a shutdown signal; past that, remaining connections are force-closed so a stuck request can't block your deploy forever. The default is already 30 seconds - set this only to lengthen (long uploads) or shorten (fast restarts) it.

Env config and logging. Read anything that changes between environments - PORT, DATABASE_URL, secrets - from the environment, not hard-coded constants. Recall from Phase 5 that Logger writes through the log facade, which does nothing until a logger is initialized - call env_logger::init() at the top of main, or your access logs stay silent in production.

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    env_logger::init(); // now the Logger middleware actually prints

    let port: u16 = std::env::var("PORT")
        .ok()
        .and_then(|p| p.parse().ok())
        .unwrap_or(8080);

    HttpServer::new(|| App::new().app_data(state()).wrap(actix_web::middleware::Logger::default()).configure(config))
        .bind(("0.0.0.0", port))?
        .run()
        .await
}

What just happened: env_logger::init() reads RUST_LOG (e.g. RUST_LOG=info) and wires up the logger Logger middleware needs - skip it and your access logs are silent. We default PORT to 8080 but let the environment override it, so a platform injecting PORT=10000 works with no code change.

Deploy shape: release build, a small container, a proxy in front

Three steps take this from "runs on my machine" to "runs in production."

First, build in release mode - debug builds are unoptimized and slow; production wants the optimized binary:

cargo build --release
# produces target/release/articles-api

What just happened: --release turns on optimizations (and strips debug assertions), producing a much faster binary. Compilation takes longer, but only once at build time, not per request.

Second, package it in a multi-stage container - compile in a stage with the full Rust toolchain, then copy only the binary into a tiny runtime image:

# Build stage - full Rust toolchain
FROM rust:1 AS build
WORKDIR /src
COPY . .
RUN cargo build --release

# Run stage - slim image, just the binary
FROM debian:stable-slim
COPY --from=build /src/target/release/articles-api /usr/local/bin/articles-api
ENV RUST_LOG=info
EXPOSE 8080
CMD ["articles-api"]

What just happened: the first stage has the whole Rust toolchain and compiles the binary; the second is a slim Debian image carrying just the executable (plus the few shared libraries a default Rust binary links against - why debian:stable-slim rather than scratch). Small image, tiny attack surface. RUST_LOG=info bakes logging on by default.

Third, put a reverse proxy in front - nginx, Caddy, or whatever your platform provides. The proxy terminates TLS, can serve static assets, and load-balances across instances. Your actix-web app speaks plain HTTP on its port; the proxy faces the public internet - don't terminate TLS in actix-web itself.

That's the whole deploy shape: a release binary, a small container, env-var config, behind a proxy. The rest - picking a host, wiring CI, domain and TLS specifics - is covered in ship your side project.

Recap

  • A test is calling your App in memory: test::init_service(App::new()...), build a request with test::TestRequest, run it with test::call_service, assert on .status(). No ports, no network. For JSON POSTs use .set_json(&body) and read the response with test::read_body_json (or the one-shot test::call_and_read_body_json).
  • Declare routes once in a fn config(cfg: &mut web::ServiceConfig) and call .configure(config) from both main and your tests - one source of truth, no drift.
  • HttpServer runs one worker per CPU by default; override with .workers(n) only when you need to.
  • Graceful shutdown is built in - actix-web handles SIGINT/SIGTERM, draining in-flight requests before exit. Tune the drain window with .shutdown_timeout(secs); you rarely need to do more.
  • Read config (PORT, DATABASE_URL) from the environment, and call env_logger::init() so the Logger middleware actually prints.
  • Ship a release build (cargo build --release) in a multi-stage container, behind a reverse proxy that terminates TLS.

Quick check

Lock in the core fact (testing is in-memory) and the two production must-haves:

[
  {
    "q": "How does actix_web::test run a request against your app?",
    "choices": ["It starts a real server on a random port and sends an HTTP request over the loopback interface", "It builds the App into an in-memory service with test::init_service and runs the request through it with test::call_service - no port, no socket", "It mocks the TCP stack at the OS level", "It can only test individual handler functions in isolation, never the full app"],
    "answer": 1,
    "explain": "test::init_service compiles the same App into an in-memory service; test::call_service pushes a TestRequest through the full middleware-routing-handler chain in-process. Nothing binds a port or opens a socket."
  },
  {
    "q": "What does .configure(config_fn) let you do?",
    "choices": ["Enable release-mode optimizations", "Set the number of worker threads", "Register routes once in a fn taking &mut web::ServiceConfig, then call it from both main and tests so they share identical wiring", "Configure TLS certificates"],
    "answer": 2,
    "explain": "A config function registers services/routes on the ServiceConfig. Calling .configure(config) in both main and tests means there's one source of truth for routing - production and tests can't drift apart."
  },
  {
    "q": "What do you have to write yourself to get graceful shutdown in actix-web?",
    "choices": ["A goroutine plus a signal channel that calls server.shutdown() on SIGTERM", "Nothing - it's built in; actix-web handles SIGINT/SIGTERM and drains in-flight requests, and you only optionally tune .shutdown_timeout(secs)", "A custom middleware that intercepts the shutdown signal", "A reverse proxy that drains connections before killing the process"],
    "answer": 1,
    "explain": "HttpServer's .run() installs signal handlers and stops accepting new connections, lets in-flight requests finish, then exits - automatically. The only knob is .shutdown_timeout to lengthen or shorten the drain window."
  }
]

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. How does actix_web::test run a request against your app?

2. What does .configure(config_fn) let you do?

3. What do you have to write yourself to get graceful shutdown in actix-web?