Skip to main content

Testing Strategy

vpt-rs uses a layered testing approach that balances speed, coverage, and realism. Fast unit tests and doc tests run in CI on every platform, while slower integration tests exercise real storage providers on Linux and Windows with root privileges.

Test Architecture​

The test pyramid has four layers, each targeting a different confidence level:

Unit Tests (59 tests)​

Inline #[test] functions spread across the source modules test pure logic without touching the filesystem or spawning external processes. The tests are distributed across 11 source files:

Btrfs backend tests (src/platform/linux/btrfs.rs)​

Six tests verify plan generation, output parsing, and error handling for the Btrfs provider:

Test functionWhat it verifies
create_plan_uses_hidden_snapshot_directorySnapshot path is derived into .vb-snapshots/ with the correct label
application_consistent_requests_are_rejectedMissingCapability error for ApplicationConsistent kind
list_output_parses_pathsbtrfs subvolume list -s output is parsed into SnapshotInfo entries
backup_plan_uses_btrfs_send_to_image_filebtrfs send command is generated with a temporary snapshot
backup_plan_uses_parent_snapshot_for_incremental_sendbtrfs send -p <parent> <source> is generated for incremental backup
restore_plan_uses_btrfs_receive_from_streambtrfs receive <destination> command is generated from stream input

Example from src/platform/linux/btrfs.rs:523-558:

