Testing
Comprehensive guide for testing scoop.
Quick Reference
cargo test # Run all tests
cargo test json # Run tests containing "json"
cargo test -- --nocapture # Show println! output
cargo clippy -- -D warnings # Lint check
Test Structure
tests/
└── cli.rs # CLI integration tests
src/
├── error.rs # Unit tests for error types
├── validate.rs # Unit tests for validation
├── paths.rs # Unit tests for path utilities
├── output/
│ └── json.rs # Unit tests for JSON output
├── core/
│ ├── virtualenv/ # virtualenv service (mod.rs + tests.rs)
│ ├── version.rs # Unit tests for version service
│ ├── metadata.rs # Unit tests for metadata
│ └── doctor.rs # Unit tests for doctor
├── shell/
│ ├── bash.rs # Shell script tests
│ └── zsh.rs # Shell script tests
└── uv/
└── client.rs # Unit tests for uv client
Running Tests
All Tests
# Run all tests
cargo test
# Run with all features enabled
cargo test --all-features
# Run in release mode (faster execution)
cargo test --release
Filtered Tests
# By name pattern
cargo test json # Tests containing "json"
cargo test error # Tests containing "error"
cargo test virtualenv # Tests containing "virtualenv"
# By module path
cargo test output::json # Tests in output/json.rs
cargo test error::tests # Tests in error.rs
cargo test core::version # Tests in core/version.rs
cargo test cli::commands # Tests in cli/commands/
# Single test
cargo test test_json_response_success_creates_correct_status
Test Output
# Show stdout/stderr (println!, dbg!, etc.)
cargo test -- --nocapture
# Show test names as they run
cargo test -- --nocapture --test-threads=1
# Only show failed tests
cargo test -- --quiet
Debugging
# Run single-threaded (easier to debug)
cargo test -- --test-threads=1
# Run ignored tests
cargo test -- --ignored
# Run specific test with output
cargo test test_name -- --nocapture --test-threads=1
Test Categories
Unit Tests
The bulk of the suite (703 tests, ~92% of the total 769) lives within source files using #[cfg(test)]:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_something() {
assert_eq!(1 + 1, 2);
}
}
}
Key test modules:
| Module | Tests | Coverage |
|---|---|---|
error::tests | 54 | Error types, codes, suggestions |
output::json::tests | 35 | JSON serialization, edge cases |
validate::tests | 30 | Name/version validation |
core::version::tests | 18 | Version file resolution |
core::virtualenv::tests | 12 | Virtualenv service |
paths::tests | 16 | Path utilities |
shell::*::tests | 14 | Shell scripts (shellcheck) |
Integration Tests (43 tests in tests/cli.rs + 2 in tests/i18n_completeness.rs)
Located in tests/cli.rs:
# Run only integration tests
cargo test --test cli
Categories:
- Error cases - Invalid inputs, missing arguments
- Output format - Help, version, JSON output
- Command behavior - list, create, use, remove
Some tests are marked #[ignore] because they require uv installed:
# Run ignored tests (requires uv)
cargo test -- --ignored
Doc Tests (21 tests)
Examples in documentation comments:
#![allow(unused)]
fn main() {
/// Validates environment name.
///
/// # Examples
///
/// ```
/// use scoop_uv::validate::is_valid_env_name;
/// assert!(is_valid_env_name("myenv"));
/// assert!(!is_valid_env_name("123bad"));
/// ```
pub fn is_valid_env_name(name: &str) -> bool { ... }
}
# Run only doc tests
cargo test --doc
Property Tests
Using proptest for randomized testing:
#![allow(unused)]
fn main() {
use proptest::prelude::*;
proptest! {
#[test]
fn prop_valid_names_accepted(name in "[a-zA-Z][a-zA-Z0-9_-]{0,49}") {
assert!(is_valid_env_name(&name));
}
}
}
Located in src/validate.rs.
Parameterized Tests
Using rstest #[case] tables for input/output matrices (e.g. version parsing,
env-name validation) so each case reports independently:
#![allow(unused)]
fn main() {
use rstest::rstest;
#[rstest]
#[case::simple("myenv", true)]
#[case::digit_start("123", false)]
#[case::reserved("activate", false)]
fn is_valid_env_name_cases(#[case] input: &str, #[case] expected: bool) {
assert_eq!(is_valid_env_name(input), expected);
}
}
Mutation Testing
cargo-mutants verifies the suite actually catches bugs (not just that lines
run). Config in .cargo/mutants.toml; CI runs it on changed lines per PR
(--in-diff) and a full pass weekly.
cargo install cargo-mutants
cargo mutants # full (scoped via mutants.toml)
git diff origin/main.. | cargo mutants --in-diff /dev/stdin # changed lines
Fuzz Testing
cargo-fuzz (libFuzzer) fuzzes the untrusted-input parsers. It lives in an
isolated fuzz/ workspace pinned to nightly, so it never affects the
MSRV-1.85 build; CI runs the targets on a weekly schedule.
cargo install cargo-fuzz
cargo +nightly fuzz run fuzz_env_name -- -max_total_time=60
Writing Tests
Unit Test Template
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
// ========================================
// Test Group Name
// ========================================
#[test]
fn test_function_name_expected_behavior() {
// Arrange
let input = "test input";
// Act
let result = function_under_test(input);
// Assert
assert_eq!(result, expected_value);
}
#[test]
fn test_function_name_edge_case() {
let result = function_under_test("");
assert!(result.is_err());
}
}
}
Integration Test Template
#![allow(unused)]
fn main() {
// tests/cli.rs
use assert_cmd::Command;
use predicates::prelude::*;
#[test]
fn test_command_success() {
Command::cargo_bin("scoop")
.unwrap()
.args(["list"])
.assert()
.success()
.stdout(predicate::str::contains("expected output"));
}
#[test]
fn test_command_failure() {
Command::cargo_bin("scoop")
.unwrap()
.args(["use", "nonexistent"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
}
}
JSON Output Testing
#![allow(unused)]
fn main() {
#[test]
fn test_json_serialization() {
let data = MyData { field: "value".into() };
let json = serde_json::to_string(&data).unwrap();
// Check JSON structure
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["field"], "value");
}
#[test]
fn test_optional_field_omitted() {
let data = MyData { optional: None, .. };
let json = serde_json::to_string(&data).unwrap();
// skip_serializing_if = "Option::is_none"
assert!(!json.contains("optional"));
}
}
Test Utilities
Located in src/test_utils.rs:
#![allow(unused)]
fn main() {
use scoop_uv::test_utils::*;
#[test]
fn test_with_temp_environment() {
with_temp_scoop_home(|temp_dir| {
// SCOOP_HOME is set to temp_dir
// Cleanup happens automatically
});
}
#[test]
fn test_with_mock_venv() {
with_temp_scoop_home(|temp_dir| {
create_mock_venv("myenv", Some("3.12"));
// Virtual environment created at temp_dir/virtualenvs/myenv
});
}
}
Coverage
Using cargo-tarpaulin
# Install
cargo install cargo-tarpaulin
# Run with HTML report
cargo tarpaulin --out Html --output-dir coverage
# Run with specific target
cargo tarpaulin --out Html --output-dir coverage --packages scoop-uv
# View report
open coverage/tarpaulin-report.html
Using cargo-llvm-cov
# Install
cargo install cargo-llvm-cov
# Run with HTML report
cargo llvm-cov --html
# View report
open target/llvm-cov/html/index.html
CI/CD Testing
Tests run automatically on:
- Every push to any branch
- Every pull request
GitHub Actions workflow (.github/workflows/ci.yml):
- name: Run tests
run: cargo test --all-features
- name: Run clippy
run: cargo clippy --all-targets -- -D warnings
Troubleshooting
Test Hangs
# Run single-threaded to identify hanging test
cargo test -- --test-threads=1
Flaky Tests
# Run specific test multiple times
for i in {1..10}; do cargo test test_name || break; done
Environment Issues
# Clear test artifacts
cargo clean
# Rebuild and test
cargo test
Shell Tests Fail
ShellCheck must be installed for shell script tests:
# macOS
brew install shellcheck
# Linux
apt install shellcheck
Best Practices
- Test naming:
test_<function>_<scenario>_<expected> - Arrange-Act-Assert: Clear test structure
- One assertion per test: When practical
- Test edge cases: Empty, unicode, special chars, boundaries
- No test interdependencies: Each test should be isolated
- Fast tests: Mock external dependencies
Codespaces / Devcontainer
.devcontainer/devcontainer.json boots a Rust 1.85 dev environment on
mcr.microsoft.com/devcontainers/rust:1-bookworm that matches the local
toolchain pinned by rust-toolchain.toml. Open the repo in VS Code
(“Reopen in Container”) or create a Codespace — both follow the same
lifecycle:
| Hook | Runs | Used for |
|---|---|---|
onCreateCommand | once (in Codespace prebuild) | install nextest / llvm-cov / mutants / uv, warm cargo build |
updateContentCommand | on every prebuild refresh | cargo fetch to keep registry warm |
postCreateCommand | when user creates the Codespace | prek install |
Cargo registry + git caches persist as named Docker volumes scoped per
project so two scoop-uv worktrees don’t share caches. target/ is NOT
volumed — it’s architecture-specific and warmed in the prebuild layer.
Enabling Codespace prebuilds
In repo Settings → Codespaces → “Set up prebuild”, configure for the
main branch on the “configuration change” trigger. This keeps Actions
minutes low (only rebuilds when .devcontainer/** or Dockerfile
changes) while still keeping the cargo registry warm via volume
persistence.
Multi-source Integration (Docker matrix)
The Dockerfile builds three per-source leaf stages — pyenv-test,
conda-test, venvwrapper-test — on top of a shared scoop-test-base.
Each carries only the source-tool it migrates from, so CI can
matrix-build just one variant.
# Local (sequential)
make test-integration-pyenv
make test-integration-conda
make test-integration-venvwrapper
# Local (all three sequentially)
make test-integration-all
# Drop into a single variant for ad-hoc debugging
make docker-shell-conda
CI runs all three in parallel via
.github/workflows/integration-test.yml’s strategy.matrix with
per-source BuildKit cache scoping (cache-from/to=type=gha,scope=<src>).
Benchmarks (Criterion)
Three bench binaries live in benches/:
| Binary | Targets |
|---|---|
parsing | clap parse, TOML manifest, JSON uv python list |
validation | is_valid_env_name across 6 representative inputs |
path_lookup | find_executable_in hit + miss |
Local workflow
# Run all benches once (no baseline diffing)
cargo bench
# Save current results as a named baseline (default: "main")
make bench-save # saves as "main"
make bench-save BENCH_BASELINE=before-X # saves as "before-X"
# Compare current results against a saved baseline
make bench-compare # vs "main"
make bench-compare BENCH_BASELINE=before-X
# Run all benches inside Docker (reproducible)
make bench
Criterion writes HTML reports to target/criterion/report/index.html
for visual diffing.
CI regression gate
.github/workflows/bench.yml runs every PR and push:
- On
main: results are pushed to thegh-pagesbranch as the new baseline. - On PRs: results are compared against that baseline.
- alert at 130% — leaves a PR comment, doesn’t block merge
- fail at 150% — fails the workflow, blocks merge
Thresholds account for GitHub-hosted runner variance (10-30% per-bench noise is normal on shared CPU). Tighten by running on a self-hosted runner with pinned hardware if needed.