Chicago TDD Tools Pattern Cookbook
Welcome to the Chicago TDD Tools Pattern Cookbook. This mdBook captures the living pattern language behind the framework. Inspired by Christopher Alexander's "A Pattern Language," each pattern documents a recurring problem and the high-leverage solution encoded in the framework. Patterns are organized across testing, architecture, and design themes so you can quickly discover what fits your current context.
Every pattern includes:
- Context – when the pattern applies
- Problem – the tension you are trying to resolve
- Solution – the structure that resolves the tension, with concrete code
- Forces – trade-offs the solution balances
- Examples – copy-pasteable Rust using Chicago TDD Tools
- Related Patterns – how the language connects
Use this cookbook as a field guide while building and testing Rust systems with Chicago TDD Tools.
Introduction
Christopher Alexander famously observed that great environments share a pattern language – a network of proven responses to recurring forces. Chicago TDD Tools embodies the same philosophy for Rust testing. Instead of isolated utilities, the framework codifies high-leverage patterns that push teams toward dependable, behavior-focused tests and extendable architecture.
This cookbook distills those patterns. Each entry is written in Alexander's form so you can quickly scan the context, recognize the tension, and apply the solution. Read the patterns sequentially to see how they reinforce each other, or jump to the problem you have today.
The language is organized into three families:
- Testing Patterns – maintainable, behavior-driven tests that fail fast and verify real outcomes.
- Architecture Patterns – structural choices that keep the framework extensible and consistent.
- Design Patterns – type-level techniques, zero-cost abstractions, and compile-time validation.
Combine these ingredients to build resilient Rust systems aligned with Chicago TDD principles: state-based testing, real collaborators, behavior verification, and the AAA pattern.
Testing Patterns
Tests are executable specifications. The Chicago TDD Tools testing patterns capture how to write fast, trustworthy, and expressive tests that verify observable behavior. Each pattern layers on the previous ones; together they reflect the AAA mindset, the insistence on real collaborators, and the bias toward fast failure.
Use these patterns when a test feels brittle, slow, or unclear. The language highlights which macro to reach for, how to structure the test body, and how to keep failures meaningful.
Pattern 1: AAA Pattern
Context
You are writing or reviewing a test in Chicago TDD Tools. You want the test to communicate intent instantly and fail with a precise message when behavior regresses.
Problem
Tests that intermingle setup, behavior, and assertions become hard to scan. When the failure occurs, teammates must untangle implicit state, which slows feedback and hides missing assertions.
Solution
Structure every test body into three explicit phases – Arrange, Act, Assert – and let the framework enforce it. Use the test!, async_test!, or fixture_test! macros so that comments and code read in a top-to-bottom narrative.
Forces
- Readability vs. flexibility: expressive labels without duplicating boilerplate
- Fast diagnosis vs. runtime overhead: compile-time enforcement should cost nothing
- Behavior proof vs. implementation detail: assertions must verify observable outcomes
Examples
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; test!(test_scaling_multiplier, { // Arrange let multiplier = 3; let input = 7; // Act let result = multiplier * input; // Assert assert_eq!(result, 21, "multiplier should scale the input"); }); }
Async tests follow the same skeleton:
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; async_test!(test_fetch_customer, { // Arrange let client = FakeCrmClient::connected(); // Act let customer = client.fetch("cust-123").await?; // Assert assert_eq!(customer.id, "cust-123"); Ok(()) }); }
Related Patterns
- Pattern 2: Error Path Testing
- Pattern 5: Real Collaborators
- Pattern 11: Zero-Cost Abstractions
Pattern 2: Error Path Testing
Context
A function returns Result or Option, and the happy path already behaves as expected. You must guarantee that every failure mode is observable and carries actionable context.
Problem
When tests only exercise the success path, regressions sneak in through unhandled errors, missing context, or broken guardrails. Production discovers the failure first, and the team spends time reconstructing the scenario.
Solution
Enumerate every documented error variant and write a focused test for each. Use test! or param_test! to drive the error case, assert the specific variant, and check that error messages include the necessary context. Prefer direct construction or builders over mocks so that you validate the real failure.
Forces
- Coverage vs. duplication: each error merits a separate assertion without creating noise
- Realistic behavior vs. test speed: use in-memory collaborators where possible, but ensure pathways stay intact
- Diagnostic clarity vs. maintenance: error text should be specific yet stable
Examples
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; use chicago_tdd_tools::core::fixture::FixtureError; param_test! { #[case("", FixtureError::CreationFailed("name required".into()))] #[case("db://unreachable", FixtureError::OperationFailed("reconnect failed".into()))] fn test_fixture_error_paths(input: &str, expected: FixtureError) { let result = TestFixture::with_data(input.to_string()).cleanup(); assert_err!(&result); let error = result.unwrap_err(); assert_eq!(format!("{}", error), format!("{}", expected)); } } }
Related Patterns
- Pattern 3: Boundary Conditions
- Pattern 18: Timeout Defense in Depth
- Pattern 19: Feature Gate Slices
Pattern 3: Boundary Conditions
Context
Logic depends on limits: minimum quantities, maximum run lengths, exclusive ranges. You must guarantee behavior at and around those boundaries.
Problem
Happy-path tests routinely miss off-by-one errors, buffer limits, or guardrail regressions. Without explicit boundary coverage, downstream invariants fail silently.
Solution
Adopt a deliberate boundary grid: below, at, and above each documented limit. Use param_test! to table-drive the grid and Chicago TDD Tools assertions such as assert_in_range! and assert_guard_constraint!. Include boundary-focused names so a failing case communicates which edge is breached.
Forces
- Thoroughness vs. duplication: table-driven tests keep boundaries concise
- Performance vs. realism: small data sets reproduce the failure quickly
- Maintainability vs. specificity: boundary constants should live in one place
Examples
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; use chicago_tdd_tools::validation::guards::{GuardValidator, MAX_RUN_LEN}; param_test! { #[case("below", MAX_RUN_LEN - 1, true)] #[case("at", MAX_RUN_LEN, true)] #[case("above", MAX_RUN_LEN + 1, false)] fn test_run_length_boundaries(label: &str, length: usize, expected_ok: bool) { let validator = GuardValidator::new(); let result = validator.validate_run_length(length); match expected_ok { true => assert_ok!(&result, "{label} should be accepted"), false => assert_err!(&result, "{label} should be rejected"), } } } }
Related Patterns
- Pattern 2: Error Path Testing
- Pattern 4: Resource Cleanup
- Pattern 20: Macro Pattern Enforcement
Pattern 4: Resource Cleanup
Context
Your tests allocate resources – files, network ports, containers, telemetry backends – that must be released even when assertions fail.
Problem
Forgetting to release resources causes nondeterministic failures, leaking containers, or state pollution between tests. Manual cleanup logic scatters across tests and is easy to miss.
Solution
Use Chicago TDD Tools fixtures and the RAII guarantees they provide. Wrap resource management inside fixture_test! or fixture_test_with_timeout!, storing handles in the fixture. Allow Drop implementations and the framework cleanup to run automatically so every test returns to a known good state.
Forces
- Determinism vs. speed: automated teardown must be reliable without slowing the hot path
- Simplicity vs. observability: cleanup should be invisible unless a failure occurs
- Isolation vs. reuse: fixtures create fresh state while sharing setup logic
Examples
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; use testcontainers::clients::Cli as DockerCli; struct DockerFixture { docker: DockerCli, } fixture_test!(test_exec_container_command, fixture, { // Arrange let docker = DockerCli::default(); let container = docker.run("alpine:3.19"); // Act let result = container.exec("echo", &["ok"])?; // Assert assert_eq!(result.stdout, "ok\n"); Ok::<(), testcontainers::Error>(()) }); }
The fixture ensures containers stop even if the assertion fails.
Related Patterns
- Pattern 5: Real Collaborators
- Pattern 16: Fixture Lifecycle Management
- Pattern 18: Timeout Defense in Depth
Pattern 5: Real Collaborators
Context
You are validating behavior that depends on external systems – telemetry, containers, queues – and want confidence that integrations behave the same way in production.
Problem
Mock-heavy tests can mask integration gaps, drift from reality, and erode trust in the test suite. When production fails, tests offer little guidance.
Solution
Use the framework's integration helpers to exercise real collaborators. For containers, enable the testcontainers feature and run against Docker. For telemetry, use otel_test! and weaver_test! to validate spans and semantic conventions. Keep tests categorized so slower integration suites run intentionally.
Forces
- Fidelity vs. speed: real dependencies cost more time, so isolate them behind feature flags and profiles
- Determinism vs. variability: control randomness via fixtures and builders
- Observability vs. complexity: prefer higher-level validators over low-level asserts
Examples
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; use chicago_tdd_tools::observability::otel::{OtelTestHelper, SpanValidator}; otel_test!(test_span_follows_conventions, { // Arrange let helper = OtelTestHelper::new(); let span = helper.capture(|tracer| tracer.span("checkout")); // Act let result = helper.assert_spans_valid(&[span.clone()]); // Assert assert_ok!(&result); }); }
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; use chicago_tdd_tools::integration::testcontainers::ContainerClient; fixture_test_with_timeout!(test_postgres_roundtrip, fixture, 30, { // Arrange let client = ContainerClient::for_image("postgres:16").await?; // Act let rows = client.query("SELECT 1").await?; // Assert assert_eq!(rows[0].get::<i32, _>(0), 1); Ok::<(), testcontainers::Error>(()) }); }
Related Patterns
- Pattern 4: Resource Cleanup
- Pattern 16: Fixture Lifecycle Management
- Pattern 18: Timeout Defense in Depth
Architecture Patterns
Chicago TDD Tools is deliberately layered so teams can extend the framework without rewriting core functionality. The architecture patterns describe how modules compose, how extensions fit, and how the framework keeps behavior predictable across crates.
Use these patterns when building custom tooling on top of the framework or when evaluating how to contribute new modules upstream.
Pattern 6: Generic Base Layer
Context
You want a reusable testing foundation that can support multiple domains without pulling in their dependencies.
Problem
If the base includes domain code, every consumer drags in unused dependencies, slowing builds and introducing coupling. Conversely, a minimal base risks missing essential primitives.
Solution
Keep the core crate focused on generic capabilities – fixtures, builders, assertions, macros, state tracking. Expose them through capability modules (core, testing, validation, observability, integration). Ensure modules depend only on the standard library and optional features. Domain-specific abstractions live in downstream crates that compose the base.
Forces
- Reuse vs. specialization: core must be broadly useful without dictating domain models
- Build time vs. flexibility: optional features load only when needed
- Stability vs. growth: base APIs should be stable; extensions add behavior
Examples
#![allow(unused)] fn main() { // src/lib.rs pub mod core; pub mod testing; pub mod validation; pub mod observability; pub mod integration; pub use core::{fixture, builders, assertions}; }
Related Patterns
- Pattern 7: Extension Layer
- Pattern 8: Composition Over Duplication
- Pattern 10: Capability Grouping
Pattern 7: Extension Layer
Context
A product team needs domain-specific fixtures, builders, or assertions on top of Chicago TDD Tools.
Problem
Embedding domain logic inside the core crate makes it hard to evolve independently and risks breaking other users. Repeated copy-paste of generic primitives leads to drift.
Solution
Create an extension crate that depends on the core. Compose base fixtures inside your domain fixture, forwarding behavior while adding fields and helpers. Re-export the pieces your team should import. Treat the base crate as an 80% solution and layer the remaining 20% locally.
Forces
- Encapsulation vs. reuse: domain modules wrap core primitives rather than modify them
- Stability vs. iteration speed: upstream stays stable while the extension can iterate quickly
- Discoverability vs. sprawl: re-export only what the domain needs
Examples
#![allow(unused)] fn main() { // workflow-fixture crate use chicago_tdd_tools::core::fixture::TestFixture; pub struct WorkflowFixture { base: TestFixture<()>, engine: WorkflowEngine, } impl WorkflowFixture { pub fn new(engine: WorkflowEngine) -> Self { let base = TestFixture::new().expect("fixture"); Self { base, engine } } } }
Related Patterns
- Pattern 6: Generic Base Layer
- Pattern 8: Composition Over Duplication
- Pattern 9: Single Source of Truth
Pattern 8: Composition Over Duplication
Context
You are adding a feature to an extension crate and need functionality already available in the base layer.
Problem
Copying helpers or macros breaks the single source of truth. Over time the copies diverge, and bug fixes must be applied in multiple places.
Solution
Compose existing primitives. Wrap fixtures inside domain fixtures, embed builders into higher-level builders, and use assertion macros rather than writing bespoke checks. When missing functionality is truly generic, add it to the base crate instead of forking it downstream.
Forces
- Launch speed vs. long-term maintenance: composition keeps future upgrades cheap
- Ergonomics vs. explicitness: wrappers can augment APIs without obscuring the base
- Ownership vs. contribution: contribute upstream when the behavior benefits all users
Examples
#![allow(unused)] fn main() { pub struct OrderBuilder { base: TestDataBuilder, } impl OrderBuilder { pub fn new() -> Self { Self { base: TestDataBuilder::new() } } pub fn with_amount(mut self, amount: u64) -> Self { self.base = self.base.with_var("amount", amount.to_string()); self } pub fn build_json(self) -> serde_json::Value { self.base.build_json().expect("json") } } }
Related Patterns
- Pattern 6: Generic Base Layer
- Pattern 7: Extension Layer
- Pattern 9: Single Source of Truth
Pattern 9: Single Source of Truth
Context
Constants, toggle lists, and feature matrices are needed across modules and extensions.
Problem
Duplicating configuration (timeouts, guard limits, feature lists) invites drift. Teams change one copy and forget the rest, producing inconsistent behavior.
Solution
Centralize invariants inside the module that owns them and re-export when necessary. Examples include timeout constants in core::macros::test, guard limits in validation::guards, and feature combinations in Cargo.toml. Extensions read these definitions instead of defining their own copies.
Forces
- Accessibility vs. encapsulation: invariants must be easy to import without exposing internals
- Flexibility vs. safety: allow customization through builders or configuration rather than duplicating constants
- Documentation vs. code: comments and docs should reference the single source, not restated values
Examples
#![allow(unused)] fn main() { // src/core/macros/test.rs pub const DEFAULT_UNIT_TEST_TIMEOUT_SECONDS: u64 = 1; pub const DEFAULT_INTEGRATION_TEST_TIMEOUT_SECONDS: u64 = 30; // src/validation/guards.rs pub const MAX_RUN_LEN: usize = 8; }
Related Patterns
- Pattern 8: Composition Over Duplication
- Pattern 10: Capability Grouping
- Pattern 18: Timeout Defense in Depth
Pattern 10: Capability Grouping
Context
You are browsing the Chicago TDD Tools codebase or designing a new module. You need to know where functionality belongs and how to expose it.
Problem
Without a consistent module taxonomy, features surface haphazardly. Consumers struggle to find capabilities, and maintainers duplicate structure.
Solution
Group modules by capability: core for foundational primitives, testing for advanced techniques, validation for guardrails, observability for telemetry, and integration for external systems. Re-export each group at the crate root to support both granular and high-level imports. New modules join one of these groups or motivate a new, clearly named capability.
Forces
- Discoverability vs. granularity: capability groups provide short import paths while preserving modularity
- Stability vs. evolution: groups rarely change, making documentation and IDE tooling reliable
- Compilation vs. optionality: feature flags enable or disable entire capability slices
Examples
#![allow(unused)] fn main() { // src/lib.rs pub mod core; // fixtures, builders, assertions, macros pub mod testing; // property testing, mutation testing, snapshots pub mod validation; // guards, coverage, performance pub mod observability; // otel, weaver pub mod integration; // testcontainers }
Related Patterns
- Pattern 6: Generic Base Layer
- Pattern 9: Single Source of Truth
- Pattern 19: Feature Gate Slices
Design Patterns
These patterns codify the type-level and zero-cost techniques that make Chicago TDD Tools safe and fast. They explain how the framework encodes invariants, leverages Rust's compiler, and prevents misuse through types.
Use them when extending the framework or designing downstream APIs that should feel at home in the ecosystem.
Pattern 11: Zero-Cost Abstractions
Context
You are designing APIs or extensions and need expressive abstractions without runtime overhead.
Problem
Runtime polymorphism or heap allocations can slow hot paths. Manual inlining or duplicated code sacrifices readability.
Solution
Lean on generics, const generics, and macros to express behavior that compiles down to the same machine code as bespoke implementations. Favor references over owned values and prefer stack allocation. When dynamic dispatch is required, isolate it behind narrow traits.
Forces
- Expressiveness vs. performance: abstractions must be ergonomic without cost
- Safety vs. control: compile-time guarantees should not block optimization
- Maintainability vs. specialization: macros and generics prevent copy-paste
Examples
#![allow(unused)] fn main() { pub fn measure_ticks<F, T>(operation: F) -> (T, u64) where F: FnOnce() -> T, { // Generic function specialized per call site; no dynamic dispatch chicago_tdd_tools::validation::performance::measure_ticks(operation) } }
Related Patterns
- Pattern 12: Type Safety with GATs
- Pattern 14: Compile-Time Validation
- Pattern 20: Macro Pattern Enforcement
Pattern 12: Type Safety with GATs
Context
You provide fixtures or builders that return references tied to the fixture lifetime.
Problem
Without explicit lifetimes, consumers can hold onto references after cleanup, causing dangling pointers or logic bugs.
Solution
Use Generic Associated Types (GATs) to bind returned data to the fixture lifetime. In AsyncFixtureProvider, Fixture<'a> ensures the borrow cannot outlive the provider. Pair GATs with sealed traits to prevent downstream crates from violating invariants.
Forces
- Safety vs. ergonomics: GATs constrain lifetimes but keep APIs pleasant
- Extensibility vs. soundness: sealing the trait permits internal evolution while preserving invariants
- Async vs. sync: async fixtures require lifetimes that sync code cannot express without GATs
Examples
#![allow(unused)] fn main() { pub trait AsyncFixtureProvider: private::Sealed { type Fixture<'a>: Send where Self: 'a; type Error: std::error::Error + Send + Sync + 'static; fn create_fixture<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<Self::Fixture<'a>, Self::Error>> + Send + 'a>>; } }
Related Patterns
- Pattern 13: Sealed Traits for API Safety
- Pattern 16: Fixture Lifecycle Management
- Pattern 17: Builder-Driven Test Data
Pattern 13: Sealed Traits for API Safety
Context
A trait defines extension points (fixtures, validators) but external implementations could break invariants.
Problem
If downstream crates implement the trait arbitrarily, the framework cannot guarantee lifecycle management or error semantics. Breaking changes become impossible.
Solution
Use the sealed trait pattern: define a private module with a Sealed trait implemented only within the crate, and require Sealed as a supertrait. Consumers can use the trait, but only framework-defined implementations exist.
Forces
- Safety vs. openness: sealing protects invariants while letting users compose functionality
- Flexibility vs. versioning: internal changes become possible without breaking downstream code
- Testability vs. encapsulation: tests can still construct fixtures via provided builders
Examples
#![allow(unused)] fn main() { mod private { pub trait Sealed {} } pub trait AsyncFixtureProvider: private::Sealed { // ... } }
Related Patterns
- Pattern 12: Type Safety with GATs
- Pattern 17: Builder-Driven Test Data
- Pattern 20: Macro Pattern Enforcement
Pattern 14: Compile-Time Validation
Context
Invariants (tick budgets, guard limits, feature combinations) should fail fast during compilation.
Problem
Runtime checks add overhead and can be bypassed in rarely executed code paths. Missing an invariant leads to subtle production bugs.
Solution
Push validation to compile time with const generics, type-level markers, and const_assert!. When runtime validation is unavoidable, encapsulate it in constructors that return Result, making misuse impossible through types.
Forces
- Safety vs. flexibility: some values remain runtime, but defaults should be encoded in types
- Compile time vs. ergonomics: const generics expose parameters without macros
- Diagnostics vs. noise: compile errors must explain the invariant succinctly
Examples
#![allow(unused)] fn main() { pub const fn assert_tick_budget(ticks: u64) { const_assert!(ticks <= 8); } pub struct SizeValidatedArray<const SIZE: usize, const MAX: usize> { data: [u8; SIZE], _marker: PhantomData<[u8; MAX]>, } }
Related Patterns
- Pattern 11: Zero-Cost Abstractions
- Pattern 15: Type State Enforcement
- Pattern 18: Timeout Defense in Depth
Pattern 15: Type State Enforcement
Context
An API has a prescribed call order (Arrange → Act → Assert) or requires configuration before use.
Problem
Runtime enforcement of order relies on documentation and can be bypassed, leading to inconsistent state and test flakiness.
Solution
Model the phases as distinct types and use PhantomData to encode the current phase. Methods consume self and return the next state, making it impossible to call methods out of order. Chicago TDD Tools uses this to enforce AAA semantics internally.
Forces
- Safety vs. ergonomic: type transitions should read naturally without verbose syntax
- Flexibility vs. constraints: provide escape hatches only when absolutely necessary
- Zero-cost vs. clarity: type state should erase at compile time
Examples
#![allow(unused)] fn main() { pub struct TestState<Phase> { context: Context, _phase: PhantomData<Phase>, } impl TestState<Arrange> { pub fn act(self) -> TestState<Act> { /* ... */ } } }
Related Patterns
- Pattern 1: AAA Pattern
- Pattern 11: Zero-Cost Abstractions
- Pattern 14: Compile-Time Validation
New Patterns from Practice
The framework continues to evolve through real-world usage. This section captures patterns that emerged after building large Chicago TDD deployments. They complement the classical design patterns with pragmatic guidance on fixtures, timeouts, feature flags, and macro enforcement.
Pattern 16: Fixture Lifecycle Management
Context
Complex tests require deterministic setup and teardown of shared state: databases, telemetry, temporary directories.
Problem
Manual lifecycle logic is error-prone. Forgetting teardown causes cascading failures across tests. Async setup complicates matters further.
Solution
Wrap lifecycle responsibilities in TestFixture or AsyncFixtureManager. Use the fixture to hold handles and expose helper methods. Let Drop and the manager .teardown() guarantee cleanup. For async resources, implement AsyncFixtureProvider and return strongly typed handles.
Forces
- Determinism vs. flexibility: fixtures must isolate state yet allow custom behavior per test
- Async vs. sync complexity: asynchronous resources require explicit lifecycle boundaries
- Performance vs. safety: reuse is tempting, but fresh fixtures avoid hidden coupling
Examples
#![allow(unused)] fn main() { struct DbProvider; impl chicago_tdd_tools::core::async_fixture::private::Sealed for DbProvider {} impl AsyncFixtureProvider for DbProvider { type Fixture<'a> = DatabaseHandle; type Error = DbError; fn create_fixture<'a>(&'a self) -> DbFuture<'a, DatabaseHandle> { Box::pin(async move { DatabaseHandle::connect().await }) } } async_test!(test_query_latency, { let manager = AsyncFixtureManager::new(DbProvider); let handle = manager.setup().await?; // ... manager.teardown().await?; Ok(()) }); }
Related Patterns
- Pattern 4: Resource Cleanup
- Pattern 12: Type Safety with GATs
- Pattern 18: Timeout Defense in Depth
Pattern 17: Builder-Driven Test Data
Context
Domain objects require multiple fields or nested structures. Hand-building them in tests scatters intent and duplicates defaults.
Problem
Verbose setup obscures the behavior under test. When requirements change, hundreds of tests need updates.
Solution
Wrap TestDataBuilder (or create your own builder) to provide fluent helpers and sensible defaults. Expose domain-specific methods (with_customer_id, with_balance) and return JSON or HashMap structures ready for assertions. Builders live close to the domain, yet reuse the underlying generic builder to avoid duplication.
Forces
- Expressiveness vs. coupling: builders should reflect domain language without leaking implementation details
- Defaults vs. explicitness: provide safe defaults but allow overrides
- Reuse vs. specialization: share base builder logic; extensions add convenience
Examples
#![allow(unused)] fn main() { pub struct CustomerBuilder { base: TestDataBuilder, } impl CustomerBuilder { pub fn new() -> Self { Self { base: TestDataBuilder::new() .with_var("status", "active"), } } pub fn with_id(mut self, id: &str) -> Self { self.base = self.base.with_var("customer_id", id.to_string()); self } pub fn build(self) -> serde_json::Value { self.base.build_json().expect("valid json") } } }
Related Patterns
- Pattern 8: Composition Over Duplication
- Pattern 11: Zero-Cost Abstractions
- Pattern 19: Feature Gate Slices
Pattern 18: Timeout Defense in Depth
Context
Async tests interact with containers, networks, or external services. A hung future or stalled process could freeze the suite.
Problem
Single-layer timeouts fail silently when they reside in the wrong place. For example, process-level timeouts kill the entire run without explaining which test stalled.
Solution
Layer timeouts at three levels:
- Test-level (
tokio::time::timeoutinside macros) – fails the specific test with a clear message. - Runner-level (
cargo-nextestprofiles) – applies SLA-based timeouts per profile. - Process-level (
timeoutwrapper inMakefile.toml) – stops catastrophic hangs.
Expose constants for standard timeouts (DEFAULT_UNIT_TEST_TIMEOUT_SECONDS = 1, DEFAULT_INTEGRATION_TEST_TIMEOUT_SECONDS = 30) and use *_with_timeout! macros for slow scenarios.
Forces
- Resilience vs. noise: timeouts must be strict enough to catch hangs but lenient for expected latency
- Diagnostics vs. overhead: detailed error messages help triage without cluttering success paths
- Configurability vs. consistency: shared constants keep expectations aligned
Examples
# .config/nextest.toml
[profile.default]
slow-timeout = { period = "1s", terminate-after = 1 }
[profile.integration]
slow-timeout = { period = "30s", terminate-after = 1 }
#![allow(unused)] fn main() { fixture_test_with_timeout!(test_container_warmup, fixture, DEFAULT_INTEGRATION_TEST_TIMEOUT_SECONDS, { // slow operation Ok(()) }); }
Related Patterns
- Pattern 4: Resource Cleanup
- Pattern 9: Single Source of Truth
- Pattern 20: Macro Pattern Enforcement
Pattern 19: Feature Gate Slices
Context
The framework offers advanced capabilities (property testing, mutation testing, testcontainers, OTEL) that not every project needs.
Problem
Enabling every feature increases compile times and pulls in heavy dependencies. Disabling a feature accidentally can break tests silently.
Solution
Group related features into named slices in Cargo.toml (e.g., testing-extras, observability-full). Document the slice and expose cfg-gated APIs accordingly. Tests and examples import the feature-specific modules only when the feature is active, keeping the base lean.
Forces
- Modularity vs. convenience: slices reduce duplication but still allow fine-grained toggles
- Discoverability vs. complexity: a small number of curated slices keeps onboarding simple
- Compatibility vs. optionality: code must compile cleanly with features disabled
Examples
[features]
default = ["logging"]
testing-extras = ["property-testing", "snapshot-testing", "fake-data"]
observability-full = ["otel", "weaver"]
#![allow(unused)] fn main() { #[cfg(feature = "weaver")] pub mod weaver; }
Related Patterns
- Pattern 6: Generic Base Layer
- Pattern 10: Capability Grouping
- Pattern 20: Macro Pattern Enforcement
Pattern 20: Macro Pattern Enforcement
Context
You need consistent test structure and timeouts without repeating boilerplate or relying on discipline alone.
Problem
Developers forget to add timeouts, skip AAA comments, or mix direct #[test] usage with framework macros, leading to drift and inconsistent behavior.
Solution
Embed enforcement inside macros. test! injects the AAA skeleton, async_test! and fixture_test! wrap bodies with tokio::time::timeout, and weaver_test! requires the weaver feature. Each macro centralizes best practices so using it guarantees compliance.
Forces
- Consistency vs. flexibility: macros enforce conventions while allowing custom logic inside
- Zero cost vs. tooling: expansions must stay small and compile quickly
- Guidance vs. noise: failures should point to the missing convention explicitly
Examples
#![allow(unused)] fn main() { #[macro_export] macro_rules! async_test { ($name:ident, $body:block) => { $crate::async_test_with_timeout!($name, 1, $body); }; } }
#![allow(unused)] fn main() { #[cfg(not(feature = "otel"))] #[macro_export] macro_rules! otel_test { ($($tt:tt)*) => { compile_error!("OTEL testing requires the 'otel' feature. Enable with: --features otel"); }; } }
Related Patterns
- Pattern 1: AAA Pattern
- Pattern 18: Timeout Defense in Depth
- Pattern 19: Feature Gate Slices