Rust unit testing: basic HTTP testing
Real-world application testing - Beginning with Axum

In previous articles in this series, I have highlighted the importance of unit testing your code. However, I must reckon that most of the examples I have used were mostly isolated. There weren't many connections between one scenario and the next, and, admittedly, not a single application that integrated all the scenarios as a whole.
I hope this has fulfilled the purpose of sharing knowledge about unit testing in Rust. But one of the first challenges I had to overcome once I had learned the very basics of writing automated tests was integrating them into a real codebase. But it is hard to walk this path alone, since the first (real) tests are also the hardest.
So, in this article and others to follow, I would like to defeat the challenge of testing something beyond the fact that 2+2=4. And, in order to do that, I am going to define some "real" application that I will implement with you1 while adding tests along the way. So, more often than not, I will be asking myself2 whether I need to write a test for a given piece of code. Please answer out loud before you continue reading 😉.
A "real-world" application
Our supervillain is an ambitious person and wants to be more efficient. A baddie with a mission that knows that the world ain't gonna conquer itself. After many productivity courses, we have been tasked to write an evilness management tool. Very similar to a time management tool, but it manages evilness.
Luckily, there are other prisoners developers that will take care of the front end, and we just have to write the
back end. It will provide a REST API that uses JSON for data exchange. I have decided to use Axum, a web application
framework, to help implement the app. And we will be using Oracle db26ai in a FREE container for
persisting the data.
I will make no assumptions on how much you know about Axum, just that you have a basic idea of what a web application framework is. Also, I don't expect you to know how to run the container with the database or how to use it from Rust. I have already published an article on running Oracle Database container, and I will explain how to connect to the database in this series.
Cleaning up after myself
All the code from the previous articles in this series is in a single Git repository with a single Cargo project. I could continue adding code in the same project, but I thought you'd appreciate a clean slate for the new application. Yet, I prefer to have all the code in a single repo. So, I have decided to use the same project and add workspaces to it. I will move the existing code into a workspace called "evil" and create another workspace for the new application.
If you are coding along and want to do the same, here are the steps I followed.
mkdir evilguys
mv Cargo.toml src evilguys/
echo -e "[workspace]\nmembers = [\"evilguys\"]\nresolver = \"3\"" > Cargo.toml
rm Cargo.lock
The cargo manifest of the evilguys workspace must be edited. The package name must match the one in the
workspace, and I have finally removed the unused binary package.
[package]
name = "evilguys"
version = "0.1.0"
edition = "2024"
[lib]
name = "evil"
path = "src/lib.rs"
[dependencies]
...
And finally, I can remove the file main.rs and run the tests to check that everything is fine.
rm evilguys/src/main.rs
cargo t -p evilguys
All the regular tests should pass, but we will get an error in our doctest. However, that problem isn't due to this
change, but to the fact that we didn't update that test when we should, and running the tests with cargo t --lib was
ignoring the doctests. Fortunately, the change that we have to make is trivial.
/// # Examples
/// ```
///# use evil::supervillain::Supervillain;
/// let lex = Supervillain {
/// first_name: "Lex".to_string(),
/// last_name: "Luthor".to_string(),
/// ..Default::default()
/// };
/// assert_eq!(lex.full_name(), "Lex Luthor");
/// ```
I have also applied all the recommendations provided by clippy. In some cases, I was trying to make the code easier
and keep the focus on what I was explaining. In others, I should have done better.
We run the tests again with cargo t -p evilguys, and we should be back on track. 🎉
Test basic HTTP server
As I promised above, the new application will be placed in a new workspace.
cargo new evilmgmtStart asynchronously
Axum requires Tokio, so let's start there.
cargo add -p evilmgmt -F macros,rt-multi-thread tokio
Asynchronous functions must be called from asynchronous functions. In evilmgmt/src/main.rs, make main async and use
tokio's main macro.
#[tokio::main]
async fn main() {
We could run the code with cargo -p evilmgmt r, so the first question is due. Do we have to run a test for this code?
I couldn't hear what you said, so I am going to share my answer: NO. Nevertheless, let me elaborate. We still talk about unit tests, and the only unit that could be tested here is the main function to verify that it is executed asynchronously. But this is none of our business. It is the Tokio development team's responsibility to verify that their macro works and a non-async main that uses the async one we have written. Not very valuable.
Simplest HTTP server
The next step is adding Axum to the dependencies.
cargo add -p evilmgmt axumDefine the address as a constant string and show it to the user to make their lives easier.
const SERVER_ADDR: &str = "127.0.0.1:8080";
println!("Launching evilmgmt: http://{server_addr}");Create an Axum's router, which is the piece that knows the relationships between the URLs and what the server has to do.
let router = Router::new();
Tell the operating system that this application will be listening on the previously defined address by creating a
listener with tokio::net::TcpListener instead of the one in the standard library.
let listener = TcpListener::bind(SERVER_ADDR)
.await
.expect("Unable to create listener");Use the router to serve requests received by the listener.
axum::serve(listener, router).await.unwrap();
If you have already noticed the stink of unwrap(), keep calm and read Axum's docs. I quote:
Although this future resolves to io::Result<()>, it will never actually complete or return an error. Errors on the TCP socket will be handled by sleeping for a short while (currently, one second).
This is enough to get the most basic HTTP server up and running.
cargo r -p evilmgmt
Time to ask again about testing. Should we write any unit tests for this code? I couldn't hear your answer this time
either. Mine is again "NO". Testing that we can create a listener, a router, or even that the application creates an
HTTP server when launched is again beyond the responsibilities of our code. All of these are produced by Axum, and we
must not write tests for somebody else's code3. The furthest we can go is to verify that the HTTP server is
handling requests at the expected address and port, and we can use curl for that.
curl -i localhost:8080/Notice that we will get a reply from the HTTP server, but it will be a "404 Not Found" because we haven't defined any routes yet.
Static handler and first test
Let's add some ingredients of our own, and that can be the simplest static handler. We can define a handler for the HTTP GET verb at the root path using an asynchronous closure and replace the line where the router was created.
let router = Router::new().route("/", get(|| async { "Evilness Management" }));
This change should compile and run with cargo r -p evilmgmt. So, I raise the question again: should we write any test
now? I know you have shouted a loud "YES" and that you aren't cheating and haven't been inspired by the title of this
section. Well done!
We would like to test that requests to the server root are returned with the correct content. We could do this manually
with curl or wget and even include the requests in a bash script. We can also use tools to automate queries,
such as Postman, Apidog, or Bruno. And even write code that does the request using reqwest or curl-rust. But all these
alternatives rely on the network and are not suitable for unit tests.
This is analogous to what I explained about file I/O. We don't need or want to use the network. It is even worse than
interacting with a file by a few orders of magnitude. Even if we run the client and server in the same system, with
localhost. We will just make Axum produce the response in memory by using the Router instance separately from the
server. We would send the request to the Router instance and receive a response without using the network at all.
We start by moving the router creation into a separate function in a different module (routes.rs).
//! Routes for the HTTP application
use axum::{routing::get, Router};
pub fn app() -> Router {
Router::new().route("/", get(|| async { "Evilness Management" }))
}
And we use it in main.rs. Including the module in the application with mod routes;.
axum::serve(listener, routes::app()).await.unwrap();
Now we can test this router. The handlers' responses will be futures, so we need to use async tests. We create our
first test inside the standard tests module.
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn root_return_static_response_and_ok() {
}
}
We will write this test using the customary three parts: arrange, act, and assert. In the arrange part, we get an
instance of the Router and prepare the request that we want to make. The request will contain the URI that we are
testing, i.e., the root of this server, and an empty body, because we don't need to pass any data in it.
let routes = app();
let request = Request::builder().uri("/").body(Body::empty()).unwrap();
axum::extract::Request can be extended with a blanket implementation of the oneshot() method, which takes a request
and returns a future for the response. But that trait requires adding the tower crate and importing the ServiceExt
trait.
cargo add -p evilmgmt -F util tower
After importing tower::ServiceExt, we can use the request and obtain a response in the act part.
let response = routes.oneshot(request).await.unwrap();
Getting the body contents from the response can be simplified using the trait BodyExt, provided by the
http_body_util crate. So, we add it to the workspace.
cargo add -p evilmgmt http_body_util
With http_body_util::BodyExt imported, we can make assertions on the response.
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body, "Evilness Management");
We run the tests with cargo t -p evilmgmt and see our first test passing. Hooray!
Test non-existing URIs
It may also be valuable to check what happens when somebody tries to reach a URI that is not defined for our server.
We define a test where the arrange and act are very similar to the previous one, with the only exception of having a non-existing URI.
#[tokio::test]
async fn nonexisting_url_returns_emply_response_and_not_found() {
let routes = app();
let request = Request::builder()
.uri("/nonexisting")
.body(Body::empty())
.unwrap();
let response = routes.oneshot(request).await.unwrap();
}The assertion part will use the "not found" status code and an empty body.
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert!(&body.is_empty());
We run the tests with cargo t -p evilmgmt, and they all pass. However, this only makes sense if we have customized
this fallback behavior. Otherwise, we would be testing Axum's ability to respond to URIs that haven't been configured in
the router. Again, none of our business.
We can change the code so it needs to be tested by adding a fallback handler. We could use an async closure, as we did with our static HTTP handler; in this case, we will use an async function instead.
async fn fallback_handler(uri: Uri) -> (StatusCode, String) {
(StatusCode::NOT_FOUND, format!("No route for {uri}"))
}
We add this handler to the router's configuration in the app() function.
.fallback(fallback_handler)We also changed the body assertion to expect what we added in our handler.
assert_eq!(&body, "No route for /nonexisting");
We run the tests again with cargo t -p evilmgmt and get OKs for both tests. We can go to our next social event and
tell everybody how good we are at writing tests. A session of fun and engagement guaranteed!
Final code
I have included the latest version of the code in the routes.rs file below, because it is the one the contains the
tests. Remember that the new project is in a workspace, but you can put it in its own Cargo project. The whole project
with its workspaces is in the repo with all the code in this series.
//! Routes for the HTTP application
use axum::{
Router,
http::{StatusCode, Uri},
routing::get,
};
pub fn app() -> Router {
Router::new()
.route("/", get(|| async { "Evilness Management" }))
.fallback(fallback_handler)
}
async fn fallback_handler(uri: Uri) -> (StatusCode, String) {
(StatusCode::NOT_FOUND, format!("No route for {}", uri))
}
#[cfg(test)]
mod tests {
use axum::{body::Body, extract::Request};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn root_return_static_response_and_ok() {
let routes = app();
let request = Request::builder().uri("/").body(Body::empty()).unwrap();
let response = routes.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body, "Evilness Management");
}
#[tokio::test]
async fn nonexisting_url_returns_emply_response_and_not_found() {
let routes = app();
let request = Request::builder()
.uri("/nonexisting")
.body(Body::empty())
.unwrap();
let response = routes.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body, "No route for /nonexisting");
}
}Summary
After completing some cleaning tasks, I have started a new application: This one is implemented as an HTTP server that provides a REST API. I have explained when tests are needed and how to write tests for static content generated by Axum.
We will explore other realistic scenarios in upcoming articles.
Stay curious. Hack your code. See you next time!