Skip to content
shellhacks.dev
Testing

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.

6 min read
Summarize in AI
Illustration for "Unit Tests vs Integration Tests for Shell Scripts Fully": a shell script in an editor, test results, a loop arrow.

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

Shell
@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

Shell
@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

Text
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

Shell
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.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.