M Logo
๐Ÿ“ Rust Backend with Axum ยท Part 35 of 42

Rust Web Server with Axum 0.8: Testing with Unit Tests and Router Tests

November 3, 2026

Episode 35 of the Rust Backend series, where we build the backend of a blog step by step. After episode 34 our API documents itself. But every episode we tested by hand with curl. Thirty-five episodes later, nobody can click through all of that after each change. Today: automatic tests. All the code below is the final, tested code from the video, ready to copy.

We continue in the same blog_api project from episode 34. New today: tower as a dev-dependency. No server runs in this episode: everything happens in cargo test.

1. Two kinds of tests

  • Unit tests check one small function.
  • Router tests send real HTTP requests to our whole app, in memory, without a network.

2. Unit tests next to the code

Unit tests live next to the code, in a tests module. #[cfg(test)] means: only compiled for cargo test. At the end of models.rs:

rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn slug_from_title() {
        assert_eq!(slugify("Hello, World"), "hello-world");
    }

    #[test]
    fn slug_never_empty() {
        assert!(!slugify("!!!").is_empty());
    }
}

The second test asks a question: what should a title of three exclamation marks give? At least not an empty slug.

bash
cargo test
text
test models::tests::slug_from_title ... ok
test models::tests::slug_never_empty ... FAILED

The slug is empty. A post titled "!!!" gets the slug "", and the next post titled "???" gets "" too: 409 Conflict! A real bug, found by asking one question.

3. The fix

If the slug is empty, we use post- and a short random part:

rust
let slug = slug.trim_end_matches('-').to_string();
if slug.is_empty() {
    return format!("post-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
}
slug
bash
cargo test

Both tests pass.

4. A test app

Now router tests. A new module, only for cargo test, in main.rs:

rust
#[cfg(test)]
mod api_tests;

and tower as a dev-dependency, for oneshot (send one request to a service):

bash
cargo add --dev tower --features util

The test app in src/api_tests.rs gets its database from the test, an in-memory file store (episode 25), and a Redis that does not exist: a lazy connection to port 1, with no retries. Our cache already survives a dead Redis (episode 28). Metrics get a recorder that is not installed globally:

rust
use super::*;

fn test_app(pool: SqlitePool) -> Router {
    let (jobs, _rx) = mpsc::channel(10);
    let (live, _) = broadcast::channel(10);
    let client = redis::Client::open("redis://127.0.0.1:1").unwrap();
    let config = ConnectionManagerConfig::new().set_number_of_retries(0).set_connection_timeout(Some(Duration::from_millis(100)));
    let cache = ConnectionManager::new_lazy_with_config(client, config).unwrap();
    let handle = PrometheusBuilder::new().build_recorder().handle();
    app(SqliteRepo { pool }, MemStorage::default(), jobs, cache, live, handle)
}

use super::* gives the test module everything from main.rs, including app.

5. Helpers: call and login

call builds a request, sends it with oneshot, and reads the status and the JSON:

rust
async fn call(app: &Router, method: Method, uri: &str, token: Option<&str>, body: Value) -> (StatusCode, Value) {
    let mut req = Request::builder().method(method).uri(uri).header(header::CONTENT_TYPE, "application/json");
    if let Some(t) = token {
        req = req.header(header::AUTHORIZATION, format!("Bearer {t}"));
    }
    let req = req.body(Body::from(body.to_string())).unwrap();
    let res = app.clone().oneshot(req).await.unwrap();
    let status = res.status();
    let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
    (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}

login signs up and logs in a test user, and returns the token:

rust
async fn login(app: &Router, email: &str) -> String {
    let creds = json!({ "email": email, "password": "hunter2hunter2" });
    call(app, Method::POST, "/signup", None, creds.clone()).await;
    let (status, body) = call(app, Method::POST, "/login", None, creds).await;
    assert_eq!(status, StatusCode::OK);
    body["token"].as_str().unwrap().to_string()
}

6. The first router test

Ana logs in, creates a post, and reads it back. #[sqlx::test] gives every test its own fresh database, with our migrations already run:

rust
#[sqlx::test]
async fn create_and_get(pool: SqlitePool) {
    let app = test_app(pool);
    let token = login(&app, "ana@example.com").await;
    let new = json!({ "title": "Hello", "body": "First post" });
    let (status, created) = call(&app, Method::POST, "/posts", Some(&token), new).await;
    assert_eq!(status, StatusCode::CREATED);
    let (status, post) = call(&app, Method::GET, &format!("/posts/{}", created["id"]), None, Value::Null).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(post["title"], "Hello");
}
bash
cargo test

It fails: the assert_eq! in login says the login answered 500.

7. The test must bring a client address

The rate limiter from episode 23 needs the client's address ("Unable To Extract Key"), and a test has no network connection. So call adds one, the same ConnectInfo that into_make_service_with_connect_info would add:

rust
use axum::extract::ConnectInfo;

let mut req = req.body(Body::from(body.to_string())).unwrap();
req.extensions_mut().insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 4000))));
bash
cargo test

All green.

8. Ownership and a dead database

The rule from episode 21: Ben cannot delete Ana's post, and Ana can:

rust
#[sqlx::test]
async fn ben_cannot_delete_ana_post(pool: SqlitePool) {
    let app = test_app(pool);
    let ana = login(&app, "ana@example.com").await;
    let ben = login(&app, "ben@example.com").await;
    let new = json!({ "title": "Mine", "body": "Hands off" });
    let (_, post) = call(&app, Method::POST, "/posts", Some(&ana), new).await;
    let uri = format!("/posts/{}", post["id"]);
    let (status, _) = call(&app, Method::DELETE, &uri, Some(&ben), Value::Null).await;
    assert_eq!(status, StatusCode::FORBIDDEN);
    let (status, _) = call(&app, Method::DELETE, &uri, Some(&ana), Value::Null).await;
    assert_eq!(status, StatusCode::NO_CONTENT);
}

And what does a user see when the database is down? We close the pool, and ask for a post: a 500 with "database error", and no details leaked:

rust
#[sqlx::test]
async fn database_down_gives_500(pool: SqlitePool) {
    let app = test_app(pool.clone());
    pool.close().await;
    let (status, body) = call(&app, Method::GET, "/posts/1", None, Value::Null).await;
    assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
    assert_eq!(body["error"], "database error");
}
bash
cargo test
text
test result: ok.

Each test has its own database, so they run in parallel. From now on, we run this before every change goes out.

The final code

src/api_tests.rs

rust
use axum::body::{to_bytes, Body};
use axum::extract::ConnectInfo;
use axum::http::{header, Method, Request, StatusCode};
use serde_json::{json, Value};
use sqlx::SqlitePool;
use tower::ServiceExt;

use super::*;

fn test_app(pool: SqlitePool) -> Router {
    let (jobs, _rx) = mpsc::channel(10);
    let (live, _) = broadcast::channel(10);
    let client = redis::Client::open("redis://127.0.0.1:1").unwrap();
    let config = ConnectionManagerConfig::new().set_number_of_retries(0).set_connection_timeout(Some(Duration::from_millis(100)));
    let cache = ConnectionManager::new_lazy_with_config(client, config).unwrap();
    let handle = PrometheusBuilder::new().build_recorder().handle();
    app(SqliteRepo { pool }, MemStorage::default(), jobs, cache, live, handle)
}

async fn call(app: &Router, method: Method, uri: &str, token: Option<&str>, body: Value) -> (StatusCode, Value) {
    let mut req = Request::builder().method(method).uri(uri).header(header::CONTENT_TYPE, "application/json");
    if let Some(t) = token {
        req = req.header(header::AUTHORIZATION, format!("Bearer {t}"));
    }
    let mut req = req.body(Body::from(body.to_string())).unwrap();
    req.extensions_mut().insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 4000))));
    let res = app.clone().oneshot(req).await.unwrap();
    let status = res.status();
    let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
    (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}

async fn login(app: &Router, email: &str) -> String {
    let creds = json!({ "email": email, "password": "hunter2hunter2" });
    call(app, Method::POST, "/signup", None, creds.clone()).await;
    let (status, body) = call(app, Method::POST, "/login", None, creds).await;
    assert_eq!(status, StatusCode::OK);
    body["token"].as_str().unwrap().to_string()
}

#[sqlx::test]
async fn create_and_get(pool: SqlitePool) {
    let app = test_app(pool);
    let token = login(&app, "ana@example.com").await;
    let new = json!({ "title": "Hello", "body": "First post" });
    let (status, created) = call(&app, Method::POST, "/posts", Some(&token), new).await;
    assert_eq!(status, StatusCode::CREATED);
    let (status, post) = call(&app, Method::GET, &format!("/posts/{}", created["id"]), None, Value::Null).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(post["title"], "Hello");
}

