ztp/docs/02-testing-hello-world.md

6.2 KiB

+++ title = "Hello World, You Need Testing" date = 2023-03-20T17:38:12Z weight = 2 +++ Zero-to-Production's project is writing a web service that signs people up for an email newsletter. The first task in the book is to set up a "Hello World!" application server.

The book uses the Actix-web web framework, but I've chosen to implement it using Axum server, the default server provided by the Tokio asynchronous runtime.

Although the book is only two years old, it is already out-of-date with respect to some commands. cargo add is now provided by default. The following commands installed the tools I'll be using:

cargo add --features tokio/full --features hyper/full tokio hyper \
    axum tracing tracing-subscriber
  • axum: The web server framework for Tokio.
  • tokio: The Rust asynchronous runtime. Has single-threaded (select) and multi-threaded variants.
  • hyper: An HTTPS request/response library, used for testing.
  • tracing: A debugging library that works with Tokio.

We start by defining the core services. In the book, they're a greeter ("Hello, World"), a greeter with a parameter ("Hello, {name}"), and a health check (returns a HTTP 200 Code, but no body). Actix-web hands a generic Request and expects a generic request, but Axum is more straightforward, providing IntoResponse handlers for most of the basic Rust types, as well as some for formats via Serde, Rust's standard serializing/deserializing library for converting data from one format to another.

All of these go into src/lib.rs:

async fn health_check() -> impl IntoResponse {
    (StatusCode::OK, ())
}

async fn anon_greet() -> &'static str {
    "Hello World!\n"
}

async fn greet(Path(name): Path<String>) -> impl IntoResponse {
    let greeting = String::from("He's dead, ") + name.as_str();
    let greeting = greeting + &String::from("!\n");
    (StatusCode::OK, greeting)
}

We then define the routes that our server will recognize. This is straightforward and familiar territory:

fn app() -> Router {
    Router::new()
        .route("/", get(anon_greet))
        .route("/:name", get(greet))
        .route("/health_check", get(health_check))
}

We then define a function to run the core server:

pub async fn run() {
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::info!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app().into_make_service())
        .await
        .unwrap()
}

And finally, in a file named src/main.rs, we instantiate the server:

use ztp::run;

#[tokio::main]
async fn main() {
    run().await
}

To make this "work," we need to define what ztp means, and make a distinction between the library and the CLI program.

In the project root's Cargo.toml file, the first three sections are needed to define these relationships:

[package]
name = "ztp"
version = "0.1.0"
edition = "2021"

[lib]
path = "src/lib.rs"

[[bin]]
path = "src/main.rs"
name = "ztp"

It is the [package.name] feature that defines how the use statement in main.rs will find the library. The [[bin]] clause defines the name of the binary when it is generated.

This project should now be runnable. In one window, type:

$ cargo run

And in another, type and see the replies:

$ curl http://localhost:3000/
Hello, World!
$ curl http://localhost:3000/Jim
He's dead, Jim!
$ curl -v http://localhost:3000/health_check
> GET /health_check HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.81.0
> Accept: */*
< HTTP/1.1 200 OK
< content-length: 0
< date: Tue, 21 Mar 2023 00:16:43 GMT

In the last command, the verbose flag shows us what we sent to the server, and what came back. We expected a "200 OK" flag and a zero-length body, and that's what we got.

Testing

In order to unit-test a web server, we must spawn a copy of it in order to exercise its functions. We'll use Tokio's spawn function to create a new server, use hyper to request data from the server, and finally Rust's own native test asserts to check that we got what we expected.

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::{Request, StatusCode},
    };
    use std::net::{SocketAddr, TcpListener};

    #[tokio::test]
    async fn the_real_deal() {
        let listener = TcpListener::bind("127.0.0.1:0".parse::<SocketAddr>()
            .unwrap()).unwrap();
        let addr = listener.local_addr().unwrap();

        tokio::spawn(async move {
            axum::Server::from_tcp(listener)
                .unwrap()serve(app().into_make_service()).await.unwrap();
        });

        let response = hyper::Client::new()
            .request(
                Request::builder().uri(format!("http://{}/", addr))
                    .body(Body::empty()).unwrap(),
            )
            .await
            .unwrap();

        let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
        assert_eq!(&body[..], b"Hello World!\n");
    }
}

One interesting trick to observe in this testing is the port number specified in the TcpListener call. It's zero. When the port is zero, the TcpListener will request from the kernel the first-free-port. Normally, you'd want to know exactly what port to call the server on, but in this case both ends of the communication are aware of the port to use and we want to ensure that port isn't hard-coded and inconveniently already in-use by someone else.