Skip to main content

write-unit-test

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".

معلومات المصدر

المستودع
torrust/torrust-tracker-deployer
آخر نشاط في المصدر
٢٥ فبراير ٢٠٢٦ في ١٩:٣٠
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
٥
التفرعات
٢

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
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
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub