Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

ModuleTestsCoverage
error::tests54Error types, codes, suggestions
output::json::tests35JSON serialization, edge cases
validate::tests30Name/version validation
core::version::tests18Version file resolution
core::virtualenv::tests12Virtualenv service
paths::tests16Path utilities
shell::*::tests14Shell 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

  1. Test naming: test_<function>_<scenario>_<expected>
  2. Arrange-Act-Assert: Clear test structure
  3. One assertion per test: When practical
  4. Test edge cases: Empty, unicode, special chars, boundaries
  5. No test interdependencies: Each test should be isolated
  6. 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:

HookRunsUsed for
onCreateCommandonce (in Codespace prebuild)install nextest / llvm-cov / mutants / uv, warm cargo build
updateContentCommandon every prebuild refreshcargo fetch to keep registry warm
postCreateCommandwhen user creates the Codespaceprek 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/:

BinaryTargets
parsingclap parse, TOML manifest, JSON uv python list
validationis_valid_env_name across 6 representative inputs
path_lookupfind_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 the gh-pages branch 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.