Introduction to Integration Testing Rust with Docker
\nIntegration testing is a key stage in developing reliable server applications in Rust. Unlike unit tests, integration tests verify the interaction of components with real dependencies: databases, caches, external APIs. Using Docker allows you to isolate the test environment and guarantee reproducibility. In this article, we will cover advanced techniques: parallel container startup, state management, mock services, and working with asynchrony via Tokio.
\n\nSetting up the Test Environment: Docker and testcontainers
\nFor integration tests in Rust, it is convenient to use the testcontainers crate. It allows you to programmatically start Docker containers, wait for them to be ready, and properly clean up resources. Let's start with the basic setup.
Installing Dependencies
\nAdd to Cargo.toml:
[dev-dependencies]\ntestcontainers = "0.21"\ntokio = { version = "1", features = ["full"] }\nreqwest = { version = "0.12", features = ["json"] }\nserde = { version = "1", features = ["derive"] }\nserde_json = "1"\nBasic Example: PostgreSQL Container
\nLet's create a test that starts PostgreSQL, performs a migration, and checks data insertion:
\nuse testcontainers::{clients, images::postgres::Postgres, Container};\nuse tokio_postgres::{NoTls, Client};\n\nasync fn setup_db() -> (Client, Container<'static, Postgres>) {\n let docker = clients::Cli::default();\n let pg_image = Postgres::default();\n let container = docker.run(pg_image);\n let host = container.get_host();\n let port = container.get_host_port_ipv4(5432);\n let connection_string = format!(\n "host={host} port={port} user=postgres dbname=postgres",\n );\n let (client, connection) = tokio_postgres::connect(&connection_string, NoTls)\n .await\n .expect("Failed to connect");\n tokio::spawn(async move {\n if let Err(e) = connection.await {\n eprintln!("Connection error: {e}");\n }\n });\n (client, container)\n}\n\n#[tokio::test]\nasync fn test_insert_user() {\n let (client, _container) = setup_db().await;\n client\n .simple_query("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT)")\n .await\n .unwrap();\n client\n .execute("INSERT INTO users (name) VALUES ($1)", &[&"Alice"])\n .await\n .unwrap();\n let rows = client.query("SELECT name FROM users", &[]).await.unwrap();\n assert_eq!(rows.len(), 1);\n assert_eq!(rows[0].get::<_, String>(0), "Alice");\n}\nThe container will be automatically removed after the test completes thanks to the Container destructor. This eliminates manual cleanup.
Parallel Test Execution and Resource Management
\nBy default, Rust runs tests in parallel threads. If each test starts its own container, this can lead to port conflicts and Docker overload. Let's consider two approaches.
\nUsing Different Ports via Dynamic Allocation
\nThe testcontainers crate automatically assigns free ports on the host. This solves the collision problem but increases resource consumption. For heavy containers (e.g., databases), it's better to use a shared instance:
use once_cell::sync::Lazy;\nuse testcontainers::clients;\n\nstatic DOCKER: Lazy<clients::Cli> = Lazy::new(|| clients::Cli::default());\n\nasync fn get_shared_postgres() -> (tokio_postgres::Client, Container<'static, Postgres>) {\n let pg_image = Postgres::default();\n let container = DOCKER.run(pg_image);\n // ... connection as in the previous example\n (client, container)\n}\nHowever, this approach is risky: tests can affect each other through shared state. It is recommended to use separate databases within a single container or reset the state