Keyboard shortcuts

Press ← or β†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Start with the Introduction to understand Chicago TDD principles
  2. Learn Core Patterns for everyday testing scenarios
  3. Explore Advanced Techniques for specific testing challenges
  4. Study Real-World Applications to see complete examples
  5. Apply Best Practices to improve your testing approach

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

  1. Introduction - Chicago TDD philosophy and principles
  2. Core Testing Patterns - Everyday testing with fixtures and builders
  3. Advanced Techniques - Specialized testing for complex scenarios
  4. Go the Extra Mile - Progressive enhancement pattern
  5. Observability & Quality - Telemetry and quality assurance
  6. Real-World Applications - Complete practical examples
  7. Best Practices - Proven patterns and patterns to avoid

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 GoalRead ThisTime
I'm just getting startedQuick Start Tutorial β†’ Fixtures Deep Dive40 min
I want to build a CLI appCLI Application Tutorial45 min
I want to build a web serviceREST Web Service Tutorial50 min
I need to find an APIGo to API Reference2-5 min
I want to understand Chicago TDDRead this introduction + Go the Extra Mile45 min
I'm migrating from traditional testingBest Practices & Migration1 hour

What is Chicago-Style TDD?

Chicago-style TDD (also called "Classicist" TDD) is a testing approach that emphasizes:

  1. Type Safety First - Use Rust's type system to prevent entire categories of errors
  2. Real Dependencies - Test with actual implementations, not mocks
  3. Error Prevention - Mistakes are prevented at compile time, not caught at runtime
  4. 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 u32 only
  • 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:

AspectBenefit
Compile-time guaranteesMany bugs prevented before testing
Ownership systemResource cleanup guaranteed
Result typeExplicit error handling (no surprises)
Trait systemGeneric code with real implementations
MacrosTest 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:

  1. Property-Based Testing: Generate random test data and verify properties hold
  2. Mutation Testing: Validate test quality by introducing mutations
  3. Snapshot Testing: Golden files to detect unintended changes
  4. CLI Testing: Test command-line interfaces with trycmd
  5. Concurrency Testing: Deterministic thread testing with loom
  6. 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:

  1. Core Patterns - Master fixtures, data builders, and assertions
  2. Error Path Testing - Learn where bugs hide
  3. Advanced Techniques - Pick techniques for your use case
  4. Real-World Applications - See complete examples
  5. 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:

  1. Introduction (10 min) - Understand Chicago TDD philosophy
  2. Quick Start Tutorial (25 min) - Write your first test
  3. Fixtures Deep Dive (15 min) - Master test isolation
  4. Core Testing Patterns (20 min) - Learn the essentials
  5. 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:

  1. Quick Start (25 min) - Review basics
  2. CLI Application Tutorial (45 min) - Build complete CLI
  3. CLI Testing (20 min) - Test CLI output with golden files
  4. Best Practices (30 min) - Production patterns

Key files to reference:


"I want to build a REST API / Web Service"

Duration: 1.5-2.5 hours | Difficulty: Intermediate

Follow this path:

  1. Quick Start (25 min) - Review basics
  2. REST Web Service Tutorial (50 min) - Build complete API
  3. Integration Testing with Docker (30 min) - Test with real database
  4. Observability & Quality (20 min) - Add monitoring

Key files to reference:


"I need to test complex logic"

Duration: 2-3 hours | Difficulty: Intermediate-Advanced

Follow this path:

  1. Error Path Testing (15 min) - Comprehensive error testing
  2. Property-Based Testing (30 min) - Generate test cases
  3. Mutation Testing (20 min) - Validate test quality
  4. Concurrency Testing (20 min) - Test thread safety

Key files to reference:


"I want to ensure high-quality tests"

Duration: 1-2 hours | Difficulty: Intermediate

Follow this path:

  1. Error Path Testing (15 min) - Test error cases thoroughly
  2. Mutation Testing (20 min) - Validate test quality
  3. Coverage & Performance (20 min) - Measure metrics
  4. Best Practices (30 min) - Industry patterns

Tools:

  • Run cargo make coverage - Generate coverage reports
  • Run cargo make test-mutation - Validate test quality
  • Use TestFixture snapshots - Track state changes

"I'm migrating from another testing framework"

Duration: 1.5-2 hours | Difficulty: Beginner-Intermediate

Follow this path:

  1. Introduction (10 min) - Understand differences
  2. Quick Start (25 min) - Learn Chicago TDD style
  3. Core Testing Patterns (20 min) - Relearn with new approach
  4. 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:

FeatureWhere to Find It
Testing basic functionsCore Patterns
Fixtures & setupFixtures Tutorial
Building test dataData Builders
AssertionsAssertions & Verification
Error testingError Path Testing
Random test dataProperty Testing
Test output validationSnapshot Testing
Testing CLI appsCLI Testing
Testing threadsConcurrency Testing
Test quality validationMutation Testing
Test metricsCoverage & Performance
ObservabilityOTEL Instrumentation
Production patternsBest 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 ...