#[test]
fn create_plan_uses_hidden_snapshot_directory() {
let backend = BtrfsBackend::new();
let root = std::env::temp_dir().join(format!("vpt-rs-btrfs-plan-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let source = root.join("subvol");
std::fs::create_dir_all(&source).unwrap();

let plan = backend
.plan_create_snapshot(&SnapshotRequest {
source: VolumeRef::new(source.display().to_string()),
kind: SnapshotKind::CrashConsistent,
label: Some("nightly backup".to_string()),
read_only: true,
})
.unwrap();

assert_eq!(
plan.snapshot_path,
root.join(".vb-snapshots").join("nightly-backup")
);
// ... asserts on command.args

let _ = std::fs::remove_dir_all(&root);
}

LVM backend tests (src/platform/linux/lvm.rs)​

Eight tests cover volume path parsing, snapshot plan generation, list output filtering, backup plan construction, and restore force-flag validation:

Test functionWhat it verifies
parses_standard_lvm_volume_path/dev/vg0/data is parsed into vg_name="vg0", lv_name="data"
create_plan_uses_lvcreate_snapshot_commandslvcreate --snapshot --extents 20%ORIGIN args are correct
parse_list_output_filters_origin_snapshotsOnly snapshots whose origin matches the source are returned
application_consistent_requests_are_rejectedMissingCapability for ApplicationConsistent
backup_plan_uses_temporary_snapshot_for_live_volumeTemporary snapshot is created before block copy
backup_plan_uses_explicit_snapshot_source_without_temporary_snapshotNo temp snapshot when source is already a snapshot
restore_plan_requires_force_flagInvalidArgument when force: false
restore_plan_uses_copy_blocks_to_write_image_into_lvCopy source/destination and block size are set correctly

ZFS backend tests (src/platform/linux/zfs.rs)​

Ten tests verify dataset reference parsing, snapshot command generation, list output filtering, incremental send planning, and restore validation:

Test functionWhat it verifies
parses_dataset_name_without_mount_path"tank/data" is parsed as dataset name only
parses_mount_path_as_dataset_reference"/tank/data" gets a mount_point hint
create_plan_uses_zfs_snapshot_commandzfs snapshot -r tank/data@name args are correct
parse_list_output_filters_matching_dataset_snapshotsOnly snapshots for the target dataset are returned
application_consistent_requests_are_rejectedMissingCapability for ApplicationConsistent
backup_plan_uses_zfs_send_with_snapshot_sourcezfs send tank/data@snap1 command is generated
backup_plan_rejects_non_snapshot_sourceInvalidArgument when volume source has no snapshot policy
backup_plan_uses_parent_snapshot_for_incremental_sendzfs send -i parent source args are correct
restore_plan_uses_zfs_receive_dataset_destinationzfs receive -F dataset command is generated
restore_plan_rejects_mount_path_destinationInvalidArgument for mount-path destinations

VSS backend tests​

The Windows VSS provider has tests spread across four files:

src/platform/windows.rs (4 tests) -- backend identity and capabilities:

Test functionWhat it verifies
backend_has_expected_nameBackend name is "windows-vss"
backend_has_expected_capabilitiesAll 5 expected capabilities are declared
volume_path_converts_drive_letter"C:" becomes "\\\\.\\C" (feature-gated)
volume_path_preserves_guid_pathGUID paths are passed through unchanged (feature-gated)

src/platform/windows/vss.rs (2 tests) -- request validation:

Test functionWhat it verifies
rejects_app_consistent_request_without_writersMissingCapability when writer coordination is disabled
rejects_device_paths_as_vss_sourcesInvalidArgument for \\.\PhysicalDrive0

src/platform/windows/vss/ffi/cli.rs (10 tests) -- CLI output parsing:

Test functionWhat it verifies
extract_guid_from_indented_lineGUID extracted from vssadmin list shadows output
extract_guid_returns_none_for_no_bracesReturns None when no {...} found
extract_guid_returns_none_for_wrong_segment_countRejects {1234-5678} (not a valid GUID)
parse_wmic_field_extracts_valueField value extracted from wmic output
parse_wmic_field_returns_none_for_missing_fieldReturns None for missing field
matches_volume_case_insensitiveVolume matching is case-insensitive
parse_vssadmin_list_output_parses_snapshotsFull vssadmin output parsed into SnapshotInfo
parse_vssadmin_list_output_returns_empty_for_no_matchReturns empty for non-matching volume
find_device_path_extracts_globalroot_pathDevice path extracted from shadow copy output
find_device_path_returns_none_for_emptyReturns None for missing device path

src/platform/windows/vss/ffi/com.rs (9 tests) -- COM FFI helpers:

Test functionWhat it verifies
parse_guid_roundtripGUID string parses and serializes back to the same value
parse_guid_without_bracesGUID without {} braces is accepted
parse_guid_rejects_invalid_formatInvalid strings are rejected
normalize_volume_path_drive_letter"C:" normalized to "C:\\"
normalize_volume_path_guid_passthroughGUID paths passed through unchanged
wide_string_produces_null_terminated_utf16UTF-16 encoding is correct with NUL terminator
from_wide_ptr_null_returns_emptyNull pointer produces empty string
from_wide_ptr_reads_utf16UTF-16 data read correctly from pointer
vss_id_zero_is_all_zerosVssId::ZERO has all fields zeroed

Other unit tests​

ModuleFileTestsWhat they verify
libsrc/lib.rs5Platform descriptor non-empty, backend name matches, available backends non-empty, SnapshotKind parsing
processsrc/process.rs1wait_with_timeout returns None for long-running child
copysrc/copy.rs3File contents copied correctly, empty file handled, zero block size rejected
linuxsrc/platform/linux/mod.rs1available_descriptors() returns btrfs, lvm, zfs

copy.rs tests in detail​

The block-level copy helper in src/copy.rs is tested by three functions:

// src/copy.rs:117-153
#[test]
fn copies_file_contents() {
let dir = tempdir().unwrap();
let src = dir.path().join("source.bin");
let dst = dir.path().join("dest.bin");
let data = vec![0xAB_u8; 1024 * 1024 + 7]; // ~1 MiB, non-aligned
fs::write(&src, &data).unwrap();

let copied = copy_blocks(&src, &dst, 64 * 1024).unwrap();
assert_eq!(copied, data.len() as u64);
assert_eq!(fs::read(&dst).unwrap(), data);
}

#[test]
fn copies_empty_file() {
// ... asserts 0 bytes copied, empty destination
}

#[test]
fn rejects_zero_block_size() {
// ... asserts InvalidArgument for block_size = 0
}

process.rs test in detail​

The timeout test in src/process.rs:167-179 spawns a sleep 1 child process and verifies that wait_with_timeout returns None after 50ms:

#[cfg(target_family = "unix")]
#[test]
fn wait_with_timeout_returns_none_for_long_running_child() {
let mut child = Command::new("sh")
.args(["-c", "sleep 1"])
.spawn()
.expect("spawn sleep");

let status = wait_with_timeout(&mut child, Duration::from_millis(50)).unwrap();
assert!(status.is_none());

let _ = child.kill();
let _ = child.wait();
}

lib.rs tests in detail​

The five tests in src/lib.rs:39-77 verify the platform dispatch layer:

#[test]
fn current_platform_descriptor_is_not_empty() {
assert!(!platform::current_platform().is_empty());
}

#[test]
fn current_backend_has_name() {
let backend = platform::current_backend();
assert!(!backend.backend_name().is_empty());
}

#[test]
fn descriptor_matches_backend_name() {
let backend = platform::current_backend();
let descriptor = platform::current_backend_descriptor();
assert_eq!(descriptor.backend_name, backend.backend_name());
assert!(!descriptor.capabilities.is_empty());
}

#[test]
fn available_backends_is_not_empty() {
assert!(!platform::available_backend_descriptors().is_empty());
}

#[test]
fn snapshot_kind_parsing_accepts_short_forms() {
assert_eq!(
"crash".parse::<SnapshotKind>().unwrap(),
SnapshotKind::CrashConsistent
);
assert_eq!(
"application".parse::<SnapshotKind>().unwrap(),
SnapshotKind::ApplicationConsistent
);
}

Run all unit tests with:

cargo test --lib
tip

Unit tests never require root. They never write to real disks or spawn privileged subprocesses. Plan-level tests create temporary directories via tempdir() and clean up after themselves.

Doc Tests (3 tests)​

Rust doc tests verify that the code examples in documentation comments actually compile and run. They live in /// doc comments on types in src/types.rs:

TypeExample verifies
VolumeRefVolumeRef::new construction and Display output
SnapshotRequestStruct construction with all fields
BackupPlanFull struct construction including SnapshotPolicy::temporary

Run only doc tests:

cargo test --doc
note

The Backend trait doc test in src/backend.rs is marked ignore because it requires a concrete backend instance that depends on the runtime platform.

Smoke Tests (6 tests, no root)​

Smoke tests in tests/test_smoke.py verify basic CLI behavior without root privileges. They run on any platform where vptcli is available:

TestWhat it checks
backend_listvptcli snapshot backend list returns platform info
capabilities_linux_providersvptcli snapshot capabilities works for each Linux provider
snapshot_usagevptcli snapshot with no args shows usage (exit 0)
backup_usagevptcli backup with no args shows usage (exit 0)
restore_usagevptcli restore with no args shows usage (exit 0)
snapshot_invalid_providerUnknown provider returns non-zero exit code

Run smoke tests:

python3 tests/test_smoke.py

Integration Tests (Python-based)​

Full round-trip tests exercise vptcli against real storage providers. They require root privileges and provider-specific system packages. See the Integration Tests page for the complete guide.

Test fileProviderRoot required
test_btrfs.pyBtrfsYes
test_lvm.pyLVMYes
test_zfs.pyZFSYes
test_vss.pyVSSYes (Admin)
test_smoke.py--No

Coverage Matrix​

AreaUnit testsDoc testsSmoke testsIntegration tests
Type constructionyesyes
Label sanitizationyes
CLI argument parsingyesyes
Plan generation logicyes
Output parsing (btrfs, lvm, zfs, vss)yes
Error variant behavioryes
Timeout behavioryes
Block-level copyyes
Snapshot create/list/deleteyes
Backup (send or dd)yes
Restore (receive or dd)yes
File content verificationyes
CLI usage outputyes
Backend discoveryyes

What is NOT Tested in Unit Tests​

Unit tests deliberately avoid:

  • Creating real snapshots -- requires root and provider tools
  • Running btrfs send/zfs send/dd -- privileged I/O operations
  • Mounting filesystems -- requires mount permissions
  • Writing to block devices -- requires device access
  • Network or remote operations -- local-only testing

These scenarios are covered by the integration test suite, which runs with root privileges and real loop devices.

CI Pipeline​

The CI workflow (.github/workflows/ci.yml) runs on every push and pull request to master:

Check job (runs on ubuntu-latest):

  1. cargo fmt --all -- --check -- enforces consistent formatting
  2. cargo clippy --all-targets -D warnings -- catches common Rust mistakes

Test jobs (run in parallel on ubuntu-latest, macos-latest, windows-latest):

  1. cargo build --verbose
  2. cargo test --verbose -- runs all unit tests + doc tests
  3. On Windows only: cargo build --all-features and cargo test --all-features to test the windows-vss feature gate

Integration tests are not part of CI because they require root privileges and specific storage tools. They are run manually or in scheduled jobs.

Running Tests Locally​

All unit and doc tests​

cargo test

Only unit tests (skip doc tests)​

cargo test --lib

Only doc tests​

cargo test --doc

Smoke tests (no root)​

python3 tests/test_smoke.py

Integration tests (root required)​

# All providers
sudo python3 tests/run_all.py

# Single provider
sudo python3 tests/test_btrfs.py

# Build first, then test
sudo python3 tests/run_all.py --build
caution

Integration tests create loop devices, LVM volume groups, and ZFS pools. Always run them in a disposable environment. Never run on a production system.