#[sqlx::test]
async fn ben_cannot_delete_ana_post(pool: SqlitePool) {
    let app = test_app(pool);
    let ana = login(&app, "ana@example.com").await;
    let ben = login(&app, "ben@example.com").await;
    let new = json!({ "title": "Mine", "body": "Hands off" });
    let (_, post) = call(&app, Method::POST, "/posts", Some(&ana), new).await;
    let uri = format!("/posts/{}", post["id"]);
    let (status, _) = call(&app, Method::DELETE, &uri, Some(&ben), Value::Null).await;
    assert_eq!(status, StatusCode::FORBIDDEN);
    let (status, _) = call(&app, Method::DELETE, &uri, Some(&ana), Value::Null).await;
    assert_eq!(status, StatusCode::NO_CONTENT);
}

#[sqlx::test]
async fn database_down_gives_500(pool: SqlitePool) {
    let app = test_app(pool.clone());
    pool.close().await;
    let (status, body) = call(&app, Method::GET, "/posts/1", None, Value::Null).await;
    assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
    assert_eq!(body["error"], "database error");
}

src/main.rs

rust
mod auth;
mod config;
mod error;
mod extract;
mod handlers;
mod models;
mod repo;
mod storage;
mod mail;
mod jobs;
mod cache;
#[cfg(test)]
mod api_tests;

use axum::{routing::{get, post, put}, Extension, Router};
use repo::{Repo, SqliteRepo};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use std::str::FromStr;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
use axum::extract::DefaultBodyLimit;
use axum::http::{header, HeaderName, HeaderValue, Method, StatusCode};
use tower_http::cors::CorsLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::timeout::TimeoutLayer;
use std::sync::Arc;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
use std::net::SocketAddr;
use storage::{LocalStorage, MemStorage, Storage};
use mail::Mailer;
use jobs::Job;
use tokio::sync::mpsc;
use tokio::time::Duration;
use redis::aio::{ConnectionManager, ConnectionManagerConfig};
use tokio::sync::broadcast;
use models::Comment;
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use axum::extract::{MatchedPath, Request};
use axum::middleware::{self, Next};
use axum::response::Response;
use utoipa::{Modify, OpenApi};
use utoipa_swagger_ui::SwaggerUi;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};

