Chicago TDD Tools - Application Guide
Welcome to the practical application guide for Chicago TDD Tools! This guide focuses on real-world usage patterns and practical techniques for testing Rust applications.
What You'll Learn
This guide covers:
- Core Patterns: Fixtures, data builders, assertions, and error path testing
- Advanced Techniques: Property-based testing, mutation testing, snapshot testing, CLI testing, and concurrency testing
- Progressive Enhancement: The "Go the Extra Mile" pattern for designing increasingly valuable solutions
- Observability: OTEL instrumentation and Weaver live-check validation
- Real-World Applications: Complete examples of testing CLI tools, web services, and integration scenarios
- Best Practices: Proven patterns and migration strategies
Who This Guide Is For
- Developers writing Rust applications and want comprehensive testing
- Teams adopting Chicago-style (Classicist) TDD practices
- Engineers improving test quality and catching bugs earlier
- Anyone learning advanced testing techniques in Rust
How to Use This Guide
- Start with the Introduction to understand Chicago TDD principles
- Learn Core Patterns for everyday testing scenarios
- Explore Advanced Techniques for specific testing challenges
- Study Real-World Applications to see complete examples
- Apply Best Practices to improve your testing approach
Quick Links
- Pattern Cookbook - Alexander-style patterns reference
- API Reference - Complete API documentation
- GitHub Repository
Key Concepts
Chicago-Style TDD (Classicist)
Chicago TDD emphasizes:
- Type Safety: Use Rust's type system to prevent errors at compile time
- Real Dependencies: Test with actual implementations, not mocks
- Error Prevention: Poka-yoke design prevents mistakes before they happen
- Quality by Default: Quality is the default, not an afterthought
The 80/20 Principle
The "second idea" typically provides 80% more value with only 20% more effort. Learn when to:
- Keep it simple (1st idea)
- Apply the sweet spot (2nd idea)
- Go all-in (3rd idea)
Getting Started
Install Chicago TDD Tools
[dev-dependencies]
chicago-tdd-tools = { version = "1.3", features = ["testing-extras"] }
Run Examples
# Run basic example
cargo run --example basic_test
# Run property-based testing
cargo run --example property_testing --features property-testing
# Run mutation testing
cargo run --example mutation_testing
# Run all examples
cargo run --example go_extra_mile
Build This Guide
# Install mdbook if you haven't already
cargo install mdbook
# Build and serve the guide
cd application-guide
mdbook serve
Then visit http://localhost:3000 in your browser.
Example: Basic Test Structure
All tests follow the AAA Pattern (Arrange-Act-Assert):
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; test!(my_test, { // Arrange: Set up test data let input = 5; // Act: Execute the code under test let result = input * 2; // Assert: Verify the result assert_eq!(result, 10); }); }
Table of Contents
- Introduction - Chicago TDD philosophy and principles
- Core Testing Patterns - Everyday testing with fixtures and builders
- Advanced Techniques - Specialized testing for complex scenarios
- Go the Extra Mile - Progressive enhancement pattern
- Observability & Quality - Telemetry and quality assurance
- Real-World Applications - Complete practical examples
- Best Practices - Proven patterns and patterns to avoid
Featured Examples
From Examples Directory
- basic_test.rs - Getting started with fixtures and data builders
- property_testing.rs - Property-based testing with proptest
- mutation_testing.rs - Validating test quality
- go_extra_mile.rs - Progressive enhancement patterns
- cli_testing.rs - Testing command-line interfaces
- snapshot_testing.rs - Golden file testing
- concurrency_testing.rs - Thread-safe testing with loom
From Playground
- CLI tool with comprehensive sub-commands
- Integration tests with Docker containers
- Quality validation with coverage and performance metrics
- OTEL/Weaver observability examples
Next Steps
π Start here: Introduction
Community & Support
- GitHub Issues: Report bugs or request features
- Discussions: Share ideas and patterns with the community
- Pattern Cookbook: Contribute Alexander-style patterns
Chicago TDD Tools - Testing with confidence, errors prevented at compile time.
Introduction: Chicago TDD Philosophy
π EXPLANATION | Learn the philosophy and principles
Quick Navigation: What's Your Goal?
Not sure where to start? See Choosing Your Learning Path for a complete navigation guide with decision matrices.
Choose your path based on what you're trying to do:
| Your Goal | Read This | Time |
|---|---|---|
| I'm just getting started | Quick Start Tutorial β Fixtures Deep Dive | 40 min |
| I want to build a CLI app | CLI Application Tutorial | 45 min |
| I want to build a web service | REST Web Service Tutorial | 50 min |
| I need to find an API | Go to API Reference | 2-5 min |
| I want to understand Chicago TDD | Read this introduction + Go the Extra Mile | 45 min |
| I'm migrating from traditional testing | Best Practices & Migration | 1 hour |
What is Chicago-Style TDD?
Chicago-style TDD (also called "Classicist" TDD) is a testing approach that emphasizes:
- Type Safety First - Use Rust's type system to prevent entire categories of errors
- Real Dependencies - Test with actual implementations, not mocks
- Error Prevention - Mistakes are prevented at compile time, not caught at runtime
- Quality by Default - Quality is the default state; prevention beats detection
This contrasts with London-style TDD, which uses mocks and message-passing verification.
Core Principles
1. Poka-Yoke Design
"Poka-yoke" (fool-proofing) means designing systems to prevent mistakes before they happen.
Example: Instead of writing a test to catch panic!() usage, the compiler enforces that production code never panics:
#![allow(unused)] fn main() { // β Compiler error (blocked by clippy `unwrap_used` deny) let value = result.unwrap(); // β Compiler accepts - proper error handling let value = result?; }
2. Type-Level Correctness
Use Rust's type system as the primary design tool. If it compiles, correctness follows.
Example: A validated number type that proves its value is valid:
#![allow(unused)] fn main() { pub struct ValidatedNumber<T> { value: T, // Can only be constructed via parse() } impl<T: FromStr> ValidatedNumber<T> { pub fn parse(input: &str) -> Result<Self, String> { // Validation logic here input.parse().map(|value| Self { value }) } } }
3. Real Collaborators
Test with actual dependencies, not mocks. This catches integration bugs and makes refactoring safer.
Why? Mocks can hide bugs at integration boundaries. Real implementations reveal actual behavior.
#![allow(unused)] fn main() { // Chicago-style: Use the real implementation let fixture = TestFixture::new()?; // Real setup let result = actual_function(&fixture)?; // Real code // London-style: Would use a mock instead // let fixture = MockFixture::new(); }
4. The 80/20 Principle
When designing solutions, consider three ideas:
- 1st Idea: Solve the immediate problem (narrow scope, simple)
- 2nd Idea: 80% more value with 20% more effort (sweet spot, usually best)
- 3rd Idea: Maximum value, but evaluate carefully (maximum scope, most complex)
Example: Number parsing
- 1st Idea: Parse
u32only - 2nd Idea: Generic parser works for all number types
- 3rd Idea: Type-validated parser with OTEL instrumentation and Weaver validation
Choose the 2nd idea most of the time. Only go to 3rd idea when type safety is critical.
Why Chicago TDD for Rust?
Rust's type system makes Chicago-style TDD especially powerful:
| Aspect | Benefit |
|---|---|
| Compile-time guarantees | Many bugs prevented before testing |
| Ownership system | Resource cleanup guaranteed |
| Result type | Explicit error handling (no surprises) |
| Trait system | Generic code with real implementations |
| Macros | Test framework enforcement at compile time |
Common Misconceptions
"Mocks are Required"
β False in Chicago TDD. Use real dependencies when possible.
Use mocks only for:
- External services (APIs, databases)
- Expensive operations (file I/O, network)
- Non-deterministic behavior (time, random numbers)
"Tests Should Only Test Public APIs"
β Partially true. Chicago TDD tests the behavior, not the interface.
Test internal functions if they:
- Have complex logic
- Are hard to test through public APIs
- Need boundary condition verification
"100% Coverage Means Bug-Free Code"
β False. Coverage measures code execution, not correctness.
Focus on:
- Error paths (the real bugs are here)
- Boundary conditions
- State transitions
Example: 100% coverage of a sorting function doesn't guarantee it handles duplicates correctly.
Testing with Chicago TDD Tools
Chicago TDD Tools provides:
- Type-level AAA enforcement: Arrange-Act-Assert structure at compile time
- Data builders: Fluent API for complex test data
- Fixture management: Test isolation and cleanup
- Assertion helpers: Clear, readable assertions
- Advanced techniques: Property-based, mutation, snapshot testing
- Observability: OTEL and Weaver integration
All with zero-cost abstractions and compile-time error prevention.
The Testing Pyramid
Chicago TDD follows a testing pyramid:
β³ (Few)
/|\
/ | \ E2E Tests (slow, fragile)
/ | \
/____|____\
| | | Integration Tests (medium speed)
|____|____|
| | Unit Tests (fast, many)
|_________|
β½ (Many)
Unit Tests (bottom): Fast, isolated, test one function
Integration Tests (middle): Medium speed, test multiple components together
E2E Tests (top): Slow, test the entire system
Chicago TDD emphasizes unit tests and integration tests. E2E tests are less important if unit+integration tests are comprehensive.
AAA Pattern
Every test follows Arrange-Act-Assert:
#![allow(unused)] fn main() { test!(test_parsing, { // Arrange: Set up test data let input = "42"; // Act: Execute the code under test let result = input.parse::<u32>(); // Assert: Verify the result assert_ok!(&result); assert_eq!(result.unwrap(), 42); }); }
This pattern ensures tests are:
- Clear: Anyone can see what's being tested
- Complete: Setup, execution, and verification are separate
- Correct: Focused on one behavior per test
Test Organization
Parallel Tests
By default, tests run in parallel. Ensure each test is independent:
#![allow(unused)] fn main() { test!(test1, { // Use unique data: TestFixture::new()? // Don't rely on global state // Don't use file/network resources shared with other tests }); }
Fixture Isolation
Each fixture is independent:
#![allow(unused)] fn main() { test!(test_with_fixture, { let fixture1 = TestFixture::new()?; let fixture2 = TestFixture::new()?; // fixture1 and fixture2 are completely independent // Automatic cleanup when they drop }); }
Advanced Topics Preview
Chicago TDD Tools provides techniques for:
- Property-Based Testing: Generate random test data and verify properties hold
- Mutation Testing: Validate test quality by introducing mutations
- Snapshot Testing: Golden files to detect unintended changes
- CLI Testing: Test command-line interfaces with trycmd
- Concurrency Testing: Deterministic thread testing with loom
- Observability: OTEL instrumentation and Weaver validation
We'll explore all of these in later sections.
Getting Started
The best way to learn is by doing. Here's the recommended learning path:
- Core Patterns - Master fixtures, data builders, and assertions
- Error Path Testing - Learn where bugs hide
- Advanced Techniques - Pick techniques for your use case
- Real-World Applications - See complete examples
- Best Practices - Avoid common pitfalls
Key Takeaways
β Chicago TDD emphasizes type safety and error prevention
β Use real dependencies, not mocks
β Follow the 80/20 principle when designing solutions
β Every test follows Arrange-Act-Assert
β Focus on error paths (where bugs hide)
β Don't rely on 100% coverage (it's not enough)
β Don't use mocks for everything
β Don't skip boundary condition testing
Next: Core Testing Patterns
Choosing Your Learning Path
πΊοΈ NAVIGATION | Find the right resources for your goals
This page helps you navigate the documentation based on what you want to accomplish.
Quick Navigation by Goal
"I'm just getting started"
Duration: 1-2 hours | Difficulty: Beginner
Start here and follow in order:
- Introduction (10 min) - Understand Chicago TDD philosophy
- Quick Start Tutorial (25 min) - Write your first test
- Fixtures Deep Dive (15 min) - Master test isolation
- Core Testing Patterns (20 min) - Learn the essentials
- Error Path Testing (15 min) - Find where bugs hide
Next step: Pick one real-world tutorial based on what you want to build.
"I want to build a CLI application"
Duration: 1-2 hours | Difficulty: Intermediate
Follow this path:
- Quick Start (25 min) - Review basics
- CLI Application Tutorial (45 min) - Build complete CLI
- CLI Testing (20 min) - Test CLI output with golden files
- Best Practices (30 min) - Production patterns
Key files to reference:
- TestFixture API - For test isolation
- Error Path Testing - Test error cases
"I want to build a REST API / Web Service"
Duration: 1.5-2.5 hours | Difficulty: Intermediate
Follow this path:
- Quick Start (25 min) - Review basics
- REST Web Service Tutorial (50 min) - Build complete API
- Integration Testing with Docker (30 min) - Test with real database
- Observability & Quality (20 min) - Add monitoring
Key files to reference:
- Data Builders - For building test data
- Error Path Testing - Test error responses
"I need to test complex logic"
Duration: 2-3 hours | Difficulty: Intermediate-Advanced
Follow this path:
- Error Path Testing (15 min) - Comprehensive error testing
- Property-Based Testing (30 min) - Generate test cases
- Mutation Testing (20 min) - Validate test quality
- Concurrency Testing (20 min) - Test thread safety
Key files to reference:
- Core Testing Patterns - For building blocks
- Best Practices - Quality assurance patterns
"I want to ensure high-quality tests"
Duration: 1-2 hours | Difficulty: Intermediate
Follow this path:
- Error Path Testing (15 min) - Test error cases thoroughly
- Mutation Testing (20 min) - Validate test quality
- Coverage & Performance (20 min) - Measure metrics
- Best Practices (30 min) - Industry patterns
Tools:
- Run
cargo make coverage- Generate coverage reports - Run
cargo make test-mutation- Validate test quality - Use
TestFixturesnapshots - Track state changes
"I'm migrating from another testing framework"
Duration: 1.5-2 hours | Difficulty: Beginner-Intermediate
Follow this path:
- Introduction (10 min) - Understand differences
- Quick Start (25 min) - Learn Chicago TDD style
- Core Testing Patterns (20 min) - Relearn with new approach
- Best Practices & Migration (30 min) - Strategies for migration
Key concepts to learn:
- Real dependencies vs mocks
- Type-level correctness
- AAA pattern enforcement
- Poka-yoke design
"I need a specific feature"
Find your feature in this table:
| Feature | Where to Find It |
|---|---|
| Testing basic functions | Core Patterns |
| Fixtures & setup | Fixtures Tutorial |
| Building test data | Data Builders |
| Assertions | Assertions & Verification |
| Error testing | Error Path Testing |
| Random test data | Property Testing |
| Test output validation | Snapshot Testing |
| Testing CLI apps | CLI Testing |
| Testing threads | Concurrency Testing |
| Test quality validation | Mutation Testing |
| Test metrics | Coverage & Performance |
| Observability | OTEL Instrumentation |
| Production patterns | Best Practices |
Learning Path Summary
Beginner (0-1 day)
Quick Start (25 min)
β
Fixtures Deep Dive (15 min)
β
Core Patterns (20 min)
β
Pick a real-world tutorial (45-50 min)
Intermediate (1-3 days)
Complete Beginner path
β
Advanced Techniques (property, mutation, snapshot)
β
Real-world applications (CLI, Web Service, Docker)
β
Best Practices
Advanced (3+ days)
Complete Intermediate path
β
Observability & OTEL
β
Performance & Coverage
β
Mutation testing & quality validation
β
Write your own applications
Decision Matrix
Choose based on your situation:
If you're ...
| Situation | Time Available | Difficulty | Start Here |
|---|---|---|---|
| Completely new to testing | 2 hours | Easy | Quick Start |
| Have testing experience | 1.5 hours | Medium | Introduction |
| Experienced developer | 1 hour | Medium | CLI or Web Service |
| Need specific feature | Varies | Varies | Feature table above |
| Want certification-level knowledge | 1 week | Hard | Complete all tutorials + real-world projects |
Common Starting Points
"Show me code examples"
β Jump to Real-World Applications
"I need to solve a problem right now"
β Search Best Practices or use feature table above
"I want to understand the philosophy"
β Read Introduction and "Go the Extra Mile"
"I'm evaluating this for my team"
β Read Introduction and skim Best Practices
"I want to learn everything"
β Follow the progressive learning paths in "Learning Path Summary" above
Next Steps After Learning
After completing Quick Start & Fixtures
β Choose one real-world tutorial to build something real
After completing a real-world tutorial
β Explore Advanced Techniques that match your needs
After exploring multiple areas
β Best Practices - See industry patterns
After mastering the basics
β Build something with Chicago TDD:
- Open-source contribution
- Side project
- Production application
- Team migration
FAQ
Q: What's the best starting point? A: Quick Start if brand new, otherwise Introduction.
Q: How long does it take to learn? A: 2-4 hours for basics, 1-2 weeks to master all techniques.
Q: Should I read everything? A: No. Focus on tutorials matching your needs, use reference as needed.
Q: Can I skip sections? A: Yes. Each section is independent except tutorials (follow in order).
Q: Where's the API reference? A: API Reference - More coming soon!
Q: Do I need prior testing experience? A: No, but experience helps. Chicago TDD teaches a specific approach.
Document Index
Tutorials (Learning-Oriented)
- Quick Start - 25 min, beginner
- Fixtures Deep Dive - 15 min, beginner
- CLI Application - 45 min, intermediate
- REST Web Service - 50 min, intermediate
Core (How-To Guides)
- Fixtures - How to use fixtures
- Data Builders - Build complex test data
- Assertions - Make clear assertions
- Error Paths - Test failure cases
Advanced (How-To Guides)
- Property Testing - Generate test cases
- Mutation Testing - Validate tests
- Snapshot Testing - Compare golden files
- CLI Testing - Test CLI apps
- Concurrency Testing - Thread safety
Guides (How-To + Explanation)
- The Extra Mile Pattern - Design decisions
- Best Practices - Production patterns
- Coverage & Performance - Metrics
- Observability - Monitoring & telemetry
- OTEL Instrumentation - Add OpenTelemetry
- Weaver Validation - Validate telemetry
- Real-World Applications - Example projects
Reference (Lookup-Oriented)
- TestFixture API - Complete API docs
Quick Links
| Need | Link |
|---|---|
| Quick start | Getting Started |
| Code examples | Real-World Applications |
| Specific feature | Search this page or use feature table |
| API documentation | API Reference |
| Best practices | Best Practices |
| Understanding concepts | Introduction |
Tip: Bookmark this page and return when you need guidance on what to learn next.
Getting Started: 25-Minute Tutorial
π TUTORIAL | Learn the basics of Chicago TDD in 25 minutes
This tutorial teaches you everything you need to write your first test with Chicago TDD Tools. No prior knowledge required.
Time: ~25 minutes Level: Beginner What you'll learn: Write a complete test from start to finish
Part 1: Your First Test (5 minutes)
Step 1: Create a test function
Chicago TDD uses the test! macro for declaring tests:
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; test!(my_first_test, { // Test code goes here }); }
Step 2: Add test logic (Arrange-Act-Assert)
Every test follows three steps:
#![allow(unused)] fn main() { test!(test_number_parsing, { // Arrange: Set up test data let input = "42"; // Act: Execute the code being tested let result = input.parse::<u32>(); // Assert: Verify the result assert_ok!(&result); assert_eq!(result.unwrap(), 42); }); }
That's it! You've written your first test. Let's break it down:
- Arrange (
let input = "42") - Prepare test data - Act (
let result = input.parse()) - Call the code you're testing - Assert (
assert_ok!andassert_eq!) - Verify it worked
Part 2: Testing Success and Failure (7 minutes)
Test the Happy Path
When code works:
#![allow(unused)] fn main() { test!(test_valid_number, { let result = "100".parse::<u32>(); // Verify it's Ok assert_ok!(&result); // Verify the value is correct assert_eq!(result.unwrap(), 100); }); }
Test the Error Path
When code fails (most bugs hide here!):
#![allow(unused)] fn main() { test!(test_invalid_number, { let result = "not_a_number".parse::<u32>(); // Verify it's an error assert_err!(&result); }); }
Test Boundary Conditions
Edge cases are important:
#![allow(unused)] fn main() { test!(test_boundaries, { // Zero (special case) assert_ok!(&"0".parse::<u32>()); // Maximum value assert_ok!(&u32::MAX.to_string().parse::<u32>()); // Negative (not valid for u32) assert_err!(&"-1".parse::<u32>()); }); }
Key Assertion Helpers
Chicago TDD provides clear assertion helpers:
| Helper | What it checks | Example |
|---|---|---|
assert_ok!(&result) | Is this an Ok? | assert_ok!(&my_result) |
assert_err!(&result) | Is this an Err? | assert_err!(&my_result) |
assert_eq!(a, b) | Are these equal? | assert_eq!(value, 42) |
assert!(condition) | Is this true? | assert!(value > 0) |
Part 3: Using Fixtures for Test Setup (6 minutes)
For complex tests, use TestFixture to set up test data:
Creating a Fixture
#![allow(unused)] fn main() { use chicago_tdd_tools::fixture::*; test!(test_with_fixture, { // Create a fixture (isolated test environment) let fixture = TestFixture::new()?; // Store test data fixture.set_metadata("user_id", "123"); fixture.set_metadata("username", "alice"); // Retrieve test data let user_id = fixture.get_metadata("user_id"); assert_eq!(user_id, Some("123")); }); }
Capturing Test State
You can save snapshots of your test data:
#![allow(unused)] fn main() { test!(test_with_snapshots, { let fixture = TestFixture::new()?; // ... do some work ... // Capture the current state let state = HashMap::from([ ("step".to_string(), "completed".to_string()), ("items_processed".to_string(), "5".to_string()), ]); fixture.capture_snapshot(state); // Access all snapshots let snapshots = fixture.snapshots(); assert_eq!(snapshots.len(), 1); }); }
Benefits of Fixtures
β Each test gets its own isolated environment β Automatic cleanup when test ends β State tracking with metadata and snapshots β Great for complex multi-step tests
Part 4: Building Test Data (5 minutes)
For complex test data, use TestDataBuilder:
Basic Data Building
#![allow(unused)] fn main() { use chicago_tdd_tools::builders::*; test!(test_with_builder, { // Create a builder let builder = TestDataBuilder::new() .with_var("name", "Alice") .with_var("email", "alice@example.com"); // Build as JSON let json = builder.build_json()?; assert!(json["name"] == "Alice"); }); }
Building Maps
#![allow(unused)] fn main() { test!(test_building_map, { let data = TestDataBuilder::new() .with_var("key1", "value1") .with_var("key2", "value2") .build(); // Returns HashMap<String, String> assert_eq!(data.get("key1"), Some(&"value1".to_string())); }); }
Fluent API Style
Builders use a fluent API - chain method calls:
#![allow(unused)] fn main() { test!(test_fluent_style, { let data = TestDataBuilder::new() .with_var("first", "hello") .with_var("second", "world") .with_var("third", "!") .build_json()?; // Clean, readable syntax }); }
Part 5: Complete Example (2 minutes)
Putting it all together:
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; use chicago_tdd_tools::builders::*; use chicago_tdd_tools::fixture::*; test!(complete_example, { // Arrange: Create fixture and test data let fixture = TestFixture::new()?; let user_data = TestDataBuilder::new() .with_var("username", "alice") .with_var("email", "alice@example.com") .build_json()?; // Act: Parse the email let email = user_data["email"].as_str().unwrap(); let valid = email.contains("@"); // Assert: Verify the result assert!(valid, "Email should contain @"); // Store result in fixture fixture.set_metadata("test_passed", "true"); }); }
Summary: What You've Learned
β
Basic test structure with test! macro
β
Arrange-Act-Assert pattern
β
Assertion helpers (assert_ok!, assert_err!, etc.)
β
Testing success, error, and boundary cases
β
Using fixtures for test isolation
β
Building complex test data
Next Steps
Ready to dive deeper?
- Fixtures Deep Dive (10 minutes) - Master test isolation
- Error Path Testing - Learn where bugs hide
- Real-World Examples - See complete projects
Or choose your path:
- I want to learn more patterns β Core Testing Patterns
- I want advanced techniques β Advanced Testing
- I want to test a web service β Web Service Tutorial
- I want to test a CLI tool β CLI Application Tutorial
Quick Reference Card
#![allow(unused)] fn main() { // Basic test test!(test_name, { /* code */ }); // Fixtures let fixture = TestFixture::new()?; fixture.set_metadata("key", "value"); // Data builders TestDataBuilder::new() .with_var("key", "value") .build_json()? // Assertions assert_ok!(&result); // Result is Ok assert_err!(&result); // Result is Err assert_eq!(actual, expected); // Values match assert!(condition); // Condition is true }
Congratulations! You can now write tests with Chicago TDD Tools. The rest is practice and learning specific patterns for your use case.
Fixtures Deep Dive: 15-Minute Tutorial
π TUTORIAL | Master test isolation with fixtures
Fixtures are the foundation of isolated testing. This tutorial builds on the basics and shows you how to use fixtures for real-world scenarios.
Prerequisites: Getting Started Time: ~15 minutes What you'll learn: Advanced fixture patterns and isolation techniques
What Are Fixtures?
A fixture is a isolated test environment. Each test gets its own copy:
#![allow(unused)] fn main() { test!(test_isolation_example, { // Each test gets a completely separate fixture let fixture = TestFixture::new()?; // Changes here don't affect other tests fixture.set_metadata("value", "100"); }); }
Why Fixtures Matter
β Isolation: Tests don't interfere with each other β Cleanup: Resources are automatically cleaned up β Repeatability: Same test always behaves the same way β Parallel execution: Tests can run safely in parallel
Storing Test State
Simple Key-Value Storage
Store and retrieve test data:
#![allow(unused)] fn main() { test!(test_storing_state, { let fixture = TestFixture::new()?; // Store data fixture.set_metadata("user_id", "123"); fixture.set_metadata("status", "active"); // Retrieve data let user_id = fixture.get_metadata("user_id"); assert_eq!(user_id, Some("123")); // Non-existent key returns None let missing = fixture.get_metadata("missing"); assert_eq!(missing, None); }); }
Practical Example: User Setup
#![allow(unused)] fn main() { test!(test_user_creation, { let fixture = TestFixture::new()?; // Setup: Create a user let user = create_user("alice", "alice@example.com")?; fixture.set_metadata("user_id", &user.id.to_string()); fixture.set_metadata("username", &user.name); // Test: Can we retrieve the user? let stored_id = fixture.get_metadata("user_id").unwrap(); let retrieved = get_user(stored_id.parse()?)?; assert_eq!(retrieved.name, "alice"); }); }
Snapshots: Capturing State Over Time
Snapshots record your test's state at different points:
Taking a Snapshot
#![allow(unused)] fn main() { use std::collections::HashMap; test!(test_with_snapshots, { let fixture = TestFixture::new()?; // Perform some operations let data = vec![1, 2, 3]; // Capture state as a snapshot let state = HashMap::from([ ("step".to_string(), "initial".to_string()), ("count".to_string(), data.len().to_string()), ]); fixture.capture_snapshot(state); // ... more operations ... let state2 = HashMap::from([ ("step".to_string(), "processed".to_string()), ("count".to_string(), "5".to_string()), ]); fixture.capture_snapshot(state2); // Access all snapshots let snapshots = fixture.snapshots(); assert_eq!(snapshots.len(), 2); // Access latest snapshot let latest = fixture.latest_snapshot(); assert_eq!(latest.get("step"), Some(&"processed".to_string())); }); }
Real-World Example: Multi-Step Workflow
#![allow(unused)] fn main() { test!(test_order_workflow, { let fixture = TestFixture::new()?; // Step 1: Create order let order = create_order("alice", 100.0)?; fixture.capture_snapshot(HashMap::from([ ("stage".to_string(), "created".to_string()), ("order_id".to_string(), order.id.to_string()), ("amount".to_string(), "100.0".to_string()), ])); // Step 2: Process payment process_payment(&order)?; fixture.capture_snapshot(HashMap::from([ ("stage".to_string(), "paid".to_string()), ("payment_status".to_string(), "completed".to_string()), ])); // Step 3: Ship order ship_order(&order)?; fixture.capture_snapshot(HashMap::from([ ("stage".to_string(), "shipped".to_string()), ("tracking".to_string(), "12345".to_string()), ])); // Verify all stages completed let snapshots = fixture.snapshots(); assert_eq!(snapshots.len(), 3); }); }
Multiple Fixtures in One Test
You can use multiple fixtures for complex scenarios:
#![allow(unused)] fn main() { test!(test_multiple_fixtures, { // Fixture 1: First test environment let fixture1 = TestFixture::new()?; fixture1.set_metadata("context", "database"); // Fixture 2: Separate test environment let fixture2 = TestFixture::new()?; fixture2.set_metadata("context", "cache"); // They don't interfere let ctx1 = fixture1.get_metadata("context"); let ctx2 = fixture2.get_metadata("context"); assert_eq!(ctx1, Some("database")); assert_eq!(ctx2, Some("cache")); assert_ne!(ctx1, ctx2); // Different values }); }
Fixtures with Error Handling
Fixtures handle errors gracefully:
#![allow(unused)] fn main() { test!(test_fixture_error_handling, { // Create fixture might fail let fixture = TestFixture::new()?; // Operations might fail if let Err(e) = risky_operation() { // Record the error fixture.set_metadata("error", &e.to_string()); // Assert error was expected let recorded = fixture.get_metadata("error"); assert!(recorded.is_some()); } // Fixture cleanup still happens automatically }); }
Fixture Initialization Pattern
For repeated setup, create a helper function:
#![allow(unused)] fn main() { fn setup_user_fixture() -> Result<(TestFixture, User), Box<dyn std::error::Error>> { let fixture = TestFixture::new()?; let user = create_user("test_user", "test@example.com")?; fixture.set_metadata("user_id", &user.id.to_string()); fixture.set_metadata("username", &user.name); Ok((fixture, user)) } test!(test_using_setup, { let (fixture, user) = setup_user_fixture()?; // Test can now use pre-initialized fixture assert_eq!(user.name, "test_user"); // ... rest of test ... }); }
Best Practices for Fixtures
β Do:
-
Create one fixture per test
#![allow(unused)] fn main() { test!(test1, { let f = TestFixture::new()?; /* ... */ }); test!(test2, { let f = TestFixture::new()?; /* ... */ }); // Separate! } -
Use metadata for state tracking
#![allow(unused)] fn main() { fixture.set_metadata("step", "processing"); } -
Capture snapshots at key points
#![allow(unused)] fn main() { fixture.capture_snapshot(state); } -
Use descriptive metadata keys
#![allow(unused)] fn main() { fixture.set_metadata("user_id", "123"); // Clear // Not: fixture.set_metadata("id", "123"); // Vague }
β Don't:
-
Share fixtures between tests
#![allow(unused)] fn main() { // WRONG - fixture is shared! let shared_fixture = TestFixture::new(); test!(test1, { /* use shared_fixture */ }); test!(test2, { /* use shared_fixture */ }); } -
Use global state
#![allow(unused)] fn main() { // WRONG - global state affects test isolation static FIXTURE: Lazy<TestFixture> = Lazy::new(|| TestFixture::new().unwrap()); } -
Forget to handle errors
#![allow(unused)] fn main() { // WRONG - TestFixture::new() can fail let fixture = TestFixture::new().unwrap(); // Better: ? }
Common Fixture Patterns
Pattern 1: Setup-Teardown
#![allow(unused)] fn main() { test!(test_setup_teardown, { let fixture = TestFixture::new()?; // Setup setup_database(&fixture)?; fixture.set_metadata("db_ready", "true"); // Test logic assert_eq!(fixture.get_metadata("db_ready"), Some("true")); // Teardown happens automatically when fixture is dropped }); }
Pattern 2: State Validation
#![allow(unused)] fn main() { test!(test_state_validation, { let fixture = TestFixture::new()?; // Perform operations let result = do_work()?; // Capture and validate state let state = HashMap::from([ ("success".to_string(), "true".to_string()), ("items".to_string(), "5".to_string()), ]); fixture.capture_snapshot(state); // Verify final state let latest = fixture.latest_snapshot().unwrap(); assert_eq!(latest.get("success"), Some(&"true".to_string())); }); }
Pattern 3: Progressive Testing
#![allow(unused)] fn main() { test!(test_progressive, { let fixture = TestFixture::new()?; // Phase 1 let result1 = operation1()?; fixture.set_metadata("phase", "1"); assert_ok!(&result1); // Phase 2 let result2 = operation2()?; fixture.set_metadata("phase", "2"); assert_ok!(&result2); // Phase 3 let result3 = operation3()?; fixture.set_metadata("phase", "3"); assert_ok!(&result3); // Verify we completed all phases assert_eq!(fixture.get_metadata("phase"), Some("3")); }); }
Summary
Fixtures provide:
β Isolation - Each test has its own environment β State tracking - Store and retrieve metadata β Snapshots - Capture state at different points β Cleanup - Automatic resource cleanup β Parallel safety - Tests can run in parallel
Next Steps
Ready for more?
- Error Path Testing - Test error cases thoroughly
- Advanced Fixtures - API reference and examples
- Real-World Applications - See fixtures in action
Quick Reference
#![allow(unused)] fn main() { // Create fixture let fixture = TestFixture::new()?; // Store metadata fixture.set_metadata("key", "value"); // Retrieve metadata let value = fixture.get_metadata("key"); // Returns Option<&str> // Capture snapshot let state = HashMap::from([("key".to_string(), "value".to_string())]); fixture.capture_snapshot(state); // Access snapshots let all_snapshots = fixture.snapshots(); let latest = fixture.latest_snapshot(); }
Congratulations! You've mastered fixtures. You can now write tests with proper isolation and state tracking.
Building a Real CLI Application: Complete Tutorial
π TUTORIAL | Build a complete CLI application using Chicago TDD
This tutorial walks you through building a real command-line todo application from scratch using Chicago TDD principles.
Prerequisites: Getting Started, Fixtures Deep Dive
Time: ~45 minutes
What you'll build: A working todo-cli application with tests
Project Overview
You'll build a todo-cli app with these features:
# Add a todo
$ todo-cli add "Buy groceries"
# List todos
$ todo-cli list
# Complete a todo
$ todo-cli done 1
# Delete a todo
$ todo-cli delete 1
This is a real, testable application.
Step 1: Project Setup (2 minutes)
Create the project
cargo new todo-cli
cd todo-cli
Add dependencies
Edit Cargo.toml:
[package]
name = "todo-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
# (no dependencies for basic version)
[dev-dependencies]
chicago-tdd-tools = { version = "1.1", features = ["testing-extras"] }
Project structure
todo-cli/
βββ src/
β βββ main.rs # CLI entry point
β βββ commands/
β β βββ mod.rs
β β βββ add.rs
β β βββ list.rs
β β βββ done.rs
β β βββ delete.rs
β βββ store.rs # Todo storage
βββ tests/
βββ cli_tests.rs
βββ commands_tests.rs
Step 2: Core Data Structures (5 minutes)
Define a Todo struct
Create src/store.rs:
#![allow(unused)] fn main() { #[derive(Clone, Debug, PartialEq)] pub struct Todo { pub id: u32, pub title: String, pub completed: bool, } #[derive(Clone, Debug)] pub struct TodoStore { todos: Vec<Todo>, next_id: u32, } impl TodoStore { pub fn new() -> Self { TodoStore { todos: Vec::new(), next_id: 1, } } pub fn add(&mut self, title: &str) -> u32 { let id = self.next_id; self.todos.push(Todo { id, title: title.to_string(), completed: false, }); self.next_id += 1; id } pub fn list(&self) -> Vec<&Todo> { self.todos.iter().collect() } pub fn mark_done(&mut self, id: u32) -> bool { if let Some(todo) = self.todos.iter_mut().find(|t| t.id == id) { todo.completed = true; true } else { false } } pub fn delete(&mut self, id: u32) -> bool { let original_len = self.todos.len(); self.todos.retain(|t| t.id != id); self.todos.len() < original_len } } }
Step 3: Test the Core Logic (8 minutes)
Create tests/commands_tests.rs:
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; use todo_cli::store::{Todo, TodoStore}; test!(test_add_todo, { let mut store = TodoStore::new(); let id = store.add("Buy groceries"); assert_eq!(id, 1); let todos = store.list(); assert_eq!(todos.len(), 1); assert_eq!(todos[0].title, "Buy groceries"); assert!(!todos[0].completed); }); test!(test_add_multiple, { let mut store = TodoStore::new(); let id1 = store.add("Task 1"); let id2 = store.add("Task 2"); let id3 = store.add("Task 3"); assert_eq!(id1, 1); assert_eq!(id2, 2); assert_eq!(id3, 3); assert_eq!(store.list().len(), 3); }); test!(test_mark_done, { let mut store = TodoStore::new(); let id = store.add("Task"); let result = store.mark_done(id); assert!(result); let todos = store.list(); assert!(todos[0].completed); }); test!(test_mark_nonexistent_as_done, { let mut store = TodoStore::new(); let result = store.mark_done(999); assert!(!result); }); test!(test_delete_todo, { let mut store = TodoStore::new(); store.add("Task 1"); store.add("Task 2"); let result = store.delete(1); assert!(result); assert_eq!(store.list().len(), 1); }); test!(test_delete_nonexistent, { let mut store = TodoStore::new(); let result = store.delete(999); assert!(!result); }); test!(test_empty_store, { let store = TodoStore::new(); assert_eq!(store.list().len(), 0); }); }
Run tests: cargo test
Step 4: CLI Commands (10 minutes)
Create src/commands/add.rs:
#![allow(unused)] fn main() { use crate::store::TodoStore; pub fn execute(store: &mut TodoStore, args: &[String]) -> Result<String, String> { if args.is_empty() { return Err("Usage: add <title>".to_string()); } let title = args.join(" "); let id = store.add(&title); Ok(format!("Added todo #{}: {}", id, title)) } #[cfg(test)] mod tests { use super::*; use chicago_tdd_tools::prelude::*; test!(test_add_command, { let mut store = TodoStore::new(); let args = vec!["Buy milk".to_string()]; let result = execute(&mut store, &args); assert_ok!(&result); assert!(result.unwrap().contains("Buy milk")); assert_eq!(store.list().len(), 1); }); test!(test_add_with_spaces, { let mut store = TodoStore::new(); let args = vec!["Buy".to_string(), "milk".to_string(), "and".to_string(), "eggs".to_string()]; let result = execute(&mut store, &args); assert_ok!(&result); let msg = result.unwrap(); assert!(msg.contains("Buy milk and eggs")); }); test!(test_add_no_args, { let mut store = TodoStore::new(); let args = vec![]; let result = execute(&mut store, &args); assert_err!(&result); }); } }
Create src/commands/list.rs:
#![allow(unused)] fn main() { use crate::store::TodoStore; pub fn execute(store: &TodoStore) -> String { let todos = store.list(); if todos.is_empty() { return "No todos".to_string(); } let mut output = String::new(); for todo in todos { let status = if todo.completed { "β" } else { " " }; output.push_str(&format!("[{}] #{}: {}\n", status, todo.id, todo.title)); } output } #[cfg(test)] mod tests { use super::*; use chicago_tdd_tools::prelude::*; test!(test_list_empty, { let store = TodoStore::new(); let output = execute(&store); assert_eq!(output, "No todos"); }); test!(test_list_with_items, { let mut store = TodoStore::new(); store.add("Task 1"); store.add("Task 2"); let output = execute(&store); assert!(output.contains("Task 1")); assert!(output.contains("Task 2")); }); test!(test_list_shows_completion, { let mut store = TodoStore::new(); let id = store.add("Task"); store.mark_done(id); let output = execute(&store); assert!(output.contains("β")); }); } }
Step 5: Main Entry Point (5 minutes)
Create src/main.rs:
use std::env; use std::io::{self, BufRead}; mod commands; mod store; use commands::{add, list, done, delete}; use store::TodoStore; fn main() -> Result<(), Box<dyn std::error::Error>> { let mut store = TodoStore::new(); let stdin = io::stdin(); let reader = stdin.lock(); for line in reader.lines() { let line = line?; let parts: Vec<&str> = line.trim().split_whitespace().collect(); if parts.is_empty() { continue; } let command = parts[0]; let args: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect(); let result = match command { "add" => add::execute(&mut store, &args), "list" => Ok(list::execute(&store)), "done" => done::execute(&mut store, &args), "delete" => delete::execute(&mut store, &args), _ => Err(format!("Unknown command: {}", command)), }; match result { Ok(msg) => println!("{}", msg), Err(e) => eprintln!("Error: {}", e), } } Ok(()) }
Step 6: Integration Tests (10 minutes)
Create tests/cli_tests.rs:
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; use todo_cli::store::TodoStore; test!(complete_workflow, { let mut store = TodoStore::new(); // Add some todos store.add("Buy groceries"); store.add("Pay bills"); store.add("Call mom"); let todos = store.list(); assert_eq!(todos.len(), 3); // Mark one as done store.mark_done(2); // Delete one store.delete(3); let final_todos = store.list(); assert_eq!(final_todos.len(), 2); assert!(!final_todos[0].completed); assert!(final_todos[1].completed); }); test!(id_increment, { let mut store = TodoStore::new(); let id1 = store.add("First"); let id2 = store.add("Second"); let id3 = store.add("Third"); assert_eq!(id1, 1); assert_eq!(id2, 2); assert_eq!(id3, 3); store.delete(id2); // Delete middle one let id4 = store.add("Fourth"); assert_eq!(id4, 4); // ID still increments }); }
Step 7: Testing with Fixtures (5 minutes)
Create tests/fixture_tests.rs:
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; use chicago_tdd_tools::fixture::*; use todo_cli::store::TodoStore; use std::collections::HashMap; test!(fixture_based_workflow, { let fixture = TestFixture::new()?; let mut store = TodoStore::new(); // Phase 1: Initial setup let id1 = store.add("Task 1"); let id2 = store.add("Task 2"); fixture.set_metadata("initial_count", "2"); fixture.capture_snapshot(HashMap::from([ ("phase".to_string(), "1".to_string()), ("todos".to_string(), "2".to_string()), ])); // Phase 2: Mark complete store.mark_done(id1); fixture.set_metadata("current_phase", "mark_done"); fixture.capture_snapshot(HashMap::from([ ("phase".to_string(), "2".to_string()), ("completed".to_string(), "1".to_string()), ])); // Phase 3: Delete store.delete(id2); fixture.set_metadata("current_phase", "delete"); fixture.capture_snapshot(HashMap::from([ ("phase".to_string(), "3".to_string()), ("remaining".to_string(), "1".to_string()), ])); // Verify assert_eq!(fixture.snapshots().len(), 3); assert_eq!(store.list().len(), 1); }); }
Step 8: Running Everything (5 minutes)
# Run all tests
cargo test
# Run unit tests only
cargo test --lib
# Run integration tests only
cargo test --test '*'
# Run with output
cargo test -- --nocapture
# Run specific test
cargo test test_add_todo
Expected output:
running X tests
...
test result: ok. X passed; 0 failed; 0 ignored; X measured
Summary
You've built a real CLI application with:
β Core data structures with tests β Command modules with unit tests β Integration tests β Fixture-based tests β Main CLI entry point β Error handling β Real-world usage patterns
Next Steps
Enhance your application:
- Add persistence (save todos to file)
- Add priorities to todos
- Add due dates
- Add categories/tags
- Build a web API version
Learn more:
- Snapshot Testing - Test CLI output
- Advanced Techniques - More sophisticated testing
- Best Practices - Production patterns
Share your code:
- Push to GitHub
- Add CI/CD pipeline
- Write documentation
- Create GitHub issues for features
Congratulations! You've built and tested a real CLI application using Chicago TDD. You're now ready to build production applications!
Building a REST Web Service: Complete Tutorial
π TUTORIAL | Build a production-ready REST API with Chicago TDD
This tutorial guides you through building a real REST web service with comprehensive tests, using Rust and common web frameworks.
Prerequisites: Getting Started, CLI Application Tutorial
Time: ~50 minutes
What you'll build: A working user-api with CRUD operations
Project Overview
You'll build a user-api REST service:
# Get all users
GET /api/users
-> [{"id": 1, "name": "Alice", "email": "alice@example.com"}, ...]
# Get specific user
GET /api/users/:id
-> {"id": 1, "name": "Alice", "email": "alice@example.com"}
# Create user
POST /api/users
-> {"id": 2, "name": "Bob", "email": "bob@example.com"}
# Update user
PUT /api/users/:id
-> {"id": 1, "name": "Alice Updated", ...}
# Delete user
DELETE /api/users/:id
-> {}
Step 1: Project Setup (3 minutes)
Create project
cargo new user-api
cd user-api
Update Cargo.toml
[package]
name = "user-api"
version = "0.1.0"
edition = "2021"
[dependencies]
# JSON support
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[dev-dependencies]
chicago-tdd-tools = { version = "1.1", features = ["testing-extras"] }
Project structure
user-api/
βββ src/
β βββ main.rs
β βββ models/
β β βββ user.rs
β βββ handlers/
β β βββ mod.rs
β β βββ users.rs
β βββ store.rs
βββ tests/
βββ user_tests.rs
βββ api_tests.rs
Step 2: Data Models (5 minutes)
Create src/models/user.rs:
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct User { pub id: u32, pub name: String, pub email: String, } #[derive(Clone, Debug, Deserialize)] pub struct CreateUserRequest { pub name: String, pub email: String, } #[derive(Clone, Debug, Deserialize)] pub struct UpdateUserRequest { pub name: Option<String>, pub email: Option<String>, } impl User { pub fn new(id: u32, name: String, email: String) -> Self { User { id, name, email } } pub fn validate(&self) -> Result<(), String> { if self.name.is_empty() { return Err("Name cannot be empty".to_string()); } if !self.email.contains('@') { return Err("Invalid email format".to_string()); } Ok(()) } } }
Step 3: Test the Models (8 minutes)
Create tests/user_tests.rs:
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; use user_api::models::User; test!(test_create_user, { let user = User::new(1, "Alice".to_string(), "alice@example.com".to_string()); assert_eq!(user.id, 1); assert_eq!(user.name, "Alice"); assert_eq!(user.email, "alice@example.com"); }); test!(test_user_validation_valid, { let user = User::new(1, "Alice".to_string(), "alice@example.com".to_string()); let result = user.validate(); assert_ok!(&result); }); test!(test_user_validation_empty_name, { let user = User::new(1, String::new(), "alice@example.com".to_string()); let result = user.validate(); assert_err!(&result); if let Err(e) = result { assert!(e.contains("Name")); } }); test!(test_user_validation_invalid_email, { let user = User::new(1, "Alice".to_string(), "not-an-email".to_string()); let result = user.validate(); assert_err!(&result); if let Err(e) = result { assert!(e.contains("email")); } }); }
Step 4: User Repository (8 minutes)
Create src/store.rs:
#![allow(unused)] fn main() { use crate::models::{User, CreateUserRequest, UpdateUserRequest}; use std::collections::HashMap; pub struct UserStore { users: HashMap<u32, User>, next_id: u32, } impl UserStore { pub fn new() -> Self { UserStore { users: HashMap::new(), next_id: 1, } } pub fn create(&mut self, req: CreateUserRequest) -> Result<User, String> { let user = User::new(self.next_id, req.name, req.email); user.validate()?; self.users.insert(user.id, user.clone()); self.next_id += 1; Ok(user) } pub fn get(&self, id: u32) -> Option<User> { self.users.get(&id).cloned() } pub fn list(&self) -> Vec<User> { let mut users: Vec<_> = self.users.values().cloned().collect(); users.sort_by_key(|u| u.id); users } pub fn update(&mut self, id: u32, req: UpdateUserRequest) -> Result<User, String> { let user = self.users.get_mut(&id) .ok_or("User not found".to_string())?; if let Some(name) = req.name { user.name = name; } if let Some(email) = req.email { user.email = email; } user.validate()?; Ok(user.clone()) } pub fn delete(&mut self, id: u32) -> bool { self.users.remove(&id).is_some() } } }
Step 5: Repository Tests (10 minutes)
Create tests/api_tests.rs:
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; use user_api::models::{User, CreateUserRequest, UpdateUserRequest}; use user_api::store::UserStore; test!(test_create_user_success, { let mut store = UserStore::new(); let req = CreateUserRequest { name: "Alice".to_string(), email: "alice@example.com".to_string(), }; let result = store.create(req); assert_ok!(&result); let user = result.unwrap(); assert_eq!(user.name, "Alice"); }); test!(test_create_user_invalid_email, { let mut store = UserStore::new(); let req = CreateUserRequest { name: "Alice".to_string(), email: "invalid".to_string(), }; let result = store.create(req); assert_err!(&result); }); test!(test_get_user, { let mut store = UserStore::new(); let req = CreateUserRequest { name: "Alice".to_string(), email: "alice@example.com".to_string(), }; let created = store.create(req).unwrap(); let retrieved = store.get(created.id); assert_eq!(retrieved, Some(created)); }); test!(test_get_nonexistent_user, { let store = UserStore::new(); let result = store.get(999); assert_eq!(result, None); }); test!(test_list_users, { let mut store = UserStore::new(); store.create(CreateUserRequest { name: "Alice".to_string(), email: "alice@example.com".to_string(), }).ok(); store.create(CreateUserRequest { name: "Bob".to_string(), email: "bob@example.com".to_string(), }).ok(); let users = store.list(); assert_eq!(users.len(), 2); assert_eq!(users[0].name, "Alice"); assert_eq!(users[1].name, "Bob"); }); test!(test_update_user, { let mut store = UserStore::new(); store.create(CreateUserRequest { name: "Alice".to_string(), email: "alice@example.com".to_string(), }).ok(); let result = store.update(1, UpdateUserRequest { name: Some("Alice Updated".to_string()), email: None, }); assert_ok!(&result); let updated = result.unwrap(); assert_eq!(updated.name, "Alice Updated"); }); test!(test_update_nonexistent_user, { let mut store = UserStore::new(); let result = store.update(999, UpdateUserRequest { name: Some("Bob".to_string()), email: None, }); assert_err!(&result); }); test!(test_delete_user, { let mut store = UserStore::new(); store.create(CreateUserRequest { name: "Alice".to_string(), email: "alice@example.com".to_string(), }).ok(); let result = store.delete(1); assert!(result); assert_eq!(store.list().len(), 0); }); test!(test_delete_nonexistent_user, { let mut store = UserStore::new(); let result = store.delete(999); assert!(!result); }); test!(complete_api_workflow, { let mut store = UserStore::new(); // Create users let alice = store.create(CreateUserRequest { name: "Alice".to_string(), email: "alice@example.com".to_string(), }).unwrap(); store.create(CreateUserRequest { name: "Bob".to_string(), email: "bob@example.com".to_string(), }).ok(); // Verify list assert_eq!(store.list().len(), 2); // Update Alice store.update(alice.id, UpdateUserRequest { name: Some("Alice Wonder".to_string()), email: None, }).ok(); // Verify update let updated = store.get(alice.id).unwrap(); assert_eq!(updated.name, "Alice Wonder"); // Delete Bob store.delete(2); // Verify deletion assert_eq!(store.list().len(), 1); }); }
Step 6: Handlers (8 minutes)
Create src/handlers/users.rs:
#![allow(unused)] fn main() { use crate::models::CreateUserRequest; use crate::store::UserStore; use serde_json::json; pub fn get_users(store: &UserStore) -> String { let users = store.list(); serde_json::to_string(&users).unwrap_or_else(|_| "[]".to_string()) } pub fn get_user(store: &UserStore, id: u32) -> Result<String, String> { let user = store.get(id).ok_or("User not found".to_string())?; serde_json::to_string(&user).map_err(|e| e.to_string()) } pub fn create_user(store: &mut UserStore, body: &str) -> Result<String, String> { let req: CreateUserRequest = serde_json::from_str(body) .map_err(|e| format!("Invalid JSON: {}", e))?; let user = store.create(req)?; serde_json::to_string(&user).map_err(|e| e.to_string()) } #[cfg(test)] mod tests { use super::*; use chicago_tdd_tools::prelude::*; test!(test_get_users_empty, { let store = UserStore::new(); let result = get_users(&store); assert_eq!(result, "[]"); }); test!(test_create_user_from_json, { let mut store = UserStore::new(); let json = r#"{"name":"Alice","email":"alice@example.com"}"#; let result = create_user(&mut store, json); assert_ok!(&result); let response = result.unwrap(); assert!(response.contains("Alice")); }); test!(test_create_user_invalid_json, { let mut store = UserStore::new(); let json = "invalid json"; let result = create_user(&mut store, json); assert_err!(&result); }); } }
Step 7: Main Application (5 minutes)
Create src/main.rs:
mod models; mod handlers; mod store; use models::{User, CreateUserRequest}; use store::UserStore; fn main() { let mut store = UserStore::new(); // Sample data store.create(CreateUserRequest { name: "Alice".to_string(), email: "alice@example.com".to_string(), }).ok(); store.create(CreateUserRequest { name: "Bob".to_string(), email: "bob@example.com".to_string(), }).ok(); // List users for user in store.list() { println!("User #{}: {} ({})", user.id, user.name, user.email); } }
Step 8: Running Tests (5 minutes)
# Run all tests
cargo test
# Run with output
cargo test -- --nocapture
# Run specific test
cargo test test_create_user_success
Expected output:
running 11 tests
...
test result: ok. 11 passed; 0 failed
Extending the API
Add error responses
#![allow(unused)] fn main() { #[derive(Serialize)] pub struct ErrorResponse { pub error: String, } pub fn format_error(message: String) -> String { serde_json::to_string(&ErrorResponse { error: message }) .unwrap_or_else(|_| r#"{"error":"Unknown error"}"#.to_string()) } }
Add pagination
#![allow(unused)] fn main() { pub fn list_users_paginated( store: &UserStore, page: u32, limit: u32, ) -> (Vec<User>, u32) { let all = store.list(); let total = all.len() as u32; let start = (page - 1) * limit; let end = std::cmp::min(start + limit, total); (all[start as usize..end as usize].to_vec(), total) } }
Next Steps
Enhance your API:
- Add authentication
- Add validation
- Add pagination
- Add filtering/searching
- Add real database (PostgreSQL/SQLite)
Testing enhancements:
- Integration Testing with Docker - Test with real database
- Snapshot Testing - Test API responses
- Property Testing - Fuzz test API handlers
Deployment:
- Build Docker image
- Deploy to cloud (AWS, Heroku, etc.)
- Set up CI/CD pipeline
- Monitor with observability tools
Congratulations! You've built a production-ready REST API with comprehensive tests. You're ready to deploy real services!
TestFixture API Reference
π REFERENCE | Complete API documentation for test fixtures
Overview
TestFixture provides isolated test environments with metadata storage and state snapshots.
Module: chicago_tdd_tools::fixture
Stability: Stable
Feature flag: Core (always available)
Constructor
TestFixture::new() -> Result<TestFixture, FixtureError>
Creates a new isolated test fixture.
Returns:
Ok(TestFixture)- New fixture instanceErr(FixtureError)- Fixture creation failed
Example:
#![allow(unused)] fn main() { let fixture = TestFixture::new()?; }
Error cases:
- IO errors during fixture initialization
- Permission errors
- System resource unavailability
Metadata Methods
set_metadata(key: impl AsRef<str>, value: impl AsRef<str>) -> ()
Stores a key-value pair in fixture metadata.
Parameters:
key: impl AsRef<str>- Metadata key (e.g., "user_id")value: impl AsRef<str>- Metadata value (e.g., "123")
Returns: Unit (always succeeds)
Example:
#![allow(unused)] fn main() { fixture.set_metadata("user_id", "123"); fixture.set_metadata("status", "active"); }
Notes:
- Overwrites existing key if present
- Keys and values are strings
- No size limits on metadata
get_metadata(key: &str) -> Option<&String>
Retrieves a value from fixture metadata.
Parameters:
key: &str- Metadata key to look up
Returns:
Some(&String)- Value if key existsNone- Key not found
Example:
#![allow(unused)] fn main() { let user_id = fixture.get_metadata("user_id"); match user_id { Some(id) => println!("User: {}", id), None => println!("User ID not set"), } }
Notes:
- Returns reference, not owned value
- Reference is valid only while fixture exists
- Returns None for non-existent keys
Snapshot Methods
capture_snapshot(state: HashMap<String, String>) -> ()
Captures the current test state as a snapshot.
Parameters:
state: HashMap<String, String>- State to capture
Returns: Unit (always succeeds)
Example:
#![allow(unused)] fn main() { use std::collections::HashMap; let state = HashMap::from([ ("step".to_string(), "processing".to_string()), ("count".to_string(), "5".to_string()), ]); fixture.capture_snapshot(state); }
Notes:
- Creates immutable snapshot of provided state
- State is copied, original HashMap can be modified
- Snapshots are stored in order
snapshots() -> &[HashMap<String, String>]
Returns all captured snapshots.
Returns:
&[HashMap<String, String>]- Slice of all snapshots
Example:
#![allow(unused)] fn main() { let all = fixture.snapshots(); println!("Total snapshots: {}", all.len()); for (i, snapshot) in all.iter().enumerate() { println!("Snapshot {}: {:?}", i, snapshot); } }
Notes:
- Returns empty slice if no snapshots captured
- Snapshots are in capture order (chronological)
- Reference is valid only while fixture exists
latest_snapshot() -> Option<&HashMap<String, String>>
Returns the most recently captured snapshot.
Returns:
Some(&HashMap)- Last captured snapshotNone- No snapshots captured yet
Example:
#![allow(unused)] fn main() { if let Some(latest) = fixture.latest_snapshot() { if let Some(step) = latest.get("step") { println!("Current step: {}", step); } } }
Notes:
- Convenience method for accessing most recent state
- Returns None if
snapshots().is_empty() - Equivalent to
snapshots().last()
Lifecycle
Creation and Cleanup
#![allow(unused)] fn main() { test!(fixture_lifecycle, { // Creation: new() initializes resources let fixture = TestFixture::new()?; // Usage: store state and snapshots fixture.set_metadata("key", "value"); // Cleanup: automatic when fixture is dropped // (at end of test scope) }); // Fixture dropped here - cleanup happens }
Automatic Cleanup
When TestFixture is dropped:
- All metadata is cleared
- All snapshots are cleared
- File handles/resources are released
- Directory/files created by fixture are removed
Note: You don't need to manually call cleanup. It happens automatically.
Common Patterns
Pattern: Setup and Verify State
#![allow(unused)] fn main() { test!(verify_state_pattern, { let fixture = TestFixture::new()?; // Setup phase fixture.set_metadata("initialized", "false"); assert_eq!(fixture.get_metadata("initialized"), Some("false")); // Initialization happens // ... // Verify state changed fixture.set_metadata("initialized", "true"); assert_eq!(fixture.get_metadata("initialized"), Some("true")); }); }
Pattern: Multi-Phase Test
#![allow(unused)] fn main() { test!(multi_phase_pattern, { let fixture = TestFixture::new()?; // Phase 1 fixture.set_metadata("phase", "1"); let result1 = do_phase_1()?; fixture.capture_snapshot(state_at_phase1()); assert_ok!(&result1); // Phase 2 fixture.set_metadata("phase", "2"); let result2 = do_phase_2()?; fixture.capture_snapshot(state_at_phase2()); assert_ok!(&result2); // Verify all phases completed assert_eq!(fixture.get_metadata("phase"), Some("2")); assert_eq!(fixture.snapshots().len(), 2); }); }
Pattern: Context Association
#![allow(unused)] fn main() { test!(context_pattern, { let fixture = TestFixture::new()?; // Associate test context fixture.set_metadata("test_name", "my_test"); fixture.set_metadata("test_version", "1.0"); fixture.set_metadata("test_category", "integration"); // ... test code ... }); }
Error Handling
Handling Fixture Creation Errors
#![allow(unused)] fn main() { test!(error_handling, { match TestFixture::new() { Ok(fixture) => { // Use fixture fixture.set_metadata("key", "value"); } Err(e) => { // Log error or skip test eprintln!("Failed to create fixture: {}", e); return; // Skip this test } } }); }
Propagating Errors with ?
#![allow(unused)] fn main() { test!(error_propagation, { let fixture = TestFixture::new()?; // If creation fails, entire test returns error }); }
Performance Characteristics
| Operation | Time | Notes |
|---|---|---|
new() | ~1ms | I/O heavy, system dependent |
set_metadata() | <1ΞΌs | In-memory |
get_metadata() | <1ΞΌs | In-memory lookup |
capture_snapshot() | <1ΞΌs | Copy HashMap to internal storage |
snapshots() | <1ΞΌs | Returns slice reference |
latest_snapshot() | <1ΞΌs | Slice operation |
Memory Usage
- Metadata: ~50 bytes per key-value pair (plus string data)
- Snapshots: ~100 bytes per snapshot (plus HashMap overhead)
- Total per test: Usually <1MB unless storing large amounts of state
Limitations and Constraints
| Constraint | Limit | Workaround |
|---|---|---|
| Metadata key length | Unlimited | Use short, memorable keys |
| Metadata value length | Unlimited | Avoid storing binary data |
| Number of snapshots | Unlimited | Limit snapshots to key points |
| Parallel tests | Fully safe | Tests run with separate fixtures |
Integration with Other Tools
With TestDataBuilder
#![allow(unused)] fn main() { test!(integration_builder, { let fixture = TestFixture::new()?; let data = TestDataBuilder::new() .with_var("key", "value") .build_json()?; fixture.set_metadata("data", &data.to_string()); }); }
With Async Tests
#![allow(unused)] fn main() { #[tokio::test] async fn async_with_fixture() -> Result<(), Box<dyn std::error::Error>> { let fixture = TestFixture::new()?; fixture.set_metadata("async", "true"); // async operations Ok(()) } }
Examples
Example 1: Simple Setup
#![allow(unused)] fn main() { test!(simple_setup, { let fixture = TestFixture::new()?; fixture.set_metadata("user", "alice"); assert_eq!(fixture.get_metadata("user"), Some("alice")); }); }
Example 2: Complex State Tracking
#![allow(unused)] fn main() { test!(complex_tracking, { let fixture = TestFixture::new()?; for i in 0..3 { fixture.set_metadata("iteration", &i.to_string()); let state = HashMap::from([ ("step".to_string(), format!("step_{}", i)), ("count".to_string(), i.to_string()), ]); fixture.capture_snapshot(state); } assert_eq!(fixture.snapshots().len(), 3); }); }
Example 3: Error Recovery
#![allow(unused)] fn main() { test!(error_recovery, { let fixture = TestFixture::new()?; fixture.set_metadata("status", "starting"); if let Err(e) = risky_operation() { fixture.set_metadata("status", "failed"); fixture.set_metadata("error", &e.to_string()); // Can still verify error was recorded assert!(fixture.get_metadata("error").is_some()); } }); }
Related Types
FixtureError- Error type for fixture operationsHashMap<String, String>- Type for snapshot state
See Also
- Getting Started - Tutorial on basic fixtures
- Fixtures Deep Dive - Advanced patterns
- Fixtures How-to - Practical guide
Core Testing Patterns
Welcome to the core testing patterns section! Here you'll learn the everyday patterns you'll use in almost every test.
Overview
Core patterns include:
- Fixtures - Isolated test state and setup
- Data Builders - Fluent API for constructing test data
- Assertions - Clear, readable verification of results
- Error Paths - Testing failure scenarios
These patterns form the foundation of all testing in Chicago TDD Tools.
Quick Start
Here's a complete test using core patterns:
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; test!(test_user_creation, { // Arrange: Set up with fixtures and builders let fixture = TestFixture::new()?; let user_data = TestDataBuilder::new() .with_var("name", "Alice") .with_var("email", "alice@example.com") .build_json()?; // Act: Execute the code under test let result = create_user(&user_data)?; // Assert: Verify with assertions assert_ok!(&result); assert_eq!(result.unwrap().name, "Alice"); }); }
Core Concepts
Test Isolation
Each test must be independent:
#![allow(unused)] fn main() { test!(test1, { let fixture = TestFixture::new()?; // Fresh fixture // test1 uses fixture1 }); test!(test2, { let fixture = TestFixture::new()?; // Different fixture // test2 uses fixture2 // Both tests can run in parallel with no interference }); }
Data Construction
Build complex test data with fluent builders:
#![allow(unused)] fn main() { let order = TestDataBuilder::new() .with_var("order_id", "ORD-001") .with_order_data("ORD-001", "100.50") .build_json()?; }
Clear Assertions
Use assertion helpers for readability:
#![allow(unused)] fn main() { assert_ok!(&result); // Checks is_ok() assert_err!(&result); // Checks is_err() assert_eq!(value, expected); // Equality check }
Sections
- Getting Started with Fixtures - Fixture lifecycle and setup
- Building Test Data - Data builders and fluent API
- Assertions & Verification - Assertion helpers and patterns
- Error Path Testing - Testing failure scenarios
Common Patterns
Pattern: Arrange-Act-Assert
Every test follows this structure:
#![allow(unused)] fn main() { test!(test_example, { // Arrange: Set up test state let fixture = TestFixture::new()?; let data = TestDataBuilder::new()...build_json()?; // Act: Execute code under test let result = function_under_test(&data)?; // Assert: Verify behavior assert_ok!(&result); assert_eq!(result.unwrap().field, expected_value); }); }
Pattern: Error Path Testing
Always test both success and failure:
#![allow(unused)] fn main() { test!(test_with_error_path, { // Success path let ok_result = parse_number("42"); assert_ok!(&ok_result); // Error path let err_result = parse_number("not_a_number"); assert_err!(&err_result); }); }
Pattern: Boundary Conditions
Test edge cases:
#![allow(unused)] fn main() { test!(test_boundaries, { // Minimum value assert_ok!(&function(0)); // Maximum value assert_ok!(&function(u32::MAX)); // Off-by-one assert_ok!(&function(1)); assert_ok!(&function(u32::MAX - 1)); }); }
When to Use Core Patterns
Use core patterns for:
- β Testing individual functions
- β Testing pure logic
- β Testing with simple setup
- β Most of your test suite (80%+)
For complex scenarios, see:
- Property-Based Testing - Random test data
- Snapshot Testing - Golden files
- CLI Testing - Command-line interfaces
Next Steps
π Start with Fixtures
Then learn:
- Data Builders - Construct test data
- Assertions - Verify results
- Error Paths - Test failures
Getting Started with Fixtures
π§ HOW-TO | π REFERENCE | Learn to use fixtures for test isolation
Fixtures are isolated test environments that provide controlled setup and automatic cleanup.
What is a Fixture?
A fixture is a test object that:
- Provides fresh, isolated state for each test
- Handles setup automatically
- Cleans up resources when the test ends
- Prevents state leakage between tests
Creating a Fixture
Basic Fixture Creation
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; test!(test_with_fixture, { // Create a fresh fixture let fixture = TestFixture::new()?; // Fixtures provide isolation and utilities // Store metadata for the test fixture.set_metadata("test_id".to_string(), "123".to_string()); // Retrieve metadata let test_id = fixture.get_metadata("test_id"); assert_eq!(test_id, Some(&"123".to_string())); }); }
Error Handling
Fixtures return Result - always handle the error:
#![allow(unused)] fn main() { test!(test_fixture_error_handling, { // β Handle the Result properly match TestFixture::new() { Ok(fixture) => { // Use fixture fixture.set_metadata("key".to_string(), "value".to_string()); } Err(e) => { alert_critical!("Fixture creation failed: {}", e); return Err(e.into()); } } }); }
Or use the ? operator:
#![allow(unused)] fn main() { test!(test_fixture_with_question_mark, { let fixture = TestFixture::new()?; // Propagates error fixture.set_metadata("test_data".to_string(), "setup_complete".to_string()); }); }
Fixture Features
The TestFixture provides utilities for tests:
#![allow(unused)] fn main() { test!(test_fixture_features, { let mut fixture = TestFixture::new()?; // Store and retrieve metadata fixture.set_metadata("user_id".to_string(), "42".to_string()); assert_eq!( fixture.get_metadata("user_id"), Some(&"42".to_string()) ); // Capture snapshots of test state let mut state = HashMap::new(); state.insert("status".to_string(), "initialized".to_string()); fixture.capture_snapshot(state); // Retrieve snapshots let snapshots = fixture.snapshots(); assert!(!snapshots.is_empty()); }); }
Quick Reference: TestFixture API
| Method | Parameters | Returns | Purpose |
|---|---|---|---|
new() | none | Result<TestFixture, FixtureError> | Create new isolated fixture |
set_metadata() | key: String, value: String | () | Store test state |
get_metadata() | key: &str | Option<&String> | Retrieve stored state |
capture_snapshot() | state: HashMap<String, String> | () | Save test state snapshot |
snapshots() | none | &[HashMap<String, String>] | Get all snapshots |
latest_snapshot() | none | Option<&HashMap<...>> | Get most recent snapshot |
Fixture Lifecycle
Setup Phase
Setup happens when TestFixture::new() is called:
#![allow(unused)] fn main() { test!(test_setup, { // This is the setup phase let fixture = TestFixture::new()?; // Fixture is fully initialized here }); }
Cleanup Phase
Cleanup happens automatically when the fixture is dropped (at the end of the test):
#![allow(unused)] fn main() { test!(test_cleanup, { let fixture = TestFixture::new()?; // Use fixture // When this scope ends, fixture is dropped and cleaned up // No explicit cleanup needed! }); }
Test Isolation
Each test gets a fresh fixture:
#![allow(unused)] fn main() { test!(test_isolation_1, { let fixture1 = TestFixture::new()?; fixture1.set_metadata("test".to_string(), "isolation_1".to_string()); // Uses fixture1 }); test!(test_isolation_2, { let fixture2 = TestFixture::new()?; fixture2.set_metadata("test".to_string(), "isolation_2".to_string()); // Uses fixture2 // fixture1 and fixture2 are completely independent }); }
Both tests can run in parallel with no interference.
Advanced: Multiple Fixtures
Create multiple fixtures in one test:
#![allow(unused)] fn main() { test!(test_with_multiple_fixtures, { let fixture1 = TestFixture::new()?; let fixture2 = TestFixture::new()?; // Both fixtures exist independently fixture1.set_metadata("fixture".to_string(), "first".to_string()); fixture2.set_metadata("fixture".to_string(), "second".to_string()); assert_eq!(fixture1.get_metadata("fixture"), Some(&"first".to_string())); assert_eq!(fixture2.get_metadata("fixture"), Some(&"second".to_string())); // Both are cleaned up when the test ends }); }
Real-World Example
Scenario: Testing a User Service
#![allow(unused)] fn main() { test!(test_user_service, { // Arrange: Set up fixture with data let mut fixture = TestFixture::new()?; fixture.set_metadata("user_id".to_string(), "123".to_string()); // Act: Perform test operations // Use fixture metadata for test coordination let user_id = fixture.get_metadata("user_id"); // Assert: Verify assert_eq!(user_id, Some(&"123".to_string())); // Cleanup: Automatic! No explicit cleanup needed. }); }
Common Patterns
Pattern: Reusable Fixture Setup
Create a helper function:
#![allow(unused)] fn main() { fn setup_user_fixture() -> Result<TestFixture, Box<dyn std::error::Error>> { let fixture = TestFixture::new()?; // Additional setup here if needed Ok(fixture) } test!(test_with_helper, { let fixture = setup_user_fixture()?; // Use configured fixture }); }
Pattern: Nested Fixtures
Fixtures can use other fixtures:
#![allow(unused)] fn main() { test!(test_nested, { let outer = TestFixture::new()?; let inner = TestFixture::new()?; // Both are available // Inner is cleaned up first (LIFO order) }); }
Troubleshooting
"Failed to create fixture"
This usually means an environment issue:
#![allow(unused)] fn main() { test!(test_fixture_error, { match TestFixture::new() { Ok(fixture) => { // Successfully created } Err(e) => { // Check your environment configuration alert_critical!("Environment issue: {}", e); return Err(e.into()); } } }); }
Tests Running Sequentially
Chicago TDD Tools tests run in parallel by default. If you see sequential execution:
- Check for shared state (file I/O, network)
- Ensure each test has its own fixture
- Use
cargo test -- --test-threads=1to force sequential (for debugging)
Best Practices
β Do:
- Create a fresh fixture in each test
- Handle the
Resultwith?ormatch - Let fixtures clean up automatically
- Use multiple fixtures if needed
β Don't:
- Share fixtures between tests
- Manually clean up (let the fixture drop)
- Rely on global state
- Use
unwrap()on fixture creation
Next Steps
Learn how to use fixtures with data builders: Building Test Data
Summary
| Concept | Purpose |
|---|---|
| Fixture | Isolated test state |
| Setup | Happens in TestFixture::new() |
| Cleanup | Automatic on drop |
| Isolation | Each test gets fresh fixture |
| Error Handling | Use ? operator or match |
Building Test Data with Data Builders
π§ HOW-TO | π REFERENCE | Learn to construct test data efficiently
Data builders provide a fluent API for constructing complex test data structures.
Why Data Builders?
Raw test data is hard to read and maintain:
#![allow(unused)] fn main() { // β Hard to understand what this represents let mut data = HashMap::new(); data.insert("key1".to_string(), "value1".to_string()); data.insert("key2".to_string(), "value2".to_string()); data.insert("order_id".to_string(), "ORD-001".to_string()); }
Data builders are readable and maintainable:
#![allow(unused)] fn main() { // β Clear intent - building an order let data = TestDataBuilder::new() .with_var("key1", "value1") .with_var("key2", "value2") .with_order_data("ORD-001", "100.00") .build_json()?; }
Basic Data Builder Usage
Creating Simple Data
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; test!(test_data_builder, { // Arrange: Build test data let builder = TestDataBuilder::new() .with_var("name", "Alice") .with_var("email", "alice@example.com"); // Build as JSON let json_data = builder.build_json()?; assert!(json_data.is_object()); }); }
Building JSON Data
The primary format is JSON:
#![allow(unused)] fn main() { let data = TestDataBuilder::new() .with_var("key", "value") .build_json()?; // Returns serde_json::Value }
All test data is built as JSON, which is flexible and works with most applications.
Fluent Builder Pattern
Builders use method chaining for readability:
#![allow(unused)] fn main() { let data = TestDataBuilder::new() .with_var("user_id", "123") .with_var("name", "Bob") .with_var("email", "bob@example.com") .with_var("status", "active") .with_order_data("ORD-001", "250.99") .build_json()?; }
Each method returns Self, allowing unlimited chaining.
Quick Reference: TestDataBuilder API
| Method | Parameters | Returns | Purpose |
|---|---|---|---|
new() | none | TestDataBuilder | Create new builder |
with_var() | key: &str, value: &str | Self | Add string variable |
with_order_data() | id: &str, amount: &str | Self | Add order info |
build_json() | none | Result<Value, Error> | Build as JSON |
build() | none | HashMap<String, String> | Build as HashMap |
build_with_otel() | span_name: &str | (HashMap, Span) | Build with OTEL span |
Builder Methods
Basic Variables
#![allow(unused)] fn main() { .with_var(key, value) // Add a string variable }
Complex Data
#![allow(unused)] fn main() { .with_order_data(id, amount) // Add order information }
Error Handling
Always handle the Result from build_*():
#![allow(unused)] fn main() { match TestDataBuilder::new() .with_var("key", "value") .build_json() { Ok(data) => { // Use data assert!(data.is_object()); } Err(e) => { alert_critical!("Failed to build data: {}", e); return Err(e.into()); } } }
Or use ?:
#![allow(unused)] fn main() { let data = TestDataBuilder::new() .with_var("key", "value") .build_json()?; // Propagates error }
Real-World Example: Building User Data
#![allow(unused)] fn main() { test!(test_user_registration, { // Build user data let user_data = TestDataBuilder::new() .with_var("username", "alice_wonder") .with_var("email", "alice@example.com") .with_var("password", "secure_password_123") .with_var("first_name", "Alice") .with_var("last_name", "Wonder") .with_var("country", "US") .build_json()?; // Use in test let result = register_user(&user_data)?; // Verify assert_ok!(&result); assert_eq!(result.unwrap().email, "alice@example.com"); }); }
Real-World Example: Building Order Data
#![allow(unused)] fn main() { test!(test_order_processing, { // Build order let order = TestDataBuilder::new() .with_order_data("ORD-12345", "499.99") .with_var("customer_id", "CUST-001") .with_var("shipping_address", "123 Main St") .with_var("payment_method", "credit_card") .build_json()?; // Process order let result = process_order(&order)?; // Verify assert_ok!(&result); assert_eq!(result.unwrap().status, "processed"); }); }
Advanced: Composition
Build complex structures by combining builders:
#![allow(unused)] fn main() { test!(test_composition, { // Build related data let user = TestDataBuilder::new() .with_var("user_id", "123") .with_var("name", "Alice") .build_json()?; let order = TestDataBuilder::new() .with_order_data("ORD-001", "100.00") .with_var("user_id", "123") // Link to user .build_json()?; // Both built, ready to use assert_eq!(user["user_id"], "123"); assert_eq!(order["user_id"], "123"); }); }
Boundary Conditions with Builders
Test edge cases:
#![allow(unused)] fn main() { test!(test_builder_boundaries, { // Empty data let empty = TestDataBuilder::new().build_json()?; assert!(empty.is_object()); // Minimal data let minimal = TestDataBuilder::new() .with_var("id", "1") .build_json()?; assert_eq!(minimal["id"], "1"); // Maximum data (many fields) let mut builder = TestDataBuilder::new(); for i in 0..1000 { builder = builder.with_var(&format!("field_{}", i), &format!("value_{}", i)); } let large = builder.build_json()?; assert!(large.is_object()); }); }
Accessing Built Data
Access as JSON
#![allow(unused)] fn main() { let data = TestDataBuilder::new() .with_var("name", "Alice") .with_var("age", "30") .build_json()?; // Access fields assert_eq!(data["name"], "Alice"); assert_eq!(data["age"], "30"); }
Serialize to Struct
#![allow(unused)] fn main() { #[derive(Deserialize)] struct User { name: String, age: u32, } let data = TestDataBuilder::new() .with_var("name", "Alice") .with_var("age", "30") .build_json()?; let user: User = serde_json::from_value(data)?; assert_eq!(user.name, "Alice"); assert_eq!(user.age, 30); }
Best Practices
β Do:
- Use descriptive variable names
- Chain methods for readability
- Handle errors with
? - Build all data before acting
- Use order data for order-specific fields
β Don't:
- Use unclear abbreviations
- Mix high-level and low-level builders
- Build data after acting (arrange first!)
- Ignore build errors
Common Patterns
Pattern: Reusable Builder Factory
#![allow(unused)] fn main() { fn create_valid_user_data() -> Result<serde_json::Value, String> { TestDataBuilder::new() .with_var("username", "test_user") .with_var("email", "test@example.com") .with_var("status", "active") .build_json() } test!(test_with_factory, { let user_data = create_valid_user_data()?; // Use pre-built data }); }
Pattern: Variation for Edge Cases
#![allow(unused)] fn main() { fn create_inactive_user_data() -> Result<serde_json::Value, String> { TestDataBuilder::new() .with_var("username", "inactive_user") .with_var("email", "inactive@example.com") .with_var("status", "inactive") // Key difference .build_json() } test!(test_inactive_user, { let user_data = create_inactive_user_data()?; // Test inactive user handling }); }
Troubleshooting
"Failed to build data: Invalid JSON"
Check for:
- Malformed variable values
- Type mismatches
- Missing required fields
#![allow(unused)] fn main() { // Debug by building step-by-step let builder1 = TestDataBuilder::new().with_var("key1", "value1"); // builder1 is valid let builder2 = builder1.with_var("key2", "value2"); // builder2 is valid // etc. }
Next Steps
Learn assertions: Assertions & Verification
Summary
| Aspect | Purpose |
|---|---|
| Fluent API | Readable data construction |
| Chaining | .with_var() returns Self |
| Error Handling | build_json() returns Result |
| Composition | Combine multiple builders |
| Reusability | Extract to helper functions |
Assertions & Verification
π§ HOW-TO | π REFERENCE | Write clear, effective assertions
Assertions verify that code behaves correctly. Chicago TDD Tools provides helpers for clear, readable assertions.
Basic Assertions
Standard Assertions
#![allow(unused)] fn main() { use chicago_tdd_tools::prelude::*; test!(test_basic_assertions, { let result: Result<u32, String> = Ok(42); // β Chicago TDD style - clear intent assert_ok!(&result); assert_eq!(result.unwrap(), 42); // Error result let error: Result<u32, String> = Err("failed".to_string()); assert_err!(&error); }); }
Common Assertion Helpers
#![allow(unused)] fn main() { // Success/Error checks assert_ok!(&result); // Verifies Ok(_) assert_err!(&result); // Verifies Err(_) // Equality assert_eq!(actual, expected); // Equality check assert_ne!(actual, expected); // Inequality check // Boolean assert!(condition); // Checks true assert!(!condition); // Checks false }
Numeric Assertions
Range Checking
#![allow(unused)] fn main() { test!(test_numeric_assertions, { let value = 42; // Standard assertions assert_eq!(value, 42); assert!(value > 40); assert!(value < 50); // Chicago TDD helper assert_in_range!(value, 40, 50); // Inclusive range }); }
Floating Point
#![allow(unused)] fn main() { test!(test_floating_point, { let value: f64 = 3.14159; let expected: f64 = 3.14; // β Epsilon comparison (handles rounding errors) assert!((value - expected).abs() < 0.01); }); }
String Assertions
#![allow(unused)] fn main() { test!(test_string_assertions, { let text = "Hello, World!"; // Equality assert_eq!(text, "Hello, World!"); // Containment assert!(text.contains("World")); // Pattern matching assert!(text.starts_with("Hello")); assert!(text.ends_with("!")); }); }
Collection Assertions
#![allow(unused)] fn main() { test!(test_collection_assertions, { let vec = vec![1, 2, 3]; // Length assert_eq!(vec.len(), 3); // Containment assert!(vec.contains(&2)); // Empty check assert!(!vec.is_empty()); }); }
Option/Result Assertions
#![allow(unused)] fn main() { test!(test_option_result_assertions, { let some_value: Option<i32> = Some(42); let none_value: Option<i32> = None; // Option checks assert!(some_value.is_some()); assert!(none_value.is_none()); // Result checks let ok_result: Result<i32, String> = Ok(42); let err_result: Result<i32, String> = Err("error".to_string()); assert!(ok_result.is_ok()); assert!(err_result.is_err()); }); }
Custom Messages
Add context to assertions:
#![allow(unused)] fn main() { test!(test_with_messages, { let result = divide(10, 2); assert_eq!(result, 5, "Expected division to work correctly"); let empty = vec![]; assert!(!empty.is_empty(), "Vector should not be empty after initialization"); }); }
Real-World Example: User Creation
#![allow(unused)] fn main() { test!(test_user_creation, { // Create user let user_data = TestDataBuilder::new() .with_var("name", "Alice") .with_var("email", "alice@example.com") .build_json()?; let result = create_user(&user_data)?; // Assert success assert_ok!(&result); let user = result.unwrap(); // Assert properties assert_eq!(user.name, "Alice"); assert_eq!(user.email, "alice@example.com"); assert!(user.id > 0); assert!(user.created_at.len() > 0); }); }
Real-World Example: Collection Processing
#![allow(unused)] fn main() { test!(test_filter_operations, { let numbers = vec![1, 2, 3, 4, 5]; // Filter evens let evens: Vec<_> = numbers .iter() .filter(|n| n % 2 == 0) .cloned() .collect(); // Assertions assert_eq!(evens.len(), 2); assert!(evens.contains(&2)); assert!(evens.contains(&4)); assert!(!evens.contains(&1)); // 1 is odd }); }
Assert Patterns
Pattern: Positive Case
#![allow(unused)] fn main() { test!(test_positive, { let result = parse_number("42"); assert_ok!(&result); assert_eq!(result.unwrap(), 42); }); }
Pattern: Negative Case
#![allow(unused)] fn main() { test!(test_negative, { let result = parse_number("invalid"); assert_err!(&result); }); }
Pattern: Boundary Case
#![allow(unused)] fn main() { test!(test_boundary, { // Zero assert_ok!(&parse_number("0")); // Large value assert_ok!(&parse_number("999999")); // Negative assert_ok!(&parse_number("-42")); }); }
Avoiding Common Pitfalls
β Don't: Use unwrap() in assertions
#![allow(unused)] fn main() { // Bad - panics if result is Err let value = result.unwrap(); assert_eq!(value, 42); }
β Do: Check first
#![allow(unused)] fn main() { // Good - checks properly assert_ok!(&result); if let Ok(value) = result { assert_eq!(value, 42); } }
β Don't: Assert implementation details
#![allow(unused)] fn main() { // Bad - depends on internal structure assert_eq!(user.internal_id, 123); }
β Do: Assert behavior
#![allow(unused)] fn main() { // Good - asserts external behavior assert_eq!(user.name, "Alice"); assert_eq!(user.email, "alice@example.com"); }
Assertion Order
Follow AAA pattern:
- Arrange - Build data
- Act - Execute code
- Assert - Verify behavior
#![allow(unused)] fn main() { test!(test_order, { // Arrange let input = 5; // Act let result = multiply_by_two(input); // Assert (all together, at the end) assert_eq!(result, 10); assert!(result > 0); assert!(result < 100); }); }
Best Practices
β Do:
- Use helper macros (
assert_ok!,assert_err!) - Assert one behavior per test
- Add context with messages
- Check both success and error paths
- Use descriptive assertion messages
β Don't:
- Use
unwrap()in assertions - Mix act and assert
- Assert implementation details
- Skip error case assertions
- Use vague assertion messages
Common Assertions Reference
| Pattern | Code |
|---|---|
| Value matches | assert_eq!(actual, expected) |
| Value different | assert_ne!(actual, expected) |
| True condition | assert!(condition) |
| Ok result | assert_ok!(&result) |
| Err result | assert_err!(&result) |
| In range | assert_in_range!(value, min, max) |
| Contains text | assert!(text.contains("substring")) |
| Empty collection | assert!(collection.is_empty()) |
Next Steps
Learn about error paths: Error Path Testing
Summary
Chicago TDD emphasizes:
- Clear assertions with helper macros
- Both success and error case verification
- AAA pattern (Arrange-Act-Assert)
- Behavior verification, not implementation details
Error Path Testing
π§ HOW-TO | Learn to test failure scenarios thoroughly
Error paths are where 80% of bugs hide. Chicago TDD emphasizes comprehensive error testing.
Why Error Paths Matter
Most code focuses on the "happy path" (success). Bugs hide in error cases:
#![allow(unused)] fn main() { // Happy path is obvious let parsed = "42".parse::<u32>()?; assert_eq!(parsed, 42); // Error path has subtle bugs // What about "not_a_number"? // What about negative "-42"? // What about overflow "99999999999999999999"? }
Testing Both Paths
Every function should test both success and failure:
#![allow(unused)] fn main() { test!(test_complete_behavior, { // Success path let ok_result = parse_number::<u32>("42"); assert_ok!(&ok_result); assert_eq!(ok_result.unwrap(), 42); // Error path let err_result = parse_number::<u32>("invalid"); assert_err!(&err_result); }); }
Common Error Scenarios
1. Invalid Input
#![allow(unused)] fn main() { test!(test_invalid_input, { let result = validate_email("not_an_email"); assert_err!(&result); let result = validate_email(""); assert_err!(&result); let result = validate_email("@"); assert_err!(&result); }); }
2. Boundary Conditions
#![allow(unused)] fn main() { test!(test_boundaries, { // Minimum assert_ok!(&process(0)); // Just above minimum assert_ok!(&process(1)); // Maximum valid assert_ok!(&process(u32::MAX - 1)); // Just past maximum assert_err!(&process(u32::MAX + 1)); // If checked }); }
3. Resource Errors
#![allow(unused)] fn main() { test!(test_resource_errors, { // File doesn't exist let result = read_file("nonexistent.txt"); assert_err!(&result); // Permission denied let result = write_file("/root/restricted.txt", "data"); assert_err!(&result); // Out of memory (hard to test, but consider it) }); }
4. State Errors
#![allow(unused)] fn main() { test!(test_state_errors, { let state = MyStateMachine::new(); // Valid transition assert_ok!(&state.transition_to_active()); // Invalid transition let already_active = MyStateMachine::new().transition_to_active(); assert_err!(&already_active.transition_to_active()); }); }
Error Messages
Test that error messages are helpful:
#![allow(unused)] fn main() { test!(test_error_messages, { let result = parse_number::<u32>("not_a_number"); match result { Err(e) => { // Verify error message is clear assert!(e.to_string().contains("parse error")); assert!(e.to_string().contains("not_a_number")); } Ok(_) => panic!("Should have failed"), } }); }
Error Recovery
Test that code recovers from errors:
#![allow(unused)] fn main() { test!(test_error_recovery, { // First attempt fails let result1 = connect_to_database("invalid_url"); assert_err!(&result1); // Code continues and retries with valid URL let result2 = connect_to_database("valid_url"); assert_ok!(&result2); }); }
Real-World Example: Form Validation
#![allow(unused)] fn main() { test!(test_form_validation, { let validator = FormValidator::new(); // Valid case let valid = validator.validate(&FormData { username: "alice".to_string(), email: "alice@example.com".to_string(), password: "secure_password_123".to_string(), }); assert_ok!(&valid); // Missing username let missing_username = validator.validate(&FormData { username: "".to_string(), email: "alice@example.com".to_string(), password: "secure_password_123".to_string(), }); assert_err!(&missing_username); // Invalid email let invalid_email = validator.validate(&FormData { username: "alice".to_string(), email: "not_an_email".to_string(), password: "secure_password_123".to_string(), }); assert_err!(&invalid_email); // Weak password let weak_password = validator.validate(&FormData { username: "alice".to_string(), email: "alice@example.com".to_string(), password: "123".to_string(), // Too short }); assert_err!(&weak_password); }); }
Real-World Example: API Endpoint
#![allow(unused)] fn main() { test!(test_api_error_handling, { let client = ApiClient::new(); // Success let result = client.get_user(123); assert_ok!(&result); assert_eq!(result.unwrap().id, 123); // User not found let result = client.get_user(999999); assert_err!(&result); // Invalid ID let result = client.get_user(-1); assert_err!(&result); // Network error (mock or integration test) let result = client.get_user(456); // Server down assert_err!(&result); }); }
Error Handling Patterns
Pattern: Check and Handle
#![allow(unused)] fn main() { test!(test_check_and_handle, { let result = risky_operation(); match result { Ok(value) => { assert_eq!(value, expected); } Err(e) => { // Handle error assert!(e.to_string().len() > 0); } } }); }
Pattern: Map Error
#![allow(unused)] fn main() { test!(test_map_error, { let result = risky_operation() .map_err(|e| format!("Operation failed: {}", e)); assert_err!(&result); if let Err(e) = result { assert!(e.contains("Operation failed")); } }); }
Pattern: Recover
#![allow(unused)] fn main() { test!(test_error_recovery, { let result = risky_operation() .or_else(|_| fallback_operation()); // Should succeed via fallback assert_ok!(&result); }); }
Comprehensive Error Test
#![allow(unused)] fn main() { test!(test_comprehensive_errors, { // Arrange let test_cases = vec![ ("valid_input", true), ("", false), ("too_long_" /* 100 chars */, false), ("special@chars#", false), ("123", true), ("-123", false), // Negative not allowed ]; // Act & Assert for (input, should_succeed) in test_cases { let result = validate_input(input); if should_succeed { assert_ok!(&result, "Input '{}' should be valid", input); } else { assert_err!(&result, "Input '{}' should be invalid", input); } } }); }
Best Practices
β Do:
- Test both success and error paths
- Use boundary conditions
- Test error messages
- Verify error recovery
- Document expected errors
β Don't:
- Only test the happy path
- Assume error handling is correct
- Ignore boundary conditions
- Skip error message verification
- Test implementation details of errors
Error Testing Checklist
For each function, test:
- Happy path (normal input)
- Invalid input
- Boundary conditions (min, max, zero, -1)
- Empty/null values
- Resource errors (if applicable)
- State errors (if applicable)
- Error messages are clear
- Error recovery is possible
Common Error Patterns
| Scenario | Test Case |
|---|---|
| Missing input | Empty string, None, empty Vec |
| Invalid format | Wrong type, malformed data |
| Out of range | Negative when only positive allowed |
| Resource unavailable | File not found, connection refused |
| State violation | Invalid state transition |
| Timeout | Operation takes too long |
Real-World Integration Example
#![allow(unused)] fn main() { test!(test_database_operations, { let db = Database::new(); // Success: Insert and retrieve let user_id = db.insert_user("alice", "alice@example.com"); let result = db.get_user(user_id); assert_ok!(&result); // Error: User not found let result = db.get_user(999999); assert_err!(&result); // Error: Duplicate email let result = db.insert_user("bob", "alice@example.com"); // Email taken assert_err!(&result); // Error: Invalid email let result = db.insert_user("carol", "not_an_email"); assert_err!(&result); }); }
Next Steps
Learn advanced techniques: Advanced Testing Techniques
Summary
Chicago TDD prioritizes error testing because:
- Bugs hide in error paths
- Error handling is often incorrect
- Users encounter errors in real use
Always test:
- β Happy path
- β Error cases
- β Boundary conditions
- β Error messages
- β Recovery
Advanced Testing Techniques
Welcome to advanced testing techniques! These specialized methods help you test complex scenarios effectively.
Overview
Advanced techniques include:
- Property-Based Testing - Generate random data and verify properties
- Mutation Testing - Validate test quality by introducing mutations
- Snapshot Testing - Golden files to detect unintended changes
- CLI Testing - Test command-line interfaces
- Concurrency Testing - Deterministic thread testing
When to Use Advanced Techniques
| Technique | Best For | When to Use |
|---|---|---|
| Property-Based | Mathematical properties, edge cases | Complex algorithms, parsing |
| Mutation | Test quality validation | Assessing test effectiveness |
| Snapshot | Stable output, complex structures | API responses, generated code |
| CLI | Command-line tools | CLI applications, scripts |
| Concurrency | Thread-safe code | Concurrent systems, race conditions |
Quick Reference
Property-Based Testing
#![allow(unused)] fn main() { // Test that addition is commutative test!(test_addition_commutative, { let strategy = ProptestStrategy::new().with_cases(100); strategy.test(any::<(u32, u32)>(), |(a, b)| a + b == b + a); }); }
Mutation Testing
#![allow(unused)] fn main() { // Verify tests catch mutations let mut tester = MutationTester::new(data); tester.apply_mutation(MutationOperator::ChangeValue(...)); let caught = tester.test_mutation_detection(|data| check_data(data)); assert!(caught); // Tests should catch the mutation }
Snapshot Testing
#![allow(unused)] fn main() { // Golden file testing let output = generate_report(); assert_matches!(output, "report"); // Compares with snapshot }
CLI Testing
#![allow(unused)] fn main() { // Test CLI commands let output = CliTest::new("myapp", vec!["list", "--verbose"]) .run()?; assert!(output.contains("item1")); }
Concurrency Testing
#![allow(unused)] fn main() { // Deterministic thread testing loom::model(|| { let data = Arc::new(Mutex::new(0)); thread::spawn({ let data = data.clone(); move || *data.lock().unwrap() += 1; }); }); }
Learning Path
- Start with Property-Based Testing - Easy to understand, powerful
- Then explore Mutation Testing - Validates your tests
- Add Snapshot Testing - For regression detection
- Test CLIs with CLI Testing - If you have CLI tools
- Thread-safe code with Concurrency Testing - For concurrent systems
Combining Techniques
You can combine advanced techniques:
#![allow(unused)] fn main() { test!(comprehensive_test, { // Use fixtures and data builders (core) let fixture = TestFixture::new()?; let data = TestDataBuilder::new()...build_json()?; // Use property-based testing (advanced) let strategy = ProptestStrategy::new().with_cases(50); // Use mutation testing (advanced) let mut tester = MutationTester::new(data.clone()); // Use snapshot testing (advanced) let result = process(&data)?; assert_matches!(result, "expected_output"); }); }
Real-World Scenarios
Scenario 1: JSON Parser
- Core: Basic tests with fixtures
- Property-Based: Test parsing properties (round-trip)
- Mutation: Validate test quality
- Snapshot: Compare against golden files
Scenario 2: CLI Tool
- Core: Basic command tests
- CLI Testing: Full CLI integration
- Snapshot: Compare output with golden files
- Property-Based: Random argument generation
Scenario 3: Concurrent System
- Core: Basic thread tests
- Concurrency: Deterministic testing with loom
- Property-Based: Test invariants across threads
- Mutation: Validate synchronization correctness
Feature Flags
Enable features for advanced techniques:
[dev-dependencies]
chicago-tdd-tools = { version = "1.3", features = [
"property-testing", # Property-based testing
"mutation-testing", # Mutation testing
"snapshot-testing", # Snapshot testing
"cli-testing", # CLI testing
"concurrency-testing", # Concurrency testing
"testing-extras", # All of above (most common)
"testing-full", # All testing features
] }
Performance Considerations
Advanced techniques can be slower:
| Technique | Speed | Trade-off |
|---|---|---|
| Unit tests | Fast (ms) | Limited scenarios |
| Property-based | Medium (seconds) | Comprehensive coverage |
| Mutation | Slow (minutes) | High confidence |
| Snapshot | Fast (ms) | Brittle to changes |
| Concurrency | Slow (seconds) | Deterministic |
Recommendation:
- Use core patterns for 80% of tests (fast feedback)
- Use advanced techniques for critical paths (high confidence)
Common Pitfalls
β Over-using advanced techniques
- Use property-based for properties, not all tests
- Use mutation occasionally, not always
β Ignoring performance
- Property-based with 10,000 cases is overkill
- Limit mutation test scope
β Replacing core patterns
- Advanced techniques complement, not replace, core patterns
- Still need AAA pattern and error paths
β Best practices:
- Use core patterns as foundation
- Add advanced techniques strategically
- Balance confidence with speed
Sections
Next Steps
π Start with Property-Based Testing
Property-Based Testing
π§ HOW-TO | π REFERENCE | Generate random test data to verify properties
Property-based testing generates random test data and verifies that properties hold for all inputs.
What is a Property?
A property is a logical assertion that should hold for all valid inputs:
#![allow(unused)] fn main() { // Property: Addition is commutative // For all a, b: a + b == b + a // Property: Parsing and formatting is round-trip safe // For all x: parse(format(x)) == x // Property: Sorted list has no inversions // For all lists: list[i] <= list[i+1] }
Property-Based vs. Example-Based
Example-Based (Traditional)
#![allow(unused)] fn main() { test!(test_addition_examples, { assert_eq!(2 + 3, 5); assert_eq!(0 + 5, 5); assert_eq!(10 + 0, 10); }); }
Limitation: Only tests specific examples. What about u32::MAX + 1?
Property-Based
#![allow(unused)] fn main() { test!(test_addition_property, { let strategy = ProptestStrategy::new().with_cases(1000); strategy.test(any::<(u32, u32)>(), |(a, b)| { a + b == b + a // Checks for 1000 random pairs }); }); }
Advantage: Tests 1000 random cases automatically.
Getting Started
Basic Property Test
#![allow(unused)] fn main() { use chicago_tdd_tools::property::*; use proptest::prelude::*; test!(test_parsing_property, { let strategy = ProptestStrategy::new().with_cases(100); strategy.test(any::<u32>(), |num| { let formatted = format!("{}", num); let parsed: u32 = formatted.parse().unwrap(); num == parsed // Property: round-trip works }); }); }
Using Generators
Generate specific types:
#![allow(unused)] fn main() { test!(test_string_properties, { let strategy = ProptestStrategy::new().with_cases(100); // Test with strings of 1-100 characters strategy.test("[a-zA-Z0-9]{1,100}", |s| { // Property: non-empty string remains non-empty !s.is_empty() }); }); }
Common Properties to Test
1. Commutativity
#![allow(unused)] fn main() { // Property: a + b == b + a strategy.test(any::<(i32, i32)>(), |(a, b)| { a + b == b + a }); }
2. Associativity
#![allow(unused)] fn main() { // Property: (a + b) + c == a + (b + c) strategy.test(any::<(i32, i32, i32)>(), |(a, b, c)| { (a + b) + c == a + (b + c) }); }
3. Distributivity
#![allow(unused)] fn main() { // Property: a * (b + c) == (a * b) + (a * c) strategy.test(any::<(i32, i32, i32)>(), |(a, b, c)| { a * (b + c) == (a * b) + (a * c) }); }
4. Identity
#![allow(unused)] fn main() { // Property: a + 0 == a strategy.test(any::<i32>(), |a| { a + 0 == a }); }
5. Inverse
#![allow(unused)] fn main() { // Property: a - a == 0 strategy.test(any::<i32>(), |a| { a - a == 0 }); }
Real-World Example: JSON Parsing
#![allow(unused)] fn main() { test!(test_json_parsing_properties, { let strategy = ProptestStrategy::new().with_cases(500); strategy.test(any::<(String, i32, bool)>(), |(name, age, active)| { // Create JSON let json = format!( r#"{{"name":"{}","age":{},"active":{}}}"#, name, age, active ); // Parse it let parsed: Result<MyData, _> = serde_json::from_str(&json); // Property: Valid input parses successfully parsed.is_ok() }); }); }
Real-World Example: String Validation
#![allow(unused)] fn main() { test!(test_email_validation, { let strategy = ProptestStrategy::new().with_cases(200); // Test valid emails strategy.test( r"[a-zA-Z0-9]+@[a-zA-Z0-9]+\.[a-zA-Z]{2,}", |email| { // Property: Valid email passes validation validate_email(email).is_ok() } ); }); }
Shrinking
When a property fails, shrinking finds the minimal failing case:
#![allow(unused)] fn main() { test!(test_with_shrinking, { let strategy = ProptestStrategy::new().with_cases(100); strategy.test(any::<Vec<i32>>(), |vec| { // Property fails for some input vec.len() < 10 // This might fail for vec![1,2,3,...,100] // Shrinking finds minimal failure: vec with length >= 10 }); }); }
Combining Strategies
Test multiple values together:
#![allow(unused)] fn main() { test!(test_combined_strategy, { let strategy = ProptestStrategy::new().with_cases(100); // Test with tuple of (String, u32, bool) strategy.test( (any::<String>(), 1u32..100u32, any::<bool>()), |(name, age, active)| { // Test with all three values !name.is_empty() && age > 0 } ); }); }
Configuration
Number of Cases
#![allow(unused)] fn main() { // Test with 100 random cases let strategy = ProptestStrategy::new().with_cases(100); // Test with 1000 cases (slower, more thorough) let strategy = ProptestStrategy::new().with_cases(1000); // Test with 10 cases (faster, less thorough) let strategy = ProptestStrategy::new().with_cases(10); }
Random Seed
For reproducible tests:
#![allow(unused)] fn main() { let mut generator = PropertyTestGenerator::<100, 5>::new() .with_seed(42); // Use specific seed let data = generator.generate_test_data(); }
When to Use Property-Based Testing
β Use for:
- Mathematical properties (commutativity, associativity)
- Round-trip properties (serialize/deserialize)
- Parsing and formatting
- List operations (sort, filter, map)
- State machine transitions
β Don't use for:
- Specific business logic (use example tests)
- Performance testing (use benchmarks)
- Complex setup (use fixtures)
Best Practices
β Do:
- Test actual properties (not specific values)
- Use meaningful generators
- Start with 100-500 cases
- Check edge cases manually
β Don't:
- Replace example tests (both have value)
- Use excessive cases (slows down tests)
- Ignore failed cases
- Only use randomly generated data
Performance
- 100 cases: ~100ms per property
- 1000 cases: ~1s per property
- 10,000 cases: ~10s per property
Recommendation: Start with 100-500 cases. Use more for critical code.
Troubleshooting
Property Fails Intermittently
Use shrinking output to find minimal case:
#![allow(unused)] fn main() { test!(test_debug_failure, { let strategy = ProptestStrategy::new().with_cases(1000); strategy.test(any::<(u32, u32)>(), |(a, b)| { // If fails: check shrunk output // Example: shrunk to (0, 0) or (u32::MAX, 0) (a as u64) + (b as u64) < u64::MAX }); }); }
Property Too Strict
Relax constraints:
#![allow(unused)] fn main() { // Too strict: a * b == b * a (fails due to overflow) // Better: a.checked_mul(b) == b.checked_mul(a) }
Next Steps
Learn mutation testing: Mutation Testing
Summary
Property-based testing:
- β Tests properties for all inputs
- β Finds edge cases automatically
- β Includes shrinking to find minimal failures
- β Great for algorithms and parsing
Use with fixtures and data builders for comprehensive testing.
Mutation Testing
π§ HOW-TO | π REFERENCE | Validate test quality by introducing mutations
Mutation testing validates test quality by introducing mutations (changes) to code and verifying tests catch them.
Quick Reference: Mutation Testing API
| Component | Purpose | Key Methods |
|---|---|---|
MutationTester::new() | Create a tester for data | apply_mutation(), test_mutation_detection() |
MutationOperator::RemoveKey() | Remove data key | Parameter: key to remove |
MutationOperator::AddKey() | Add new key-value | Parameters: key, value |
MutationOperator::ChangeValue() | Change existing value | Parameters: key, new_value |
MutationOperator::SwapValues() | Swap two values | Parameters: key1, key2 |
MutationOperator::ToggleBoolean() | Flip bool trueβfalse | Parameter: key |
MutationOperator::NumericDelta() | Adjust numeric by delta | Parameters: key, delta_value |
MutationOperator::StringCase() | Change string case | Parameters: key, CaseMode |
MutationScore::calculate() | Compute score | Parameters: caught, total |
SpanStatus enum | Mutation test results | Variants: Ok, Error, Unknown |
Why Mutation Testing?
High code coverage doesn't guarantee good tests:
#![allow(unused)] fn main() { fn dangerous_function(x: u32) -> u32 { if x > 0 { return x * 2; // Intentional bug: should be x * 3 } 0 } test!(test_bad_coverage, { // This test gives 100% code coverage assert_eq!(dangerous_function(5), 10); // This passes even with bug! // But tests don't verify the result is CORRECT // Bad test - doesn't verify behavior assert!(dangerous_function(5) > 0); // Passes even if returns 99 }); }
Mutation testing fixes this:
#![allow(unused)] fn main() { test!(test_with_mutation, { // Arrange let mut tester = MutationTester::new(dangerous_function); // Apply mutation: change * 2 to * 3 tester.apply_mutation(MutationOperator::ChangeValue(...)); // Act: Test catches the mutation let caught = tester.test_mutation_detection(|func| { func(5) == 10 // This will fail with mutation }); // Assert: Mutation was caught assert!(caught); // β Good test catches mutation }); }
Basic Mutation Testing
Creating a Mutation Tester
#![allow(unused)] fn main() { use chicago_tdd_tools::mutation::*; use std::collections::HashMap; test!(test_mutation_basic, { let mut data = HashMap::new(); data.insert("key1".to_string(), "value1".to_string()); // Create tester let mut tester = MutationTester::new(data); // Apply mutation: remove a key tester.apply_mutation(MutationOperator::RemoveKey("key1".to_string())); // Test detects the mutation let caught = tester.test_mutation_detection(|data| { !data.is_empty() // Should fail with mutation }); assert!(caught); }); }
Mutation Operators
RemoveKey
Remove a key from the data:
#![allow(unused)] fn main() { tester.apply_mutation(MutationOperator::RemoveKey("key".to_string())); }
AddKey
Add a new key:
#![allow(unused)] fn main() { tester.apply_mutation(MutationOperator::AddKey( "new_key".to_string(), "new_value".to_string() )); }
ChangeValue
Change a value:
#![allow(unused)] fn main() { tester.apply_mutation(MutationOperator::ChangeValue( "key".to_string(), "different_value".to_string() )); }
SwapValues
Swap values between two keys:
#![allow(unused)] fn main() { tester.apply_mutation(MutationOperator::SwapValues( "key1".to_string(), "key2".to_string() )); }
ToggleBoolean
Toggle a boolean value (flip true/false):
#![allow(unused)] fn main() { tester.apply_mutation(MutationOperator::ToggleBoolean( "is_active".to_string() )); }
NumericDelta
Change a numeric value by a delta:
#![allow(unused)] fn main() { tester.apply_mutation(MutationOperator::NumericDelta( "count".to_string(), 10 // Add 10 to the value )); }
StringCase
Change string case:
#![allow(unused)] fn main() { use chicago_tdd_tools::mutation::CaseMode; tester.apply_mutation(MutationOperator::StringCase( "name".to_string(), CaseMode::Upper // or Lower, Mixed )); }
Mutation Score
Calculate how many mutations your tests catch:
#![allow(unused)] fn main() { test!(test_mutation_score, { let mut data = HashMap::new(); data.insert("key1".to_string(), "value1".to_string()); data.insert("key2".to_string(), "value2".to_string()); let mut tester = MutationTester::new(data); // Apply 3 mutations let mut caught = 0; // Mutation 1: Remove key1 tester.apply_mutation(MutationOperator::RemoveKey("key1".to_string())); if tester.test_mutation_detection(|d| d.contains_key("key1")) { caught += 1; } // Mutation 2: Remove key2 tester.apply_mutation(MutationOperator::RemoveKey("key2".to_string())); if tester.test_mutation_detection(|d| d.contains_key("key2")) { caught += 1; } // Mutation 3: Add key3 tester.apply_mutation(MutationOperator::AddKey("key3".to_string(), "value3".to_string())); if tester.test_mutation_detection(|d| d.len() == 2) { caught += 1; } // Calculate score let score = MutationScore::calculate(caught, 3); assert!(score.is_acceptable()); // >= 80% alert_info!("Mutation score: {}%", score.score()); }); }
Real-World Example: User Service
#![allow(unused)] fn main() { test!(test_user_service_mutations, { let user = User { id: 123, name: "Alice".to_string(), email: "alice@example.com".to_string(), }; let mut tester = MutationTester::new(user); let mut caught = 0; let mut total = 0; // Test 1: Mutation removes user ID total += 1; tester.apply_mutation(MutationOperator::ChangeValue( "id".to_string(), "0".to_string() )); if tester.test_mutation_detection(|u| u.id > 0) { caught += 1; } // Test 2: Mutation changes name total += 1; tester.apply_mutation(MutationOperator::ChangeValue( "name".to_string(), "Bob".to_string() )); if tester.test_mutation_detection(|u| u.name == "Alice") { caught += 1; } // Test 3: Mutation changes email total += 1; tester.apply_mutation(MutationOperator::ChangeValue( "email".to_string(), "bob@example.com".to_string() )); if tester.test_mutation_detection(|u| u.email == "alice@example.com") { caught += 1; } let score = MutationScore::calculate(caught, total); alert_info!("User service mutation score: {}%", score.score()); assert!(score.is_acceptable()); }); }
Interpreting Results
High Mutation Score (>80%)
β Good: Tests are catching mutations
Mutation Score: 95%
- 95 out of 100 mutations caught
- Tests are effective
- High confidence in code quality
Low Mutation Score (<80%)
β οΈ Warning: Some mutations slip through
Mutation Score: 60%
- Only 60 out of 100 mutations caught
- 40 mutations go undetected
- May have weak tests or untested branches
Mutations Caught vs. Missed
Mutation "Remove key1" β CAUGHT (test failed)
Mutation "Change value" β MISSED (test still passed!)
When mutations are missed, improve tests:
#![allow(unused)] fn main() { // Before: Weak test assert!(data.contains_key("key1")); // After: Strong test assert_eq!(data.get("key1").unwrap(), "expected_value"); }
Mutation Testing Workflow
- Write tests (core patterns)
- Measure coverage (80%+ code coverage)
- Run mutation tests (catch mutations)
- Improve weak tests (missing mutations)
- Reach 80%+ mutation score
When to Use Mutation Testing
β Use for:
- Validating test suite quality
- Critical code paths
- Security-sensitive code
- Core algorithms
β Don't use for:
- Every test (slow)
- Simple tests
- Learning phase
- Every build
Recommendation: Use mutation testing:
- During development (spot check)
- For critical code (ensure quality)
- Occasionally in CI (weekly)
- Not every build (too slow)
Performance
- Small mutation set: 1-10 mutations (seconds)
- Medium set: 10-100 mutations (minutes)
- Large set: 100+ mutations (hours)
Recommendation: Limit to 20-50 mutations for regular testing.
Best Practices
β Do:
- Focus on critical code
- Test both success and error paths
- Improve tests that miss mutations
- Calculate mutation score
β Don't:
- Run mutation tests on every commit (slow)
- Expect 100% mutation score (impossible)
- Ignore missed mutations
- Over-optimize for mutation score
Common Mutations to Check
| Mutation | What to Test |
|---|---|
| RemoveKey | Verify required keys exist |
| AddKey | Verify exact set of keys |
| ChangeValue | Verify exact value, not just type |
| SwapValues | Verify values aren't accidentally swapped |
| ToggleBoolean | Verify both true and false cases |
| NumericDelta | Test boundary values and edge cases |
| StringCase | Verify case-sensitive comparisons |
Troubleshooting
Mutation Not Caught
Your test is too weak:
#![allow(unused)] fn main() { // β Weak - passes even with mutation assert!(user.id > 0); // β Strong - catches changes assert_eq!(user.id, 123); }
Mutation Score Unrealistic
Adjust your mutations to match actual bugs:
#![allow(unused)] fn main() { // Only test realistic mutations tester.apply_mutation(MutationOperator::ChangeValue(...)); // Skip mutations that don't matter // (e.g., changing comments, unused variables) }
Next Steps
Learn snapshot testing: Snapshot Testing
Summary
Mutation testing:
- β Validates test quality
- β Catches weak tests
- β Increases confidence
- β Target 80%+ score
Use for critical code to ensure maximum quality.
Snapshot Testing
π§ HOW-TO | π REFERENCE | Detect unintended changes with golden file comparisons
Snapshot testing captures output on the first run and compares it on subsequent runs to detect unintended changes.
Why Snapshot Testing?
Perfect for testing output that's complex but stable:
#![allow(unused)] fn main() { // β Hard to maintain let report = generate_report(); assert_eq!(report, "Employee Report\nAlice: ...\nBob: ...\n"); // 100 lines! // β Easy to maintain with snapshots assert_matches!(report, "employee_report"); // Stored in file }
How It Works
First Run: Create Snapshot
#![allow(unused)] fn main() { test!(test_report, { let report = generate_report(); assert_matches!(report, "report"); // Creates report.snap // report.snap contains the generated report }); }
Subsequent Runs: Compare
If output changes:
- Old:
Employee Report\nAlice: 50000\n - New:
Employee Report\nAlice: 60000\n(salary changed)
Test shows a diff:
- Alice: 50000
+ Alice: 60000
You review and decide:
- β Accept change (intentional update)
- β Reject change (bug introduced)
Basic Usage
String Snapshots
#![allow(unused)] fn main() { test!(test_string_snapshot, { let output = "Hello, World!"; assert_matches!(output, "greeting"); }); }
File greeting.snap contains: Hello, World!
JSON Snapshots
#![allow(unused)] fn main() { test!(test_json_snapshot, { let data = TestDataBuilder::new() .with_var("name", "Alice") .with_var("age", "30") .build_json()?; assert_json_matches!(data, "user_data"); }); }
File user_data.snap contains JSON:
{
"name": "Alice",
"age": "30"
}
Debug Snapshots
#![allow(unused)] fn main() { test!(test_debug_snapshot, { let user = User { id: 123, name: "Alice".to_string() }; assert_debug_matches!(user, "user_debug"); }); }
File user_debug.snap contains:
User {
id: 123,
name: "Alice",
}
Real-World Example: API Response
#![allow(unused)] fn main() { test!(test_api_response_snapshot, { let client = ApiClient::new(); let response = client.get_users()?; // Snapshot the API response assert_json_matches!(response, "api_users_response"); // If API adds fields, you'll see a diff // Review and accept if intentional }); }
Real-World Example: Report Generation
#![allow(unused)] fn main() { test!(test_report_snapshot, { let data = vec![ Employee { name: "Alice".to_string(), salary: 50000 }, Employee { name: "Bob".to_string(), salary: 60000 }, ]; let report = generate_report(&data)?; // Snapshot the entire report assert_matches!(report, "employee_report"); // Changes to formatting or content show up immediately }); }
Workflow: Accepting Changes
When you intentionally change output:
Step 1: Run Tests
cargo test --features snapshot-testing
# Tests fail with diff if snapshot changed
Step 2: Review Diff
- Alice: 50000
+ Alice: 60000
Step 3: Accept or Reject
# Review snapshot changes
cargo insta review
# Or programmatically
insta::assert_snapshot!(output); // Accepts in CI if --accept-all
Step 4: Commit Changes
git add snapshot.snap
git commit -m "Update snapshot for salary changes"
Configuration
Snapshot Paths
Control where snapshots are stored:
#![allow(unused)] fn main() { #[test] fn test_with_custom_path() { let settings = insta::Settings::clone_current(); settings.set_snapshot_dir("tests/snapshots"); settings.bind(|| { insta::assert_snapshot!("my_test", "output"); }); } }
Snapshot Cleanup
Remove old snapshots:
# Remove unused snapshots
insta::cleanup_unused_snapshots!();
Snapshot Comparisons
Inline Snapshots
Store snapshot in test file (useful for small outputs):
#![allow(unused)] fn main() { test!(test_inline_snapshot, { let result = simple_function(); insta::assert_snapshot!("simple_function", @"expected output"); }); }
File Snapshots
Store snapshot in separate file (better for large outputs):
#![allow(unused)] fn main() { test!(test_file_snapshot, { let result = large_report(); assert_matches!(result, "large_report"); // Stored in file }); }
Best Practices
β Do:
- Use for stable, complex output
- Review diffs carefully
- Commit snapshot changes
- Version control snapshots
- Update when intentional changes occur
β Don't:
- Use for simple outputs (too much overhead)
- Use for non-deterministic output (timestamps, random data)
- Blindly accept all changes
- Skip reviewing diffs
- Use for performance data (it changes)
When to Use Snapshots
β Use for:
- API responses
- Generated reports
- Formatted output
- Complex data structures
- UI/HTML output
β Don't use for:
- Simple assertions (
assert_eq!) - Non-deterministic output
- Performance metrics
- Timestamps
Performance
Snapshots are fast:
- First run: Create snapshot (~1ms)
- Subsequent runs: Compare (~1ms)
No performance overhead.
Troubleshooting
Snapshot Not Updating
Check file permissions:
ls -la tests/snapshots/
# Should be readable/writable
Snapshot Too Long
Break into multiple snapshots:
#![allow(unused)] fn main() { // β One large snapshot assert_matches!(entire_report, "report"); // β Multiple focused snapshots assert_matches!(report.header, "report_header"); assert_matches!(report.body, "report_body"); assert_matches!(report.footer, "report_footer"); }
Non-Deterministic Output
Normalize data before snapshotting:
#![allow(unused)] fn main() { // β Timestamps change every run let output = format!("Time: {}", now()); assert_matches!(output, "output"); // β Normalize timestamps let output = "Time: [TIMESTAMP]"; assert_matches!(output, "output"); }
Combining with Other Techniques
Snapshots + Property-Based Testing
#![allow(unused)] fn main() { test!(test_snapshot_property, { let strategy = ProptestStrategy::new().with_cases(10); strategy.test(any::<u32>(), |num| { let formatted = format!("{}", num); let parsed: u32 = formatted.parse().unwrap(); // Snapshot the first case if num == 1 { assert_matches!(formatted, "formatted_number"); } num == parsed }); }); }
Snapshots + Fixtures
#![allow(unused)] fn main() { test!(test_snapshot_fixture, { let fixture = TestFixture::new()?; let report = generate_report(&fixture)?; assert_matches!(report, "fixture_report"); }); }
Next Steps
Learn CLI testing: CLI Testing
Summary
Snapshot testing:
- β Captures complex output
- β Detects unintended changes
- β Easy to review diffs
- β Great for regression detection
Perfect for generated output and API responses.
CLI Testing
π§ HOW-TO | π REFERENCE | Test command-line interfaces with golden files
Test command-line interfaces using golden files (.trycmd) to verify commands work correctly.
Why CLI Testing?
CLIs are complex because they involve:
- Argument parsing
- Environment variables
- Output formatting
- Exit codes
- Error messages
Golden file testing captures all of this:
#![allow(unused)] fn main() { // β Golden file testing test!(test_cli, { let output = run_command("myapp", vec!["list", "--verbose"]); assert_matches!(output, "cli_list_verbose"); // .trycmd file }); }
The .trycmd file contains:
$ myapp list --verbose
stdout: Item 1
Item 2
exit-code: 0
Basic CLI Testing
Creating a Test Command
#![allow(unused)] fn main() { use chicago_tdd_tools::cli::*; test!(test_cli_basic, { let output = CliTest::new("myapp", vec!["help"]) .run()?; assert!(output.contains("Usage:")); assert!(output.contains("Options:")); }); }
Testing with Arguments
#![allow(unused)] fn main() { test!(test_cli_with_args, { let output = CliTest::new("myapp", vec![ "process", "--input", "data.txt", "--output", "result.txt", "--verbose" ]).run()?; assert!(output.contains("Processing")); }); }
Testing Environment Variables
#![allow(unused)] fn main() { test!(test_cli_with_env, { let output = CliTest::new("myapp", vec!["list"]) .env("LOG_LEVEL", "DEBUG") .env("TIMEOUT", "30") .run()?; assert!(output.contains("DEBUG")); }); }
Golden File Format (.trycmd)
Golden files store expected output:
$ myapp list
Item 1
Item 2
Item 3
$ myapp list --filter active
Item 1
Item 3
Command Line
$ myapp [args]
Output
stdout:
Actual command output
Goes here
stderr:
Error output if applicable
Exit Code
exit-code: 0 (Success)
exit-code: 1 (Failure)
Real-World Example: File Tool
#![allow(unused)] fn main() { test!(test_file_commands, { // List files let list_output = CliTest::new("filetool", vec!["list"]) .run()?; assert!(list_output.contains("data.txt")); // Copy file let copy_output = CliTest::new("filetool", vec![ "copy", "source.txt", "dest.txt" ]).run()?; assert!(copy_output.contains("Copied")); // Delete file let del_output = CliTest::new("filetool", vec![ "delete", "old.txt" ]).run()?; assert!(del_output.contains("Deleted")); }); }
Real-World Example: Configuration Tool
#![allow(unused)] fn main() { test!(test_config_commands, { // Get config let output = CliTest::new("config", vec!["get", "database.host"]) .env("CONFIG_PATH", "./config.toml") .run()?; assert!(output.contains("localhost")); // Set config let output = CliTest::new("config", vec!["set", "database.port", "5433"]) .env("CONFIG_PATH", "./config.toml") .run()?; assert!(output.contains("Updated")); // List all config let output = CliTest::new("config", vec!["list"]) .run()?; assert!(output.contains("database.host")); }); }
Error Testing
Command Failures
#![allow(unused)] fn main() { test!(test_cli_errors, { // Wrong arguments let output = CliTest::new("myapp", vec!["invalid-command"]) .run(); assert!(output.is_err()); // Command failed }); }
Exit Codes
#![allow(unused)] fn main() { test!(test_exit_codes, { // Success let result = CliTest::new("myapp", vec!["list"]).run()?; assert_eq!(result.exit_code, 0); // Failure let result = CliTest::new("myapp", vec!["error"]).run()?; assert_ne!(result.exit_code, 0); }); }
Assertion Helpers
Contains
#![allow(unused)] fn main() { test!(test_cli_contains, { let output = CliTest::new("myapp", vec!["help"]).run()?; assert!(output.contains("Usage:")); }); }
Matches Pattern
#![allow(unused)] fn main() { test!(test_cli_pattern, { let output = CliTest::new("myapp", vec!["version"]).run()?; assert!(output.contains("v1.")); // Matches v1.0, v1.1, etc. }); }
Snapshot
#![allow(unused)] fn main() { test!(test_cli_snapshot, { let output = CliTest::new("myapp", vec!["help"]).run()?; assert_matches!(output, "myapp_help"); // Golden file }); }
Comprehensive CLI Test
#![allow(unused)] fn main() { test!(test_cli_comprehensive, { // Test 1: Help works let help = CliTest::new("mytool", vec!["help"]).run()?; assert!(help.contains("Usage:")); // Test 2: List works let list = CliTest::new("mytool", vec!["list"]).run()?; assert!(list.contains("Item")); // Test 3: Filter works let filtered = CliTest::new("mytool", vec![ "list", "--filter", "active" ]).run()?; assert!(filtered.contains("Item 1")); // Test 4: Sort works let sorted = CliTest::new("mytool", vec![ "list", "--sort", "name" ]).run()?; let lines: Vec<_> = sorted.lines().collect(); assert!(lines.len() >= 2); // Test 5: Output format let json = CliTest::new("mytool", vec![ "list", "--format", "json" ]).run()?; assert!(json.contains("{")); }); }
Best Practices
β Do:
- Test all major commands
- Test error cases
- Test environment variables
- Test output format
- Use golden files for complex output
β Don't:
- Hard-code full output (use snapshots)
- Test implementation details
- Ignore error exit codes
- Use shell pipes in tests
- Test external commands
When to Use CLI Testing
β Use for:
- CLI applications
- Command subcommands
- Argument parsing
- Output formatting
- Error messages
β Don't use for:
- Library functions (use unit tests)
- Web services (use integration tests)
- Complex pipelines (too fragile)
Combining with Other Techniques
CLI + Snapshots
#![allow(unused)] fn main() { test!(test_cli_snapshot, { let output = CliTest::new("myapp", vec!["help"]).run()?; assert_matches!(output, "help_output"); // Snapshot }); }
CLI + Properties
#![allow(unused)] fn main() { test!(test_cli_properties, { let strategy = ProptestStrategy::new().with_cases(100); strategy.test(any::<String>(), |cmd| { let output = CliTest::new("myapp", vec![&cmd]).run(); // Property: Command doesn't crash true // If crashes, test fails }); }); }
Troubleshooting
Test Fails with Different Output
Check for:
- Timestamps (use
[TIMESTAMP]) - UUIDs (use
[UUID]) - Paths (use relative paths)
Command Not Found
Ensure binary is built:
cargo build --bin myapp
# Then tests can run it
Flaky Tests
Normalize output:
#![allow(unused)] fn main() { let output = CliTest::new("myapp", vec!["status"]).run()?; let normalized = output.replace("2024-11-15", "[DATE]"); assert!(normalized.contains("Started on [DATE]")); }
Next Steps
Learn concurrency testing: Concurrency Testing
Summary
CLI testing:
- β Tests command-line interfaces
- β Uses golden files
- β Detects output changes
- β Verifies exit codes
Perfect for CLI applications and scripts.
Concurrency Testing
π§ HOW-TO | π REFERENCE | Test thread safety with deterministic thread ordering
Test thread-safe code with deterministic thread ordering using loom.
Why Concurrency Testing?
Normal tests run threads in random order - race conditions may not appear:
#![allow(unused)] fn main() { // β This might pass or fail randomly test!(test_race_condition, { let data = Arc::new(Mutex::new(0)); let data_clone = data.clone(); thread::spawn(move || { *data_clone.lock().unwrap() += 1; }); thread::sleep(Duration::from_millis(1)); let result = *data.lock().unwrap(); assert_eq!(result, 1); // Might fail if thread hasn't run yet }); }
Loom testing explores all possible interleavings:
#![allow(unused)] fn main() { // β This tests all possible thread orderings test!(test_with_loom, { loom::model(|| { let data = Arc::new(Mutex::new(0)); let data_clone = data.clone(); thread::spawn(move || { *data_clone.lock().unwrap() += 1; }); let result = *data.lock().unwrap(); assert_eq!(result, 1); // Tests all interleavings }); }); }
Basic Loom Testing
Simple Loom Model
#![allow(unused)] fn main() { use chicago_tdd_tools::concurrency::*; use std::sync::{Arc, Mutex}; test!(test_basic_loom, { loom::model(|| { let data = Arc::new(Mutex::new(0)); let value = *data.lock().unwrap(); assert_eq!(value, 0); }); }); }
Two Threads
#![allow(unused)] fn main() { test!(test_two_threads, { loom::model(|| { let data = Arc::new(Mutex::new(0)); let data_clone = data.clone(); thread::spawn(move || { *data_clone.lock().unwrap() += 1; }); let result = *data.lock().unwrap(); // Loom tests both possible interleavings: // 1. Main thread reads first (0) // 2. Worker thread increments first (1) }); }); }
Real-World Example: Counter
#![allow(unused)] fn main() { test!(test_concurrent_counter, { loom::model(|| { let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; // Spawn 3 threads for _ in 0..3 { let counter = counter.clone(); let handle = thread::spawn(move || { *counter.lock().unwrap() += 1; }); handles.push(handle); } // Wait for all threads for handle in handles { handle.join().unwrap(); } // All threads should have incremented assert_eq!(*counter.lock().unwrap(), 3); }); }); }
Real-World Example: Channel Communication
#![allow(unused)] fn main() { test!(test_channel_communication, { loom::model(|| { let (tx, rx) = std::sync::mpsc::channel(); thread::spawn(move || { tx.send(42).unwrap(); }); let value = rx.recv().unwrap(); assert_eq!(value, 42); }); }); }
Common Concurrency Patterns
Mutex Protection
#![allow(unused)] fn main() { test!(test_mutex_safety, { loom::model(|| { let data = Arc::new(Mutex::new(vec![])); let data_clone = data.clone(); thread::spawn(move || { data_clone.lock().unwrap().push(1); }); data.lock().unwrap().push(2); let result = data.lock().unwrap(); assert_eq!(result.len(), 2); }); }); }
RwLock (Reader-Writer Lock)
#![allow(unused)] fn main() { test!(test_rwlock, { loom::model(|| { let data = Arc::new(RwLock::new(0)); let data_clone = data.clone(); thread::spawn(move || { *data_clone.write().unwrap() = 42; }); let value = *data.read().unwrap(); assert_eq!(value, 42); }); }); }
Atomic Operations
#![allow(unused)] fn main() { test!(test_atomic, { loom::model(|| { use std::sync::atomic::{AtomicU32, Ordering}; let counter = Arc::new(AtomicU32::new(0)); let counter_clone = counter.clone(); thread::spawn(move || { counter_clone.fetch_add(1, Ordering::SeqCst); }); let value = counter.load(Ordering::SeqCst); // Value might be 0 or 1 depending on scheduling }); }); }
Detecting Race Conditions
Race Condition Example
#![allow(unused)] fn main() { test!(test_detects_race_condition, { loom::model(|| { let data = Arc::new(Cell::new(0)); // β Not thread-safe! let data_clone = data.clone(); thread::spawn(move || { data_clone.set(data_clone.get() + 1); }); // This will fail with loom! // Cell doesn't provide synchronization }); }); }
Loom detects this because Cell isn't thread-safe.
Use Mutex Instead
#![allow(unused)] fn main() { test!(test_thread_safe, { loom::model(|| { let data = Arc::new(Mutex::new(0)); // β Thread-safe let data_clone = data.clone(); thread::spawn(move || { *data_clone.lock().unwrap() += 1; }); let result = *data.lock().unwrap(); // Now safe for all interleavings }); }); }
Testing for Deadlocks
Loom can detect potential deadlocks:
#![allow(unused)] fn main() { test!(test_deadlock_detection, { loom::model(|| { let lock1 = Arc::new(Mutex::new(0)); let lock2 = Arc::new(Mutex::new(0)); let (lock1_clone, lock2_clone) = (lock1.clone(), lock2.clone()); thread::spawn(move || { // Thread 1: Lock in order lock1, lock2 let _g1 = lock1_clone.lock().unwrap(); let _g2 = lock2_clone.lock().unwrap(); }); // Main thread: Lock in opposite order lock2, lock1 // Loom will explore both interleavings // Can detect potential deadlock! }); }); }
Best Practices
β Do:
- Test small, focused scenarios
- Use appropriate synchronization primitives
- Test with few threads (2-3 typical)
- Verify all possible interleavings
- Use Loom for critical concurrent code
β Don't:
- Test large thread pools (explodes combinations)
- Mix blocking I/O with Loom (I/O not deterministic)
- Over-test (Loom is slow, only use for critical code)
- Assume one test covers all cases
Performance
Loom explores all interleavings - it's slow:
- Simple model (2 threads): 10ms - 100ms
- Complex model (3 threads): 100ms - 1s
- Many threads: Can be very slow
Recommendation:
- Only use Loom for critical synchronization
- Test with 2-3 threads, not more
- Use normal tests for non-concurrent code
Limitations
Loom only works with:
- Loom-aware primitives (
loom::sync) - Thread creation (
loom::thread) - Standard Rust types it instruments
Cannot test:
- Real time (time is controlled)
- System I/O (returns dummy values)
- External libraries (unless they use loom)
Combining with Other Techniques
Concurrency + Property-Based
#![allow(unused)] fn main() { test!(test_concurrent_property, { loom::model(|| { let counter = Arc::new(Mutex::new(0)); for i in 0..5 { let counter = counter.clone(); thread::spawn(move || { *counter.lock().unwrap() += i; }); } // Verify invariant holds let sum: u32 = (0..5).sum(); assert!(*counter.lock().unwrap() <= sum); }); }); }
Real-World Integration Example
#![allow(unused)] fn main() { test!(test_thread_pool_safety, { loom::model(|| { let task_queue = Arc::new(Mutex::new(vec![])); let result_queue = Arc::new(Mutex::new(vec![])); // Producer { let queue = task_queue.clone(); thread::spawn(move || { queue.lock().unwrap().push("task1"); }); } // Consumer { let task_queue = task_queue.clone(); let result_queue = result_queue.clone(); thread::spawn(move || { if let Some(task) = task_queue.lock().unwrap().pop() { result_queue.lock().unwrap().push(format!("done: {}", task)); } }); } // Verify result eventually // (Loom explores all orderings) }); }); }
Troubleshooting
"Too many interleavings"
Reduce complexity:
- Use fewer threads
- Smaller critical sections
- Simpler synchronization patterns
"This synchronization is not supported"
Use only Loom-supported primitives:
loom::sync::Mutexloom::sync::RwLockstd::sync::atomicstd::sync::mpsc
Test Still Hangs/Deadlocks
Loom doesn't catch all deadlocks. Use timeouts:
#![allow(unused)] fn main() { #[test] #[timeout = "5s"] // Add timeout fn test_with_timeout() { loom::model(|| { // Test code }); } }
Next Steps
Learn the "Go the Extra Mile" pattern: Go the Extra Mile
Summary
Concurrency testing with Loom:
- β Tests all possible thread interleavings
- β Detects race conditions
- β Verifies synchronization correctness
- β Finds potential deadlocks
Use for critical concurrent code to ensure thread safety.
The "Go the Extra Mile" Pattern
π EXPLANATION | Learn the three-idea framework for design decisions
The "Go the Extra Mile" pattern demonstrates progressive enhancement from simple solutions to maximum-value solutions.
The Three Ideas Framework
1st Idea: Solve the Problem
Minimal scope, just solves the immediate need:
#![allow(unused)] fn main() { // 1st Idea: Parse u32 only pub fn parse_u32(input: &str) -> Result<u32, String> { input.parse().map_err(|e| format!("Parse error: {e}")) } }
Characteristics:
- Single type (
u32only) - No telemetry
- No validation
- Solves the problem β
2nd Idea: 80/20 Sweet Spot
Generic version with significant value added:
#![allow(unused)] fn main() { // 2nd Idea: Generic parser pub fn parse_number<T: FromStr>(input: &str) -> Result<T, String> where T::Err: Display, { input.parse().map_err(|e| format!("Parse error: {e}")) } }
Characteristics:
- Works for all number types (
u32,i32,f64, etc.) - Minimal additional effort
- 80% more value
- 20% more work
When to use: Most of the time (best cost/benefit ratio)
3rd Idea: Maximum Value
Full-featured solution with complete correctness:
#![allow(unused)] fn main() { // 3rd Idea: Type-validated with OTEL instrumentation pub struct ValidatedNumber<T> { value: T, span: Span, // OTEL instrumentation } impl<T: FromStr> ValidatedNumber<T> { pub fn parse(input: &str, span_name: &str) -> Result<Self, String> { // Type-level validation prevents errors // OTEL spans provide observability // Weaver validation ensures compliance } } }
Characteristics:
- Type-level validation (prevents entire classes of errors)
- OTEL instrumentation (observability)
- Weaver validation (schema compliance)
- Maximum value
- Significant additional effort
When to use: For critical code where correctness is paramount
Decision Framework
Does the code need:
ββ Basic functionality only?
β ββ 1st Idea β
ββ Works for multiple types + some observability?
β ββ 2nd Idea β (usually best choice)
ββ Type safety + full observability + validation?
ββ 3rd Idea β (for critical paths)
Real-World Example: Configuration Loader
1st Idea: Load from ENV
#![allow(unused)] fn main() { pub fn load_config() -> Result<Config, String> { let host = std::env::var("DB_HOST") .map_err(|e| format!("Missing DB_HOST: {e}"))?; let port = std::env::var("DB_PORT") .map_err(|e| format!("Missing DB_PORT: {e}"))? .parse::<u16>() .map_err(|e| format!("Invalid port: {e}"))?; Ok(Config { host, port }) } }
β Works β Only ENV, no file support, no validation
2nd Idea: ENV + File Support
#![allow(unused)] fn main() { pub fn load_config(source: &str) -> Result<Config, String> { match source { "env" => load_from_env(), "file" => load_from_file(), _ => Err("Invalid source".to_string()), } } fn load_from_env() -> Result<Config, String> { /* ... */ } fn load_from_file() -> Result<Config, String> { /* ... */ } }
β Works for multiple sources β 80% more value (supports files, ENV) β Best choice for most cases
3rd Idea: Type-Safe with Validation + OTEL
#![allow(unused)] fn main() { pub struct ValidatedConfig { config: Config, span: Span, // OTEL span } impl ValidatedConfig { pub fn load(source: &str, span_name: &str) -> Result<Self, String> { let start = SystemTime::now(); // Load config let config = load_config(source)?; // Validate validate_config(&config)?; // Create OTEL span let mut span = create_span(span_name); span.attributes.insert("source".to_string(), source.to_string()); let end = SystemTime::now(); span.complete(end.duration_since(start).ok()?)?; Ok(Self { config, span }) } } }
β Type-safe configuration β OTEL instrumentation β Validation enforcement β For mission-critical systems
Applying the Pattern: Step by Step
Step 1: Start Simple
Write the simplest thing that works:
#![allow(unused)] fn main() { pub fn process_user(id: u32) -> Result<User, String> { // Query database let user = query_db(id)?; Ok(user) } }
Step 2: Consider 80/20
Does adding a feature provide disproportionate value?
#![allow(unused)] fn main() { // 2nd Idea: Support both ID and email lookup pub fn get_user(identifier: &str) -> Result<User, String> { if let Ok(id) = identifier.parse::<u32>() { query_db_by_id(id) } else { query_db_by_email(identifier) } } }
Cost: +10 lines Value: 80% more functionality
Step 3: Evaluate Going Further
Is maximum value worth the effort?
#![allow(unused)] fn main() { // 3rd Idea: Type-safe, validated, instrumented pub struct ValidatedUser { user: User, span: Span, } impl ValidatedUser { pub fn get(identifier: &str) -> Result<Self, String> { // Validation + OTEL + error handling } } }
Cost: +50 lines Value: Type safety + observability
Decision: Only go to 3rd idea if the value justifies the effort.
When to Stop at 1st Idea
β For utilities that are:
- Well-isolated
- Simple logic
- Low risk
- Rarely changed
#![allow(unused)] fn main() { // 1st idea is fine here - simple utility pub fn format_currency(amount: f64) -> String { format!("${:.2}", amount) } }
When to Use 2nd Idea (Most Common)
β For code that is:
- Reused in multiple places
- Needs flexibility
- Not mission-critical
- Has room for improvements
#![allow(unused)] fn main() { // 2nd idea - generic, flexible, good value pub fn parse<T: FromStr>(input: &str) -> Result<T, String> { input.parse().map_err(|e| format!("Parse error: {e}")) } }
When to Use 3rd Idea
β For code that is:
- Mission-critical (payments, security, core logic)
- Needs full observability
- Must prevent errors at compile time
- Complex enough to benefit from type system
#![allow(unused)] fn main() { // 3rd idea - type-safe, critical path pub struct ValidatedPayment { amount: PositiveAmount, currency: ValidatedCurrency, span: Span, } }
Combining Ideas in One System
A production system uses all three:
#![allow(unused)] fn main() { // 1st Idea: Simple utilities fn format_time(secs: u64) -> String { /* simple */ } // 2nd Idea: Core operations (most code) fn parse_config(source: &str) -> Result<Config, String> { /* generic */ } // 3rd Idea: Mission-critical operations struct ValidatedPayment { /* type-safe, instrumented */ } }
Benefits of This Pattern
- Clear thinking: Forces you to consider scope and value
- Cost-benefit: Justified effort for each level
- Flexibility: Easy to upgrade later
- Clarity: Team understands why certain code is complex
Common Mistakes
β Always using 3rd idea
- Over-engineered simple code
- Too much complexity
- Slower development
β Use appropriate idea level
β Stuck at 1st idea
- Limited by narrow scope
- Duplicate code
- Poor reusability
β Identify when 2nd idea helps
β Skipping evaluation
- Random complexity levels
- Inconsistent codebase
β Evaluate intentionally
Practical Checklist
For each piece of code, ask:
-
Does 1st idea solve the problem?
- If no β Can't proceed
- If yes β Consider 2nd idea
-
Would 2nd idea add 80% value with 20% effort?
- If no β Stop at 1st idea
- If yes β Consider 2nd idea
-
Does 3rd idea add critical value?
- If mission-critical β Use 3rd idea
- If improved but not critical β Use 2nd idea
- If over-engineering β Use 1st or 2nd idea
Real-World Example: Web Service
GET User Endpoint
1st Idea (minimal):
#![allow(unused)] fn main() { pub fn get_user(id: u32) -> Result<User, String> { query_database(id) } }
2nd Idea (flexible, instrumented):
#![allow(unused)] fn main() { pub fn get_user(id: u32) -> Result<(User, Span), String> { let span = create_span("get_user"); let user = query_database(id)?; Ok((user, span)) } }
3rd Idea (type-safe, fully instrumented):
#![allow(unused)] fn main() { pub struct ValidatedUserResponse { user: ValidatedUser, span: Span, } impl ValidatedUserResponse { pub fn get(id: ValidUserId) -> Result<Self, String> { // Type-safe, instrumented, validated } } }
Recommendation: Use 2nd idea for most endpoints. Only use 3rd for sensitive data (auth, payments).
Next Steps
- Observability & Quality - Implement 2nd and 3rd ideas
- Real-World Applications - See complete examples
Summary
The "Go the Extra Mile" pattern:
1st Idea: Minimal, solves the problem 2nd Idea: 80% more value, 20% more effort (usually best) 3rd Idea: Maximum value, significant effort (for critical paths)
Use this framework to make intentional design decisions.
Observability & Quality
π§ HOW-TO | π REFERENCE | Add observability and measure quality
Chicago TDD Tools provides comprehensive observability and quality measurement capabilities.
Quick Reference: Observability API
| Component | Purpose | Key Methods/Fields |
|---|---|---|
Span::new_active() | Create an OTEL span | Parameters: context, name, start_time, attributes, events, status |
Span.attributes | Store span metadata | .insert(key, value) |
Span.complete() | Finish span timing | Parameter: end_time |
Span.status | Set result status | Variants: Ok, Error, Unknown |
Span.validate() | Verify span correctness | Returns: Result<(), Error> |
Metric struct | Track measurements | Fields: name, value, timestamp_ms, attributes |
MetricValue enum | Metric types | Variants: Counter, Gauge, Histogram |
MetricValidator::new() | Validate metrics | .validate(&metric) |
WeaverValidator::check_weaver_available() | Check Weaver CLI | Returns: Result<(), Error> |
SpanValidator::new() | Validate span format | .validate(&span) |
Overview
Observability helps you understand what your code is doing:
- OTEL Instrumentation: Track operations with spans and metrics
- Weaver Validation: Ensure telemetry matches semantic conventions
- Coverage Measurement: Verify test coverage
- Performance Tracking: Measure operation timing
OTEL Spans
OTEL (OpenTelemetry) spans track operations:
#![allow(unused)] fn main() { use chicago_tdd_tools::otel::*; test!(test_with_span, { let start_time = SystemTime::now() .duration_since(UNIX_EPOCH) .ok()? .as_millis() as u64; // Create a span let mut span = Span::new_active( SpanContext::root(TraceId(123), SpanId(456), 1), "parse_operation", start_time, BTreeMap::new(), Vec::new(), SpanStatus::Unset, ); // Add attributes span.attributes.insert("input".to_string(), "42".to_string()); // Do work let result = "42".parse::<u32>()?; // Complete span let end_time = SystemTime::now() .duration_since(UNIX_EPOCH) .ok()? .as_millis() as u64; span.complete(end_time)?; span.status = SpanStatus::Ok; // Span is now complete with timing assert_ok!(&span.validate()); }); }
OTEL Metrics
Track measurements over time:
#![allow(unused)] fn main() { use chicago_tdd_tools::otel::*; test!(test_with_metric, { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .ok()? .as_millis() as u64; let mut metric = Metric { name: "requests_total".to_string(), value: MetricValue::Counter(42), timestamp_ms: timestamp, attributes: BTreeMap::new(), }; metric.attributes.insert("endpoint".to_string(), "/api/users".to_string()); metric.attributes.insert("status".to_string(), "success".to_string()); // Validate metric let validator = MetricValidator::new(); assert_ok!(&validator.validate(&metric)); }); }
Weaver Live-Check
Validate telemetry against semantic conventions:
#![allow(unused)] fn main() { test!(test_weaver_validation, { // Check if Weaver is available match WeaverValidator::check_weaver_available() { Ok(()) => { // Weaver is available // Can validate OTEL spans against semantic conventions alert_success!("Weaver available"); } Err(e) => { alert_info!("Weaver not available: {}", e); alert_info!("Install with: cargo make weaver-bootstrap"); } } }); }
Coverage Measurement
Measure test coverage:
# Run coverage
cargo make coverage
# Generate report
cargo make coverage-report
Coverage shows:
- Code coverage percentage
- Covered lines
- Uncovered lines
- Branch coverage
Target: 80%+ coverage for critical code
Best Practices
OTEL Spans
β Do:
- Add meaningful attributes
- Track timing
- Mark errors with SpanStatus
- Propagate context between services
β Don't:
- Create spans for every operation (too noisy)
- Include sensitive data in attributes
- Forget to complete spans
Weaver Validation
β Do:
- Use semantic conventions
- Validate telemetry early
- Document telemetry schema
- Keep conventions up-to-date
β Don't:
- Use custom attribute names
- Skip validation
- Ignore schema mismatches
Coverage
β Do:
- Aim for 80%+ coverage
- Focus on critical paths
- Test error paths (often uncovered)
- Review coverage reports
β Don't:
- Obsess over 100% coverage
- Ignore untested lines
- Only focus on coverage number
Combining Observability with Testing
OTEL + Unit Tests
#![allow(unused)] fn main() { test!(test_with_otel, { let span = create_test_span("my_operation"); // Do work let result = my_function()?; // Verify behavior AND telemetry assert_ok!(&result); span.validate()?; assert_eq!(span.status, SpanStatus::Ok); }); }
Metrics + Property-Based Testing
#![allow(unused)] fn main() { test!(test_with_metrics, { let strategy = ProptestStrategy::new().with_cases(100); strategy.test(any::<u32>(), |num| { let timestamp = SystemTime::now()...; let mut metric = Metric { name: "parsing_attempts".to_string(), value: MetricValue::Counter(1), timestamp_ms: timestamp, attributes: BTreeMap::new(), }; // Validate metric let validator = MetricValidator::new(); validator.validate(&metric).is_ok() }); }); }
Real-World Example
#![allow(unused)] fn main() { test!(test_api_with_observability, { // Span for entire operation let mut operation_span = Span::new_active( SpanContext::root(TraceId(1), SpanId(1), 1), "api_request", start_time, BTreeMap::new(), Vec::new(), SpanStatus::Unset, ); // Make API call let result = api_client.get_user(123)?; // Record metric let mut metric = Metric { name: "api_requests_total".to_string(), value: MetricValue::Counter(1), timestamp_ms: current_time, attributes: { let mut m = BTreeMap::new(); m.insert("endpoint".to_string(), "/users".to_string()); m.insert("status".to_string(), "success".to_string()); m }, }; // Validate everything let span_validator = SpanValidator::new(); assert_ok!(&span_validator.validate(&operation_span)); let metric_validator = MetricValidator::new(); assert_ok!(&metric_validator.validate(&metric)); }); }
Observability Checklist
For production code:
- Operations tracked with OTEL spans
- Meaningful attributes on spans
- Metrics for important measurements
- Error cases marked in telemetry
- Telemetry validates against conventions
- 80%+ test coverage
- Error paths covered
- Boundary conditions tested
Next Steps
See how to combine observability with real applications: Real-World Applications
Summary
Observability provides:
- β OTEL spans for operation tracking
- β Metrics for measurements
- β Weaver validation for compliance
- β Coverage for test quality
Combined with testing for complete confidence.
OTEL Instrumentation
π§ HOW-TO | π REFERENCE | Add OpenTelemetry observability to code
OpenTelemetry instrumentation provides observability into your operations.
Creating Spans
#![allow(unused)] fn main() { use chicago_tdd_tools::otel::*; use std::time::{SystemTime, UNIX_EPOCH}; use std::collections::BTreeMap; test!(test_span_creation, { let start_time = SystemTime::now() .duration_since(UNIX_EPOCH) .ok()? .as_millis() as u64; let mut span = Span::new_active( SpanContext::root(TraceId(12345), SpanId(67890), 1), "parse_user_data", start_time, BTreeMap::new(), Vec::new(), SpanStatus::Unset, ); // Add attributes span.attributes.insert("user_id".to_string(), "123".to_string()); span.attributes.insert("operation".to_string(), "parse".to_string()); // Complete span let end_time = SystemTime::now() .duration_since(UNIX_EPOCH) .ok()? .as_millis() as u64; span.complete(end_time)?; span.status = SpanStatus::Ok; assert_eq!(span.status, SpanStatus::Ok); }); }
Span Status
Mark success or error:
#![allow(unused)] fn main() { // Success span.status = SpanStatus::Ok; // Error span.status = SpanStatus::Error; // Unset span.status = SpanStatus::Unset; }
Creating Metrics
#![allow(unused)] fn main() { test!(test_metric_creation, { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .ok()? .as_millis() as u64; let mut metric = Metric { name: "parsing_operations_total".to_string(), value: MetricValue::Counter(1), timestamp_ms: timestamp, attributes: BTreeMap::new(), }; metric.attributes.insert("type".to_string(), "user".to_string()); metric.attributes.insert("success".to_string(), "true".to_string()); // Metric is ready to send to observability backend assert_eq!(metric.name, "parsing_operations_total"); }); }
Span Validation
#![allow(unused)] fn main() { test!(test_span_validation, { let span = create_valid_span()?; let validator = SpanValidator::new(); assert_ok!(&validator.validate(&span)); }); }
Best Practices
β Do:
- Use consistent span names
- Add meaningful attributes
- Track operation timing
- Mark errors explicitly
β Don't:
- Include sensitive data in attributes
- Create excessive spans
- Forget timing information
Next Steps
Learn more:
- Weaver Validation - Validate telemetry against semantic conventions
- Observability & Quality - Full observability framework
- Best Practices - Observability patterns
Ready to instrument?
- Add spans to your critical code paths
- Include meaningful attributes for debugging
- Validate spans with
SpanValidator - Combine with Weaver for compliance checking
Weaver Live-Check Validation
π§ HOW-TO | π REFERENCE | Validate telemetry against semantic conventions
Weaver validates telemetry against semantic conventions.
What is Weaver?
Weaver ensures your OTEL telemetry complies with OpenTelemetry semantic conventions - the industry standard for attribute names and structure.
Checking Weaver Availability
#![allow(unused)] fn main() { use chicago_tdd_tools::observability::weaver::WeaverValidator; test!(test_weaver_check, { match WeaverValidator::check_weaver_available() { Ok(()) => { alert_success!("Weaver is available"); // Can validate telemetry } Err(e) => { alert_info!("Weaver not available: {}", e); // Install with: cargo make weaver-bootstrap } } }); }
Installing Weaver
# Bootstrap Weaver
cargo make weaver-bootstrap
# Run smoke test
cargo make weaver-smoke
Semantic Conventions
Weaver checks that your attributes follow conventions:
#![allow(unused)] fn main() { // β Correct - follows semantic conventions let mut span = create_span("http.request"); span.attributes.insert("http.method".to_string(), "GET".to_string()); span.attributes.insert("http.target".to_string(), "/api/users".to_string()); span.attributes.insert("http.status_code".to_string(), "200".to_string()); // β Wrong - custom attributes span.attributes.insert("method".to_string(), "GET".to_string()); span.attributes.insert("endpoint".to_string(), "/api/users".to_string()); }
Common Conventions
| Attribute | Format | Example |
|---|---|---|
http.method | HTTP method | GET, POST, PUT |
http.status_code | Integer | 200, 404, 500 |
http.target | Path | /api/users |
db.system | Database type | mysql, postgresql |
db.operation | SQL operation | SELECT, INSERT |
Best Practices
β Do:
- Follow semantic conventions
- Validate with Weaver
- Document telemetry schema
- Keep conventions up-to-date
β Don't:
- Use custom attribute names
- Ignore Weaver validation
- Duplicate information in attributes
Troubleshooting
Weaver Binary Not Found
Install Weaver:
cargo make weaver-bootstrap
Validation Fails
Check attribute names against conventions:
# See Weaver registry
cargo make weaver-smoke
Next Steps
Combine observability with testing: Observability & Quality
Coverage & Performance
π§ HOW-TO | π REFERENCE | Measure test coverage and performance
Measure test coverage and performance metrics.
Test Coverage
Coverage shows which code is executed by tests:
# Generate coverage report
cargo make coverage
# View coverage report
cargo make coverage-report
Coverage Metrics
- Line Coverage: % of lines executed
- Branch Coverage: % of branches executed
- Function Coverage: % of functions executed
Target Coverage
- Minimum: 70% (warning level)
- Target: 80%+ (good)
- Excellent: 90%+ (very thorough)
β Don't obsess over 100% (often impossible/impractical)
Improving Coverage
Focus on uncovered lines:
#![allow(unused)] fn main() { // β Uncovered error path if let Err(e) = operation() { // This might not be tested log_error(e); } // β Test the error path too test!(test_error_handling, { let result = risky_operation(); assert_err!(&result); }); }
Performance Testing
Measure operation timing:
#![allow(unused)] fn main() { test!(performance_test, { let start = std::time::Instant::now(); // Code to benchmark for _ in 0..1000 { let _result = parse_number("42"); } let elapsed = start.elapsed(); println!("Time for 1000 parses: {:?}", elapsed); // Assert performance target assert!(elapsed.as_millis() < 100); // < 100ms }); }
Performance Targets
| Operation | Target | Too Slow |
|---|---|---|
| Parse number | <1ΞΌs | >10ΞΌs |
| Database query | <10ms | >100ms |
| API call | <100ms | >1s |
| Test execution | <10ms | >100ms |
Profiling
# Run performance tests
cargo make test-timings
# Profile with cargo flamegraph (if installed)
cargo flamegraph --test performance_tests
Combining Coverage and Performance
Track both:
#![allow(unused)] fn main() { test!(comprehensive_test, { let start = std::time::Instant::now(); // Success path (covered) let ok = parse_number("42"); assert_ok!(&ok); // Error path (covered) let err = parse_number("invalid"); assert_err!(&err); // Performance assertion let elapsed = start.elapsed(); assert!(elapsed.as_millis() < 10); }); }
Best Practices
β Coverage:
- Aim for 80%+ overall
- Focus on critical paths
- Test error cases
- Review uncovered lines
β Coverage:
- Don't chase 100%
- Don't test generated code
- Don't test trivial code
β Performance:
- Set realistic targets
- Measure on target hardware
- Profile before optimizing
- Test under load
β Performance:
- Optimize prematurely
- Ignore benchmarks
- Assume fast
- Measure on dev machine only
Next Steps
Learn more:
- Mutation Testing - Validate test quality beyond coverage
- Best Practices - Quality assurance patterns
- Observability - Track real-world metrics
Ready to measure?
- Run
cargo make coverageto see current coverage - Identify uncovered error paths
- Add tests for critical code paths
- Set performance baselines with
cargo make test-timings
Real-World Applications
π TUTORIAL | See complete examples of Chicago TDD Tools in action
See complete examples of Chicago TDD Tools in action.
CLI Application Example
The playground includes a complete CLI tool with multiple commands.
Running the Playground
# Build the playground
cargo build --release -p playground
# Run CLI help
./target/release/playground help
# Run specific commands
./target/release/playground test --help
./target/release/playground quality --help
./target/release/playground obs --help
Playground Structure
playground/
βββ src/
β βββ main.rs # CLI entry point
β βββ lib.rs # Library exports
β βββ cli/ # Subcommands
β βββ test.rs # Testing commands
β βββ quality.rs # Quality commands
β βββ obs.rs # Observability commands
β βββ ...
βββ tests/ # Integration tests
βββ core_tests.rs
βββ testing_tests.rs
βββ integration_tests.rs
Example Commands
# Test fixtures
playground test fixtures
# Run mutation tests
playground quality mutation
# Check OTEL compliance
playground obs validate
# Generate coverage
playground quality coverage
Example-Based Learning
Example: basic_test.rs
Demonstrates core patterns:
- Fixture creation
- Data builders
- Assertions
- Error handling
cargo run --example basic_test
Example: property_testing.rs
Property-based testing:
- Random data generation
- Property verification
- Shrinking failed cases
cargo run --example property_testing --features property-testing
Example: go_extra_mile.rs
Progressive enhancement:
- 1st idea (basic)
- 2nd idea (generic)
- 3rd idea (validated)
cargo run --example go_extra_mile --features otel,weaver
Integration Testing Patterns
With Docker Containers
#![allow(unused)] fn main() { test!(test_with_docker_db, { // Requires Docker to be running let fixture = TestFixture::new()?; // Fixture provides Docker container support // (when testcontainers feature enabled) // Run test against real database let result = query_database(&fixture)?; assert_ok!(&result); }); }
Run with:
cargo make test-integration
Testing Workflows
Quick Feedback Loop (5 seconds)
# Format + check + unit tests
cargo make pre-commit
# Then fix issues
Comprehensive Testing (1-2 minutes)
# Format + lint + all tests
cargo make test-all
# Includes integration tests (requires Docker)
Release Validation (5-10 minutes)
# Full validation before release
cargo make release-validate
# Includes:
# - All tests
# - Coverage
# - Mutation testing
# - Documentation
Architecture Patterns
Fixture-Based Setup
#![allow(unused)] fn main() { test!(test_with_fixture, { // Arrange: Create isolated fixture let fixture = TestFixture::new()?; // Act: Use fixture in test let result = process(&fixture)?; // Assert: Verify behavior assert_ok!(&result); // Cleanup: Automatic (fixture dropped) }); }
Builder-Driven Test Data
#![allow(unused)] fn main() { test!(test_with_builders, { let user = TestDataBuilder::new() .with_var("name", "Alice") .with_var("email", "alice@example.com") .build_json()?; let result = create_user(&user)?; assert_ok!(&result); }); }
Property-Based Coverage
#![allow(unused)] fn main() { test!(test_property, { let strategy = ProptestStrategy::new().with_cases(1000); strategy.test(any::<(u32, u32)>(), |(a, b)| { a + b == b + a // Commutativity }); }); }
Complete Example: User Service
#![allow(unused)] fn main() { test!(complete_user_service_test, { // Setup let fixture = TestFixture::new()?; // Create user with builder let user_data = TestDataBuilder::new() .with_var("name", "Alice") .with_var("email", "alice@example.com") .build_json()?; // Act: Create let create_result = create_user(&user_data)?; assert_ok!(&create_result); let user = create_result.unwrap(); // Act: Read let read_result = get_user(user.id)?; assert_ok!(&read_result); assert_eq!(read_result.unwrap().name, "Alice"); // Act: Update let mut updated = user.clone(); updated.email = "alice.new@example.com".to_string(); let update_result = update_user(&updated)?; assert_ok!(&update_result); // Act: Delete let delete_result = delete_user(user.id)?; assert_ok!(&delete_result); // Verify deleted let read_result = get_user(user.id); assert_err!(&read_result); }); }
Best Practices from Examples
β From examples:
- Clear Arrange-Act-Assert structure
- Comprehensive error testing
- Progressive complexity
- Reusable patterns
β From playground:
- Multiple testing techniques
- Integration with Docker
- CLI testing patterns
- Quality metrics
Next Steps
Apply what you've learned:
- Start with Core Patterns
- Add Advanced Techniques
- Implement Observability
- Follow Best Practices
Building a CLI Application
π TUTORIAL | Complete example of testing a CLI application
Complete example of testing a CLI application with Chicago TDD Tools.
Project Structure
myapp/
βββ src/
β βββ main.rs # CLI entry point
β βββ commands/
β βββ list.rs # List command
β βββ add.rs # Add command
β βββ delete.rs # Delete command
βββ tests/
βββ cli_tests.rs # CLI integration tests
βββ commands_tests.rs
Testing CLI Commands
Example: List Command Test
#![allow(unused)] fn main() { test!(test_list_command, { let output = CliTest::new("myapp", vec!["list"]) .run()?; assert!(output.contains("Item")); assert!(output.exit_code == 0); }); }
Example: Add Command Test
#![allow(unused)] fn main() { test!(test_add_command, { let output = CliTest::new("myapp", vec![ "add", "--name", "New Item", "--priority", "high" ]).run()?; assert!(output.contains("Added")); assert!(output.exit_code == 0); }); }
Example: Error Handling
#![allow(unused)] fn main() { test!(test_invalid_command, { let result = CliTest::new("myapp", vec!["invalid"]) .run(); assert!(result.is_err()); }); }
Best Practices for CLI Testing
β Do:
- Test all commands
- Test argument combinations
- Test error cases
- Use snapshots for complex output
- Test environment variables
β Don't:
- Hard-code full output
- Test shell integration
- Test external tools
See: CLI Testing
Next Steps
Learn more:
- CLI Testing Guide - Deep dive into trycmd patterns
- Best Practices - CLI testing best practices
- Web Service Testing - Compare with testing web services
Ready to test?
- Set up a new project with Chicago TDD Tools
- Start with a simple command
- Add tests as you build features
Testing a Web Service
π TUTORIAL | Complete example of testing a web service
Complete example of testing a web service with Chicago TDD Tools.
Project Structure
myservice/
βββ src/
β βββ main.rs
β βββ handlers/
β β βββ users.rs
β β βββ orders.rs
β β βββ auth.rs
β βββ models/
βββ tests/
βββ integration_tests.rs
βββ api_tests.rs
Testing Endpoints
Example: GET /users
#![allow(unused)] fn main() { test!(test_get_users, { let client = TestClient::new()?; // Act let response = client.get("/users")?; // Assert assert_eq!(response.status, 200); let users: Vec<User> = response.json()?; assert!(!users.is_empty()); }); }
Example: POST /users
#![allow(unused)] fn main() { test!(test_create_user, { let client = TestClient::new()?; let user_data = TestDataBuilder::new() .with_var("name", "Alice") .with_var("email", "alice@example.com") .build_json()?; // Act let response = client.post("/users", &user_data)?; // Assert assert_eq!(response.status, 201); // Created assert_ok!(&response.json::<User>()); }); }
Example: Error Cases
#![allow(unused)] fn main() { test!(test_create_user_validation_error, { let client = TestClient::new()?; let invalid_data = TestDataBuilder::new() .with_var("email", "not_an_email") .build_json()?; // Act let response = client.post("/users", &invalid_data)?; // Assert assert_eq!(response.status, 400); // Bad request }); }
Testing with Real Database
Use integration tests with fixtures:
#![allow(unused)] fn main() { test!(test_with_database, { let fixture = TestFixture::new()?; // Fixture provides database connection let db = fixture.db_connection(); // Create user in database let user = db.create_user("Alice", "alice@example.com")?; // Test retrieval let retrieved = db.get_user(user.id)?; assert_eq!(retrieved.name, "Alice"); }); }
Testing Authentication
#![allow(unused)] fn main() { test!(test_auth_required, { let client = TestClient::new()?; // No authentication let response = client.get("/protected")?; assert_eq!(response.status, 401); // Unauthorized // With authentication let token = client.login("alice", "password")?; let response = client.get_with_auth("/protected", &token)?; assert_eq!(response.status, 200); }); }
Best Practices
β Do:
- Test with real database (in tests)
- Test all HTTP methods (GET, POST, PUT, DELETE)
- Test error cases (400, 401, 404, 500)
- Test response structure
- Use fixtures for isolation
β Don't:
- Mock the entire HTTP layer
- Test framework code
- Hard-code full responses
Testing Workflow
# 1. Unit tests (fast)
cargo make test-unit
# 2. Integration tests (requires database)
cargo make test-integration
# 3. Full CI simulation
cargo make ci-local
See: Advanced Techniques
Next Steps
Learn more:
- Integration Testing with Docker - Test with real services
- Best Practices - Web service testing patterns
- CLI Application Testing - Compare with CLI testing
- Observability - Add OTEL instrumentation
Ready to build?
- Create a simple REST API
- Test each endpoint (GET, POST, PUT, DELETE)
- Test error cases and authentication
Integration Testing with Docker
π§ HOW-TO | π REFERENCE | Test with real services using Docker
Test with real services using Docker containers.
Why Docker for Testing?
Docker provides:
- Real service instances (not mocks)
- Isolated test environment
- Reproducible results
- Easy cleanup
Prerequisites
# Ensure Docker is running
docker --version
# Enable testcontainers feature
[dev-dependencies]
chicago-tdd-tools = { version = "1.3", features = ["testcontainers"] }
Docker Compose for Tests
Create docker-compose.test.yml:
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
POSTGRES_DB: test_db
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
Run tests:
# Start services
docker-compose -f docker-compose.test.yml up -d
# Run tests
cargo make test-integration
# Stop services
docker-compose -f docker-compose.test.yml down
Testing with Database
#![allow(unused)] fn main() { test!(test_with_postgres, { // Fixture provides database connection let fixture = TestFixture::new()?; // Create test user fixture.db().create_user("alice", "alice@example.com")?; // Query database let user = fixture.db().get_user_by_email("alice@example.com")?; assert_eq!(user.name, "alice"); }); }
Testing with Redis
#![allow(unused)] fn main() { test!(test_with_redis, { let fixture = TestFixture::new()?; // Use Redis from fixture let cache = fixture.redis(); // Set value cache.set("key", "value")?; // Get value let value = cache.get("key")?; assert_eq!(value, "value"); }); }
Complete Integration Test
#![allow(unused)] fn main() { test!(complete_integration_test, { let fixture = TestFixture::new()?; let db = fixture.db(); let cache = fixture.redis(); // 1. Create user in database let user = db.create_user("alice", "alice@example.com")?; // 2. Cache user cache.set(&format!("user:{}", user.id), &user.to_json())?; // 3. Verify database let retrieved = db.get_user(user.id)?; assert_eq!(retrieved.email, "alice@example.com"); // 4. Verify cache let cached = cache.get(&format!("user:{}", user.id))?; assert!(!cached.is_empty()); }); }
Handling Docker Failures
If Docker is unavailable:
# Skip integration tests
WEAVER_ALLOW_SKIP=1 cargo make test-unit
# Or just run unit tests
cargo test --lib
Performance Optimization
Docker containers have overhead:
- Slow: 30-60 seconds per test
- Solution: Batch related tests
#![allow(unused)] fn main() { test!(test_db_operations_batch, { let fixture = TestFixture::new()?; // Test 1: Create let user = fixture.db().create_user("alice", "alice@example.com")?; assert_ok!(&user); // Test 2: Read let retrieved = fixture.db().get_user(user.id)?; assert_ok!(&retrieved); // Test 3: Update fixture.db().update_user(user.id, "new_email@example.com")?; let updated = fixture.db().get_user(user.id)?; assert_eq!(updated.email, "new_email@example.com"); // Test 4: Delete fixture.db().delete_user(user.id)?; let result = fixture.db().get_user(user.id); assert_err!(&result); // One test, multiple operations, one fixture overhead }); }
CI/CD Pipeline
GitHub Actions Example
name: Integration Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_PASSWORD: password
options: --health-cmd pg_isready
steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
- run: cargo make test-integration
Best Practices
β Do:
- Use Docker for real services
- Batch related tests
- Use fixtures for isolation
- Clean up after tests
β Don't:
- Mock Docker services
- Share containers between tests
- Run Docker tests in CI for every commit
- Forget about cleanup
Troubleshooting
"Docker daemon not running"
Start Docker:
# macOS
open /Applications/Docker.app
# Linux
sudo systemctl start docker
# Windows
Start Docker Desktop
"Port already in use"
Check ports:
docker ps # See running containers
docker stop <container>
Tests Timeout
Increase timeout:
cargo test --lib -- --test-threads=1 # Sequential
Next Steps
See: Best Practices
Best Practices & Migration
π§ HOW-TO | π REFERENCE | Proven patterns and migration strategies
Proven patterns and migration strategies for Chicago TDD.
Testing Best Practices
1. Write Tests First (TDD)
#![allow(unused)] fn main() { // 1. Write failing test test!(test_parse_positive_number, { let result = parse_number("42"); assert_ok!(&result); assert_eq!(result.unwrap(), 42); }); // 2. Implement minimal code pub fn parse_number(input: &str) -> Result<u32, String> { input.parse().map_err(|e| format!("Parse failed: {e}")) } // 3. Refactor (improve design, remove duplication) // 4. Test passes β }
2. Test Both Paths
#![allow(unused)] fn main() { test!(test_complete_behavior, { // Success path assert_ok!(&parse_number("42")); // Error path assert_err!(&parse_number("invalid")); }); }
3. Focus on Error Cases
80% of bugs hide in error paths:
#![allow(unused)] fn main() { test!(test_error_cases, { // Test invalid input assert_err!(&parse_number("")); assert_err!(&parse_number("not_a_number")); assert_err!(&parse_number("-1")); // If negative not allowed // Test boundaries assert_ok!(&parse_number("0")); assert_ok!(&parse_number("4294967295")); // u32::MAX }); }
4. Keep Tests Focused
One test per behavior:
#![allow(unused)] fn main() { // β Focused test!(test_parse_valid_number, { let result = parse_number("42"); assert_ok!(&result); }); test!(test_parse_invalid_number, { let result = parse_number("invalid"); assert_err!(&result); }); // β Too many behaviors test!(test_parsing, { // Tests both valid and invalid // Hard to know what failed }); }
5. Use Descriptive Names
#![allow(unused)] fn main() { // β Clear intent test!(test_parse_handles_negative_numbers_gracefully, { /* */ }); // β Vague test!(test_parse, { /* */ }); }
Organization Best Practices
1. Mirror Source Structure
src/
βββ users/
β βββ service.rs
βββ orders/
βββ service.rs
tests/
βββ users/
β βββ service_tests.rs
βββ orders/
βββ service_tests.rs
2. Shared Utilities
tests/
βββ common.rs # Shared utilities
βββ users_tests.rs
βββ orders_tests.rs
In common.rs:
#![allow(unused)] fn main() { pub fn create_test_user() -> Result<User, String> { TestDataBuilder::new() .with_var("name", "Test User") .build_json()? } }
3. Fixture Factory Pattern
#![allow(unused)] fn main() { fn setup_database_fixture() -> Result<TestFixture, String> { let fixture = TestFixture::new()?; // Additional setup Ok(fixture) } }
Performance Best Practices
1. Isolate Slow Tests
Mark slow tests:
#![allow(unused)] fn main() { #[ignore] // Run with --ignored flag test!(slow_integration_test, { // Takes 10 seconds }); }
Run separately:
cargo test --ignored # Only slow tests
2. Parallel Execution
Tests run in parallel by default:
cargo test -- --test-threads=4 # 4 threads (default: CPU count)
cargo test -- --test-threads=1 # Sequential (slow, for debugging)
3. Cache Expensive Operations
#![allow(unused)] fn main() { test!(test_expensive_setup, { // Reuse expensive setup lazy_static::lazy_static! { static ref EXPENSIVE_DATA: Data = { /* expensive */ }; } // Use cached data assert_ok!(&process(&EXPENSIVE_DATA)); }); }
Migration from Traditional Testing
From: No Tests β To: Core Tests
- Start with core patterns (fixtures, builders, assertions)
- Test public APIs
- Focus on error cases
- Gradually increase coverage
From: Mocks β To: Real Dependencies
- Replace mocks with real implementations
- Use fixtures for isolation
- Only mock external services (APIs, DBs)
#![allow(unused)] fn main() { // Before: Mock-heavy let mock_db = MockDatabase::new(); let result = process(&mock_db); // After: Real implementations let fixture = TestFixture::new()?; let result = process(&fixture)?; }
From: Global State β To: Fixtures
- Remove global state
- Create fixtures for test isolation
- Pass fixtures as parameters
#![allow(unused)] fn main() { // Before: Global static mut TEST_DATA: Option<Data> = None; // After: Fixture-based test!(test_with_data, { let fixture = TestFixture::new()?; // Use fixture }); }
From: 100% Coverage β To: 80% + Error Paths
- Stop obsessing over coverage
- Focus on critical paths
- Test error cases thoroughly
Common Pitfalls & Solutions
Pitfall 1: Tests Coupled to Implementation
#![allow(unused)] fn main() { // β Brittle - depends on internal structure test!(test_struct_format, { let user = create_user(); assert_eq!(format!("{:?}", user), "User { id: 123, ... }"); }); // β Robust - tests behavior test!(test_user_creation, { let user = create_user(); assert_eq!(user.id, 123); assert_eq!(user.name, "Alice"); }); }
Pitfall 2: Flaky Tests
#![allow(unused)] fn main() { // β Flaky - depends on time test!(test_timing_dependent, { let start = Instant::now(); operation(); assert!(start.elapsed() < Duration::from_secs(1)); // Unreliable }); // β Reliable - deterministic test!(test_result_correct, { let result = operation(); assert_eq!(result, expected); // Same result every time }); }
Pitfall 3: Test Interdependencies
#![allow(unused)] fn main() { // β Tests depend on order test!(test_1_setup, { /* setup */ }); test!(test_2_use_setup_from_1, { /* depends on test_1 */ }); // β Each test is independent test!(test_setup, { let fixture = TestFixture::new()?; // setup complete }); test!(test_use, { let fixture = TestFixture::new()?; // independent }); }
Quality Checklist
For each test, verify:
- AAA Pattern: Arrange, Act, Assert clearly separated
- Isolation: No dependencies on other tests
- Error Paths: Tests both success and failure
- Clear Name: Describes what's being tested
- One Assertion: Focused on one behavior
- Deterministic: Same result every run
- Fast: <100ms per test (unless integration)
Continuous Integration
Pre-Commit
cargo make pre-commit # Format + lint + unit tests
Before Push
cargo make ci-local # Simulate full CI pipeline
In CI
cargo make test-all # All tests including integration
Graduation Path
Learning
β
Core Patterns (fixtures, builders, assertions)
β
Error Path Testing
β
Advanced Techniques (properties, mutations, snapshots)
β
Observability (OTEL, Weaver)
β
Expert
Resources
Next Steps
- Pick a project to refactor
- Start with core patterns
- Add tests incrementally
- Build confidence with error paths
- Add advanced techniques where beneficial
Chicago TDD Tools: Testing with confidence, errors prevented at compile time.