Fixtures, Temp Environments, and Coverage
Build isolated test fixtures and measure which script branches your tests actually exercise.
Fixtures, Temp Environments, and Coverage is a free DevOps Bootcamp lesson on CoddyKit — lesson 3 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.
Why Fixtures and Isolation Matter
When testing Bash scripts, the biggest risk is side effects: your tests accidentally modify real files, real databases, or real system state. A test that passes on your machine but corrupts production data is worse than no test at all.
The solution is test fixtures — controlled, disposable environments that mirror real conditions without touching anything real. Good fixtures give you:
- Reproducibility — tests produce the same result every run
- Isolation — tests do not interfere with each other or with the host
- Safety — destructive operations only touch throwaway data
- Speed — no network calls, no heavy I/O unless absolutely necessary
In Bash testing, fixtures are typically temporary directories populated with known files, mock executables placed early on $PATH, and environment variables scoped to the test process.
Creating and Cleaning Up Temp Directories
The standard pattern for a per-test temporary directory uses mktemp -d, which creates a unique directory in /tmp and prints its path. You store the path and register a trap to remove it automatically when the shell exits — even on failure.
This two-line idiom should appear in every test file that touches the filesystem:
#!/usr/bin/env bash
set -euo pipefail
# Create an isolated temp directory
TMPDIR=$(mktemp -d)
# Always clean up, even if the script exits early or errors out
trap 'rm -rf "$TMPDIR"' EXIT
echo "Working in: $TMPDIR"
# Simulate creating fixture files
mkdir -p "$TMPDIR/project/{src,tests,logs}"
echo 'version=1.2.3' > "$TMPDIR/project/.env"
echo 'Hello fixture' > "$TMPDIR/project/src/main.sh"
ls -R "$TMPDIR/project"
echo 'Temp dir will be removed automatically on exit'Structuring a Fixture Directory Tree
A well-structured fixture mirrors the directory layout your script under test actually expects. Think of it as a miniature fake project root. The fixture setup function creates this layout before each test and the teardown removes it.
Key practices:
- Use a
setup()function that your test framework calls before each test - Use a
teardown()orcleanup()that runs after each test, even on failure - Keep fixture files minimal — only what the script actually reads
- Name fixture files descriptively so failures are easy to diagnose
#!/usr/bin/env bash
# fixture_helpers.bash — source this from your test files
FIXTURE_ROOT=''
setup_fixture() {
FIXTURE_ROOT=$(mktemp -d)
# Build the directory tree the deploy script expects
mkdir -p "$FIXTURE_ROOT"/{dist,config,logs}
echo '{"version":"2.0"}' > "$FIXTURE_ROOT/config/app.json"
echo 'console.log("app")' > "$FIXTURE_ROOT/dist/index.js"
touch "$FIXTURE_ROOT/logs/.gitkeep"
export FIXTURE_ROOT
echo "[setup] Fixture ready at $FIXTURE_ROOT"
}
teardown_fixture() {
if [[ -n "$FIXTURE_ROOT" && -d "$FIXTURE_ROOT" ]]; then
rm -rf "$FIXTURE_ROOT"
echo '[teardown] Fixture removed'
fi
}
# Self-test
setup_fixture
ls "$FIXTURE_ROOT"
teardown_fixtureMocking Executables with a Fake PATH
Many Bash scripts call external tools like curl, aws, docker, or git. In tests you do not want to hit real services, so you replace those tools with fake executables.
The technique is simple:
- Create a temporary
bin/directory inside your fixture - Write tiny shell scripts there with the same names as the real tools
- Prepend that directory to
$PATHbefore calling your script under test
Because $PATH is searched left-to-right, the fake wins. The real binary is never invoked.
#!/usr/bin/env bash
set -euo pipefail
FIXTURE=$(mktemp -d)
trap 'rm -rf "$FIXTURE"' EXIT
# Create a fake 'curl' that records calls and returns canned data
FAKE_BIN="$FIXTURE/bin"
mkdir -p "$FAKE_BIN"
cat > "$FAKE_BIN/curl" << 'SCRIPT'
#!/usr/bin/env bash
# Log every argument for later inspection
echo "curl $*" >> "$FIXTURE_BIN_LOG"
# Return a canned HTTP 200 response body
echo '{"status":"ok","id":42}'
SCRIPT
chmod +x "$FAKE_BIN/curl"
# Export the log path so the fake can find it
export FIXTURE_BIN_LOG="$FIXTURE/curl_calls.log"
# Prepend fake bin directory to PATH
export PATH="$FAKE_BIN:$PATH"
# Now any call to 'curl' hits our fake
curl -s https://api.example.com/health
curl -X POST https://api.example.com/deploy
echo '--- Recorded curl calls ---'
cat "$FIXTURE_BIN_LOG"Capturing and Asserting Command Output
A fixture is only useful if you can assert what your script did. The standard patterns are:
- Capture stdout/stderr into variables with
$()or process substitution - Inspect log files written by fake binaries
- Check exit codes explicitly with
$?or conditional logic - Verify that certain files were created, modified, or left untouched
Writing small, focused assertion helpers makes your tests readable and gives you precise failure messages when something goes wrong.
#!/usr/bin/env bash
set -euo pipefail
# Minimal assertion helpers
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then
echo "PASS: $desc"
else
echo "FAIL: $desc"
echo " expected: $expected"
echo " actual: $actual"
return 1
fi
}
assert_file_exists() {
local desc="$1" file="$2"
if [[ -f "$file" ]]; then
echo "PASS: $desc"
else
echo "FAIL: $desc — file not found: $file"
return 1
fi
}
assert_contains() {
local desc="$1" needle="$2" haystack="$3"
if [[ "$haystack" == *"$needle"* ]]; then
echo "PASS: $desc"
else
echo "FAIL: $desc — '$needle' not found in output"
return 1
fi
}
# Demo usage
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
echo 'hello world' > "$TMPDIR/greeting.txt"
OUT=$(cat "$TMPDIR/greeting.txt")
assert_eq 'file content matches' 'hello world' "$OUT"
assert_file_exists 'greeting file created' "$TMPDIR/greeting.txt"
assert_contains 'output has hello' 'hello' "$OUT"Environment Variable Scoping in Tests
Scripts often read from environment variables like $HOME, $CONFIG_PATH, or $DATABASE_URL. In tests you must override these without polluting the real environment.
The safest approach is to run the script under test in a subshell with only the variables you explicitly set. The env command lets you strip the environment down and re-add just what you need:
env -i VAR=val ./script.sh— completely clean environment(export VAR=val; ./script.sh)— subshell inherits parent env plus your overrides
Using subshells also means that if the script changes $IFS, $PWD, or other global state, those changes never escape back to your test runner.
#!/usr/bin/env bash
set -euo pipefail
FIXTURE=$(mktemp -d)
trap 'rm -rf "$FIXTURE"' EXIT
# Write a tiny script under test that reads env vars
cat > "$FIXTURE/deploy.sh" << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
ENV_NAME=${DEPLOY_ENV:-unknown}
CFG=${CONFIG_DIR:-/etc/app}
echo "Deploying to $ENV_NAME using config from $CFG"
SCRIPT
chmod +x "$FIXTURE/deploy.sh"
echo '--- Test 1: staging env ---'
# Run in a clean subshell with explicit vars
(
export DEPLOY_ENV=staging
export CONFIG_DIR="$FIXTURE/config"
"$FIXTURE/deploy.sh"
)
echo '--- Test 2: production env ---'
(
export DEPLOY_ENV=production
export CONFIG_DIR=/etc/prod-config
"$FIXTURE/deploy.sh"
)
echo '--- Test 3: defaults ---'
# No env vars set — script should use its own defaults
env -i PATH="$PATH" "$FIXTURE/deploy.sh"Introduction to kcov for Bash Coverage
Coverage answers the question: which lines (and branches) of my script did the tests actually execute? A high coverage number does not guarantee correctness, but a low one reveals untested paths that are likely to harbour bugs.
The main tool for Bash coverage is kcov. It works by instrumenting the script at the OS level using PTRACE (Linux) or dtrace (macOS), so it requires no changes to your source code. It produces an HTML report showing red (uncovered) and green (covered) lines.
Basic usage:
kcov --include-path=./src coverage-out/ ./src/myscript.sh- Open
coverage-out/index.htmlin a browser to inspect results - In CI, parse
coverage-out/myscript.sh/coverage.jsonfor a machine-readable percentage
Note: kcov must be installed separately (brew install kcov on macOS, apt install kcov on Ubuntu 20.04+).
Running kcov Against a Real Script
Here is an end-to-end example showing a deployable script, a test that exercises it, and the kcov invocation that measures coverage. Notice how the output directory is per-test so you can merge results from multiple test runs later.
#!/usr/bin/env bash
# This demo shows the *structure* of a kcov workflow.
# It will not run kcov itself (not guaranteed to be installed),
# but the script under test and test runner are fully runnable.
set -euo pipefail
FIXTURE=$(mktemp -d)
trap 'rm -rf "$FIXTURE"' EXIT
# 1. Script under test
cat > "$FIXTURE/process.sh" << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
INPUT="$1"
if [[ ! -f "$INPUT" ]]; then
echo "ERROR: file not found" >&2
exit 1
fi
LINE_COUNT=$(wc -l < "$INPUT")
if (( LINE_COUNT == 0 )); then
echo "WARNING: file is empty"
else
echo "Processed $LINE_COUNT lines"
fi
SCRIPT
chmod +x "$FIXTURE/process.sh"
# 2. Happy path test (exercises line 6 and 11)
echo -e 'one\ntwo\nthree' > "$FIXTURE/data.txt"
OUT=$("$FIXTURE/process.sh" "$FIXTURE/data.txt")
echo "Happy path: $OUT"
# 3. Empty file test (exercises line 9)
touch "$FIXTURE/empty.txt"
OUT=$("$FIXTURE/process.sh" "$FIXTURE/empty.txt")
echo "Empty file: $OUT"
# 4. Missing file test (exercises line 5-6)
if ! OUT=$("$FIXTURE/process.sh" "$FIXTURE/missing.txt" 2>&1); then
echo "Missing file (expected error): $OUT"
fi
# To measure coverage, wrap each call with kcov:
# kcov --include-path="$FIXTURE" "$FIXTURE/cov/happy" "$FIXTURE/process.sh" ...
# kcov --merge "$FIXTURE/cov/all" "$FIXTURE/cov/happy" "$FIXTURE/cov/empty"Merging Coverage from Multiple Test Runs
A single test rarely covers all branches. You run multiple tests — each in its own coverage output directory — and then merge them. kcov's --merge flag combines multiple runs into a single unified report.
The typical pattern in a CI pipeline:
- Run test A → output to
cov/test_a/ - Run test B → output to
cov/test_b/ - Merge →
kcov --merge cov/all/ cov/test_a/ cov/test_b/ - Parse
cov/all/<script>/coverage.jsonto get the final percentage
You can also enforce a minimum threshold and fail the CI build if coverage drops below it:
#!/usr/bin/env bash
# extract_coverage.sh — parse kcov JSON and fail below threshold
set -euo pipefail
MIN_COVERAGE=80 # percent
COV_JSON="${1:-coverage-out/myscript.sh/coverage.json}"
if [[ ! -f "$COV_JSON" ]]; then
echo "ERROR: coverage JSON not found at $COV_JSON" >&2
exit 1
fi
# kcov JSON contains a key like: "percent_covered": "87.50"
PERCENT=$(grep -oP '"percent_covered":\s*"\K[0-9.]+' "$COV_JSON")
PERCENT_INT=${PERCENT%%.*} # truncate decimal
echo "Coverage: ${PERCENT}% (minimum: ${MIN_COVERAGE}%)"
if (( PERCENT_INT < MIN_COVERAGE )); then
echo "FAIL: coverage ${PERCENT}% is below threshold ${MIN_COVERAGE}%" >&2
exit 1
fi
echo "PASS: coverage threshold met"Branch Coverage vs Line Coverage
There are two main coverage metrics you will encounter:
- Line coverage — was this line executed at all? Easy to game: a single test can touch many lines while missing important conditional paths.
- Branch coverage — was each branch of every
if,case, and&&/||taken? Much stronger signal. Requires tests for both the true and false side of every decision.
kcov reports both. The key insight: 100% line coverage does not imply 100% branch coverage. Consider this script — a single test with a non-empty file will cover every line, but the empty-file branch (line 9 below) is never reached:
#!/usr/bin/env bash
# Illustrates line vs branch coverage gap
set -euo pipefail
check_file() {
local f="$1"
if [[ -f "$f" ]]; then # branch A (true) OR branch B (false)
local lines
lines=$(wc -l < "$f")
if (( lines > 0 )); then # branch C (true) OR branch D (false)
echo "File has $lines lines"
else
echo "File is empty" # branch D — unreached if only tested with non-empty file
fi
else
echo "File missing" # branch B — unreached if only tested with existing file
fi
}
# Only one test: covers lines 5-10 (4 of 6 branches)
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
echo 'data' > "$TMPDIR/sample.txt"
check_file "$TMPDIR/sample.txt"
# To reach 100% branch coverage you also need:
# check_file "/nonexistent/path"
# check_file "$TMPDIR/empty.txt" (after: touch "$TMPDIR/empty.txt")Integrating Fixtures and Coverage in CI
Putting it all together: a robust CI pipeline for Bash projects combines fixture setup, test execution under kcov, merge, and a threshold check in a single script. This script becomes the CI entry point — one command to run everything.
Design principles for the CI test runner:
- Each test case calls
setup_fixtureand registersteardown_fixtureviatrap - The script under test is invoked with fake
$PATHand scoped env vars - kcov wraps each invocation, writing to a numbered subdirectory
- After all tests, kcov merges and the threshold script gates the build
- The CI runner exits non-zero if any test or the coverage check fails
#!/usr/bin/env bash
# ci_runner.sh — full fixture + coverage pipeline entry point
set -euo pipefail
SRC="./src/deploy.sh"
COV_ROOT="$(mktemp -d)/coverage"
trap 'rm -rf "$COV_ROOT"' EXIT
mkdir -p "$COV_ROOT"
PASS=0
FAIL=0
RUN_NUM=0
run_test() {
local name="$1" test_fn="$2"
local fixture
fixture=$(mktemp -d)
local cov_out="$COV_ROOT/run_$((++RUN_NUM))"
if (
trap 'rm -rf "$fixture"' EXIT
export FIXTURE="$fixture"
# Fake bin directory shadowing real tools
mkdir -p "$fixture/bin"
export PATH="$fixture/bin:$PATH"
"$test_fn" "$fixture"
); then
echo "PASS: $name"
(( PASS++ )) || true
else
echo "FAIL: $name"
(( FAIL++ )) || true
fi
}
# Example test function
test_happy_path() {
local fx="$1"
mkdir -p "$fx/dist"
echo 'app.js' > "$fx/dist/index.js"
# Would normally run: kcov "$cov_out" "$SRC" --env=staging "$fx"
echo "[test] happy path executed in $fx"
}
run_test 'happy_path' test_happy_path
echo "Results: $PASS passed, $FAIL failed"
(( FAIL == 0 ))Knowledge Check: Fake PATH Technique
Test your understanding of the fake PATH technique used in Bash test fixtures.
Recap: Fixtures, Temp Environments, and Coverage
This lesson covered the full toolkit for trustworthy, isolated Bash testing:
- Temp directories —
mktemp -dplus atrap ... EXITguarantee automatic cleanup regardless of how the test ends. - Fixture structure — a
setup_fixture/teardown_fixturepair populates and destroys a minimal directory tree that mirrors real script inputs. - Fake PATH — place stub executables in
$FIXTURE/bin/and prepend it to$PATHto intercept calls tocurl,aws,docker, or any external tool without touching system binaries. - Environment scoping — run the script under test in a subshell (
()orenv -i) so changed variables never leak back to the test runner. - Assertions — small helper functions (
assert_eq,assert_file_exists,assert_contains) produce clear pass/fail output and meaningful error messages. - kcov coverage — wraps script execution with no source changes; produces HTML and JSON reports for line and branch coverage.
- Merge and threshold — combine multiple kcov runs with
--merge, parse the JSON, and fail CI if coverage drops below your minimum. - Branch vs line coverage — always target branch coverage; line coverage alone can miss entire conditional paths and give false confidence.
With these techniques your Bash tests become as rigorous as tests for any compiled language.
Frequently asked questions
Is the “Fixtures, Temp Environments, and Coverage” lesson free?
Yes — the full text of “Fixtures, Temp Environments, and Coverage” 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 “Fixtures, Temp Environments, and Coverage”?
Build isolated test fixtures and measure which script branches your tests actually exercise. 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 3 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Fixtures, Temp Environments, and Coverage” 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