# Urur Test Plan
This document outlines a comprehensive test strategy for the urur codebase, prioritized by risk and coverage gaps.
---
## Table of Contents
1. [Testing Philosophy](#testing-philosophy)
2. [Test Infrastructure](#test-infrastructure)
3. [Priority 1: Critical Path Tests](#priority-1-critical-path-tests)
4. [Priority 2: Provider API Tests](#priority-2-provider-api-tests)
5. [Priority 3: Workspace Operations Tests](#priority-3-workspace-operations-tests)
6. [Priority 4: Error Handling Tests](#priority-4-error-handling-tests)
7. [Priority 5: Edge Cases & Platform Tests](#priority-5-edge-cases--platform-tests)
8. [Test Fixtures](#test-fixtures)
9. [Mocking Strategy](#mocking-strategy)
10. [CI Integration](#ci-integration)
---
## Testing Philosophy
### Principles
1. **Risk-based prioritization**: Test critical paths that could cause data loss first
2. **Isolation**: Unit tests should not require network or filesystem side effects
3. **Determinism**: Tests must be reproducible and not flaky
4. **Speed**: Unit tests < 10ms each, integration tests < 1s each
5. **Coverage targets**:
- Critical modules (cloud, providers): 80%+
- Core modules (workspace, config): 70%+
- UI modules: 60%+ (already well-covered)
### Test Types
| Unit | `src/**/tests` | Test individual functions in isolation |
| Integration | `tests/` | Test CLI commands end-to-end |
| Doc tests | Inline | Verify documentation examples work |
---
## Test Infrastructure
### Required Test Dependencies
Add to `Cargo.toml` under `[dev-dependencies]`:
```toml
[dev-dependencies]
# Existing
assert_cmd = "2.0"
predicates = "3.0"
tempfile = "3.10"
# New additions needed
mockall = "0.12" # Mocking framework
wiremock = "0.6" # HTTP mocking for provider tests
tokio-test = "0.4" # Async test utilities
test-case = "3.3" # Parameterized tests
serial_test = "3.0" # Tests that can't run in parallel
fake = "2.9" # Generate fake test data
```
### Test Helper Module
Create `tests/common/mod.rs` with shared utilities:
```rust
// tests/common/mod.rs
use std::path::{Path, PathBuf};
use tempfile::TempDir;
/// Create a temp workspace with .urur.toml
pub fn create_temp_workspace() -> TempDir {
let temp = tempfile::tempdir().unwrap();
// ... existing implementation
temp
}
/// Create a temp workspace with a git repo initialized
pub fn create_temp_workspace_with_git() -> TempDir {
let temp = create_temp_workspace();
std::process::Command::new("git")
.args(["init"])
.current_dir(temp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(temp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp.path())
.output()
.unwrap();
temp
}
/// Create a mock git repository with commits
pub fn create_mock_repo(path: &Path, commits: usize) -> PathBuf {
// Initialize repo, create commits
todo!()
}
/// Create a .urur.toml with specific content
pub fn write_config(path: &Path, content: &str) {
std::fs::write(path.join(".urur.toml"), content).unwrap();
}
/// Assert file contains specific content
pub fn assert_file_contains(path: &Path, expected: &str) {
let content = std::fs::read_to_string(path).unwrap();
assert!(content.contains(expected), "File {:?} missing: {}", path, expected);
}
```
---
## Priority 1: Critical Path Tests
These tests prevent data loss and corruption. **Must be implemented first.**
### 1.1 Cloud Sync Tests
**File**: `src/cli/cloud_utils.rs`
```rust
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
// ========================================================================
// TempClone Tests
// ========================================================================
#[tokio::test]
async fn test_temp_clone_creates_temp_directory() {
// Mock git clone to succeed without network
// Verify temp directory is created in URUR_TEMP_DIR
// Verify directory is cleaned up on drop
}
#[tokio::test]
async fn test_temp_clone_with_branch() {
// Verify --branch flag is passed to git clone
}
#[tokio::test]
async fn test_temp_clone_timeout() {
// Mock a slow git operation
// Verify timeout error is returned after GIT_CLONE_TIMEOUT
}
#[tokio::test]
async fn test_validate_repo_access_success() {
// Mock successful ls-remote
// Verify no error returned
}
#[tokio::test]
async fn test_validate_repo_access_auth_failure() {
// Mock ls-remote with exit code 128
// Verify CloudAuthError returned with helpful message
}
#[tokio::test]
async fn test_validate_repo_access_empty_repo() {
// Mock ls-remote with exit code 2
// Verify specific empty repo error message
}
#[tokio::test]
async fn test_validate_git_config_missing_name() {
// Temporarily unset git config user.name
// Verify helpful error message
}
#[tokio::test]
async fn test_validate_git_config_missing_email() {
// Temporarily unset git config user.email
// Verify helpful error message
}
// ========================================================================
// Git Operation Tests
// ========================================================================
#[tokio::test]
async fn test_commit_and_push_success() {
// Create real temp git repo
// Add file, commit, verify commit exists
}
#[tokio::test]
async fn test_commit_and_push_timeout() {
// Mock slow push operation
// Verify timeout error
}
#[tokio::test]
async fn test_get_remote_head_sha() {
// Create repo with known HEAD
// Verify correct SHA returned
}
#[tokio::test]
async fn test_get_remote_head_sha_non_standard_branch() {
// Create repo with 'develop' as default
// Verify correct branch detected
}
#[tokio::test]
async fn test_fetch_and_check_changes_no_changes() {
// Repo in sync with remote
// Verify false returned
}
#[tokio::test]
async fn test_fetch_and_check_changes_with_changes() {
// Remote has new commits
// Verify true returned
}
// ========================================================================
// Temp Directory Cleanup Tests
// ========================================================================
#[test]
fn test_cleanup_stale_temp_dirs_removes_old() {
// Create temp dir with old mtime (>1 hour)
// Call cleanup
// Verify removed
}
#[test]
fn test_cleanup_stale_temp_dirs_preserves_recent() {
// Create temp dir with recent mtime
// Call cleanup
// Verify preserved
}
#[test]
fn test_cleanup_only_runs_once_per_process() {
// Call cleanup multiple times
// Verify CLEANUP_ONCE prevents duplicate runs
}
}
```
**File**: `src/cli/cloud.rs`
```rust
#[cfg(test)]
mod tests {
use super::*;
// ========================================================================
// Directory Copy Tests
// ========================================================================
#[test]
fn test_copy_dir_recursive_basic() {
// Create dir with files
// Copy to new location
// Verify all files copied
}
#[test]
fn test_copy_dir_recursive_preserves_permissions() {
// Create executable file
// Copy directory
// Verify +x bit preserved
}
#[test]
fn test_copy_dir_recursive_handles_symlinks() {
// Create dir with symlink
// Copy directory
// Verify symlink recreated (not followed)
}
#[test]
fn test_copy_dir_recursive_detects_cycles() {
// Create directory with circular symlink
// Copy directory
// Verify no infinite loop, cycle skipped
}
#[test]
fn test_copy_dir_recursive_nested_directories() {
// Create deeply nested structure
// Copy directory
// Verify structure preserved
}
// ========================================================================
// Move Directory Tests
// ========================================================================
#[test]
fn test_move_directory_same_filesystem() {
// Move within same filesystem
// Verify rename used (fast path)
}
#[test]
#[cfg(unix)]
fn test_move_directory_cross_filesystem() {
// This requires /tmp to be on different filesystem
// Or mock the EXDEV error
// Verify copy+delete fallback works
}
// ========================================================================
// Sanitization Tests
// ========================================================================
#[test]
fn test_sanitize_filename_basic() {
assert_eq!(sanitize_filename("my-workspace"), "my-workspace");
}
#[test]
fn test_sanitize_filename_path_traversal() {
assert!(!sanitize_filename("../../../etc/passwd").contains(".."));
}
#[test]
fn test_sanitize_filename_special_chars() {
let result = sanitize_filename("foo/bar\\baz:qux");
assert!(!result.contains('/'));
assert!(!result.contains('\\'));
assert!(!result.contains(':'));
}
#[test]
fn test_sanitize_filename_empty() {
assert_eq!(sanitize_filename(""), "unnamed");
}
#[test]
fn test_sanitize_filename_only_dots() {
assert_eq!(sanitize_filename("..."), "unnamed");
}
// ========================================================================
// Config Diff Tests
// ========================================================================
#[test]
fn test_show_config_diff_no_changes() {
// Same content
// Verify no diff output
}
#[test]
fn test_show_config_diff_additions() {
// New lines added
// Verify green + prefix
}
#[test]
fn test_show_config_diff_deletions() {
// Lines removed
// Verify red - prefix
}
#[test]
fn test_show_config_diff_truncation() {
// More than MAX_DIFF_LINES changes
// Verify "... (N more changes)" message
}
// ========================================================================
// UUID Migration Tests
// ========================================================================
#[test]
fn test_ensure_all_repos_have_uuids_adds_missing() {
// Config with repos without UUIDs
// Call function
// Verify UUIDs added, returns true
}
#[test]
fn test_ensure_all_repos_have_uuids_preserves_existing() {
// Config with repos that already have UUIDs
// Call function
// Verify UUIDs unchanged, returns false
}
}
```
### 1.2 Atomic Write Tests
**File**: `src/config/mod.rs`
```rust
#[cfg(test)]
mod atomic_write_tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[test]
fn test_atomic_write_creates_file() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("test.txt");
atomic_write(&path, "content").unwrap();
assert!(path.exists());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "content");
}
#[test]
fn test_atomic_write_overwrites_existing() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("test.txt");
std::fs::write(&path, "old").unwrap();
atomic_write(&path, "new").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
}
#[test]
fn test_atomic_write_no_partial_content_on_failure() {
// Write should be atomic - either complete or not at all
// Simulate failure mid-write (difficult without mocking)
// For now, verify temp file is cleaned up on success
}
#[test]
#[cfg(unix)]
fn test_atomic_write_preserves_permissions() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("test.txt");
std::fs::write(&path, "old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
atomic_write(&path, "new").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}
```
---
## Priority 2: Provider API Tests
These tests ensure API integrations work correctly. Use HTTP mocking.
### 2.1 GitHub Provider Tests
**File**: `src/providers/github.rs`
```rust
#[cfg(test)]
mod tests {
use super::*;
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path, header};
async fn setup_mock_server() -> (MockServer, GitHub) {
let server = MockServer::start().await;
let github = GitHub::new(Some("test-token".to_string()));
// Override base URL to point to mock server
// This requires refactoring GitHub to accept base_url
(server, github)
}
// ========================================================================
// Repository Listing Tests
// ========================================================================
#[tokio::test]
async fn test_list_user_repos_success() {
let (server, github) = setup_mock_server().await;
Mock::given(method("GET"))
.and(path("/users/testuser/repos"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{
"name": "repo1",
"clone_url": "https://github.com/testuser/repo1.git",
"ssh_url": "git@github.com:testuser/repo1.git",
"archived": false,
"default_branch": "main"
}
])))
.mount(&server)
.await;
let repos = github.list_user_repos("testuser").await.unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].name, "repo1");
}
#[tokio::test]
async fn test_list_user_repos_pagination() {
// Mock multiple pages
// Verify all pages fetched
}
#[tokio::test]
async fn test_list_user_repos_rate_limited() {
let (server, github) = setup_mock_server().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(429)
.insert_header("X-RateLimit-Reset", "1234567890"))
.mount(&server)
.await;
let result = github.list_user_repos("testuser").await;
assert!(matches!(result, Err(UrurError::ProviderError { .. })));
}
#[tokio::test]
async fn test_list_user_repos_unauthorized() {
// Mock 401 response
// Verify appropriate error message
}
#[tokio::test]
async fn test_list_org_repos_success() {
// Similar to user repos but with /orgs/ endpoint
}
// ========================================================================
// Pull Request Tests
// ========================================================================
#[tokio::test]
async fn test_get_prs_success() {
// Mock PR list response
// Verify PRs parsed correctly
}
#[tokio::test]
async fn test_get_prs_empty() {
// Mock empty PR list
// Verify empty vec returned
}
#[tokio::test]
async fn test_get_pr_details_success() {
// Mock PR detail response
// Verify all fields parsed
}
#[tokio::test]
async fn test_create_pr_success() {
let (server, github) = setup_mock_server().await;
Mock::given(method("POST"))
.and(path("/repos/owner/repo/pulls"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"number": 42,
"title": "Test PR",
"html_url": "https://github.com/owner/repo/pull/42",
"state": "open",
"user": { "login": "testuser" },
"head": { "ref": "feature" },
"base": { "ref": "main" },
"draft": false
})))
.mount(&server)
.await;
let pr = github.create_pr("owner", "repo", "feature", "main", "Test PR", "Body").await.unwrap();
assert_eq!(pr.number, 42);
}
#[tokio::test]
async fn test_create_pr_conflict() {
// Mock 422 response (PR already exists)
// Verify error message
}
#[tokio::test]
async fn test_merge_pr_success() {
// Mock successful merge
}
#[tokio::test]
async fn test_merge_pr_not_mergeable() {
// Mock 405 response
// Verify error
}
// ========================================================================
// Issue Tests
// ========================================================================
#[tokio::test]
async fn test_get_issues_success() {
// Mock issue list
}
#[tokio::test]
async fn test_create_issue_success() {
// Mock issue creation
}
// ========================================================================
// Review Tests
// ========================================================================
#[tokio::test]
async fn test_get_review_state_approved() {
// Mock reviews with APPROVED state
}
#[tokio::test]
async fn test_get_review_state_changes_requested() {
// Mock reviews with CHANGES_REQUESTED
}
#[tokio::test]
async fn test_get_review_state_pending() {
// Mock reviews that are pending
}
}
```
### 2.2 GitLab Provider Tests
**File**: `src/providers/gitlab.rs`
```rust
#[cfg(test)]
mod tests {
use super::*;
use wiremock::{Mock, MockServer, ResponseTemplate};
// Similar structure to GitHub tests but with GitLab API format
#[tokio::test]
async fn test_list_user_repos_success() {
// GitLab uses /users/:id/projects
}
#[tokio::test]
async fn test_list_group_repos_success() {
// GitLab uses /groups/:id/projects
}
#[tokio::test]
async fn test_create_project_success() {
// GitLab-specific project creation
}
#[tokio::test]
async fn test_archive_project_success() {
// GitLab archive endpoint
}
#[tokio::test]
async fn test_self_hosted_gitlab() {
// Verify custom base_url works
}
}
```
### 2.3 Provider Cache Tests
**File**: `src/providers/cache.rs` (extend existing)
```rust
#[cfg(test)]
mod tests {
// ... existing tests ...
#[test]
fn test_cache_expiration() {
// Set short TTL
// Verify data expires
}
#[test]
fn test_cache_different_providers() {
// Cache GitHub and GitLab separately
// Verify no cross-contamination
}
#[test]
fn test_cache_concurrent_access() {
// Multiple threads reading/writing
// Verify no corruption
}
}
```
---
## Priority 3: Workspace Operations Tests
### 3.1 Hook Execution Tests
**File**: `src/workspace/hooks.rs`
```rust
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_run_hook_success() {
// Run simple echo command
// Verify success
}
#[tokio::test]
async fn test_run_hook_failure_non_strict() {
// Hook returns non-zero
// strict=false
// Verify warning but continues
}
#[tokio::test]
async fn test_run_hook_failure_strict() {
// Hook returns non-zero
// strict=true
// Verify HookFailed error
}
#[tokio::test]
async fn test_run_hook_environment_variables() {
// Run hook that echoes env vars
// Verify URUR_* vars are set
}
#[tokio::test]
async fn test_run_hook_timeout() {
// Hook that sleeps forever
// Verify timeout
}
#[test]
fn test_effective_hook_repo_overrides_global() {
// Repo has post_clone hook
// Global has different post_clone
// Verify repo hook used
}
#[test]
fn test_effective_hook_falls_back_to_global() {
// Repo has no hook
// Global has hook
// Verify global used
}
}
```
### 3.2 Workspace Core Tests
**File**: `src/workspace/mod.rs`
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_workspace_open_success() {
let temp = create_temp_workspace();
let ws = Workspace::open(temp.path()).unwrap();
assert!(ws.config().repositories.is_empty());
}
#[test]
fn test_workspace_open_not_initialized() {
let temp = tempfile::tempdir().unwrap();
let result = Workspace::open(temp.path());
assert!(matches!(result, Err(UrurError::NotWorkspace)));
}
#[test]
fn test_workspace_save_config() {
let temp = create_temp_workspace();
let mut ws = Workspace::open(temp.path()).unwrap();
ws.config_mut().workspace.name = Some("test".to_string());
ws.save_config().unwrap();
// Re-open and verify
let ws2 = Workspace::open(temp.path()).unwrap();
assert_eq!(ws2.config().workspace.name, Some("test".to_string()));
}
#[test]
fn test_workspace_repo_path() {
// Verify absolute paths resolved correctly
}
#[test]
fn test_workspace_find_repo_by_name() {
// Add repo, find by name
}
#[test]
fn test_workspace_find_repo_by_uuid() {
// Add repo with UUID, find by UUID
}
}
```
### 3.3 Clone Operations Tests
**File**: `src/workspace/clone.rs`
```rust
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_clone_single_repo() {
// Mock git clone
// Verify directory created
}
#[tokio::test]
async fn test_clone_with_branch() {
// Clone specific branch
// Verify branch checked out
}
#[tokio::test]
async fn test_clone_shallow() {
// Shallow clone
// Verify --depth 1 used
}
#[tokio::test]
async fn test_clone_already_exists() {
// Path already exists
// Verify skip or error based on config
}
#[tokio::test]
async fn test_clone_parallel() {
// Clone multiple repos
// Verify parallelism
}
#[tokio::test]
async fn test_clone_with_hooks() {
// pre_clone and post_clone hooks
// Verify executed in order
}
}
```
### 3.4 Status Operations Tests
**File**: `src/workspace/status.rs`
```rust
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_status_clean_repo() {
// No changes
// Verify dirty=false
}
#[tokio::test]
async fn test_status_dirty_repo() {
// Uncommitted changes
// Verify dirty=true
}
#[tokio::test]
async fn test_status_ahead_behind() {
// Repo ahead/behind remote
// Verify counts correct
}
#[tokio::test]
async fn test_status_detached_head() {
// Detached HEAD state
// Verify branch shows commit SHA
}
#[tokio::test]
async fn test_status_not_cloned() {
// Repo in config but not cloned
// Verify cloned=false
}
#[tokio::test]
async fn test_status_parallel() {
// Multiple repos
// Verify parallel execution
}
}
```
### 3.5 Workflow Execution Tests
**File**: `src/workspace/workflow.rs` (extend existing)
```rust
#[cfg(test)]
mod tests {
// ... existing tests ...
#[tokio::test]
async fn test_execute_workflow_success() {
// Simple workflow with one step
// Verify all repos processed
}
#[tokio::test]
async fn test_execute_workflow_step_failure() {
// Step fails on one repo
// Verify error reported, other repos continue
}
#[tokio::test]
async fn test_execute_workflow_with_filter() {
// Step has filter (tag:rust)
// Verify only matching repos processed
}
#[tokio::test]
async fn test_execute_workflow_with_condition() {
// Step has condition (is_dirty)
// Verify only matching repos processed
}
#[tokio::test]
async fn test_execute_workflow_continue_on_error() {
// continue_on_error: true
// Verify workflow continues despite failure
}
#[tokio::test]
async fn test_execute_workflow_abort_on_error() {
// continue_on_error: false (default)
// Verify workflow stops on first error
}
#[tokio::test]
async fn test_workflow_variable_interpolation() {
// Workflow with variables
// Verify ${VAR} replaced
}
}
```
---
## Priority 4: Error Handling Tests
### 4.1 Error Module Tests
**File**: `src/error.rs`
```rust
#[cfg(test)]
mod tests {
use super::*;
// ========================================================================
// Exit Code Tests
// ========================================================================
#[test]
fn test_exit_code_io_error() {
let err = UrurError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
assert_eq!(err.exit_code(), 1);
}
#[test]
fn test_exit_code_config_error() {
let err = UrurError::Config("test".to_string());
assert_eq!(err.exit_code(), 2);
}
#[test]
fn test_exit_code_clone_failed() {
let err = UrurError::CloneFailed("repo".to_string(), "reason".to_string());
assert_eq!(err.exit_code(), 3);
}
#[test]
fn test_exit_code_hook_failed() {
let err = UrurError::HookFailed { hook: "post_clone".to_string(), repo: "test".to_string() };
assert_eq!(err.exit_code(), 4);
}
#[test]
fn test_exit_code_cloud_errors() {
assert_eq!(UrurError::CloudNotInitialized.exit_code(), 13);
assert_eq!(UrurError::CloudMergeConflict.exit_code(), 13);
}
// ========================================================================
// Recovery Hint Tests
// ========================================================================
#[test]
fn test_recovery_hint_clone_auth_failed() {
let err = UrurError::CloneFailed("repo".to_string(), "Authentication failed".to_string());
let hint = err.recovery_hint();
assert!(hint.is_some());
assert!(hint.unwrap().contains("SSH key"));
}
#[test]
fn test_recovery_hint_clone_not_found() {
let err = UrurError::CloneFailed("repo".to_string(), "Repository not found".to_string());
let hint = err.recovery_hint();
assert!(hint.unwrap().contains("Verify the repository URL"));
}
#[test]
fn test_recovery_hint_provider_401() {
let err = UrurError::ProviderError {
provider: "GitHub".to_string(),
message: "401 Unauthorized".to_string()
};
let hint = err.recovery_hint();
assert!(hint.unwrap().contains("token"));
}
#[test]
fn test_recovery_hint_provider_rate_limit() {
let err = UrurError::ProviderError {
provider: "GitHub".to_string(),
message: "rate limit exceeded".to_string()
};
let hint = err.recovery_hint();
assert!(hint.unwrap().contains("Wait"));
}
#[test]
fn test_recovery_hint_cloud_not_initialized() {
let hint = UrurError::CloudNotInitialized.recovery_hint();
assert!(hint.unwrap().contains("urur cloud init"));
}
#[test]
fn test_recovery_hint_merge_conflict() {
let hint = UrurError::CloudMergeConflict.recovery_hint();
assert!(hint.unwrap().contains("--force"));
}
// ========================================================================
// Edit Distance Tests
// ========================================================================
#[test]
fn test_edit_distance_identical() {
assert_eq!(edit_distance("test", "test"), 0);
}
#[test]
fn test_edit_distance_one_char() {
assert_eq!(edit_distance("test", "tест"), 1);
assert_eq!(edit_distance("test", "tests"), 1);
assert_eq!(edit_distance("test", "est"), 1);
}
#[test]
fn test_edit_distance_case_insensitive() {
assert_eq!(edit_distance("Test", "test"), 0);
assert_eq!(edit_distance("TEST", "test"), 0);
}
#[test]
fn test_edit_distance_empty() {
assert_eq!(edit_distance("", "test"), 4);
assert_eq!(edit_distance("test", ""), 4);
}
// ========================================================================
// Find Similar Tests
// ========================================================================
#[test]
fn test_find_similar_exact_match_excluded() {
let candidates = vec!["test", "tset", "other"];
let similar = find_similar("test", &candidates, 2, 5);
// Exact match has distance 0, but filter requires > 0
assert!(!similar.contains(&"test".to_string()));
}
#[test]
fn test_find_similar_typo() {
let candidates = vec!["frontend", "backend", "database"];
let similar = find_similar("frontned", &candidates, 2, 3);
assert!(similar.contains(&"frontend".to_string()));
}
#[test]
fn test_find_similar_max_results() {
let candidates = vec!["a", "b", "c", "d", "e"];
let similar = find_similar("x", &candidates, 10, 2);
assert!(similar.len() <= 2);
}
#[test]
fn test_find_similar_max_distance() {
let candidates = vec!["test", "completely_different"];
let similar = find_similar("test", &candidates, 2, 5);
assert!(!similar.contains(&"completely_different".to_string()));
}
// ========================================================================
// Error Display Tests
// ========================================================================
#[test]
fn test_repo_not_found_with_suggestions() {
let err = UrurError::RepoNotFound {
name: "frontned".to_string(),
suggestions: vec!["frontend".to_string()],
};
let msg = err.to_string();
assert!(msg.contains("frontned"));
assert!(msg.contains("Did you mean"));
assert!(msg.contains("frontend"));
}
#[test]
fn test_repo_not_found_no_suggestions() {
let err = UrurError::RepoNotFound {
name: "xyz".to_string(),
suggestions: vec![],
};
let msg = err.to_string();
assert!(msg.contains("urur list"));
}
}
```
---
## Priority 5: Edge Cases & Platform Tests
### 5.1 Cross-Platform Tests
**File**: `tests/platform_tests.rs`
```rust
//! Platform-specific tests.
//!
//! These tests verify behavior on different platforms.
#[test]
#[cfg(unix)]
fn test_unix_permissions_preserved() {
// Create file with specific mode
// Copy/move it
// Verify mode preserved
}
#[test]
#[cfg(unix)]
fn test_unix_symlink_handling() {
// Create symlink
// Operations should handle it correctly
}
#[test]
#[cfg(windows)]
fn test_windows_path_handling() {
// Paths with backslashes
// UNC paths
}
#[test]
#[cfg(windows)]
fn test_windows_symlink_handling() {
// Windows symlinks (may require admin)
}
```
### 5.2 Concurrency Tests
**File**: `tests/concurrency_tests.rs`
```rust
use std::sync::Arc;
use std::thread;
#[test]
fn test_concurrent_config_writes() {
// Multiple threads writing config
// Verify no corruption (atomic writes)
}
#[test]
fn test_concurrent_state_updates() {
// Multiple threads updating state
// Verify consistency
}
#[tokio::test]
async fn test_concurrent_cloud_operations() {
// Simulate concurrent push attempts
// Verify optimistic locking works
}
```
### 5.3 Large Scale Tests
**File**: `tests/scale_tests.rs`
```rust
#[test]
#[ignore] // Run with --ignored for slow tests
fn test_many_repos() {
// Workspace with 100+ repos
// Verify performance acceptable
}
#[test]
#[ignore]
fn test_large_config_file() {
// Config file > 1MB
// Verify parsing works
}
#[test]
#[ignore]
fn test_deep_directory_structure() {
// Deeply nested repo paths
// Verify all operations work
}
```
---
## Test Fixtures
### Sample Config Files
Create `tests/fixtures/` directory:
```
tests/fixtures/
├── configs/
│ ├── minimal.toml # Minimum valid config
│ ├── full.toml # Config with all features
│ ├── with_hooks.toml # Config with hooks
│ ├── with_workflows.toml # Config with advanced workflows
│ ├── with_cloud.toml # Config with cloud sync
│ └── invalid/
│ ├── bad_url.toml
│ ├── duplicate_names.toml
│ └── path_traversal.toml
├── api_responses/
│ ├── github/
│ │ ├── repos.json
│ │ ├── prs.json
│ │ └── issues.json
│ └── gitlab/
│ ├── projects.json
│ └── merge_requests.json
└── git_repos/
└── README.md # Instructions for creating test repos
```
### Fixture Loading Helper
```rust
// tests/common/fixtures.rs
use std::path::PathBuf;
pub fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name)
}
pub fn load_fixture(name: &str) -> String {
std::fs::read_to_string(fixture_path(name))
.expect(&format!("Failed to load fixture: {}", name))
}
pub fn load_json_fixture<T: serde::de::DeserializeOwned>(name: &str) -> T {
let content = load_fixture(name);
serde_json::from_str(&content)
.expect(&format!("Failed to parse fixture: {}", name))
}
```
---
## Mocking Strategy
### Git Command Mocking
For unit tests that need to mock git commands:
```rust
// src/test_utils.rs (only compiled in test mode)
#[cfg(test)]
pub mod git_mock {
use std::collections::HashMap;
use std::sync::Mutex;
lazy_static::lazy_static! {
static ref MOCK_RESPONSES: Mutex<HashMap<String, MockResponse>> =
Mutex::new(HashMap::new());
}
pub struct MockResponse {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
pub fn mock_git_command(args: &str, response: MockResponse) {
MOCK_RESPONSES.lock().unwrap().insert(args.to_string(), response);
}
pub fn clear_mocks() {
MOCK_RESPONSES.lock().unwrap().clear();
}
// In actual git execution code, check for mocks first in test mode
}
```
### HTTP Mocking with wiremock
```rust
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path};
async fn setup_github_mock() -> MockServer {
let server = MockServer::start().await;
// Common mocks
Mock::given(method("GET"))
.and(path("/rate_limit"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"rate": { "remaining": 5000 }
})))
.mount(&server)
.await;
server
}
```
---
## CI Integration
### GitHub Actions Workflow
```yaml
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
rust: [stable, beta]
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
with:
toolchain: ${{ matrix.rust }}
- name: Configure git
run: |
git config --global user.email "test@test.com"
git config --global user.name "Test User"
- name: Run tests
run: cargo test --all-features
- name: Run ignored tests
run: cargo test --all-features -- --ignored
if: matrix.os == 'ubuntu-latest' && matrix.rust == 'stable'
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
with:
components: llvm-tools-preview
- name: Install cargo-llvm-cov
run: cargo install cargo-llvm-cov
- name: Generate coverage
run: cargo llvm-cov --all-features --lcov --output-path lcov.info
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: lcov.info
```
---
## Implementation Order
### Phase 1: Critical (Week 1-2)
1. [ ] Add test dependencies to Cargo.toml
2. [ ] Create test helper infrastructure
3. [ ] Implement cloud sync tests (1.1, 1.2)
4. [ ] Implement atomic write tests
### Phase 2: Providers (Week 3-4)
5. [ ] Refactor providers to accept base_url for testing
6. [ ] Implement GitHub provider tests (2.1)
7. [ ] Implement GitLab provider tests (2.2)
8. [ ] Extend provider cache tests (2.3)
### Phase 3: Workspace (Week 5-6)
9. [ ] Implement hook execution tests (3.1)
10. [ ] Implement workspace core tests (3.2)
11. [ ] Implement clone operation tests (3.3)
12. [ ] Implement status operation tests (3.4)
13. [ ] Extend workflow tests (3.5)
### Phase 4: Error & Edge Cases (Week 7-8)
14. [ ] Implement error module tests (4.1)
15. [ ] Implement platform-specific tests (5.1)
16. [ ] Implement concurrency tests (5.2)
17. [ ] Implement scale tests (5.3)
### Phase 5: CI & Coverage (Week 9)
18. [ ] Set up GitHub Actions workflow
19. [ ] Configure coverage reporting
20. [ ] Add coverage badges to README
21. [ ] Document testing guidelines
---
## Success Criteria
| Unit test count | 200+ new tests |
| Integration test count | 70+ tests |
| Code coverage (critical modules) | 80%+ |
| Code coverage (overall) | 65%+ |
| CI pass rate | 100% |
| Test execution time | < 5 minutes |
---
## Maintenance
### Adding New Features
When adding new features:
1. Write tests first (TDD) or alongside implementation
2. Add integration test for CLI commands
3. Add unit tests for core logic
4. Update fixtures if needed
5. Verify coverage doesn't decrease
### Test Review Checklist
- [ ] Tests cover happy path
- [ ] Tests cover error cases
- [ ] Tests are deterministic (no flakiness)
- [ ] Tests are isolated (no shared state)
- [ ] Tests have descriptive names
- [ ] Tests include assertions with helpful messages