Step-by-Step Guide to Test Driven Development (TDD) in Rust Axum

Ansari Mustafa
IntelliconnectQ Engineering
8 min readMar 6, 2024
Ferris the crab, unofficial mascot of the Rust programming language

Test Driven Development (TDD) also synonymous with Unit Testing in the programming world is term you come across frequently. Have you wondered at some point what is it and why is it necessary? Why is writing a bunch of tests for a piece of code that you wrote all by yourself is so heavily emphasized by veteran software engineers worldwide? Is it even worth the time and effort?

I have pondered about these questions quite a bit and I have to admit, it was quite a journey through the rabbit hole riddled with errors, endless articles, debates on what format is the best etc etc. I thought the best way for me to learn and master unit testing would be to craft up a tutorial for the same, as per the Richard Feynman technique. In this article today I will present to you one of the approaches that you can undertake for unit testing a basic CRUD application written in Axum framework of the Rust programming language.

Just in case you are wondering, this is by no means the only way to conduct unit tests nor are all the steps exhaustive, but it’s still gonna be a great foundation to build and expand on.

The following are some of the most compelling reasons for incorporating unit tests into our code:

  • Allows full coverage for all edge cases for the code in question
  • Provides assurance of the code working exactly as intended
  • Prevents unexpected behavior at the time of integration of the code
  • Reduces the probability of application crashes for end users

For this short tutorial we are going to be using a simple CRUD application made using the Axum framework, the entire code of which can be found here. This API application contains 4 endpoint URLs in total and we will implement unit testing for all of them one by one. I have used ElephantSQL as my database, the DDL queries used for this application are within the scripts.sql file in the root directory.

The recommended approach to testing any server routes from within an API is to connect it to a test database which essentially prevents any disruption to our production code. Or if connecting a test database is not an option then we can create mock database objects within our code and perform testing as required. We are going to connect to our test database hosted on the ElephantSQL server for conducting unit tests.

Broadly speaking we will be conducting the unit tests in 4 steps and those steps are going to be as follows:

  1. Check the database connectivity.
  2. Extract complex logic in a helper function to test separately. (Optional)
  3. Check the endpoint URL for validity.
  4. Ensure the endpoint handler returns a success code for valid input.

To start off let’s take a look at the handler named as create_user which is in src\controllers\add_user.rs

pub async fn create_user(
Extension(pool): Extension<PgPool>,
Json(new_user): Json<NewUser>,
) -> Result<Json<Value>, String> {
// Check if the username already exists
let username_exists = sqlx::query_scalar::<_, i32>(
"SELECT COUNT(*) FROM Users WHERE Username = $1",
)
.bind(&new_user.username)
.fetch_one(&pool)
.await
.map_err(|err| {
let err_message = format!("Error checking if username exists, error returned from the database: \n{}", err);
println!("{}",&err_message);
err_message
})?;

if username_exists > 0 {
return Err("Username already exists. Please choose a unique username.".to_string());
}

// If the username is unique, insert the new user
sqlx::query(
r#"
INSERT INTO Users (Username, FirstName, LastName, Email, BirthDate)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(&new_user.username)
.bind(&new_user.first_name)
.bind(&new_user.last_name)
.bind(&new_user.email)
.bind(&new_user.birthdate)
.execute(&pool)
.await
.map_err(|err| {
let err_message = format!("Error inserting new user into the database: \n{}", err);
println!("{}",&err_message);
err_message
})?;

Ok(Json(json!({"message": "User registered successfully."})))
}

At the very first glance it seems that this handler function can be used to create a new user as per the given JSON object. Overall it also seems to be correct but just to be sure conduct some unit tests on the handler.

If you look closely in the handler function, it performs two different operations. The first one is that it checks whether the username within the request body is unique or not. If it happens to be unique then it will make a new insertion operation in the database for the table Users as per the given JSON payload, which is the second operation. Let’s go through the steps one by one.

Step 1: Check the database connectivity.

As per our steps to perform unit testing for this particular handler ie create_user we first have to check the connectivity to our test database. And to do that we will need to add the following code just the create_user function. It is the convention in Rust to write our unit tests in the same module where the code to be tested exists.

mod tests{
use sqlx_core::postgres::PgPoolOptions;
use super::*;

#[tokio::test]
async fn check_database_connectivity(){
let durl = std::env::var("DATABASE_URL_ONLINE").expect("set DATABASE_URL env variable");

let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&durl)
.await
.expect("unable to make connection");

// assert to check if connection is successful
assert_eq!(pool.is_closed(),false);

}
}

Now upon running this command cargo test check_database_connectivity in the terminal window, we will be able to see if we could connect to our test database. The output can be something like:

But in any case if we are unable to connect to the database then the terminal will show us

This essentially means that the connection attempt has failed. To resolve it you can cross check the database URL that is being passed in as your environment variable along with the correct name of the variable. Because of this unit test we can for sure guarantee that our application is connected to our test database. Now we just have to worry about how we will be conducting subsequent tests that follow.

Step 2: Extract complex logic in a helper function to test separately.

