Learning Testing Patterns: 90-Minute Mastery
π TUTORIAL | Master the 5 fundamental testing patterns
This tutorial guides you through the 5 testing patterns in a natural progression. Each builds on the previous one.
Time: ~90 minutes | Difficulty: Beginner | Prerequisites: Basic Rust knowledge
Module Overview
| Pattern | Time | Focus |
|---|---|---|
| Pattern 1: AAA | 15 min | Test structure and readability |
| Pattern 2: Error Paths | 20 min | Testing failures and error cases |
| Pattern 3: Boundaries | 15 min | Edge cases and limits |
| Pattern 4: Resource Cleanup | 15 min | Automatic cleanup and fixtures |
| Pattern 5: Real Collaborators | 15 min | Testing with real dependencies |
| Practice Exercises | 15 min | Apply what you've learned |
Part 1: AAA Pattern (15 minutes)
Goal: Understand how to structure tests for readability
The Three Phases
Every test has three parts:
- Arrange: Set up the test data and environment
- Act: Execute the behavior you're testing
- Assert: Verify the result is correct
Example Structure
#![allow(unused)] fn main() { test!(test_name, { // Arrange: Set up let input = 42; // Act: Execute let result = process(input); // Assert: Verify assert_eq!(result, 84); }); }
Key Points
β Do: Label each phase with comments β Do: Test one behavior per test β Do: Make test names describe what they test
β Don't: Mix phases together β Don't: Put logic in assertions β Don't: Test multiple behaviors in one test
Checkpoint Question
How would you structure a test that:
- Creates a user
- Verifies the user was created correctly
Answer: Create β Verify (Pattern 1: AAA structure)
Part 2: Error Path Testing (20 minutes)
Goal: Test both success AND failure cases
Why Error Testing Matters
Tests that only verify success hide bugs. Real systems fail. You must test how code behaves when things go wrong.
Success Path vs. Error Path
#![allow(unused)] fn main() { // Success path: Everything works test!(test_add_user_success, { // Arrange let mut db = Database::new(); // Act let result = db.add_user("alice@example.com"); // Assert assert_ok!(&result); // Should succeed }); // Error path: Something goes wrong test!(test_add_duplicate_user_fails, { // Arrange let mut db = Database::new(); db.add_user("alice@example.com").ok(); // Add once // Act let result = db.add_user("alice@example.com"); // Try to add again // Assert assert_err!(&result); // Should fail }); }
What to Test
For each behavior, test:
- Normal case - Everything works perfectly
- Invalid input - Bad data
- Boundary case - Limits (empty, max size, etc.)
- Error case - Something fails
- Concurrent case (if applicable) - Multiple threads
Checkpoint Question
You have a function divide(a, b) that returns Result<i32, Error>.
What test cases should you write?
Answers:
- β
Normal:
divide(10, 2)βOk(5) - β
Error:
divide(10, 0)βErr(Division by zero) - β Boundary: Negative numbers? Large numbers?
Part 3: Boundary Conditions (15 minutes)
Goal: Systematically test edge cases
What Are Boundaries?
Boundaries are the limits where bugs often hide:
- Empty collections (size 0)
- Full collections (size = capacity)
- Negative numbers
- Maximum values
- Null/None cases
- First vs. Last elements
Boundary Testing Pattern
#![allow(unused)] fn main() { test!(test_boundaries, { // Arrange let mut list = List::new(); // Test: Empty list (boundary) assert_eq!(list.len(), 0); // Test: Add one (crossing boundary) list.add(42); assert_eq!(list.len(), 1); // Test: Add many (stress) for i in 0..1000 { list.add(i); } assert_eq!(list.len(), 1001); }); }
Common Boundaries to Test
| Boundary | Test Values | Example |
|---|---|---|
| Empty | 0 items | vec![] |
| Single | 1 item | vec![1] |
| Multiple | 2-many items | vec![1, 2, 3...] |
| Negative | Negative numbers | -1, -100 |
| Maximum | Max value | u32::MAX |
| Minimum | Min value | u32::MIN |
Checkpoint Question
You're testing a split_string(s, limit) function.
What boundary cases should you test?
Answers:
- β Empty string
- β Limit = 0
- β Limit = 1
- β Limit > length
- β Very long string
Part 4: Resource Cleanup (15 minutes)
Goal: Ensure tests clean up automatically
Why Cleanup Matters
Tests often create resources:
- Files
- Database connections
- Network sockets
- Memory allocations
If not cleaned up, tests leak resources and fail.
The Fixture Pattern
Chicago TDD provides TestFixture for automatic cleanup:
#![allow(unused)] fn main() { test!(test_with_cleanup, { // Create fixture - sets up let fixture = TestFixture::new()?; // Use the fixture fixture.set_metadata("key", "value"); // No need to cleanup - happens automatically // when fixture is dropped! }); // Cleanup happens here automatically }
What Gets Cleaned Up
- File handles closed
- Connections closed
- Memory freed
- Temporary directories removed
All automatic! β
Checkpoint Question
You're writing tests that use a database. What should you do?
Answer: Use a TestFixture that:
- Connects to test database on creation
- Runs tests
- Closes connection automatically on drop
Part 5: Real Collaborators (15 minutes)
Goal: Test with real implementations, not mocks
Mocks vs. Real Implementations
#![allow(unused)] fn main() { // β Using a mock (hides integration bugs) test!(test_with_mock, { let mock_client = MockApiClient::new(); // Fake mock_client.set_response(Ok(User { ... })); let result = my_service.fetch_user(&mock_client); assert_ok!(&result); // But does real client work? Who knows! }); // β Using real implementation (catches real bugs) test!(test_with_real_client, { let real_client = RealApiClient::new(); // Actually calls API let result = my_service.fetch_user(&real_client); assert_ok!(&result); // Proves it works with the real API }); }
When to Use Real Collaborators
β Always preferred:
- Database queries
- File I/O
- Web services
- Network calls
β When it's fast (< 100ms):
- Real implementations
β Only when necessary:
- Slow external services (use test doubles)
- Non-deterministic behavior
- Expensive operations
The Philosophy
Chicago TDD principle: Test behavior with real dependencies. This proves code actually works, not just in the test's imagination.
Checkpoint Question
You're testing a payment service. What should you test against?
Answer: A real (test) payment processor, not a mock. Why? Because the real one is what you'll use in production.
Putting It Together: Practice Exercise (15 minutes)
Exercise: User Registration Service
Write tests for this service:
#![allow(unused)] fn main() { pub struct UserService { db: UserDatabase, } impl UserService { pub fn register(&mut self, email: &str, password: &str) -> Result<User, Error> { // Validate input if email.is_empty() || password.len() < 8 { return Err(Error::InvalidInput); } // Check if user exists if self.db.user_exists(email) { return Err(Error::UserExists); } // Create user let user = User::new(email, password); self.db.save(&user)?; Ok(user) } } }
What Tests Should You Write?
Using all 5 patterns, write tests for:
- Pattern 1 (AAA): Structure tests clearly
- Pattern 2 (Error Paths):
- Empty email β
- Short password β
- User already exists β
- Valid registration β
- Pattern 3 (Boundaries):
- Minimum password length (7, 8, 9 chars)
- Very long email
- Pattern 4 (Resource Cleanup):
- Use fixture for database cleanup
- Pattern 5 (Real Collaborators):
- Use real UserDatabase, not mock
Example Solution Framework
#![allow(unused)] fn main() { test!(test_register_success, { // Arrange let fixture = TestFixture::new()?; let db = fixture.test_database(); // Real database let mut service = UserService::new(db); // Act let result = service.register("alice@example.com", "password123"); // Assert assert_ok!(&result); let user = result.unwrap(); assert_eq!(user.email, "alice@example.com"); }); }
Summary: The 5 Testing Patterns
| Pattern | Goal | Use When |
|---|---|---|
| Pattern 1: AAA | ReadΒable tests | Every test |
| Pattern 2: Error Paths | Test failures | Every function |
| Pattern 3: Boundaries | Test limits | Every input |
| Pattern 4: Resource Cleanup | Auto cleanup | Complex tests |
| Pattern 5: Real Collaborators | Real deps | Integration tests |
Next Steps
Immediate (Today)
Write 5-10 tests using all patterns for something in your codebase.
Short-term (This Week)
Review existing tests. Rewrite any that don't follow these patterns.
Long-term (This Month)
Learn Architecture Patterns to organize your code structure.
Checkpoint: Do You Know...?
- How to structure a test with AAA?
- How to test both success and error cases?
- What boundary conditions to test?
- How fixtures provide automatic cleanup?
- Why real collaborators are better than mocks?
If you answered yes to all, you've mastered Testing Patterns! π
Resources
- Full Pattern Details: Testing Patterns
- Decision Guide: Choosing Your Pattern
- All Patterns: Quick Reference
Congratulations! You now understand the 5 fundamental testing patterns. Next, learn how to organize your code with Architecture Patterns.