writing, structuring and permanently securing pure functions
Anyone who writes Bash scripts as a monolithic block can hardly test them in any meaningful way. Only splitting the script into pure, isolated functions makes unit tests for Bash possible in the first place, and with bats-core you can check return values, stdout and error paths just as structured as in any other programming language.
Table of Contents
- 1. Why pure functions are the foundation of testable Bash scripts
- 2. Separating library and entry point
- 3. Structuring test cases with bats-core
- 4. Assertions: checking return values, stdout and stderr
- 5. Table driven tests for many input combinations
- 6. Edge cases: empty input and special characters
- 7. Test coverage for error paths and exit codes
- 8. Integrating unit tests locally and in pre-commit hooks
- 9. Unit tests compared to integration tests for Bash
- 10. Summary
- 11. FAQ
1. Why pure functions are the foundation of testable Bash scripts
Most Bash scripts are written as a linear flow: set variables, run commands, write files, all in one file from top to bottom. This exact structure is what makes unit tests for Bash functions difficult, because individual steps cannot be run in isolation without triggering side effects such as network access or filesystem changes. A pure function, on the other hand, only reads its arguments, produces an output or return value, and does not change any global state outside its own scope.
The decisive advantage of pure functions for unit tests in Bash: they can be called as often as needed with different inputs, without first having to set up a database, start a network service, or bring a filesystem into a specific state. A function like parse_version_string, which takes a string and returns a normalized version, can be tested hundreds of times in milliseconds. A function that instead directly calls curl and processes the result blurs logic and side effect together and makes real unit tests practically impossible.
Building testable Bash functions therefore does not start with writing the tests, but with the design of the script itself. Anyone who separates pure logic from side effects from the start ends up with a complete test suite with considerably less effort later on. The following sections show what this separation looks like in concrete terms and how bats-core, as an established testing framework, provides the structure for maintainable Bash unit tests.
# lib/version.sh — pure functions, no side effects
# Pure function: takes a string, returns a normalized version, no I/O
normalize_version() {
local raw="$1"
# Strip leading "v" and trailing whitespace
local cleaned="${raw#v}"
cleaned="${cleaned%% }"
echo "$cleaned"
}
# Pure function: compares two version strings, returns exit code only
version_is_greater() {
local v1="$1"
local v2="$2"
[[ "$(printf '%s\n%s' "$v1" "$v2" | sort -V | tail -n1)" == "$v1" ]]
}
2. Separating library and entry point
For unit tests in Bash, separating the library file from the executable entry point is essential. All testable functions move into a file such as lib/functions.sh, which contains no execution logic of its own, only function definitions. The actual script that calls these functions with real arguments and triggers side effects stays in a separate file. A test script can then include the library via source without ever running the main script and its side effects.
The Bash pattern for this at the end of every main script: [[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@". This line only runs the main function when the script is executed directly, not when it is included via source. That way, the same file can run both as a standalone script and be sourced by a test suite, without main accidentally running and triggering real side effects.
#!/usr/bin/env bash
# deploy.sh — entry point, sources the testable library
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/functions.sh"
main() {
local target_version="$1"
local normalized
normalized="$(normalize_version "$target_version")"
echo "Deploying version: $normalized"
# ... actual deployment side effects happen only here
}
# Only run main() when executed directly, not when sourced by a test
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@"
This pattern is the basic prerequisite for unit tests for Bash functions in the first place: without this separation, every sourcing of the script during a test run would immediately trigger the complete deployment, including all network calls and filesystem changes. With the separation, lib/functions.sh stays completely free of side effects and can safely be included in any test suite.
3. Structuring test cases with bats-core
bats-core is the established testing framework for Bash unit tests and brings a syntax modeled after other testing frameworks: every test case begins with the @test keyword, followed by a descriptive string and a code block. Inside this block, run executes the function under test and captures both the exit code and the complete output in the $status and $output variables, without the test script itself aborting if the function returns an error.
The structure of a bats-core test file follows a clear pattern: setup() runs before every single test case and typically loads the library under test via source. teardown() runs after every test case and cleans up temporary resources. This structure ensures that every test case runs isolated and independent of the execution order of the other tests, a central principle for reliable unit tests in Bash.
#!/usr/bin/env bats
# test/version.bats
setup() {
load "../lib/functions.sh"
export LIB_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
}
@test "normalize_version strips leading v prefix" {
run normalize_version "v2.4.1"
[ "$status" -eq 0 ]
[ "$output" = "2.4.1" ]
}
@test "normalize_version handles version without v prefix" {
run normalize_version "2.4.1"
[ "$status" -eq 0 ]
[ "$output" = "2.4.1" ]
}
@test "version_is_greater returns success for a higher version" {
run version_is_greater "2.5.0" "2.4.1"
[ "$status" -eq 0 ]
}
@test "version_is_greater returns failure for a lower version" {
run version_is_greater "2.3.0" "2.4.1"
[ "$status" -eq 1 ]
}
An important detail: run starts the called function in a subshell, which means changes to environment variables inside the tested function do not affect the test context. That is usually desired for unit tests for Bash functions, but can be surprising when a test deliberately checks whether a function modifies a variable in the caller's scope, for example via a name reference with local -n.
4. Assertions: checking return values, stdout and stderr
A solid set of assertions is the backbone of every test suite. For unit tests in Bash with bats-core, three things matter: the exit code in $status, the combined stdout and stderr output in $output, and, when needed, individual output lines in the ${lines[@]} array. Plain comparisons with double square brackets are sufficient for most cases, but the bats-assert library adds more readable assertion functions like assert_output and assert_failure.
A common mistake in unit tests for Bash functions: stdout and stderr are merged together into $output by default. If a function produces both regular output and error messages, these must be captured separately for precise assertions, for example by calling the function in the test environment with explicit redirection or by configuring bats in a mode with separate streams.
#!/usr/bin/env bats
load "test_helper/bats-assert/load"
load "test_helper/bats-support/load"
setup() {
load "../lib/functions.sh"
}
@test "sanitize_filename replaces unsafe characters" {
run sanitize_filename "my file (v2).txt"
assert_success
assert_output "my_file__v2_.txt"
}
@test "sanitize_filename fails on empty input with clear error" {
run sanitize_filename ""
assert_failure
assert_output --partial "filename must not be empty"
}
@test "parse_csv_line splits into exactly three fields" {
run parse_csv_line "alice,42,berlin"
assert_success
assert_equal "${#lines[@]}" 3
assert_equal "${lines[1]}" "42"
}
The combination of assert_success/assert_failure for the exit code and assert_output/assert_output --partial for the content covers most test cases. For numeric comparisons, assert_equal gives clearer error messages than raw [[ ]] syntax, which especially helps keep an overview in table driven tests with many test cases when one of them actually fails.
5. Table driven tests for many input combinations
As soon as a function warrants more than two or three test cases, a table driven approach pays off: a list of input expectation pairs is iterated in a loop, instead of writing a separate @test block for every combination. That drastically reduces code duplication and turns a new test case into a one line addition instead of a whole new test block.
In bats-core, table driven tests are realized most cleanly with an associative array or a series of delimiter separated strings, over which a loop iterates inside a single @test block. If one of the cases fails, bats-core prints the concrete input value in the error message, so it is immediately clear which combination caused the problem, without having to search through dozens of individual test functions.
#!/usr/bin/env bats
load "test_helper/bats-assert/load"
setup() {
load "../lib/functions.sh"
}
@test "normalize_version handles many input formats correctly" {
local -a cases=(
"v2.4.1|2.4.1"
"2.4.1|2.4.1"
"V2.4.1|V2.4.1"
"v10.0.0|10.0.0"
" v1.0.0 |1.0.0"
)
for case in "${cases[@]}"; do
local input="${case%%|*}"
local expected="${case##*|}"
run normalize_version "$input"
assert_success
assert_equal "$output" "$expected" \
"Failed for input: '$input' (expected '$expected', got '$output')"
done
}
An important note about the last test case in the table: V2.4.1 with an uppercase V is deliberately not normalized, because normalize_version only strips a lowercase v prefix. Making exactly these boundary cases visible in a table is the real value of table driven tests: you see at a glance which behavior is expected for which input, instead of piecing it together from scattered individual tests.
6. Edge cases: empty input and special characters
The most valuable unit tests for Bash functions are rarely the ones that check the normal case, but the ones that cover boundary cases: an empty string, a string with spaces, quotes or special characters like backslashes, negative numbers for numeric functions, or an array with exactly zero elements. Bash specific quoting problems almost exclusively show up in these boundary cases, while the normal case usually happens to work even with faulty quoting.
A particularly important edge case for Bash unit tests: input that looks like shell metacharacters, for example a filename with an embedded $(...) or backtick. A function that internally uses eval or passes variables unquoted can lead to unwanted command execution with such input. A test case that checks exactly this input value uncovers security holes that would never become visible in daily use with harmless input.
#!/usr/bin/env bats
load "test_helper/bats-assert/load"
setup() {
load "../lib/functions.sh"
}
@test "sanitize_filename rejects empty input" {
run sanitize_filename ""
assert_failure
}
@test "sanitize_filename handles a string with only special characters" {
run sanitize_filename "!!!???"
assert_success
assert_output "______"
}
@test "sanitize_filename does not execute embedded command substitution" {
run sanitize_filename '$(rm -rf /tmp/should-not-run)'
assert_success
refute_output --partial "should-not-run"
# The literal string is sanitized, never evaluated as a command
}
@test "sum_array handles an empty array without error" {
local -a empty=()
run sum_numbers "${empty[@]}"
assert_success
assert_output "0"
}
The third test case in the example is especially instructive: it ensures that a potentially dangerous input is never interpreted as a command but treated purely as a string. Tests like this should be a permanent part of every test suite for functions that process user input or external data, regardless of how unlikely an attack seems in the specific use case.
7. Test coverage for error paths and exit codes
An often neglected area in unit tests in Bash is deliberately testing error paths. It is not enough to test only the success case if a function signals several different error causes with different exit codes. A function that reports file not found with exit code 2 and invalid format with exit code 3 should have its own test case for both, checking exactly the expected code, not just a generic failure.
For functions that access external resources such as files, BATS_TMPDIR helps, which bats-core automatically provides for every test run. That makes it possible to reproducibly create error paths such as missing files or permission problems without affecting the real filesystem of the test machine. After every test, teardown() should remove these temporary files again, so test runs do not influence each other through leftovers from previous cases.
#!/usr/bin/env bats
load "test_helper/bats-assert/load"
setup() {
load "../lib/functions.sh"
TEST_DIR="$(mktemp -d "${BATS_TMPDIR}/config-test.XXXXXX")"
}
teardown() {
rm -rf "$TEST_DIR"
}
@test "load_config returns exit code 2 when file is missing" {
run load_config "$TEST_DIR/does-not-exist.conf"
assert_equal "$status" 2
assert_output --partial "config file not found"
}
@test "load_config returns exit code 3 on invalid format" {
echo "this is not valid config syntax" > "$TEST_DIR/broken.conf"
run load_config "$TEST_DIR/broken.conf"
assert_equal "$status" 3
assert_output --partial "invalid config format"
}
@test "load_config succeeds and returns exit code 0 on valid file" {
echo "key=value" > "$TEST_DIR/valid.conf"
run load_config "$TEST_DIR/valid.conf"
assert_success
}
This kind of test turns exit codes into a reliable API between function and caller, instead of a random byproduct. When a caller later distinguishes between different error handling strategies based on the exit code, the test suite ensures that these codes do not shift unnoticed with future changes to the function.
8. Integrating unit tests locally and in pre-commit hooks
For unit tests for Bash functions to actually provide value, they need to run regularly, ideally before every commit. A pre-commit hook that calls bats test/ prevents faulty code from ever reaching the repository in the first place. Since bats tests typically run in milliseconds to a few seconds, the extra effort per commit is usually negligible, even with a growing test suite of hundreds of test cases.
For local development, a watch mode is additionally recommended that automatically reruns tests on every file change, for example via entr combined with find lib/ test/ -name "*.sh" -o -name "*.bats" | entr bats test/. That substantially shortens the feedback cycle while writing new functions, since test results become visible immediately without manually triggering the test run.
#!/usr/bin/env bash
# .git/hooks/pre-commit — run bats tests before every commit
set -euo pipefail
echo "Running Bash unit tests before commit..."
if ! bats test/ 2>&1; then
echo "[ERROR] Unit tests failed. Commit aborted." >&2
exit 1
fi
echo "All unit tests passed."
exit 0
In CI pipelines, the same bats test/ call is extended with TAP compliant output (bats --tap test/), which can be integrated directly into common CI systems like GitLab CI and GitHub Actions and shown there as structured test results, instead of just raw console output.
9. Unit tests compared to integration tests for Bash
Unit tests for pure functions and integration tests for complete scripts serve different purposes and should complement, not replace, each other.
| Criterion | Unit tests (pure functions) | Integration tests (whole script) |
|---|---|---|
| Runtime | Milliseconds per test | Seconds to minutes per test |
| Isolation | Complete, no side effects | Requires real environment or Docker |
| Fault localization | Very precise, one function | Coarse, whole script affected |
| Setup effort | Low, no external state | High, mocks or real services needed |
| Covers | Logic errors in individual functions | Interaction and side effects |
In practice, unit tests for pure functions form the broad, fast test base, while a smaller number of integration tests secure the interaction of functions with real side effects such as filesystem and network. This test pyramid, many fast unit tests at the bottom, fewer slower integration tests at the top, is also the proven ratio in Bash development for a maintainable, fast running test suite.
Mironsoft
Shell automation, testing and deployment infrastructure
Running Bash scripts without a reliable test suite?
We break down existing Bash scripts into testable functions, build a structured bats-core test suite and integrate it into your pre-commit hooks and CI pipeline.
Refactoring
Splitting monolithic scripts into testable, pure functions
Test suite setup
bats-core test suite with table driven tests and edge case coverage
CI integration
Pre-commit hooks and TAP output for GitLab CI and GitHub Actions
10. Summary
Unit tests for Bash functions start with a deliberate design decision: separating pure logic from side effects so functions can be called in isolation and repeatedly. Separating library and executable entry point with the BASH_SOURCE pattern makes it possible to build test suites without triggering real side effects. bats-core provides the structure to systematically check return values and outputs with @test, run and $status/$output.
Table driven tests reduce duplication for many similar test cases, while targeted edge case tests for empty input, special characters and error paths secure exactly the spots where Bash scripts most often break in practice. Integrated into pre-commit hooks and CI pipelines, these unit tests for Bash become a permanent safety net that prevents regressions long before they become visible in production.
Unit Tests for Bash Functions — The Essentials at a Glance
Pure functions
Move logic without side effects into a separate library file, that is the prerequisite for real unit tests.
bats-core
@test, run, $status and $output structure every test case in a consistent pattern.
Table driven tests
Many input expectation pairs in a loop instead of dozens of individual test functions.
Edge cases
Test empty input, special characters and error paths deliberately, that is where Bash scripts break most often.