Unit Tests vs Integration Tests for Shell Scripts Fully
Unit versus integration tests for shell scripts: what each checks, how to split a suite, speed and cost, fakes versus real tools, and a simple pyramid.

Shell scripts mostly glue tools together, so a test can check logic (does it choose the right file?) or wiring (does it work with the real tar and rsync?). Both matter and they fail differently. A split into two layers keeps feedback quick while still catching the bugs that only appear when everything is connected. This builds on testable shell scripts and mocks in shell. The examples use Bats 1.11.
What a unit test checks
@test "choose_backup_name adds the date and keeps the extension" {
source "$BATS_TEST_DIRNAME/../backup.sh"
run choose_backup_name "/srv/data.tar.gz" "2026-11-03"
[ "$status" -eq 0 ]
[ "$output" = "data-2026-11-03.tar.gz" ]
}
@test "retention keeps the newest seven" {
source "$BATS_TEST_DIRNAME/../backup.sh"
run select_old_backups 7 <<'EOF'
b-01
b-02
b-03
b-04
b-05
b-06
b-07
b-08
b-09
EOF
[ "$output" = $'b-01\nb-02' ]
}These call one function, with input given as arguments or on stdin, and check the output. They run in a few milliseconds each, need no root, no network and no real data, and a failure points to the function. Pure logic functions (parsing, naming, selecting, formatting) are the best units; this is why separating decisions from effects pays.
What an integration test checks
@test "backup.sh creates an archive and prunes old ones" {
src=$(mktemp -d); dest=$(mktemp -d)
echo "hello" > "$src/file.txt"
for d in 01 02 03 04 05 06 07 08 09; do touch "$dest/data-2026-11-$d.tar.gz"; done
run "$BATS_TEST_DIRNAME/../backup.sh" --source "$src" --dest "$dest" --keep 7
[ "$status" -eq 0 ]
[ "$(ls "$dest" | wc -l)" -eq 7 ]
tar -tzf "$(ls -t "$dest"/*.tar.gz | head -1)" | grep -q file.txt
}The script runs as a program, with its arguments, the real tar, a real temporary directory and a real exit status. It proves that the parts connect: option parsing, the archive command, the listing used for pruning. It takes tens or hundreds of milliseconds and it is the test that fails when tar behaves differently on another platform. The temporary-directory pattern is in temporary directories in shell tests.
A pyramid for scripts
few end-to-end real host or VM, real services, run before release
some integration real script + real tools, temp dirs, a container for services
many unit functions with fake commands, milliseconds each
Most tests should be units, because they are cheap and precise. A handful of integration tests cover the main workflows and the error paths that users hit. One or two end-to-end checks run before releases, in a clean virtual machine or container. If every test needs a container, the suite is slow and people stop running it. If no test is integration, you will ship scripts that pass their tests and fail on the first real use.
Speed and where they run
bats tests/unit/ # on every save
bats --jobs 4 tests/integration/ # in CI
docker run --rm -v "$PWD":/w -w /w debian:13-slim bats tests/integration/Run units constantly, using a watcher like entr, and integration tests in CI on every push. Run integration tests in a container so they do not depend on the developer's machine and so destructive scripts cannot hurt it; see testing destructive scripts in a sandbox. Matrix the integration suite across distributions when portability matters, as in testing on multiple distros.
Fakes versus real tools
A rule that works: fake what is slow, costly or dangerous (network calls, email, deletion of real data, time) and use the real thing when it is cheap (tar, sort, a temp directory, a local SQLite). Overusing fakes tests your imagination of the tool. A failing integration test with a clear cause also tells you which unit test is missing: add one that reproduces the bug at function level.
Designing scripts so they can be tested
Testability is a design property, and shell scripts get it by separating three things: deciding, doing and starting. The decision functions take values and return values, with no side effects. The doing functions perform one effect, such as running a command or writing a file. A small main section parses arguments, calls the decision functions and passes the results to the doing functions. Unit tests cover the first group, integration tests the second, and the third is exercised by the end-to-end run. A script written as one long sequence of commands can only be tested as a whole, which is why such scripts are rarely tested at all.
Put a guard around main so that sourcing the file loads functions without running anything, as described in the post on sourcing. This one change makes every function reachable from a test, and it costs a single line at the bottom of the script. Pass dependencies in as arguments or variables with defaults, for example a directory or a command name, so a test can substitute a temporary path or a stub.
Choosing what to fake
Fakes in shell are usually functions or executables placed first on the path. A fake curl that prints a canned response lets a unit test run offline and quickly, and also lets it simulate failures that are hard to trigger for real, such as a timeout or a 500. The risk is drift: the fake behaves the way you think the tool behaves, and a real upgrade changes the output format without any test noticing. Counter that with a small number of integration tests that use the real tool, run on a schedule or on every pull request, and treat a mismatch between the fake and the real tool as a bug in the fake.
Record what the fake was called with. A fake that appends its arguments to a file lets the test assert that the script called the tool with the right options in the right order, which verifies behaviour that has no visible output. Keep such assertions few and focused; a test that pins every argument breaks whenever the implementation changes without changing behaviour.
Testing failure paths
The code that matters most is the code that runs when something goes wrong. Test it deliberately: a missing input file, an unreadable directory, a full disk simulated with a tiny tmpfs, a command that exits non-zero halfway. For each, assert two things, the exit status and the state left behind. A script that fails with the right message and leaves partial output in the destination has a bug that a test of the success path never finds. Run these tests in a container so a destructive scenario cannot touch the developer's machine.
Keeping the suite fast and trusted
Developers run tests that are fast and ignore tests that are slow or flaky. Keep unit tests under a second in total, put slower ones behind a separate command and fix flakiness immediately, since a test that fails at random teaches people to rerun until it passes. Report the results in the pull request, show which tests failed and keep the output short. When a bug escapes, add the test that would have caught it first, then fix the code. The suite grows from real failures, which makes it small and relevant.
Where this does not apply
A script of ten lines needs one integration test and nothing more. Systems whose behaviour comes from the environment (provisioning scripts, installers) are almost all integration, and are tested in throwaway VMs. Do not chase coverage numbers on shell; see coverage with kcov for what they do and do not tell you. See the practical test pyramid essay and the Bats documentation.


