0Pricing
DevOps Bootcamp · Lesson

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() or cleanup() 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_fixture

Mocking 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:

  1. Create a temporary bin/ directory inside your fixture
  2. Write tiny shell scripts there with the same names as the real tools
  3. Prepend that directory to $PATH before 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.html in a browser to inspect results
  • In CI, parse coverage-out/myscript.sh/coverage.json for 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.json to 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_fixture and registers teardown_fixture via trap
  • The script under test is invoked with fake $PATH and 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 directoriesmktemp -d plus a trap ... EXIT guarantee automatic cleanup regardless of how the test ends.
  • Fixture structure — a setup_fixture / teardown_fixture pair populates and destroys a minimal directory tree that mirrors real script inputs.
  • Fake PATH — place stub executables in $FIXTURE/bin/ and prepend it to $PATH to intercept calls to curl, aws, docker, or any external tool without touching system binaries.
  • Environment scoping — run the script under test in a subshell (() or env -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

  1. Unit Testing Functions with Bats-core
  2. Mocking Commands and Stubbing External Tools
  3. Fixtures, Temp Environments, and Coverage
  4. Running Shell Tests in CI Pipelines
← Back to DevOps Bootcamp