Testing in Rust and Axum

In the previous blog post we introduced error handling and saw how clear, well-structured error types help make a Rust backend predictable and robust. Testing is the natural next step: while the type system and error handling guard many paths in your application, they cannot guarantee that the overall behavior is correct. Business rules, service logic, and HTTP endpoints still need to be verified at runtime.
Rust gives you powerful tools to do exactly that. With lightweight unit tests and full end-to-end integration tests, you can ensure that your internal logic behaves correctly and that your API responds as expected when real HTTP requests hit it. In this post, we explore how to write these tests step by step, starting with the service layer and ending with full API checks through Axum.
How Testing Works in Rust
Rust’s testing system is built directly into the language and tooling. When you run the test command, Cargo compiles your code in test mode and automatically discovers and executes your tests. There are two main types you will use in development: unit tests and integration tests.
Unit tests live inside the same file as the code they test and are typically placed at the bottom inside a tests module. This module is only compiled when running tests. A minimal unit test looks like this:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_add_two_numbers() {
let result = 2 + 2;
assert_eq!(result, 4);
}
}The #[cfg(test)] attribute tells Rust to include this module only when running tests. The #[test] attribute marks a function as a test case. Assertions like assert!, assert_eq!, or assert_ne! express expectations about your code.
When your code is asynchronous you can use the #[tokio::test] attribute to run tests inside Tokio’s async runtime:
#[tokio::test]
async fn should_fetch_data() {
let data = async_fetch().await;
assert!(data.is_ok());
}Integration tests live in the tests/ directory at the root of your project. Each .rs file in that directory becomes its own test suite compiled as if it were an external crate. Integration tests validate how your crate behaves from the outside and are ideal for testing whole flows like HTTP request handling.
Here’s a minimal integration test:
// tests/simple_integration_test.rs
#[test]
fn should_be_true() {
assert!(true);
}Unlike unit tests, integration tests do not use #[cfg(test)]. They don’t need it. The reason is simple: Cargo automatically compiles everything inside the tests/ directory only when you run the test command. These files are completely ignored during cargo build or when using your library normally. Because they live outside your main crate and are treated as separate test crates, they naturally form the “external perspective” on your code.
Because integration tests import your crate as a library, they require your project to expose a lib.rs. Binary-only projects (containing only main.rs) cannot be used for integration tests unless they also provide a library module. For that reason, many Rust applications include both a library crate for reusable logic and a binary crate that calls into it.
Both unit tests and integration tests run when you call:
cargo testAnd you can filter individual tests by name:
cargo test should_be_trueThis makes it easy to focus on the part of the system you’re working on. Unit tests help you verify the internal details in isolation, while integration tests ensure everything works when the pieces are combined, such as when handling a real HTTP request. They complement each other and give you confidence that your backend behaves correctly at all levels.
Unit Tests for the TrickService
Before writing tests, let’s recall the TrickService implementation from the previous blog post. It performs validation, delegates persistence to the repository, and exposes methods consumed by the Axum handlers:
use std::sync::Arc;
use crate::trick_models::{Trick, TrickCreateInput, TrickError, TrickReplaceInput};
use crate::trick_repository::TrickRepository;
use uuid::Uuid;
pub struct TrickService {
trick_repository: Arc<TrickRepository>,
}
impl TrickService {
pub fn new(trick_repository: Arc<TrickRepository>) -> Self {
Self { trick_repository }
}
pub async fn create(&self, input: TrickCreateInput) -> Result<Trick, TrickError> {
self.validate_create(&input)?;
Ok(self.trick_repository.create(input).await)
}
pub async fn replace(&self, id: Uuid, input: TrickReplaceInput) -> Result<Trick, TrickError> {
self.validate_replace(id, &input).await?;
Ok(self.trick_repository.replace(id, input).await)
}
pub async fn find_all(&self) -> Vec<Trick> {
self.trick_repository.find_all().await
}
pub async fn find_by_id(&self, id: Uuid) -> Result<Trick, TrickError> {
self.trick_repository
.find_by_id(id)
.await
.ok_or(TrickError::NotFound(id))
}
pub async fn delete_by_id(&self, id: Uuid) {
self.trick_repository.delete_by_id(id).await
}
fn validate_create(&self, data: &TrickCreateInput) -> Result<(), TrickError> {
if data.title.trim().is_empty() {
return Err(TrickError::Validation(
"title must not be empty".to_string(),
));
}
Ok(())
}
async fn validate_replace(&self, id: Uuid, data: &TrickReplaceInput) -> Result<(), TrickError> {
self.find_by_id(id).await?;
if data.title.trim().is_empty() {
return Err(TrickError::Validation(
"Title must not be empty".to_string(),
));
}
Ok(())
}
}Because we implemented an in-memory version of TrickRepository in the previous post, we can reuse it here to keep our tests fast, isolated, and predictable.
#[cfg(test)]
mod tests {
use super::*;
use crate::trick_models::{TrickCreateInput, TrickError};
use std::sync::Arc;
mod create {
use super::*;
#[tokio::test]
async fn should_create_a_trick() {
let trick_repository = Arc::new(TrickRepository::new());
let trick_service = TrickService::new(trick_repository.clone());
let input = TrickCreateInput {
title: "Sit".to_string(),
description: "Sit...".to_string(),
instructions: vec![],
};
// Act: call the service method
let created_trick = trick_service.create(input).await.unwrap();
// Assert: returned value contains the expected data
assert_eq!(created_trick.title, "Sit");
assert_eq!(created_trick.description, "Sit...");
assert!(created_trick.instructions.is_empty());
// Assert: repository contains the persisted trick
let all_tricks = trick_repository.find_all().await;
assert_eq!(all_tricks.len(), 1);
assert_eq!(all_tricks[0].title, "Sit");
assert_eq!(all_tricks[0].description, "Sit...");
}
#[tokio::test]
async fn should_fail_when_title_is_empty() {
let trick_repository = Arc::new(TrickRepository::new());
let trick_service = TrickService::new(trick_repository.clone());
let input = TrickCreateInput {
title: "".to_string(),
description: "".to_string(),
instructions: vec![],
};
// Act: call the service method with invalid data
let result = trick_service.create(input).await;
// Assert: the call must fail
assert!(result.is_err());
// Assert: the error is the expected validation error
match result.err().unwrap() {
TrickError::Validation(msg) => {
assert_eq!(msg, "title must not be empty");
}
other => panic!("expected validation error, got: {:?}", other),
}
// Assert: repository should not contain any data
let all_tricks = trick_repository.find_all().await;
assert_eq!(all_tricks.len(), 0);
}
}
}These tests verify the behavior of the create method in complete isolation from any HTTP or Axum context. In should_create_a_trick, we begin by constructing a fresh repository and a TrickService that uses it. We pass valid input to the service and check that the returned trick has the expected content. After that, we inspect the internal storage of the repository to ensure that the service actually persisted the trick. This confirms both the correctness of the return value and the correctness of the side effect.
In should_fail_when_title_is_empty, we intentionally provide invalid input. The service validates the data before performing any persistence, so the test first ensures that the method returns an error and that the error is the specific validation variant we expect. Finally, we verify that the store remains empty, demonstrating that validation rules prevent invalid data from being saved.
Together, these tests validate both the success path and the failure path of the create method. They give you confidence that the service behaves predictably and consistently, independent of any HTTP or routing layer that may call it.
Integration Tests for the Axum Trick Handlers
With the service layer thoroughly tested, we can now turn to integration tests that verify how our HTTP API behaves from the outside. Integration tests exercise the complete request pipeline, routing, extractors, validation, serialization, error mapping, and ensure that all components work together correctly.
Before we can write these tests, a few structural adjustments to the project are necessary. As explained earlier, integration tests are compiled as separate crates that import your application as a library. This means our Axum setup must be exposed through a lib.rs file, and the integration tests will call functions exported from there.
To prepare for that, we first extract the creation of our Axum router into its own function so that it can be reused by both the binary entrypoint and the integration tests. We will place this in src/trick_router.rs:
use std::sync::Arc;
use crate::trick_handlers::{
create_trick, delete_trick, find_trick_by_id, find_tricks, replace_trick,
};
use crate::trick_repository::TrickRepository;
use crate::trick_service::TrickService;
use axum::{Router, routing::get};
pub fn create_trick_router() -> Router {
let service = Arc::new(TrickService::new(Arc::new(TrickRepository::new())));
Router::new()
.route("/tricks", get(find_tricks).post(create_trick))
.route(
"/tricks/{id}",
get(find_trick_by_id)
.put(replace_trick)
.delete(delete_trick),
)
.with_state(service)
}Next, because integration tests only work when a project exposes a library crate, we add a src/lib.rs file that exports everything the tests need: the create_trick_router function and our models.
mod trick_handlers;
pub mod trick_models;
mod trick_repository;
pub mod trick_router;
mod trick_service;This allows integration tests to import the router like this:
use dog_tricks_api_testing::trick_router::create_trick_router;Finally, our integration tests rely on several additional crates for crafting HTTP requests, inspecting responses, and working with JSON. These should be added to your Cargo.toml under [dev-dependencies]:
[dev-dependencies]
http-body-util = "0.1.3"
hyper = "1.8.1"
tower = "0.5.2"
serde_json = "1.0.145"With these preparations in place, we can now focus on writing the actual integration tests:
// tests/trick_handlers_test.rs
use axum::{
body::Body,
http::{Request, StatusCode},
};
use dog_tricks_api_testing::trick_models::{ApiError, Trick};
use dog_tricks_api_testing::trick_router::create_trick_router;
use http_body_util::BodyExt;
use hyper::body::Bytes;
use tower::ServiceExt;
#[tokio::test]
async fn should_create_a_trick() {
let router = create_trick_router();
let payload = serde_json::json!({
"title": "Sit",
"description": "Sit...",
"instructions": []
});
let response = router
.oneshot(
Request::post("/tricks")
.header("Content-Type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.into_body().collect().await.unwrap();
let bytes: Bytes = body.to_bytes();
let trick: Trick = serde_json::from_slice(&bytes).unwrap();
assert_eq!(trick.title, "Sit");
assert_eq!(trick.description, "Sit...");
assert!(trick.instructions.is_empty());
}
#[tokio::test]
async fn should_return_400_when_title_is_empty() {
let router = create_trick_router();
let payload = serde_json::json!({
"title": "",
"description": "Sit...",
"instructions": []
});
let response = router
.oneshot(
Request::post("/tricks")
.header("Content-Type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap();
let bytes: Bytes = body.to_bytes();
let api_error: ApiError = serde_json::from_slice(&bytes).unwrap();
assert_eq!(api_error.status_code, StatusCode::BAD_REQUEST.as_u16());
assert_eq!(api_error.message, "title must not be empty");
}Each test interacts with a fully configured Axum router that uses a fresh repository and real TrickService. The first test confirms that a valid trick is created and that the returned JSON body maps cleanly to a Trick struct. The second test validates the error path, ensuring that invalid input produces a 400 Bad Request response with the correct validation message.
These integration tests ensure that your HTTP layer behaves correctly end-to-end, from request parsing and validation to service calls and JSON serialization.
Conclusion
Testing in Rust is a natural extension of the language’s focus on correctness and reliability. While the compiler eliminates many classes of bugs at compile time, tests verify the behavior that types alone cannot express: edge cases in business logic, the structure of JSON endpoints, the way errors are mapped, and the overall correctness of your service layer. By combining unit tests for isolated components with integration tests that exercise the entire HTTP interface, you gain confidence that your application behaves consistently and predictably as it grows.
All code used in this blog post is available in the corresponding GitHub repository, where you can explore the full project, run the tests, or adapt the examples to your own applications.