How to Test Destructive Scripts With a Sandbox Step by Step
Test scripts that delete or overwrite in a throwaway sandbox: temp trees, containers with no network, fake commands and checks that nothing else changed.

The scripts that most need tests are the ones that delete, overwrite, format or migrate, and they are the ones nobody wants to run for the sake of a test. The answer is to arrange for the worst case to be harmless: run them where nothing matters, and prove that nothing outside that place was touched. This post gives four layers, cheap to heavy, as an application of testing shell scripts. Examples use Bash 5.2, Bats 1.11 and Docker Engine 27.
Layer 1: design for testability
#!/usr/bin/env bash
set -euo pipefail
root="${CLEANUP_ROOT:?set CLEANUP_ROOT}"
dry_run=0
[[ ${1:-} == --dry-run ]] && dry_run=1
[[ -d $root ]] || { echo "no such directory: $root" >&2; exit 2; }
[[ $root != / && $root != "$HOME" ]] || { echo "refusing $root" >&2; exit 2; }
find "$root" -type f -name '*.tmp' -mtime +7 | while IFS= read -r f; do
if (( dry_run )); then echo "would remove $f"; else rm -- "$f"; fi
doneThe target comes from a variable, the dangerous values are refused and a dry-run mode prints instead of deleting. With the root injectable, a test can point it at a temporary directory. A script that hardcodes /var/lib/app can only be tested by running it on a real system.
Layer 2: a temp fixture and a canary
setup() {
TMP="$(mktemp -d)"
mkdir -p "$TMP/target" "$TMP/outside"
touch -d '30 days ago' "$TMP/target/old.tmp"
touch "$TMP/target/new.tmp" "$TMP/outside/old.tmp"
touch -d '30 days ago' "$TMP/outside/old.tmp"
}
teardown() { rm -rf "$TMP"; }
@test "removes only old tmp files under the root" {
CLEANUP_ROOT="$TMP/target" run ./cleanup.sh
[ "$status" -eq 0 ]
[ ! -e "$TMP/target/old.tmp" ]
[ -e "$TMP/target/new.tmp" ]
[ -e "$TMP/outside/old.tmp" ]
}
@test "dry run changes nothing" {
CLEANUP_ROOT="$TMP/target" run ./cleanup.sh --dry-run
[ -e "$TMP/target/old.tmp" ]
[[ $output == *"would remove"* ]]
}
@test "refuses the filesystem root" {
CLEANUP_ROOT=/ run ./cleanup.sh
[ "$status" -eq 2 ]
}The fixture is built fresh for every test, and the "outside" file is the canary: if the script wandered beyond its target, the canary is gone and the test fails. The refuses test runs the dangerous input on purpose, because the guard stops it. Do not write tests that would do damage if the guard failed.
Layer 3: fake commands
mkdir -p "$TMP/bin"
cat > "$TMP/bin/mkfs.ext4" <<'EOF'
#!/bin/sh
echo "mkfs.ext4 $*" >> "$FAKE_LOG"
EOF
chmod +x "$TMP/bin/mkfs.ext4"
PATH="$TMP/bin:$PATH" FAKE_LOG="$TMP/calls.log" ./provision-disk.sh /dev/loop9
grep -q 'mkfs.ext4 /dev/loop9' "$TMP/calls.log"A stub that records its arguments lets you test what the script would have run, including for commands that could never be run safely on the test machine, such as mkfs, dd or iptables. The script must call the command by name (not by an absolute path) for the fake to win. The same technique is described in mocking commands in shell tests.
Layer 4: a disposable machine
docker run --rm --network none -v "$PWD:/work:ro" -w /work debian:13 \
bash -c 'cp -r /work /tmp/w && cd /tmp/w && ./migrate.sh /srv/data'When the script really needs to operate on system paths, run it in a container. The filesystem disappears with --rm, --network none removes the chance of reaching real services and the source is mounted read-only. For anything involving disks, a loop device on a file inside a VM or container with --privileged is a last resort and belongs in a VM, not on a laptop. A lighter option is a bubblewrap sandbox.
Assert the negative
Besides checking what changed, snapshot what should not: record find "$TMP" -type f -exec sha256sum {} + | sort before and after, and compare the parts outside the target. That catches a script that changed a file in a way you did not think to check. Combine with set -u tests: run the script with an unset variable and confirm it stops rather than expanding to an empty path, as discussed in shell script anti-patterns.
Where this does not apply
Tests prove the script behaves under the scenarios you thought of. Real data has surprises such as odd file names and mounts in unexpected places. Before the first real run, use the dry run on the real system, read its output and take a backup or snapshot.
Make the dangerous part small and swappable
A script that deletes, overwrites or reformats is hard to test because the cost of a mistake is the real thing. The best defense is design. Put the destructive operation behind a single function, so there is one place where files are removed, and give that function a dry-run mode that prints the action instead of performing it. Take every path from an argument or a variable and validate it before use: resolve it with realpath, check that it is inside an allowed prefix, refuse the empty string, the root and the home directory, and fail with a message. Use ${dir:?} so an unset variable stops the script instead of expanding to nothing, which is how rm -rf "$dir/" becomes rm -rf /. These guards are tests in themselves: write cases that pass empty, relative and dangerous paths and check that the script refuses each. A script designed this way can be exercised against a temporary directory with the same code that will run for real, so the test is a faithful one.
Keep the logic that decides what to delete separate from the code that deletes it. A function that prints the list of targets can be checked with ordinary assertions, and the deleter becomes a loop too simple to get wrong.
Fixtures, canaries and stubs
Run the tests in a throwaway directory made with mktemp -d, build the tree the script should act on, and add canaries: files and directories outside the target that must survive. After the run, check that the targets are gone and every canary is intact. A stronger form records a checksum of the whole tree outside the target, for example with find . -type f | sort | xargs sha256sum, before and after, and compares them, so even a subtle change to a neighbor is caught. For commands that cannot run safely even in a fixture, such as mkfs, dd, iptables or systemctl, put stubs first on the PATH: small scripts that append their arguments to a log file and exit with success. The test then asserts on the log, confirming that the script would have run exactly the commands you intended, with the right arguments and in the right order, without touching the machine.
Make the stub fail on demand as well, with an environment variable that makes it exit nonzero, so you can test what the script does when a step fails halfway. Error paths in destructive scripts are the ones that most often do real damage.
A disposable machine for the final check
Some behavior needs a real system: partitioning, package removal, service management, permissions as root. For that, use a container or virtual machine that you can throw away. A container started with docker run --rm from a clean image runs the script as root with no risk to the host, provided you do not mount anything important into it and do not use privileged mode. For things containers cannot do, such as kernel modules and boot loaders, use a virtual machine with a snapshot taken before the test, and revert it afterwards. Assert the negative as well as the positive: not only that the script removed what it should, but that it left everything else alone, and that running it a second time does nothing harmful. Keep the whole thing automated, so the test is run every time the script changes, and not once on the day it was written. A destructive script that has been run against a copy of the real data a few times is far safer than one that has only been read carefully.
See the Bats guide to writing tests and the Docker run network options.


