745-integration-test-setup — Integration Test Setup
Tutorial Video
Text description (accessibility)
This video demonstrates the "745-integration-test-setup — Integration Test Setup" functional Rust example. Difficulty level: Fundamental. Key concepts covered: Functional Programming. Integration tests verify that components work together through their public APIs, not just in isolation. Key difference from OCaml: 1. **Directory convention**: Rust uses `tests/` for integration tests with special cargo handling; OCaml uses `test/` or `tests/` as a convention without special build
Tutorial
The Problem
Integration tests verify that components work together through their public APIs, not just in isolation. In Rust, integration tests live in the tests/ directory and link against the compiled library — they cannot access private internals. This forces a clean API boundary. Setting up shared state (database connections, server sockets, test data) across multiple integration tests requires patterns for initialization, teardown, and parallelism-safe shared fixtures.
🎯 Learning Outcomes
tests/ integration tests and #[cfg(test)] unit testsConfig type with a validate method that integration tests exercise through the public APIOnceLock<Mutex<T>> for global test state that is initialized once per test runCode Example
// tests/common/mod.rs
use my_crate::{Config, validate_config};
pub fn test_config() -> Config {
Config::new("test-host", 9999, 10)
}
pub fn assert_valid(c: &Config) {
assert!(validate_config(c).is_ok(), "config should be valid: {:?}", c);
}Key Differences
tests/ for integration tests with special cargo handling; OCaml uses test/ or tests/ as a convention without special build-tool support.open Module to access any exported function including those not in the public .mli.OnceLock/LazyLock for one-time test initialization; OCaml's Alcotest supports explicit before_all/after_all hooks.OCaml Approach
OCaml integration tests typically live in a test/ directory with separate executables per test suite. Alcotest organizes tests as groups with setup/teardown via before_test_all and after_test_all hooks. The OUnit2 framework provides bracket for RAII-style setup/teardown around individual tests. Shared state is usually passed explicitly as a function argument rather than using global mutable state.
Full Source
#![allow(clippy::all)]
//! # Integration Test Structure
//!
//! Demonstrates the pattern for organizing integration tests in Rust projects.
//! Integration tests live in `tests/` directory and test the public API.
/// Configuration for a service
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
pub host: String,
pub port: u16,
pub max_connections: u32,
}
impl Config {
/// Create a new configuration
pub fn new(host: impl Into<String>, port: u16, max_connections: u32) -> Self {
Config {
host: host.into(),
port,
max_connections,
}
}
/// Create default configuration
pub fn default_config() -> Self {
Config::new("localhost", 8080, 100)
}
/// Format as address string
pub fn to_addr(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
/// Configuration validation errors
#[derive(Debug, PartialEq)]
pub enum ConfigError {
PortOutOfRange(u16),
EmptyHost,
InvalidMaxConnections,
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::PortOutOfRange(p) => write!(f, "port {} is invalid (use 1-65535)", p),
ConfigError::EmptyHost => write!(f, "host cannot be empty"),
ConfigError::InvalidMaxConnections => write!(f, "max_connections must be > 0"),
}
}
}
/// Validate a configuration
pub fn validate_config(c: &Config) -> Result<(), ConfigError> {
if c.host.is_empty() {
return Err(ConfigError::EmptyHost);
}
if c.port == 0 {
return Err(ConfigError::PortOutOfRange(0));
}
if c.max_connections == 0 {
return Err(ConfigError::InvalidMaxConnections);
}
Ok(())
}
/// Parse a port string into a u16
pub fn parse_port(s: &str) -> Result<u16, String> {
let n: u32 = s.parse().map_err(|_| format!("'{}' is not a number", s))?;
if n == 0 || n > 65535 {
return Err(format!("port {} out of range (1-65535)", n));
}
Ok(n as u16)
}
// Test helpers module - simulates tests/common/mod.rs pattern
pub mod test_helpers {
use super::*;
/// Create a test configuration
pub fn test_config() -> Config {
Config::new("test-host", 9999, 10)
}
/// Assert a configuration is valid
pub fn assert_valid(c: &Config) {
assert!(
validate_config(c).is_ok(),
"config should be valid: {:?}",
c
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_helpers::*;
#[test]
fn test_default_config_is_valid() {
let cfg = Config::default_config();
assert_valid(&cfg);
}
#[test]
fn test_test_config_helper_works() {
let cfg = test_config();
assert_eq!(cfg.host, "test-host");
assert_eq!(cfg.port, 9999);
assert_valid(&cfg);
}
#[test]
fn test_to_addr_formats_correctly() {
let cfg = Config::new("example.com", 443, 50);
assert_eq!(cfg.to_addr(), "example.com:443");
}
#[test]
fn test_empty_host_is_invalid() {
let cfg = Config::new("", 80, 10);
assert_eq!(validate_config(&cfg), Err(ConfigError::EmptyHost));
}
#[test]
fn test_zero_port_is_invalid() {
let cfg = Config::new("localhost", 0, 10);
assert_eq!(validate_config(&cfg), Err(ConfigError::PortOutOfRange(0)));
}
#[test]
fn test_zero_max_connections_is_invalid() {
let cfg = Config::new("localhost", 8080, 0);
assert_eq!(
validate_config(&cfg),
Err(ConfigError::InvalidMaxConnections)
);
}
#[test]
fn test_parse_port_valid() {
assert_eq!(parse_port("8080"), Ok(8080));
assert_eq!(parse_port("1"), Ok(1));
assert_eq!(parse_port("65535"), Ok(65535));
}
#[test]
fn test_parse_port_invalid() {
assert!(parse_port("0").is_err());
assert!(parse_port("65536").is_err());
assert!(parse_port("not_a_number").is_err());
assert!(parse_port("").is_err());
}
}#[cfg(test)]
mod tests {
use super::*;
use test_helpers::*;
#[test]
fn test_default_config_is_valid() {
let cfg = Config::default_config();
assert_valid(&cfg);
}
#[test]
fn test_test_config_helper_works() {
let cfg = test_config();
assert_eq!(cfg.host, "test-host");
assert_eq!(cfg.port, 9999);
assert_valid(&cfg);
}
#[test]
fn test_to_addr_formats_correctly() {
let cfg = Config::new("example.com", 443, 50);
assert_eq!(cfg.to_addr(), "example.com:443");
}
#[test]
fn test_empty_host_is_invalid() {
let cfg = Config::new("", 80, 10);
assert_eq!(validate_config(&cfg), Err(ConfigError::EmptyHost));
}
#[test]
fn test_zero_port_is_invalid() {
let cfg = Config::new("localhost", 0, 10);
assert_eq!(validate_config(&cfg), Err(ConfigError::PortOutOfRange(0)));
}
#[test]
fn test_zero_max_connections_is_invalid() {
let cfg = Config::new("localhost", 8080, 0);
assert_eq!(
validate_config(&cfg),
Err(ConfigError::InvalidMaxConnections)
);
}
#[test]
fn test_parse_port_valid() {
assert_eq!(parse_port("8080"), Ok(8080));
assert_eq!(parse_port("1"), Ok(1));
assert_eq!(parse_port("65535"), Ok(65535));
}
#[test]
fn test_parse_port_invalid() {
assert!(parse_port("0").is_err());
assert!(parse_port("65536").is_err());
assert!(parse_port("not_a_number").is_err());
assert!(parse_port("").is_err());
}
}
Deep Comparison
OCaml vs Rust: Integration Test Setup
Project Structure
OCaml (Dune)
my_lib/
├── lib/
│ └── my_lib.ml
├── test/
│ ├── dune
│ ├── common.ml (* shared test helpers *)
│ └── test_main.ml (* integration tests *)
└── dune-project
Rust (Cargo)
my_crate/
├── src/
│ └── lib.rs
├── tests/
│ ├── common/
│ │ └── mod.rs // shared helpers (NOT a test binary)
│ ├── config_test.rs
│ └── api_test.rs
└── Cargo.toml
Shared Test Helpers
OCaml
(* test/common.ml *)
let test_config = MyLib.with_config "test-host" 9999 10
let make_test_config ?(host="test") ?(port=9999) ?(max=10) () =
MyLib.with_config host port max
Rust
// tests/common/mod.rs
use my_crate::{Config, validate_config};
pub fn test_config() -> Config {
Config::new("test-host", 9999, 10)
}
pub fn assert_valid(c: &Config) {
assert!(validate_config(c).is_ok(), "config should be valid: {:?}", c);
}
Integration Tests
OCaml
(* test/test_main.ml *)
let () =
let cfg = Common.make_test_config () in
assert (cfg.MyLib.port = 9999);
match MyLib.parse_port "8080" with
| MyLib.Ok n -> assert (n = 8080)
| MyLib.Err e -> failwith e
Rust
// tests/config_test.rs
mod common;
use my_crate::{Config, parse_port, ConfigError};
#[test]
fn default_config_is_valid() {
let cfg = Config::default();
common::assert_valid(&cfg);
}
#[test]
fn parse_port_rejects_invalid() {
assert!(parse_port("0").is_err());
assert!(parse_port("65536").is_err());
}
Key Differences
| Aspect | OCaml | Rust |
|---|---|---|
| Test location | test/ directory with Dune config | tests/ directory (auto-discovered) |
| Shared helpers | Separate module linked in | tests/common/mod.rs explicitly imported |
| Visibility | Module signatures | Only pub items visible from tests/ |
| Running tests | dune test | cargo test |
| Single file | dune test test_name | cargo test --test config_test |
| Test binary | One or more executables | Each tests/*.rs is a separate crate |
Why This Matters
Both OCaml and Rust enforce that integration tests can only access the public API:
tests/ files are separate crates; they can only see pub itemsThis ensures your public API is tested as users will actually use it, not with access to private internals.
Exercises
Config, validates it, starts a TestServer, sends a request, and verifies the response — covering the full request lifecycle.ConfigError display strings are user-friendly (not just assert they equal some specific string, but check they contain key words like "invalid", "empty").TestHarness that shares a single validated Config across all tests in the suite using OnceLock, avoiding redundant initialization.