SituationTime AvailableDifficultyStart Here
Completely new to testing2 hoursEasyQuick Start
Have testing experience1.5 hoursMediumIntroduction
Experienced developer1 hourMediumCLI or Web Service
Need specific featureVariesVariesFeature table above
Want certification-level knowledge1 weekHardComplete 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)

Core (How-To Guides)

Advanced (How-To Guides)

Guides (How-To + Explanation)

Reference (Lookup-Oriented)


NeedLink
Quick startGetting Started
Code examplesReal-World Applications
Specific featureSearch this page or use feature table
API documentationAPI Reference
Best practicesBest Practices
Understanding conceptsIntroduction

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! and assert_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:

HelperWhat it checksExample
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?

  1. Fixtures Deep Dive (10 minutes) - Master test isolation
  2. Error Path Testing - Learn where bugs hide
  3. Real-World Examples - See complete projects

Or choose your path:


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:

  1. Create one fixture per test

    #![allow(unused)]
    fn main() {
    test!(test1, { let f = TestFixture::new()?; /* ... */ });
    test!(test2, { let f = TestFixture::new()?; /* ... */ }); // Separate!
    }
  2. Use metadata for state tracking

    #![allow(unused)]
    fn main() {
    fixture.set_metadata("step", "processing");
    }
  3. Capture snapshots at key points

    #![allow(unused)]
    fn main() {
    fixture.capture_snapshot(state);
    }
  4. Use descriptive metadata keys

    #![allow(unused)]
    fn main() {
    fixture.set_metadata("user_id", "123");  // Clear
    // Not: fixture.set_metadata("id", "123");  // Vague
    }

❌ Don't:

  1. 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 */ });
    }
  2. Use global state

    #![allow(unused)]
    fn main() {
    // WRONG - global state affects test isolation
    static FIXTURE: Lazy<TestFixture> = Lazy::new(|| TestFixture::new().unwrap());
    }
  3. 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?

  1. Error Path Testing - Test error cases thoroughly
  2. Advanced Fixtures - API reference and examples
  3. 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:

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:

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 instance
  • Err(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 exists
  • None - 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 snapshot
  • None - 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:

  1. All metadata is cleared
  2. All snapshots are cleared
  3. File handles/resources are released
  4. 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

OperationTimeNotes
new()~1msI/O heavy, system dependent
set_metadata()<1ΞΌsIn-memory
get_metadata()<1ΞΌsIn-memory lookup
capture_snapshot()<1ΞΌsCopy HashMap to internal storage
snapshots()<1ΞΌsReturns slice reference
latest_snapshot()<1ΞΌsSlice 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

ConstraintLimitWorkaround
Metadata key lengthUnlimitedUse short, memorable keys
Metadata value lengthUnlimitedAvoid storing binary data
Number of snapshotsUnlimitedLimit snapshots to key points
Parallel testsFully safeTests 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());
    }
});
}

  • FixtureError - Error type for fixture operations
  • HashMap<String, String> - Type for snapshot state

See Also

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:

  1. Fixtures - Isolated test state and setup
  2. Data Builders - Fluent API for constructing test data
  3. Assertions - Clear, readable verification of results
  4. 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

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:

Next Steps

πŸ‘‰ Start with Fixtures

Then learn:

  1. Data Builders - Construct test data
  2. Assertions - Verify results
  3. 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

MethodParametersReturnsPurpose
new()noneResult<TestFixture, FixtureError>Create new isolated fixture
set_metadata()key: String, value: String()Store test state
get_metadata()key: &strOption<&String>Retrieve stored state
capture_snapshot()state: HashMap<String, String>()Save test state snapshot
snapshots()none&[HashMap<String, String>]Get all snapshots
latest_snapshot()noneOption<&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:

  1. Check for shared state (file I/O, network)
  2. Ensure each test has its own fixture
  3. Use cargo test -- --test-threads=1 to force sequential (for debugging)

Best Practices

βœ… Do:

  • Create a fresh fixture in each test
  • Handle the Result with ? or match
  • 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

ConceptPurpose
FixtureIsolated test state
SetupHappens in TestFixture::new()
CleanupAutomatic on drop
IsolationEach test gets fresh fixture
Error HandlingUse ? 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

MethodParametersReturnsPurpose
new()noneTestDataBuilderCreate new builder
with_var()key: &str, value: &strSelfAdd string variable
with_order_data()id: &str, amount: &strSelfAdd order info
build_json()noneResult<Value, Error>Build as JSON
build()noneHashMap<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

AspectPurpose
Fluent APIReadable data construction
Chaining.with_var() returns Self
Error Handlingbuild_json() returns Result
CompositionCombine multiple builders
ReusabilityExtract 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:

  1. Arrange - Build data
  2. Act - Execute code
  3. 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

