- 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
```text
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**:
```rust
// Testing: EnvironmentName validation
// Behavior: Reject names with uppercase letters
// Condition: When name contains uppercase
// Outcome: Return ValidationError
```
### 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**:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_return_error_when_name_contains_uppercase_letters() {
// Arrange: Set up test data
let invalid_name = "MyEnvironment".to_string();
// Act: Execute the behavior
let result = EnvironmentName::new(invalid_name);
// Assert: Verify the outcome
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ValidationError::InvalidCharacters));
}
#[test]
fn it_should_create_valid_name_when_using_lowercase_and_hyphens() {
// Arrange
let valid_name = "my-environment".to_string();
// Act
let result = EnvironmentName::new(valid_name.clone());
// Assert
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "my-environment");
}
}
```
### Step 3: Apply AAA Pattern
**Structure every test with three clear sections**:
```rust
#[test]
fn it_should_{behavior}_when_{condition}() {
// Arrange: Set up test data and preconditions
let input = setup_test_data();
let expected = calculate_expected_result();
// Act: Execute the behavior being tested
let actual = function_under_test(input);
// Assert: Verify the expected outcome
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**:
```bash
# Run specific test
cargo test it_should_return_error_when_name_contains_uppercase
# Run all tests in module
cargo test environment_name::tests
# Run with output
cargo test -- --nocapture
```
**Expected Results**:
```text
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**:
```rust
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(), // Injectable time
}
}
}
```
### Step 2: Use MockClock in Tests
```rust
#[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() {
// Arrange: Set up mock clock with fixed time
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);
// Act: Record event
let event = recorder.record_event("test-event");
// Assert: Verify exact timestamp
assert_eq!(event.name, "test-event");
assert_eq!(event.timestamp, fixed_time);
}
#[test]
fn it_should_track_time_progression_when_clock_advances() {
// Arrange: Set up mock clock
let start_time = Utc.with_ymd_and_hms(2025, 10, 7, 12, 0, 0).unwrap();
let clock = Arc::new(MockClock::new(start_time));
let recorder = EventRecorder::new(clock.clone());
// Act: Record first event
let event1 = recorder.record_event("first");
// Simulate 5 minutes passing
clock.advance_secs(300);
// Record second event
let event2 = recorder.record_event("second");
// Assert: Verify time difference
let expected_second_time = Utc.with_ymd_and_hms(2025, 10, 7, 12, 5, 0).unwrap();
assert_eq!(event1.timestamp, start_time);
assert_eq!(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](../../../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):
```rust
#[test]
fn it_should_convert_environment_to_dto() {
// Arrange - DUPLICATED SETUP
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);
// ... test logic
}
#[test]
fn it_should_handle_missing_ip() {
// Arrange - SAME DUPLICATED SETUP AGAIN
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,
);
// ... copies continue
}
```
### 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:
```rust
#[cfg(test)]
mod tests {
use super::*;
// Test fixtures and helpers section
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, 190, 39))
}
// Higher-level builder that composes the parts
fn create_configured_environment_with_ip(ip: IpAddr) -> Environment<Configured> {
let env_name = EnvironmentName::new("test-env".to_string()).unwrap();
let ssh_credentials = create_test_ssh_credentials();
let provider_config = create_test_provider_config();
let created_at = create_test_timestamp();
Environment::new(env_name, provider_config, ssh_credentials, 22, created_at)
.start_provisioning()
.provisioned(ip, ProvisionMethod::Provisioned)
.start_configuring()
.configured()
}
// Tests section
#[test]
fn it_should_convert_configured_environment_to_dto() {
// Arrange - Now concise and clear!
let test_ip = create_test_ip();
let env = create_configured_environment_with_ip(test_ip);
// Act
let dto = ConfigureDetailsData::from(&env);
// Assert
assert_eq!(dto.instance_ip, Some(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):
```rust
// ❌ BAD: create_expected_dto() internally calls create_test_ip()
fn create_expected_dto() -> ConfigureDetailsData {
ConfigureDetailsData {
instance_ip: Some(create_test_ip()), // Hardcoded dependency
// ...
}
}
#[test]
fn test_conversion() {
let env = create_configured_environment_with_ip(create_test_ip());
let expected = create_expected_dto(); // Uses different IP internally!
assert_eq!(ConfigureDetailsData::from(&env), expected);
}
```
**Fixed - Decoupled helpers** (GOOD):
```rust
عرض على GitHub