Skip to main content

Rust's Testing Philosophy

Rust has a built-in testing framework that's simple yet powerful. The testing philosophy is:

  1. Tests live close to the code they test: Unit tests often live in the same file as the implementation
  2. No separate test framework needed: Tests run with the cargo test command
  3. First-class support in the language: Testing is a core feature of Rust, not an afterthought
  4. Compile-time guarantees reduce need for some tests: Many bugs caught by tests in other languages are caught by the compiler in Rust

Unit Testing in Rust​

Unit tests in Rust typically live in the same file as the code they test, in a special module annotated with #[cfg(test)]. This attribute ensures the test code is only compiled when running tests.

Here's an example:

// Implementation code
pub fn add(a: i32, b: i32) -> i32 {
a + b
}

// Test module
#[cfg(test)]
mod tests {
// Import parent scope
use super::*;

#[test]
fn test_add() {
assert_eq!(add(2, 2), 4);
}

#[test]
fn test_add_negative() {
assert_eq!(add(-1, -2), -3);
}
}

Key points:

  • #[cfg(test)] ensures test code is only included when testing
  • use super::* imports all items from the parent module
  • #[test] marks functions as test cases
  • assert_eq!, assert!, and assert_ne! macros help with assertions

Integration Testing​

Integration tests live in a separate directory called tests at the root of your project. Each file in this directory is compiled as a separate crate.

my_project/
├── src/
│ └── lib.rs
└── tests/
├── api_tests.rs
└── data_tests.rs

An integration test might look like:

use reqwest::redirect::Policy;
use reqwest::Client;
use uuid::Uuid;

#[tokio::test]
async fn when_a_user_registers_they_should_then_be_able_to_login() {
let id = Uuid::new_v4();
let email_under_test = format!("{}@test.com", id);

let api_endpoint = retrieve_api_endpoint().await;

let http_client = Client::builder()
.timeout(std::time::Duration::from_secs(2))
.redirect(Policy::none())
.build()
.unwrap();


let result = http_client
.post(format!("{}users", api_endpoint))
.header("Content-Type", "application/json")
.body(serde_json::json!({"emailAddress": email_under_test, "password": "Testing!23", "name": "James"}).to_string())
.send()
.await;

assert!(result.is_ok());

let response = result.unwrap();

assert_eq!(response.status(), 201);

let login_response = http_client
.post(format!("{}login", api_endpoint))
.header("Content-Type", "application/json")
.body(serde_json::json!({"emailAddress": email_under_test, "password": "Testing!23"}).to_string())
.send()
.await;

assert_eq!(login_response.unwrap().status(), 200);
}

async fn retrieve_api_endpoint() -> String {
// You could write code here to dynamically retrieve the API endpoint from your environment or configuration.

"http://localhost:3000/".to_string()
}

Here you're using the reqwest crate, which gives you a simple HTTP client, to actually make requests to a running instance of your API.

Integration tests:

  • Test your code's public API
  • Verify that components work together correctly
  • Run with the same cargo test command

Testing Async Code​

To test async functions, you need to use a runtime like tokio:

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

#[tokio::test]
async fn test_async_function() {
let result = fetch_data().await;
assert!(result.is_ok());
}
}

The #[tokio::test] attribute sets up the tokio runtime for your async test.

Test Fixtures​

For tests that need similar setup and teardown, you can use Rust's Drop trait:

struct TestFixture {
// Test data and state.
db_connection: PgPool,
}

impl TestFixture {
async fn new() -> Self {
// Setup code.
let db_connection = PgPool::connect("test_db_url").await.unwrap();
// Run migrations, seed data.

Self { db_connection }
}
}

impl Drop for TestFixture {
fn drop(&mut self) {
// Cleanup code.
// This runs when the fixture goes out of scope
}
}

#[tokio::test]
async fn test_with_fixture() {
let fixture = TestFixture::new().await;

// Test using fixture.
let result = get_user(&fixture.db_connection, "test@example.com").await;
assert!(result.is_ok());

// Fixture is automatically cleaned up when it goes out of scope
}