PatternCode
Value matchesassert_eq!(actual, expected)
Value differentassert_ne!(actual, expected)
True conditionassert!(condition)
Ok resultassert_ok!(&result)
Err resultassert_err!(&result)
In rangeassert_in_range!(value, min, max)
Contains textassert!(text.contains("substring"))
Empty collectionassert!(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

ScenarioTest Case
Missing inputEmpty string, None, empty Vec
Invalid formatWrong type, malformed data
Out of rangeNegative when only positive allowed
Resource unavailableFile not found, connection refused
State violationInvalid state transition
TimeoutOperation 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:

  1. Property-Based Testing - Generate random data and verify properties
  2. Mutation Testing - Validate test quality by introducing mutations
  3. Snapshot Testing - Golden files to detect unintended changes
  4. CLI Testing - Test command-line interfaces
  5. Concurrency Testing - Deterministic thread testing

When to Use Advanced Techniques

TechniqueBest ForWhen to Use
Property-BasedMathematical properties, edge casesComplex algorithms, parsing
MutationTest quality validationAssessing test effectiveness
SnapshotStable output, complex structuresAPI responses, generated code
CLICommand-line toolsCLI applications, scripts
ConcurrencyThread-safe codeConcurrent 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

  1. Start with Property-Based Testing - Easy to understand, powerful
  2. Then explore Mutation Testing - Validates your tests
  3. Add Snapshot Testing - For regression detection
  4. Test CLIs with CLI Testing - If you have CLI tools
  5. 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:

TechniqueSpeedTrade-off
Unit testsFast (ms)Limited scenarios
Property-basedMedium (seconds)Comprehensive coverage
MutationSlow (minutes)High confidence
SnapshotFast (ms)Brittle to changes
ConcurrencySlow (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

ComponentPurposeKey Methods
MutationTester::new()Create a tester for dataapply_mutation(), test_mutation_detection()
MutationOperator::RemoveKey()Remove data keyParameter: key to remove
MutationOperator::AddKey()Add new key-valueParameters: key, value
MutationOperator::ChangeValue()Change existing valueParameters: key, new_value
MutationOperator::SwapValues()Swap two valuesParameters: key1, key2
MutationOperator::ToggleBoolean()Flip bool true↔falseParameter: key
MutationOperator::NumericDelta()Adjust numeric by deltaParameters: key, delta_value
MutationOperator::StringCase()Change string caseParameters: key, CaseMode
MutationScore::calculate()Compute scoreParameters: caught, total
SpanStatus enumMutation test resultsVariants: 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

  1. Write tests (core patterns)
  2. Measure coverage (80%+ code coverage)
  3. Run mutation tests (catch mutations)
  4. Improve weak tests (missing mutations)
  5. 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

MutationWhat to Test
RemoveKeyVerify required keys exist
AddKeyVerify exact set of keys
ChangeValueVerify exact value, not just type
SwapValuesVerify values aren't accidentally swapped
ToggleBooleanVerify both true and false cases
NumericDeltaTest boundary values and edge cases
StringCaseVerify 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::Mutex
  • loom::sync::RwLock
  • std::sync::atomic
  • std::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 (u32 only)
  • 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

  1. Clear thinking: Forces you to consider scope and value
  2. Cost-benefit: Justified effort for each level
  3. Flexibility: Easy to upgrade later
  4. 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:

  1. Does 1st idea solve the problem?

    • If no β†’ Can't proceed
    • If yes β†’ Consider 2nd idea
  2. Would 2nd idea add 80% value with 20% effort?

    • If no β†’ Stop at 1st idea
    • If yes β†’ Consider 2nd idea
  3. 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


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

ComponentPurposeKey Methods/Fields
Span::new_active()Create an OTEL spanParameters: context, name, start_time, attributes, events, status
Span.attributesStore span metadata.insert(key, value)
Span.complete()Finish span timingParameter: end_time
Span.statusSet result statusVariants: Ok, Error, Unknown
Span.validate()Verify span correctnessReturns: Result<(), Error>
Metric structTrack measurementsFields: name, value, timestamp_ms, attributes
MetricValue enumMetric typesVariants: Counter, Gauge, Histogram
MetricValidator::new()Validate metrics.validate(&metric)
WeaverValidator::check_weaver_available()Check Weaver CLIReturns: 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:

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

AttributeFormatExample
http.methodHTTP methodGET, POST, PUT
http.status_codeInteger200, 404, 500
http.targetPath/api/users
db.systemDatabase typemysql, postgresql
db.operationSQL operationSELECT, 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

OperationTargetToo 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:

Ready to measure?

  • Run cargo make coverage to 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:

  1. Start with Core Patterns
  2. Add Advanced Techniques
  3. Implement Observability
  4. 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:

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:

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

  1. Start with core patterns (fixtures, builders, assertions)
  2. Test public APIs
  3. Focus on error cases
  4. Gradually increase coverage

From: Mocks β†’ To: Real Dependencies

  1. Replace mocks with real implementations
  2. Use fixtures for isolation
  3. 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

  1. Remove global state
  2. Create fixtures for test isolation
  3. 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

  1. Stop obsessing over coverage
  2. Focus on critical paths
  3. 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

  1. Pick a project to refactor
  2. Start with core patterns
  3. Add tests incrementally
  4. Build confidence with error paths
  5. Add advanced techniques where beneficial

Chicago TDD Tools: Testing with confidence, errors prevented at compile time.