ExamplesBy LevelBy TopicLearning Paths
745 Fundamental

745-integration-test-setup — Integration Test Setup

Functional Programming

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

  • • Understand the structural difference between tests/ integration tests and #[cfg(test)] unit tests
  • • Create a Config type with a validate method that integration tests exercise through the public API
  • • Build shared test helpers that initialize complex state once and reuse it across tests
  • • Use OnceLock<Mutex<T>> for global test state that is initialized once per test run
  • • Write integration tests that test complete request-response cycles through a service
  • Code 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

  • Directory convention: Rust uses tests/ for integration tests with special cargo handling; OCaml uses test/ or tests/ as a convention without special build-tool support.
  • Visibility: Rust integration tests can only access public items; OCaml tests can use open Module to access any exported function including those not in the public .mli.
  • Shared setup: Rust uses OnceLock/LazyLock for one-time test initialization; OCaml's Alcotest supports explicit before_all/after_all hooks.
  • Parallelism: Rust integration tests run in parallel by default; OCaml's test runners are typically sequential.
  • 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());
        }
    }
    ✓ Tests Rust test suite
    #[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

    AspectOCamlRust
    Test locationtest/ directory with Dune configtests/ directory (auto-discovered)
    Shared helpersSeparate module linked intests/common/mod.rs explicitly imported
    VisibilityModule signaturesOnly pub items visible from tests/
    Running testsdune testcargo test
    Single filedune test test_namecargo test --test config_test
    Test binaryOne or more executablesEach tests/*.rs is a separate crate

    Why This Matters

    Both OCaml and Rust enforce that integration tests can only access the public API:

  • OCaml: Module signatures control what's exported
  • Rust: tests/ files are separate crates; they can only see pub items
  • This ensures your public API is tested as users will actually use it, not with access to private internals.

    Exercises

  • Write an integration test that creates a Config, validates it, starts a TestServer, sends a request, and verifies the response — covering the full request lifecycle.
  • Add a test that verifies the ConfigError display strings are user-friendly (not just assert they equal some specific string, but check they contain key words like "invalid", "empty").
  • Implement a TestHarness that shares a single validated Config across all tests in the suite using OnceLock, avoiding redundant initialization.
  • Open Source Repos