pub fn app<R: Repo, S: Storage>(repo: R, store: S, jobs: mpsc::Sender<Job>, cache: ConnectionManager, live: broadcast::Sender<Comment>, handle: PrometheusHandle) -> Router {
    let cors = CorsLayer::new()
        .allow_origin(HeaderValue::from_static("http://localhost:5500"))
        .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
        .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
        .allow_credentials(true);
    let governor = Arc::new(GovernorConfigBuilder::default().per_second(2).burst_size(5).finish().unwrap());
    let auth_routes = Router::new()
        .route("/signup", post(handlers::signup::<R>))
        .route("/login", post(handlers::login::<R>))
        .route("/auth/refresh", post(handlers::refresh::<R>))
        .layer(GovernorLayer::new(governor));
    let streaming = Router::new()
        .route("/posts/{id}/live", get(handlers::live))
        .route("/posts/{id}/events", get(handlers::events));
    Router::new()
        .nest("/api/v1", posts_v1())
        .nest("/api/v2", posts_v2())
        .merge(posts_v1())
        .route("/posts/{id}/restore", post(handlers::restore_post::<R>))
        .route("/me", get(handlers::me::<R>))
        .route("/admin/users", get(handlers::list_users::<R>))
        .route("/auth/logout", post(handlers::logout::<R>))
        .route(
            "/posts/{id}/images",
            post(handlers::upload_image::<R, S>).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
        )
        .route("/files/{name}", get(handlers::get_file::<S>))
        .route("/debug/slow", get(handlers::slow))
        .route("/health/live", get(handlers::health_live))
        .route("/health/ready", get(handlers::health_ready::<R>))
        .route("/metrics", get(handlers::metrics_text))
        .merge(auth_routes)
        .layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(10)))
        .merge(streaming)
        .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
        .route_layer(middleware::from_fn(track))
        .with_state(repo)
        .layer(Extension(Arc::new(store)))
        .layer(Extension(jobs))
        .layer(Extension(cache))
        .layer(Extension(live))
        .layer(Extension(handle))
        .layer(cors)
        .layer(SetResponseHeaderLayer::if_not_present(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")))
        .layer(SetResponseHeaderLayer::if_not_present(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")))
        .layer(SetResponseHeaderLayer::if_not_present(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer")))
        .layer(TraceLayer::new_for_http())
}

#[tokio::main]
async fn main() {
    let cfg = config::config();
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
        .init();
    let addr = format!("{}:{}", cfg.host, cfg.port);
    let options = SqliteConnectOptions::from_str(&cfg.database_url)
        .unwrap()
        .create_if_missing(true)
        .journal_mode(SqliteJournalMode::Wal);
    let pool = SqlitePoolOptions::new()
        .max_connections(5)
        .connect_with(options)
        .await
        .unwrap();
    sqlx::migrate!().run(&pool).await.unwrap();
    let repo = SqliteRepo { pool };
    let (tx, rx) = mpsc::channel::<Job>(100);
    let mailer = Mailer::new(cfg);
    tokio::spawn(jobs::worker(rx, mailer));
    tokio::spawn(jobs::cleanup(repo.clone(), Duration::from_secs(cfg.cleanup_secs)));
    let client = redis::Client::open(cfg.redis_url.as_str()).unwrap();
    let redis_config = ConnectionManagerConfig::new()
        .set_number_of_retries(1)
        .set_connection_timeout(Some(Duration::from_millis(300)))
        .set_response_timeout(Some(Duration::from_millis(300)));
    let cache = ConnectionManager::new_with_config(client, redis_config).await.unwrap();
    let (live, _) = broadcast::channel::<Comment>(100);
    let handle = PrometheusBuilder::new().set_buckets(&[0.005, 0.01, 0.05, 0.1, 0.5, 1.0]).unwrap().install_recorder().unwrap();
    match cfg.storage.as_str() {
        "memory" => serve(app(repo, MemStorage::default(), tx, cache, live, handle), &addr).await,
        _ => serve(app(repo, LocalStorage::new("uploads"), tx, cache, live, handle), &addr).await,
    }
}

async fn serve(app: Router, addr: &str) {
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    tracing::info!(%addr, "listening");
    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
        .with_graceful_shutdown(shutdown_signal())
        .await
        .unwrap();
}

fn posts_common<R: Repo>() -> Router<R> {
    Router::new()
        .route("/posts", get(handlers::list_posts::<R>).post(handlers::create_post::<R>))
        .route(
            "/posts/{id}",
            put(handlers::update_post::<R>).delete(handlers::delete_post::<R>),
        )
        .route("/posts/{id}/comments", get(handlers::list_comments::<R>).post(handlers::add_comment::<R>))
        .route("/search", get(handlers::search_posts::<R>))
}

fn posts_v1<R: Repo>() -> Router<R> {
    posts_common()
        .route("/posts/{id}", get(handlers::get_post::<R>))
        .layer(SetResponseHeaderLayer::overriding(HeaderName::from_static("deprecation"), HeaderValue::from_static("true")))
        .layer(SetResponseHeaderLayer::overriding(
            HeaderName::from_static("sunset"),
            HeaderValue::from_static("Wed, 30 Jun 2027 00:00:00 GMT"),
        ))
        .layer(SetResponseHeaderLayer::overriding(header::LINK, HeaderValue::from_static(r#"</api/v2>; rel="successor-version""#)))
}

fn posts_v2<R: Repo>() -> Router<R> {
    posts_common().route("/posts/{id}", get(handlers::get_post_v2::<R>))
}

async fn shutdown_signal() {
    let ctrl_c = async {
        tokio::signal::ctrl_c().await.expect("Ctrl+C handler");
    };
    let terminate = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("SIGTERM handler")
            .recv()
            .await;
    };
    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
    tracing::info!("shutting down");
}

async fn track(req: Request, next: Next) -> Response {
    let start = std::time::Instant::now();
    let method = req.method().to_string();
    let path = req
        .extensions()
        .get::<MatchedPath>()
        .map(|p| p.as_str().to_string())
        .unwrap_or_else(|| "unmatched".to_string());
    let response = next.run(req).await;
    let status = response.status().as_u16().to_string();
    let labels = [("method", method), ("path", path), ("status", status)];
    metrics::counter!("http_requests_total", &labels).increment(1);
    metrics::histogram!("http_request_duration_seconds", &labels).record(start.elapsed().as_secs_f64());
    response
}

#[derive(OpenApi)]
#[openapi(
    paths(handlers::get_post_v2, handlers::create_post),
    components(schemas(models::PostV2, models::NewPost, models::PostWithTags, models::Post, error::ErrorBody)),
    modifiers(&SecurityAddon)
)]
struct ApiDoc;

struct SecurityAddon;

impl Modify for SecurityAddon {
    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
        let components = openapi.components.get_or_insert_with(Default::default);
        components.add_security_scheme(
            "bearer",
            SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build()),
        );
    }
}

src/models.rs

rust
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};
use utoipa::ToSchema;

#[derive(Serialize, Deserialize, Clone, sqlx::FromRow, ToSchema)]
pub struct Post {
    pub id: i64,
    pub title: String,
    pub body: String,
    pub slug: String,
    pub author_id: Option<i64>,
    pub created_at: String,
}

#[derive(Deserialize, Validate, ToSchema)]
pub struct NewPost {
    #[validate(length(min = 1, max = 200, message = "1 to 200 characters"), custom(function = "not_blank"))]
    pub title: String,
    #[validate(length(min = 1, max = 20000, message = "must not be empty"))]
    pub body: String,
    #[serde(default)]
    #[validate(length(max = 5, message = "at most 5 tags"))]
    pub tags: Vec<String>,
}

#[derive(Serialize, Clone)]
pub struct Comment {
    pub id: i64,
    pub post_id: i64,
    pub body: String,
    pub created_at: String,
}

#[derive(Deserialize, Validate)]
pub struct NewComment {
    #[validate(length(min = 1, max = 2000, message = "1 to 2000 characters"), custom(function = "not_blank"))]
    pub body: String,
}

pub fn slugify(title: &str) -> String {
    let mut slug = String::new();
    for c in title.trim().to_lowercase().chars() {
        if c.is_alphanumeric() {
            slug.push(c);
        } else if !slug.ends_with('-') {
            slug.push('-');
        }
    }
    let slug = slug.trim_end_matches('-').to_string();
    if slug.is_empty() {
        return format!("post-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
    }
    slug
}

#[derive(Serialize, ToSchema)]
pub struct PostWithTags {
    #[serde(flatten)]
    pub post: Post,
    pub tags: Vec<String>,
}

#[derive(Deserialize)]
pub struct ListParams {
    pub page: Option<u32>,
    pub per_page: Option<u32>,
    pub tag: Option<String>,
    #[serde(default)]
    pub sort: Sort,
}

#[derive(Deserialize, Clone, Copy, Default)]
#[serde(rename_all = "lowercase")]
pub enum Sort {
    #[default]
    Newest,
    Oldest,
    Title,
}

#[derive(Serialize)]
pub struct Page<T> {
    pub items: Vec<T>,
    pub page: u32,
    pub per_page: u32,
    pub total: i64,
}

pub struct ListFilter {
    pub page: u32,
    pub per_page: u32,
    pub tag: Option<String>,
    pub sort: Sort,
}

#[derive(Serialize)]
pub struct Hit {
    pub id: i64,
    pub title: String,
    pub snippet: String,
}

#[derive(Deserialize)]
pub struct SearchParams {
    pub q: String,
}

pub fn not_blank(value: &str) -> Result<(), ValidationError> {
    if value.trim().is_empty() {
        return Err(ValidationError::new("blank").with_message("must not be blank".into()));
    }
    Ok(())
}

#[derive(Deserialize, Validate)]
pub struct NewUser {
    #[validate(email(message = "must be a valid email"))]
    pub email: String,
    #[validate(length(min = 10, max = 200, message = "10 to 200 characters"))]
    pub password: String,
}

#[derive(Serialize, Clone)]
pub struct User {
    pub id: i64,
    pub email: String,
    pub role: String,
    pub created_at: String,
}

pub struct UserWithHash {
    pub id: i64,
    pub email: String,
    pub password_hash: String,
    pub role: String,
}

#[derive(Serialize)]
pub struct Image {
    pub id: i64,
    pub post_id: i64,
    pub url: String,
}

#[derive(Serialize, ToSchema)]
pub struct PostV2 {
    pub id: i64,
    #[schema(example = "Hello")]
    pub title: String,
    /// The post's text in Markdown.
    pub content: String,
    pub slug: String,
    pub author_id: Option<i64>,
    pub created_at: String,
}

impl From<Post> for PostV2 {
    fn from(p: Post) -> PostV2 {
        PostV2 { id: p.id, title: p.title, content: p.body, slug: p.slug, author_id: p.author_id, created_at: p.created_at }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn slug_from_title() {
        assert_eq!(slugify("Hello, World"), "hello-world");
    }

    #[test]
    fn slug_never_empty() {
        assert!(!slugify("!!!").is_empty());
    }
}

Recap

  • Unit tests sit in #[cfg(test)] mod tests next to the code; one good question found a real bug
  • Router tests send requests to the whole app with tower::ServiceExt::oneshot, no network needed
  • #[sqlx::test] gives each test a fresh database with migrations applied
  • Helpers (call, login) keep tests short; fake what tests can't have (Redis, file storage)
  • Middleware that needs the client address needs ConnectInfo inserted by the test

Next episode: Docker. One image that runs our server anywhere. Watch the series on YouTube.

ยฉ 2026 All Rights Reserved By Mahmudul Alam