๐Ÿฆ€ Functional Rust

745: Integration Test Structure: tests/ Directory

Difficulty: 2 Level: Intermediate Files in the `tests/` directory test your library's public API exactly as an external user would โ€” each file is its own crate.

The Problem This Solves

Unit tests in `#[cfg(test)]` blocks are great for individual functions, but they can access private internals. You need a separate layer that tests only what you publicly export โ€” the way a downstream crate would use your library. If a refactor breaks the public API while keeping all unit tests green, you want to catch that. Integration tests also let you test realistic workflows that span multiple functions. Validating a config object, connecting to a service, and making a request is a multi-step scenario that belongs in integration tests, not unit tests. The `tests/` directory also solves the problem of shared test utilities. A `tests/common/mod.rs` file can export fixture builders and assertion helpers that multiple test files share โ€” without that code appearing in any test file itself (Rust treats `common/mod.rs` as a helper, not a test file, because it has no `#[test]` functions at the top level).

The Intuition

In Python, integration tests often live in a separate `tests/integration/` folder and import from your package's public API. In Jest, you'd import from the package entry point rather than internal modules. Rust enforces this boundary structurally: files in `tests/` are compiled as separate crates. They can only use items you've marked `pub`. The compiler physically cannot see your private functions. There's no lint rule needed โ€” the visibility rules do the work. Each file in `tests/` is an independent test binary. Running `cargo test` compiles and runs all of them. You can run a specific integration test file with `cargo test --test config_test`.

How It Works in Rust

Real project layout:
my_crate/
โ”œโ”€โ”€ src/
โ”‚   โ””โ”€โ”€ lib.rs              โ† your library (pub API)
โ”œโ”€โ”€ tests/
โ”‚   โ”œโ”€โ”€ common/
โ”‚   โ”‚   โ””โ”€โ”€ mod.rs          โ† shared helpers (NOT a test binary)
โ”‚   โ”œโ”€โ”€ config_test.rs      โ† integration tests for config
โ”‚   โ””โ”€โ”€ api_test.rs         โ† integration tests for the full API
โ””โ”€โ”€ Cargo.toml
`tests/common/mod.rs` โ€” shared fixtures:
// This module is NOT auto-discovered as a test binary.
// Test files explicitly declare: mod common;
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);
}
`tests/config_test.rs` โ€” integration tests:
mod common;  // pulls in shared helpers

use my_crate::{Config, validate_config, parse_port, ConfigError};

#[test]
fn default_config_is_valid() {
 let cfg = Config::default();
 common::assert_valid(&cfg);
}

#[test]
fn empty_host_is_invalid() {
 let cfg = Config::new("", 80, 10);
 assert_eq!(validate_config(&cfg), Err(ConfigError::EmptyHost));
}

#[test]
fn parse_port_rejects_zero_and_overflow() {
 assert!(parse_port("0").is_err());
 assert!(parse_port("65536").is_err());
 assert!(parse_port("abc").is_err());
}
Key points:

What This Unlocks

Key Differences

ConceptOCamlRust
Integration testsSeparate executable linked against the library`tests/` directory โ€” each file is a separate crate
Shared helpersSeparate module compiled with tests`tests/common/mod.rs` โ€” referenced explicitly
API visibilityModule signature controls exports`pub` keyword โ€” `tests/` files can only see `pub` items
Running one fileSelect by name in Dune`cargo test --test filename`
Setup/teardown`with_setup` or resource bracketingNo built-in; use helper functions that return fixtures
/// 745: Integration Test Structure
///
/// In a real project, integration tests live in `tests/` directory:
///
/// ```
/// my_crate/
/// โ”œโ”€โ”€ src/
/// โ”‚   โ””โ”€โ”€ lib.rs       โ† library code (pub API)
/// โ”œโ”€โ”€ tests/
/// โ”‚   โ”œโ”€โ”€ common/
/// โ”‚   โ”‚   โ””โ”€โ”€ mod.rs   โ† shared helpers (NOT a test file itself)
/// โ”‚   โ”œโ”€โ”€ config_test.rs
/// โ”‚   โ””โ”€โ”€ api_test.rs
/// โ””โ”€โ”€ Cargo.toml
/// ```
///
/// This file shows both the library code AND simulates the integration test pattern.

// โ”€โ”€ "Library" public API (would be in src/lib.rs) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

#[derive(Debug, Clone, PartialEq)]
pub struct Config {
    pub host:            String,
    pub port:            u16,
    pub max_connections: u32,
}

impl Config {
    pub fn new(host: impl Into<String>, port: u16, max_connections: u32) -> Self {
        Config { host: host.into(), port, max_connections }
    }

    pub fn default() -> Self {
        Config::new("localhost", 8080, 100)
    }

    pub fn to_addr(&self) -> String {
        format!("{}:{}", self.host, self.port)
    }
}

#[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"),
        }
    }
}

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(())
}

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)
}

fn main() {
    let cfg = Config::default();
    println!("Default config: {}", cfg.to_addr());
    match validate_config(&cfg) {
        Ok(())   => println!("Config valid"),
        Err(e)   => println!("Config error: {}", e),
    }
}

// โ”€โ”€ Simulated integration tests (in real project: tests/config_test.rs) โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//
// Real integration test file would start with:
//   use my_crate::{Config, validate_config, parse_port};
//
// And `tests/common/mod.rs` would contain:
//   pub fn test_config() -> Config { Config::new("test-host", 9999, 10) }
//   pub fn assert_valid(c: &Config) { assert!(validate_config(c).is_ok()); }

// For this self-contained example, we put them in a sub-module:

#[cfg(test)]
mod common {
    use super::*;
    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);
    }
}

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

    #[test]
    fn default_config_is_valid() {
        let cfg = Config::default();
        assert_valid(&cfg);
    }

    #[test]
    fn test_config_helper_works() {
        let cfg = test_config();
        assert_eq!(cfg.host, "test-host");
        assert_eq!(cfg.port, 9999);
        assert_valid(&cfg);
    }

    #[test]
    fn to_addr_formats_correctly() {
        let cfg = Config::new("example.com", 443, 50);
        assert_eq!(cfg.to_addr(), "example.com:443");
    }

    #[test]
    fn empty_host_is_invalid() {
        let cfg = Config::new("", 80, 10);
        assert_eq!(validate_config(&cfg), Err(ConfigError::EmptyHost));
    }

    #[test]
    fn zero_port_is_invalid() {
        let cfg = Config::new("localhost", 0, 10);
        assert_eq!(validate_config(&cfg), Err(ConfigError::PortOutOfRange(0)));
    }

    #[test]
    fn 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 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());
    }
}
(* 745: Integration Test Setup โ€” OCaml
   In OCaml with Dune, integration tests are separate executables
   in a test/ directory. We simulate the pattern here. *)

(* === lib.ml (the library) === *)
module MyLib = struct
  type config = {
    host: string;
    port: int;
    max_connections: int;
  }

  let default_config = { host = "localhost"; port = 8080; max_connections = 100 }

  let with_config host port max_connections =
    { host; port; max_connections }

  let config_to_string c =
    Printf.sprintf "%s:%d (max=%d)" c.host c.port c.max_connections

  type 'a result = Ok of 'a | Err of string

  let parse_port s =
    match int_of_string_opt s with
    | Some n when n > 0 && n < 65536 -> Ok n
    | Some n -> Err (Printf.sprintf "port %d out of range" n)
    | None   -> Err (Printf.sprintf "'%s' is not a number" s)
end

(* === tests/common.ml (shared helpers) === *)
module TestCommon = struct
  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
end

(* === tests/config_test.ml === *)
let () =
  (* Integration test: uses only public API *)
  let cfg = TestCommon.make_test_config () in
  assert (cfg.MyLib.port = 9999);

  let s = MyLib.config_to_string cfg in
  assert (String.length s > 0);

  (match MyLib.parse_port "8080" with
  | MyLib.Ok n -> assert (n = 8080)
  | MyLib.Err e -> failwith e);

  (match MyLib.parse_port "99999" with
  | MyLib.Err _ -> ()
  | MyLib.Ok _ -> failwith "expected error");

  Printf.printf "Integration tests: OK\n"