Unit Testing Functions with Bats-core
Structure test files, assertions, and setup/teardown to verify individual Bash functions.
Unit Testing Functions with Bats-core is a free DevOps Bootcamp lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the DevOps Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
What Is Bats-core and Why Use It?
Bats-core (Bash Automated Testing System) is the de-facto unit testing framework for Bash. It lets you write structured, repeatable tests for your shell functions and scripts — the same way you would use JUnit for Java or pytest for Python.
- Each test is a
@testblock with a human-readable description. - Tests pass when every command inside returns exit code
0. - Tests fail on the first non-zero exit code or a failed assertion.
- Output is TAP-compatible, so CI systems (GitHub Actions, Jenkins, GitLab CI) understand it natively.
Install via your package manager or clone the repository:
# Install via git (recommended — always latest)
git clone https://github.com/bats-core/bats-core.git
cd bats-core && sudo ./install.sh /usr/local
# Or on macOS with Homebrew
brew install bats-core
# Verify installation
bats --version
# bats 1.x.yYour First Bats Test File
A Bats test file has the extension .bats and starts with a special shebang. The core building block is the @test directive followed by a description string and a block of commands.
- The shebang
#!/usr/bin/env batstells the shell how to execute the file. - Each
@testblock is an independent test case. - You can run a single file with
bats my_tests.batsor an entire directory withbats test/.
Below is the minimal structure for a Bats test file:
#!/usr/bin/env bats
# File: test/hello.bats
@test "echo outputs the expected string" {
result=$(echo "hello world")
[ "$result" = "hello world" ]
}
@test "false command causes test to fail" {
# Uncommenting the next line would make this test fail:
# false
true
}Loading the Function Under Test with 'load'
In real projects, your Bash functions live in library files, not inside the test file itself. Bats provides the load helper to source external files relative to the test file's directory.
load '../lib/math.sh'sources the file before each test runs.- After loading, all functions defined in that file are available in your test blocks.
- Keep your library functions in a
lib/directory and tests in atest/directory for clean separation.
Example project layout and corresponding test:
# Project layout:
# lib/math.sh <- functions to test
# test/math.bats <- test file
# lib/math.sh
add() {
echo $(( $1 + $2 ))
}
divide() {
if [ "$2" -eq 0 ]; then
echo "Error: division by zero" >&2
return 1
fi
echo $(( $1 / $2 ))
}
# test/math.bats
#!/usr/bin/env bats
load '../lib/math.sh'
@test "add returns correct sum" {
result=$(add 3 4)
[ "$result" = "7" ]
}Core Assertions: run, $status, $output
The run command is the heart of Bats testing. Instead of executing a command directly, wrapping it with run captures its exit code and output without causing the test to immediately fail.
$status— holds the exit code of the lastruncommand.$output— holds the combined stdout of the lastruncommand.$lines— an array where each element is one line of output (${lines[0]},${lines[1]}, etc.).
This lets you assert on both success and failure cases:
#!/usr/bin/env bats
load '../lib/math.sh'
@test "divide 10 by 2 returns 5" {
run divide 10 2
[ "$status" -eq 0 ]
[ "$output" = "5" ]
}
@test "divide by zero returns exit code 1" {
run divide 10 0
[ "$status" -eq 1 ]
}
@test "divide by zero prints error message" {
run divide 10 0
# $output captures stderr too when redirected inside the function
[[ "$output" == *"division by zero"* ]]
}Using bats-assert for Expressive Assertions
The built-in [ ] assertions work but give poor failure messages. The bats-assert helper library provides expressive assertion functions that print exactly what went wrong.
assert_success— asserts$statusis 0.assert_failure— asserts$statusis non-zero.assert_output— asserts$outputequals the given string.assert_output --partial— asserts the output contains the substring.refute_output --partial— asserts the output does NOT contain the substring.
Install by cloning bats-core/bats-assert into a test/helpers/ folder, then load it:
#!/usr/bin/env bats
# Load bats-assert (cloned into test/helpers/bats-assert)
load 'helpers/bats-assert/load'
load '../lib/math.sh'
@test "add 5 and 3 gives 8" {
run add 5 3
assert_success
assert_output "8"
}
@test "divide by zero fails with descriptive message" {
run divide 9 0
assert_failure
assert_output --partial "division by zero"
}
@test "add does not output an error" {
run add 1 1
refute_output --partial "Error"
}setup and teardown: Test Lifecycle Hooks
Bats provides two special functions — setup and teardown — that run automatically around each test. Use them to prepare and clean up shared state so each test starts with a known environment.
setup()runs before each individual@testblock.teardown()runs after each individual@testblock, even if the test fails.- Common uses: creating temp directories, setting environment variables, removing temp files after the test.
#!/usr/bin/env bats
load '../lib/fileutils.sh'
setup() {
# Create a fresh temp directory before every test
TEST_DIR=$(mktemp -d)
export TEST_DIR
}
teardown() {
# Always clean up, even on test failure
rm -rf "$TEST_DIR"
}
@test "write_file creates a file with correct content" {
run write_file "$TEST_DIR/hello.txt" "hello world"
assert_success
[ -f "$TEST_DIR/hello.txt" ]
[ "$(cat "$TEST_DIR/hello.txt")" = "hello world" ]
}
@test "write_file fails when directory does not exist" {
run write_file "/nonexistent/dir/file.txt" "data"
assert_failure
}setup_file and teardown_file: Suite-Level Hooks
Sometimes you only need to set up expensive resources once per file — not before every single test. Bats provides setup_file and teardown_file for this purpose.
setup_file()runs once before all tests in the file.teardown_file()runs once after all tests in the file.- Use
BATS_FILE_TMPDIR(available automatically) to share data betweensetup_fileand your tests — regular variables won't persist across subshells.
Typical use case: starting a mock server or building a binary once, then tearing it down at the end:
#!/usr/bin/env bats
setup_file() {
# Build the project binary once for all tests in this file
make build --silent
export BINARY="$PWD/bin/myapp"
echo "Binary built: $BINARY"
}
teardown_file() {
# Remove the binary after all tests complete
rm -f "$BINARY"
echo "Cleaned up binary"
}
setup() {
# Still runs before each individual test
TEST_TMP=$(mktemp -d)
}
teardown() {
rm -rf "$TEST_TMP"
}
@test "myapp --version outputs version string" {
run "$BINARY" --version
assert_output --partial "1.0"
}Testing Functions That Modify Files
A very common pattern is testing Bash functions that read from or write to the filesystem. The key technique is using temporary directories (via mktemp -d in setup) so tests never touch real files and never interfere with each other.
- Always work inside
$TEST_DIR(or$BATS_TEST_TMPDIR— available automatically in recent Bats). - Use
bats-filehelper library for clean file assertions likeassert_file_existsandassert_file_contains. - Never hardcode paths like
/tmp/myfile— parallel test runs will collide.
#!/usr/bin/env bats
load 'helpers/bats-assert/load'
load 'helpers/bats-file/load'
load '../lib/fileutils.sh'
setup() {
TEST_DIR="$BATS_TEST_TMPDIR"
}
# lib/fileutils.sh defines:
# append_line() { echo "$2" >> "$1"; }
@test "append_line adds a line to an existing file" {
echo "first line" > "$TEST_DIR/log.txt"
run append_line "$TEST_DIR/log.txt" "second line"
assert_success
assert_file_contains "$TEST_DIR/log.txt" "second line"
}
@test "append_line creates file if it does not exist" {
run append_line "$TEST_DIR/new.txt" "hello"
assert_success
assert_file_exists "$TEST_DIR/new.txt"
}Mocking External Commands
Functions often call external programs like curl, aws, or git. In unit tests you want to test your logic, not the real external command. The cleanest Bats mocking technique is to define a shell function with the same name as the command inside setup — it takes precedence over the real binary.
- Define a function like
curl() { echo 'mocked response'; return 0; }insetupand export it. - Use
export -f curlso the function is visible in subshells spawned byrun. - You can also write the mock to a temporary file on
PATHfor more complex scenarios.
#!/usr/bin/env bats
load 'helpers/bats-assert/load'
load '../lib/network.sh'
# lib/network.sh defines:
# fetch_status() {
# local url="$1"
# local code
# code=$(curl -s -o /dev/null -w "%{http_code}" "$url")
# echo "$code"
# }
setup() {
# Override 'curl' with a mock function
curl() {
# Simulate a 200 OK response
echo "200"
return 0
}
export -f curl
}
@test "fetch_status returns 200 when curl reports 200" {
run fetch_status "https://example.com"
assert_success
assert_output "200"
}Skipping Tests and Tagging
Not every test can always run — sometimes you need a real network connection, a specific tool, or a certain OS. Bats provides skip to conditionally bypass a test with an informative message, instead of commenting it out or breaking the suite.
- Call
skip "reason"anywhere inside a@testblock to skip that test. - Skipped tests appear in output as
Sand do not count as failures. - Bats 1.5+ supports tags: annotate tests with
# bats test_tags=slow,networkand filter withbats --filter-tags network test/.
#!/usr/bin/env bats
# bats test_tags=network
@test "API returns valid JSON" {
# Skip if no internet connectivity
if ! ping -c1 -W1 8.8.8.8 &>/dev/null; then
skip "No network connection available"
fi
run curl -s "https://api.example.com/health"
assert_success
assert_output --partial '"status"'
}
# bats test_tags=unit
@test "slug function lowercases and replaces spaces" {
# Always runs — pure function, no external deps
slug() { echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '-'; }
run slug "Hello World"
assert_output "hello-world"
}
# Run only unit tests:
# bats --filter-tags unit test/Structuring a Complete Test Suite
A well-organised Bats project follows a predictable directory layout, making it easy to onboard new contributors and integrate with CI pipelines.
Recommended structure:
lib/— production Bash functions (one file per concern:math.sh,fileutils.sh).test/— one.batsfile per library file (math.bats,fileutils.bats).test/helpers/— bats-assert, bats-file, bats-support as git submodules.Makefile— atesttarget so contributors just runmake test.
Run the full suite in one command:
# Makefile
.PHONY: test
test:
bats test/
# Run all tests recursively (Bats 1.5+)
# bats --recursive test/
# Run a specific file
# bats test/math.bats
# Run with verbose (TAP) output for CI
# bats --tap test/
# Example directory tree:
# .
# |-- lib/
# | |-- math.sh
# | `-- fileutils.sh
# |-- test/
# | |-- helpers/
# | | |-- bats-assert/
# | | `-- bats-file/
# | |-- math.bats
# | `-- fileutils.bats
# `-- MakefileKnowledge Check: Bats-core Assertions
Test your understanding of the core Bats-core testing mechanism.
Recap: Unit Testing Bash with Bats-core
In this lesson you learned how to structure and write unit tests for individual Bash functions using Bats-core. Here are the key takeaways:
- Test file structure — use the
#!/usr/bin/env batsshebang and@testblocks with descriptive names. load— source your library files so functions are available in tests without copy-pasting.run+$status+$output— the core trio; always userunto capture results without causing immediate test failure.- bats-assert — prefer
assert_success,assert_failure, andassert_outputover raw[ ]for readable failure messages. setup/teardown— run before/after each test;setup_file/teardown_filerun once per file.- Mocking — shadow external commands with same-name shell functions exported with
export -f. skip— conditionally bypass tests that depend on unavailable resources.- Project layout — keep
lib/,test/, andtest/helpers/separated for maintainability and CI integration.
These patterns give your Bash projects the same testing discipline you would apply to any modern software project.
Frequently asked questions
Is the “Unit Testing Functions with Bats-core” lesson free?
Yes — the full text of “Unit Testing Functions with Bats-core” is free to read here on the web, and the DevOps Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the DevOps Bootcamp course, upgrade to CoddyKit PRO.
What will I learn in “Unit Testing Functions with Bats-core”?
Structure test files, assertions, and setup/teardown to verify individual Bash functions. You practise DevOps Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start DevOps Bootcamp?
No prior experience is required. DevOps Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Unit Testing Functions with Bats-core” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this DevOps Bootcamp lesson?
Yes. Every DevOps Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Unit Testing Functions with Bats-core
- Mocking Commands and Stubbing External Tools
- Fixtures, Temp Environments, and Coverage
- Running Shell Tests in CI Pipelines