Advanced Testing in Rust: from unit to E2E
Testing is an integral part of developing reliable system software in Rust. While basic concepts (simple test writing with #[test]) are familiar to everyone, advanced techniques — working with mocks, parameterization, custom attributes, and organizing E2E tests — require separate analysis. In this article, we will look at how to take Rust testing to a professional level using unit, integration, and end-to-end approaches.
1. Unit Testing: mock, parameterization, and custom attributes
Unit tests in Rust are usually written in the same file as the code, in the tests module. However, at an advanced level, there is a need to isolate the code under test from external dependencies.
Mock objects with traits
To create mocks in Rust, traits are most often used. You define a trait, implement it for the real object and for the test mock. Popular crates: mockall and mockito (for HTTP).
use mockall::automock;
#[automock]trait Database { fn get_user(&self, id: u32) -> String;}
fn process_user(db: &dyn Database, id: u32) -> String { let user = db.get_user(id); format!("User: {}", user)}
#[cfg(test)]mod tests { use super::*;
#[test] fn test_process_user() { let mut mock = MockDatabase::new(); mock.expect_get_user() .with(predicate::eq(42)) .returning(|_| "Alice".to_string()); assert_eq!(process_user(&mock, 42), "User: Alice"); }}Parameterized tests
Repeating the same test with different input data is a common task. The rstest crate provides an elegant solution.
use rstest::rstest;
#[rstest]#[case(2, 3, 5)]#[case(0, 0, 0)]#[case(-1, 1, 0)]fn test_add(#[case] a: i32, #[case] b: i32, #[case] expected: i32) { assert_eq!(a + b, expected);}Custom test attributes
Sometimes logic before or after a test is required. You can create your own attributes via procedural macros, but it is simpler to use the test-case or ctor crate for initialization.
// Example with ctor for logger initialization#[cfg(test)]mod tests { use ctor::ctor;
#[ctor] fn init() { // Initialization once before all tests }}2. Integration Testing: organization and working with external dependencies
Integration tests in Rust are located in the tests/ folder at the project root level. Each file there is a separate crate. Advanced techniques include common modules and testing with real databases or APIs.
Common utilities for integration tests
To avoid duplicating code (e.g., setting up a test server), create a common module inside tests/.
// tests/common/mod.rspub fn setup_test_db() -> String { // Setting up a test DB "connected".to_string()}
// tests/integration_test.rsmod common;
#[test]fn test_db_connection() { let db = common::setup_test_db(); assert_eq!(db, "connected");}Working with HTTP requests
For testing HTTP clients, use mockito — it spins up a local mock server.
use mockito::{mock, server_url};
#[tokio::test]async fn test_api_call() { let _m = mock("GET", "/users/1") .with_status(200) .with_body(r#"{"id":1,"name":"Alice"}"#) .create();
let client = reqwest::Client::new(); let resp = client.get(&format!("{}/users/1", server_url())) .send() .await .unwrap(); assert!(resp.status().is_success());}3. E2E Testing: full application cycle
End-to-end tests check the operation of the system as a whole: from start