Unit Tests for Bash Functions: Writing and Structuring Them
AI generated
$_
#!/
Bash · Unit Tests · bats-core · Testing
Unit Tests for Bash Functions
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.

19 min read bats-core · pure functions · assertions · table driven tests Bash 4.x · 5.x · Linux · macOS

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.

11. FAQ: Unit Tests for Bash Functions

1What makes a function testable?
Purity: only read arguments, return a result, do not change global state or external resources.
2Separate logic from side effects?
Move pure functions into a separate library file, include via source.
3What is bats-core?
A testing framework with @test blocks, run, setup()/teardown() for isolated Bash test cases.
4Check exit code and output?
With run, then $status and $output are available, no test abort on failure.
5What are table driven tests?
Many input expectation pairs in a loop instead of individual test blocks.
6Which edge cases to always test?
Empty input, special characters, negative numbers, empty arrays.
7Test different exit codes?
Exit codes are an API. One test per error cause permanently secures that API.
8Bats in pre-commit hooks?
A git hook calls bats test/, aborts the commit on failure.
9Unit vs. integration tests?
Unit is isolated and fast, integration checks interaction with real side effects.
10Completely rewrite an existing script?
Not needed, extracting logic gradually is usually enough.