| name | write-unit-test |
| description | Guide for writing unit tests following project conventions including behavior-driven naming (it_should_*), AAA pattern, MockClock for time testing, TempDir for isolation, and parameterized tests with rstest. Use when adding tests for domain entities, value objects, utilities, or command logic. Triggers on "write unit test", "add test", "test coverage", "unit testing", or "add unit tests". |
| metadata | {"author":"torrust","version":"1.0"} |
Writing Unit Tests
This skill guides you through writing unit tests that follow project conventions and quality standards.
Why This Matters
Unit tests are first-class citizens - They should be as clean, maintainable, and well-structured as production code.
Key Principles:
- ✅ Behavior-driven naming - Test names document what the code does
- ✅ AAA Pattern - Clear structure: Arrange → Act → Assert
- ✅ Deterministic - Same input always produces same output
- ✅ Isolated - Tests don't depend on each other or external state
- ✅ Fast - Unit tests run in milliseconds
Quick Decision Tree
What are you testing?
├── Domain entity/value object?
│ └── → Phase 1: Simple unit test with naming conventions
├── New test with repeated setup code?
│ └── → Phase 2: Check existing tests for duplicate patterns
├── Duplicate setup code across tests?
│ └── → Phase 3: Extract helper functions and avoid coupling
├── Time-dependent code?
│ └── → Phase 4: Use MockClock for deterministic time
├── File operations?
│ └── → Phase 5: Use TempDir for isolation
├── Multiple input/output combinations?
│ └── → Phase 6: Use parameterized tests (rstest)
└── Command or handler?
└── → See write-integration-test skill instead
Phase 1: Basic Unit Test with Proper Naming
Goal: Write a simple, well-named test following AAA pattern.
Step 1: Identify What You're Testing
Questions to answer:
- What behavior am I validating?
- When does this behavior happen? (condition/scenario)
- What is the expected outcome?
Example:
Step 2: Write Test with Behavior-Driven Naming
Format: it_should_{expected_behavior}_when_{condition}
Pattern Rules:
- ✅ ALWAYS use
it_should_ prefix
- ❌ NEVER use
test_ prefix
- ✅ Use
when_ or given_ for conditions
- ✅ Be specific and descriptive
Example:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_return_error_when_name_contains_uppercase_letters() {
let invalid_name = "MyEnvironment".to_string();
let result = EnvironmentName::new(invalid_name);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ValidationError::InvalidCharacters));
}
#[test]
fn it_should_create_valid_name_when_using_lowercase_and_hyphens() {
let valid_name = "my-environment".to_string();
let result = EnvironmentName::new(valid_name.clone());
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "my-environment");
}
}
Step 3: Apply AAA Pattern
Structure every test with three clear sections:
#[test]
fn it_should_{behavior}_when_{condition}() {
let input = setup_test_data();
let expected = calculate_expected_result();
let actual = function_under_test(input);
assert_eq!(actual, expected);
}
Benefits:
- Clarity - Each section has a clear purpose
- Debugging - Easy to identify which phase is failing
- Maintenance - Simple to modify specific parts
Test Phase 1:
cargo test it_should_return_error_when_name_contains_uppercase
cargo test environment_name::tests
cargo test -- --nocapture
Expected Results:
test domain::environment_name::tests::it_should_return_error_when_name_contains_uppercase_letters ... ok
test domain::environment_name::tests::it_should_create_valid_name_when_using_lowercase_and_hyphens ... ok
Commit: test: add unit tests for EnvironmentName validation
Step 1: Inject Clock Dependency
Production code:
use crate::shared::clock::Clock;
use std::sync::Arc;
pub struct EventRecorder {
clock: Arc<dyn Clock>,
}
impl EventRecorder {
pub fn new(clock: Arc<dyn Clock>) -> Self {
Self { clock }
}
pub fn record_event(&self, name: &str) -> Event {
Event {
name: name.to_string(),
timestamp: self.clock.now(),
}
}
}
Step 2: Use MockClock in Tests
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockClock;
use chrono::{TimeZone, Utc};
use std::sync::Arc;
#[test]
fn it_should_record_event_with_fixed_timestamp_when_clock_is_mocked() {
let fixed_time = Utc.with_ymd_and_hms(2025, 10, 7, 12, 0, 0).unwrap();
let clock = Arc::new(MockClock::new(fixed_time));
let recorder = EventRecorder::new(clock);
let event = recorder.record_event("test-event");
assert_eq!(event.name, "test-event");
assert_eq!(event.timestamp, fixed_time);
}
#[test]
fn it_should_track_time_progression_when_clock_advances() {
let start_time = Utc.with_ymd_and_hms(2025, 10, , , , ).();
= Arc::(MockClock::(start_time));
= EventRecorder::(clock.());
= recorder.();
clock.();
= recorder.();
= Utc.(, , , , , ).();
(event1.timestamp, start_time);
(event2.timestamp, expected_second_time);
}
}
Key MockClock Methods:
MockClock::new(timestamp) - Create clock with fixed time
clock.advance_secs(seconds) - Move time forward
clock.now() - Get current time
Benefits:
- ✅ Deterministic tests - same result every time
- ✅ Fast execution - no actual time delays
- ✅ Edge case testing - easily test timeouts, expirations
Documentation: docs/contributing/testing/unit-testing/mock-clock.md
Commit: test: add time-dependent tests using MockClock
Phase 3: Avoiding Duplicate Test Code
When to use: When you notice repeated setup code across multiple tests.
Why: DRY principle applies to tests - duplicate code makes tests harder to maintain and increases the risk of inconsistencies.
Step 1: Identify Duplicate Code Patterns
Watch for these code smells:
- ❌ Repeated Arrange sections - Same setup code copy-pasted across tests
- ❌ Coupled helpers - Helper functions that internally call other helpers with hardcoded values
- ❌ Magic values - Hardcoded test data scattered throughout tests
- ❌ Complex setup - More than 5-10 lines of boilerplate in Arrange section
Example of duplicate code (BAD):
#[test]
fn it_should_convert_environment_to_dto() {
let env_name = EnvironmentName::new("test-env".to_string()).unwrap();
let ssh_username = Username::new("deployer".to_string()).unwrap();
let ssh_credentials = SshCredentials::new(
PathBuf::from("./keys/test_rsa"),
PathBuf::from("./keys/test_rsa.pub"),
ssh_username,
);
let provider_config = ProviderConfig::Lxd(LxdConfig {
profile_name: ProfileName::new("lxd-test".to_string()).unwrap(),
});
let created_at = Utc.with_ymd_and_hms(2026, 2, 23, 10, 0, 0).unwrap();
let env = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
}
() {
= EnvironmentName::(.()).();
= Username::(.()).();
= SshCredentials::(
PathBuf::(),
PathBuf::(),
ssh_username,
);
}
Step 2: Extract Helper Functions
Rule of thumb: If you copy code 2+ times, extract it.
Pattern: Create focused helper functions for different aspects of setup:
#[cfg(test)]
mod tests {
use super::*;
fn create_test_ssh_credentials() -> SshCredentials {
let ssh_username = Username::new("deployer".to_string()).unwrap();
SshCredentials::new(
PathBuf::from("./keys/test_rsa"),
PathBuf::from("./keys/test_rsa.pub"),
ssh_username,
)
}
fn create_test_provider_config() -> ProviderConfig {
ProviderConfig::Lxd(LxdConfig {
profile_name: ProfileName::new("lxd-test-env".to_string()).unwrap(),
})
}
fn create_test_timestamp() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 2, 23, 10, 0, 0).unwrap()
}
fn create_test_ip() -> IpAddr {
IpAddr::V4(Ipv4Addr::new(10, 140, , ))
}
(ip: IpAddr) Environment<Configured> {
= EnvironmentName::(.()).();
= ();
= ();
= ();
Environment::(env_name, provider_config, ssh_credentials, , created_at)
.()
.(ip, ProvisionMethod::Provisioned)
.()
.()
}
() {
= ();
= (test_ip);
= ConfigureDetailsData::(&env);
(dto.instance_ip, (test_ip));
}
}
Step 3: Avoid Coupling Between Helpers
Problem: Helpers that call each other with hardcoded values create hidden dependencies.
Anti-pattern - Coupled helpers (BAD):
fn create_expected_dto() -> ConfigureDetailsData {
ConfigureDetailsData {
instance_ip: Some(create_test_ip()),
}
}
#[test]
fn test_conversion() {
let env = create_configured_environment_with_ip(create_test_ip());
let expected = create_expected_dto();
assert_eq!(ConfigureDetailsData::from(&env), expected);
}
Fixed - Decoupled helpers (GOOD):
fn create_expected_dto(ip: IpAddr) -> ConfigureDetailsData {
ConfigureDetailsData {
instance_ip: Some(ip),
}
}
#[test]
fn test_conversion() {
let test_ip = create_test_ip();
let env = create_configured_environment_with_ip(test_ip);
let expected = create_expected_dto(test_ip);
assert_eq!(ConfigureDetailsData::from(&env), expected);
}
Benefits:
- ✅ No hidden dependencies - All inputs are explicit
- ✅ Single source of truth - Test data defined once
- ✅ Easy to vary - Can test different IPs without changing helpers
- ✅ Clear intent - Obvious that both use the same value
Step 4: Use Derives to Simplify Assertions
Add PartialEq to DTOs to enable single-line assertions:
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ConfigureDetailsData {
pub environment_name: String,
pub instance_name: String,
}
Before - Field-by-field assertions:
assert_eq!(dto.environment_name, "test-env");
assert_eq!(dto.instance_name, "torrust-tracker-vm-test-env");
assert_eq!(dto.provider, "lxd");
assert_eq!(dto.state, "Configured");
assert_eq!(dto.instance_ip, Some(test_ip));
assert_eq!(dto.created_at, expected_created_at);
After - Single assertion:
let expected = create_expected_dto(test_ip);
assert_eq!(dto, expected);
Step 5: Organize Test Modules
Pattern: Separate helpers from tests with clear sections:
#[cfg(test)]
mod tests {
use super::*;
fn create_test_ssh_credentials() -> SshCredentials { }
fn create_test_provider_config() -> ProviderConfig { }
fn create_configured_environment_with_ip(ip: IpAddr) -> Environment<Configured> { }
#[test]
fn it_should_convert_configured_environment_to_dto() { }
#[test]
fn it_should_handle_none_instance_ip() { }
}
Benefits:
- ✅ Clear separation - Helpers vs actual tests
- ✅ Easy navigation - Find what you need quickly
- ✅ Reusability - Helper functions available to all tests in module
Step 6: Create Meaningful Assertion Helpers
Problem: Repetitive assertion patterns make tests verbose and harder to maintain.
Pattern: Extract repeated assertion logic into descriptive helper functions.
Anti-pattern - Multiple similar assertions (BAD):
#[test]
fn it_should_render_text_output() {
let text = render_output(&data);
assert!(text.contains("Environment Details:"));
assert!(text.contains("Name:"));
assert!(text.contains("test-env"));
assert!(text.contains("Instance:"));
assert!(text.contains("torrust-tracker-vm-test-env"));
assert!(text.contains("Provider:"));
assert!(text.contains("lxd"));
assert!(text.contains("State:"));
assert!(text.contains("Configured"));
assert!(text.contains("Instance IP:"));
assert!(text.contains("10.140.190.39"));
assert!(text.contains("Created:"));
assert!(text.contains("2026-02-23 10:00:00 UTC"));
}
Fixed - Single assertion helper (GOOD):
fn assert_contains_all(text: &str, expected: &[&str]) {
for substring in expected {
assert!(
text.contains(substring),
"Expected text to contain '{}' but it didn't.\nActual text:\n{}",
substring,
text
);
}
}
#[test]
fn it_should_render_text_output() {
let text = render_output(&data);
assert_contains_all(
&text,
&[
"Environment Details:",
"Name:",
"test-env",
"Instance:",
"torrust-tracker-vm-test-env",
"Provider:",
"lxd",
"State:",
"Configured",
"Instance IP:",
"10.140.190.39",
"Created:",
"2026-02-23 10:00:00 UTC",
],
);
}
When to create assertion helpers:
- ✅ 3+ similar assertions - Pattern emerges that can be abstracted
- ✅ Repeated validation logic - Same check used across multiple tests
- ✅ Complex validation - Multi-step verification that obscures test intent
- ✅ Error clarity matters - Custom messages improve debugging experience
Common assertion helper patterns:
fn assert_contains_all(text: &str, expected: &[&str]) { }
fn assert_json_fields(json: &str, fields: &[(&str, &str)]) { }
fn assert_collection_contains<T>(collection: &[T], predicate: impl Fn(&T) -> bool) { }
fn assert_valid_state(obj: &MyType, expectations: &StateExpectations) { }
Benefits:
- ✅ Reduced verbosity - 10+ assertions → 1 function call
- ✅ Better readability - Clear list of expectations
- ✅ Maintainability - Change validation logic in one place
- ✅ Clear error messages - Custom messages show what failed and why
- ✅ Reusability - Use same helper across multiple tests
See: PR #373 for complete example of refactoring duplicate test code.
Commit: refactor: extract duplicate test code and decouple test setup
Phase 4: Time-Dependent Tests with MockClock
When to use: Testing code that uses Utc::now() or time-based logic.
Why: Direct use of Utc::now() makes tests non-deterministic.
When to use: Testing code that creates files, directories, or modifies filesystem.
Why: Tests should never interfere with production data or leave artifacts.
Step 1: Use TempDir for Isolation
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn it_should_create_environment_directories_when_initializing() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let env_name = EnvironmentName::new("test-env".to_string()).unwrap();
let environment = Environment::new_in_dir(env_name, temp_path);
assert!(environment.data_dir().exists());
assert!(environment.build_dir().exists());
assert!(environment.data_dir().starts_with(temp_path));
}
#[test]
fn it_should_write_config_file_when_saving_environment() {
let temp_dir = TempDir::().();
= temp_dir.().();
= ();
environment.(&config_path).();
(config_path.());
= std::fs::(&config_path).();
(content.());
}
}
Anti-Pattern - DON'T DO THIS:
#[test]
fn bad_test_creates_real_directories() {
let env = Environment::new("test".to_string());
}
Benefits:
- ✅ Test isolation - no interference between tests
- ✅ No cleanup code needed - TempDir handles it
- ✅ No pollution of working directory
Documentation: docs/contributing/testing/unit-testing/temp-directories.md
Commit: test: add filesystem tests using TempDir
Phase 6: Parameterized Tests with rstest
When to use: Testing same behavior with multiple input/output combinations.
Why: Better isolation, clearer output, easier debugging than loops in test body.
Step 1: Add rstest Dependency
[dev-dependencies]
rstest = "0.23"
Step 2: Write Parameterized Test
Before (Loop in Test Body - DON'T DO THIS):
#[test]
fn test_various_inputs() {
let cases = vec![
("valid-name", true),
("invalid_name", false),
("UPPERCASE", false),
];
for (input, should_succeed) in cases {
let result = validate(input);
assert_eq!(result.is_ok(), should_succeed);
}
}
After (Parameterized with rstest - GOOD):
use rstest::rstest;
#[rstest]
#[case("valid-name", true)]
#[case("invalid_name", false)]
#[case("UPPERCASE", false)]
#[case("my-env-123", true)]
fn it_should_validate_environment_name_format(
#[case] input: &str,
#[case] expected_valid: bool,
) {
let name_str = input.to_string();
let result = EnvironmentName::new(name_str);
assert_eq!(result.is_ok(), expected_valid);
}
Test Output:
test it_should_validate_environment_name_format::case_1 ... ok
test it_should_validate_environment_name_format::case_2 ... ok
test it_should_validate_environment_name_format::case_3 ... ok
test it_should_validate_environment_name_format::case_4 ... ok
Step 3: Advanced Parameterization
Testing multiple conditions:
#[rstest]
#[case("e2e-config", "data/e2e-config", "build/e2e-config")]
#[case("production", "data/production", "build/production")]
#[case("dev-test", "data/dev-test", "build/dev-test")]
fn it_should_create_correct_paths_for_different_environments(
#[case] env_name: &str,
#[case] expected_data_path: &str,
#[case] expected_build_path: &str,
) {
let temp_dir = TempDir::new().unwrap();
let name = EnvironmentName::new(env_name.to_string()).unwrap();
let environment = Environment::new_in_dir(name, temp_dir.path());
assert!(environment.data_dir().to_str().unwrap().ends_with(expected_data_path));
assert!(environment.build_dir().to_str().unwrap().ends_with(expected_build_path));
}
When to use:
- ✅ Testing edge cases with different values
- ✅ Validating configuration variations
- ✅ Verifying data transformation with various inputs
- ✅ Testing boundary conditions
When NOT to use:
- ❌ Each case tests fundamentally different behavior (use separate tests)
- ❌ Test logic differs significantly between cases
- ❌ Only one or two cases (just write separate tests)
Documentation: docs/contributing/testing/unit-testing/parameterized-tests.md
Commit: test: add parameterized tests for input validation
Phase 7: Verify and Fix
Step 1: Run Tests
cargo test
cargo test --test environment_name_tests
cargo test -- --nocapture
cargo test it_should_validate_environment_name
Step 2: Check Coverage (Optional)
cargo cov-check
cargo cov-html
Step 3: Verify Test Quality
Checklist:
Step 4: Common Issues and Fixes
Issue: Test Output Shows User Messages
Problem:
test output:
⏳ Processing...
✅ Complete!
Fix: Use silent verbosity in test setup
let context = TestContext::new();
let output = TestUserOutput::wrapped_silent();
Documentation: docs/contributing/testing/quality/clean-output.md
Issue: Flaky Time-Dependent Tests
Problem: Tests pass sometimes, fail other times
Fix: Replace Utc::now() with MockClock
let timestamp = Utc::now();
let clock = Arc::new(MockClock::new(fixed_time));
let timestamp = clock.now();
Issue: Tests Leave Artifacts
Problem: Directories like ./data/test persist after tests
Fix: Use TempDir instead of real directories
let env = Environment::new("test".to_string());
let temp_dir = TempDir::new().unwrap();
let env = Environment::new_in_dir("test".to_string(), temp_dir.path());
Issue: Can't Identify Which Parameterized Case Failed
Problem: Test output only shows test name, not which #[case] failed
Fix: Each case runs as a separate test with rstest
#[rstest]
#[case("input1", expected1)]
#[case("input2", expected2)]
fn test_name(#[case] input: Type, #[case] expected: Type) {
}
Commit: test: fix test isolation and determinism issues
Examples from Codebase
Example 1: Simple Domain Entity Test
File: src/domain/environment_name/tests.rs
#[test]
fn it_should_create_valid_name_when_using_lowercase_with_hyphens() {
let valid_name = "my-environment".to_string();
let result = EnvironmentName::new(valid_name);
assert!(result.is_ok());
}
#[test]
fn it_should_return_error_when_name_contains_uppercase() {
let invalid_name = "MyEnvironment".to_string();
let result = EnvironmentName::new(invalid_name);
assert!(matches!(result.unwrap_err(), ValidationError::InvalidCharacters));
}
Example 2: Test with MockClock
File: src/shared/clock/tests.rs
#[test]
fn it_should_return_fixed_time_when_mock_clock_is_set() {
let fixed_time = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
let clock = MockClock::new(fixed_time);
let current_time = clock.now();
assert_eq!(current_time, fixed_time);
}
Example 3: Test with TempDir
File: src/domain/environment/tests.rs
#[test]
fn it_should_create_data_directory_when_environment_is_initialized() {
let temp_dir = TempDir::new().unwrap();
let name = EnvironmentName::new("test".to_string()).unwrap();
let env = Environment::new_in_dir(name, temp_dir.path());
assert!(env.data_dir().exists());
assert!(env.data_dir().starts_with(temp_dir.path()));
}
Example 4: Parameterized Test
File: src/domain/ip_address/tests.rs
#[rstest]
#[case("192.168.1.1", true)]
#[case("invalid.ip", false)]
#[case("256.1.1.1", false)]
#[case("::1", true)]
fn it_should_validate_ip_address_format(
#[case] input: &str,
#[case] expected_valid: bool,
) {
let result = IpAddress::from_str(input);
assert_eq!(result.is_ok(), expected_valid);
}
Tips & Best Practices
Naming Tests
- DO:
it_should_return_error_when_input_is_invalid
- DON'T:
test_validation, test_error_case, invalid_input_test
Test Organization
- Group related tests in the same
#[cfg(test)] mod tests block
- Order tests: success cases first, then error cases
- Use descriptive module names:
domain::environment_name::tests
AAA Pattern
- Always add comments marking Arrange/Act/Assert sections
- Keep each section focused and minimal
- If Arrange is complex, extract to helper function
MockClock
- Use for any code that uses time
- Don't mix real
Utc::now() with MockClock
- Test time progression with
advance_secs()
TempDir
- Use for all filesystem operations
- Never use hardcoded paths like
./data/test
- TempDir cleans up automatically - no manual cleanup needed
Parameterized Tests
- Use rstest when testing 3+ similar cases
- Each
#[case] should test the same behavior with different inputs
- If behavior differs, write separate tests
Test Independence
- Tests should not depend on execution order
- Each test should set up its own data
- Use TempDir to avoid shared state
Quick Reference
Test Naming Pattern:
it_should_{expected_behavior}_when_{condition}
it_should_{expected_behavior}_given_{state}
AAA Structure:
Import MockClock:
use crate::testing::MockClock;
Import TempDir:
use tempfile::TempDir;
Import rstest:
use rstest::rstest;
Related Documentation
Next Steps After Writing Tests
- Run pre-commit checks:
./scripts/pre-commit.sh
- Check coverage:
cargo cov-check (optional)
- Commit changes:
test: add unit tests for [component]
- Consider integration tests: If testing commands, see
write-integration-test skill