As discussed earlier our handler function create_user checks whether the username in the payload is unique or not. This is an entire new functionality that can be extracted into a helper function and we can test it separately, here is the helper function that we can test:

async fn _check_username_uniqueness(
Extension(pool): Extension<PgPool>,
Json(new_user): Json<NewUser>,
) -> Result<Json<Value>, String> {
// Check if the username already exists
let username_exists = sqlx::query_scalar::<_, i32>(
"SELECT COUNT(*) FROM Users WHERE Username = $1",
)
.bind(&new_user.username)
.fetch_one(&pool)
.await
.map_err(|err| {
let err_message = format!("Error checking if username exists, error returned from the database: \n{}", err);
println!("{}",&err_message);
err_message
})?;

if username_exists > 0 {
return Err("Username already exists. Please choose a unique username.".to_string());
}

Ok(Json(json!({"message": "Username is unique."})))
}

Now let’s test it by passing in the parameters:

#[cfg(test)]
mod tests{
use axum::{http::{Request, Method}, body::Body, routing::{get, post} , Router};
use sqlx_core::postgres::PgPoolOptions;
use tower::util::ServiceExt;
use super::*;

async fn create_connection_pool() -> PgPool{
let durl = std::env::var("DATABASE_URL_ONLINE").expect("set DATABASE_URL env variable");

let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&durl)
.await
.expect("unable to make connection");

return pool;
}
#[tokio::test]
async fn test_username_uniqueness(){

let pool = create_connection_pool().await;

let app = Router::new()
.route("/check-username",post(_check_username_uniqueness))
.layer(Extension(pool));

let req = Request::builder()
.method(Method::POST)
.uri("/check-username")
.header("content-type", "application/json")
.body(Body::from(
r#"{
"username": "johndoe87"
}"#,
))
.unwrap();

let response = app
.oneshot(req)
.await
.unwrap();

assert_eq!(response.status(),200);
}
}

Now lets run the command in the terminal cargo test test_username_uniqueness

It appears that our test has failed, but since our helper function _check_username_uniqueness returns the error messages, we should be able to see exactly what went wrong if we check the output in the terminal.

Oh so we made a tiny error and rust type of integer i32 is not compatible with what is returned from our PostgreSQL database. We need to change the type in the _check_username_uniqueness helper function from i32 to i64 as:

let username_exists = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM Users WHERE Username = $1",
)
.bind(&new_user.username)
.fetch_one(&pool)
.await
.map_err(|err| {
let err_message = format!("Error checking if username exists, error returned from the database: \n{}", err);
println!("{}",&err_message);
err_message
})?;

Now that should resolve the issue and our error with the incompatible types, but also don’t forget to change the types in the create_user handler as well.

This is the exact reason why we do unit tests, to catch these nuanced bugs and errors that occur when we are working on business logic. These small mistakes happen all the time to all kinds of developers irrespective of how much experience they have.

Okay now let’s pick up where we left off.

We can add more assert statements to thoroughly test our helper function _check_username_uniqueness

#[cfg(test)]
mod tests{
use axum::{http::{Request, Method}, body::Body, routing::{get, post} , Router};
use sqlx_core::postgres::PgPoolOptions;
use tower::util::ServiceExt;
use super::*;

async fn create_connection_pool() -> PgPool{
let durl = std::env::var("DATABASE_URL_ONLINE").expect("set DATABASE_URL env variable");

let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&durl)
.await
.expect("unable to make connection");

return pool;
}
#[tokio::test]
async fn test_username_uniqueness(){

let pool = create_connection_pool().await;

let app = Router::new()
.route("/check-username",post(_check_username_uniqueness))
.layer(Extension(pool));

let req = Request::builder()
.method(Method::POST)
.uri("/check-username")
.header("content-type", "application/json")
.body(Body::from(
r#"{
"username": "johndoe87"
}"#,
))
.unwrap();

let response = app
.oneshot(req)
.await
.unwrap();

// assert the username is unique from the response
let body_bytes = hyper::body::to_bytes(response.into_body())
.await
.expect("Failed to read response body");

let body_str = String::from_utf8(body_bytes.to_vec())
.expect("Failed to convert body to string");

let body: Value = serde_json::from_str(&body_str)
.expect("Failed to parse JSON");

println!("{:?}",body);
assert_eq!(body["message"], "Username is unique.");
}
}

Now upon running the command cargo test _check_username_uniqueness, we see that the test has failed yet again

Checking the terminal reveals that the problem was that the JSON couldn’t be parsed correctly:

If we take a closer look at the success and error responses being returned from the _check_username_uniqueness helper function, we see that they are not consistent. The success response is being returned in the JSON format while the error message is in String. To remedy this problem we need to make the format same throughout.

Changing the response for when a username is not unique will fix this error and our test will pass.

if username_exists > 0 {
// return json response
return Ok(Json(json!({"message": "Username already exists. Please choose a unique username."})));
}

Notice how nuanced these errors and bugs are in our code, the code that seemingly looks correct at first. This is why a saying goes among scientists as: “Cut once major twice“.

--

--