ShellCheck left the toolkit clean of formal errors, but it knows nothing about what it does. It cannot tell you whether veloz_percentage 0 0 returns anything sensible instead of blowing up, whether backup.sh really honors the thirty days of retention, or whether the awk we used to speed up the report in 08-02 produces the same figures as the loop it replaced. Only running the code and comparing the result against what is expected answers that. This lesson answers the question the module opened with — how do I know a change breaks nothing? — with Bats, the Bash testing framework.

Contents

  1. Why operations scripts are rarely tested, and why that is a mistake
  2. What is worth testing and what is not
  3. Testing by hand first
  4. Installing bats-core and its libraries
  5. Anatomy of a .bats file
  6. Running the tests
  7. Isolating the test from the environment
  8. Test doubles: replacing an external command
  9. The structure of tests/ in the toolkit
  10. Hook, CI and a coverage criterion
  11. Application: tests/common.bats

  1. Why operations scripts are rarely tested, and why that is a mistake

Nobody argues that a web application gets tested. With system scripts the custom is different: you run them by hand once, "it works", and off to production. The usual excuses have their apparent logic: "it is only an 80-line script" — one that runs rm -rf as root every night — "I test it by running it" — once, with today's data, on your machine, with your PATH — and "there is no way to test something that touches the system", which is false and is solved in section 8.

The argument in favor is stronger than all of those: operations scripts fail at dawn with nobody watching. A user sees a downed web application within ten seconds; backup.sh can spend three weeks storing empty archives and nobody finds out until a restore is needed. And there is a second, more practical argument: tests make refactoring possible. All of Module 8 consists of changing code that works; without tests, every change is an act of faith, and with them the performance refactor from 08-02 is validated in two seconds.

  1. What is worth testing and what is not

What Payoff Why
Pure functions in lib/common.sh (veloz_percentage, validators) Very high Input → output, no effects; they test in milliseconds
Functions with bounded effects (veloz_log, writing files) High You check the resulting file in a temporary directory
The whole script: exit code, output, files it generates Medium-high It is what the user really uses, and where most silent bugs are
systemctl, ssh to the fleet, sending real email Low They are tested with doubles, not for real
Headers, terminal colors None You see it at a glance and it changes constantly

The rule: test the logic, not the plumbing. If a function decides something — a threshold, a format, a validation, a sum — test it. If it only chains two system commands together, check the whole and not the pieces.

  1. Testing by hand first

Before installing anything it is worth seeing what Bats automates. A function is tested in an interactive session:

$ source ~/veloz-ops/lib/common.sh
$ veloz_percentage 412 1284
32.1
$ veloz_percentage 0 0
veloz_percentage: total cannot be zero           # and $? is 1

# The same dialogue, written as an automatic check
[[ $(veloz_percentage 412 1284) == "32.1" ]] || echo "FAIL: basic percentage"
veloz_percentage 0 0 2>/dev/null           && echo "FAIL: should fail with total 0"

That is already a test: run with a known input and compare against the expected output. For three checks it may be enough. What Bats brings when you go beyond three is everything missing here: readable names per case, a failure that does not stop the others, capturing output and exit code at the same time, preparing and cleaning the environment of each test and a final summary with the count of passes and failures.

  1. Installing bats-core and its libraries

bats-core is the project maintained today (the original sstephenson/bats is abandoned).

$ sudo apt install bats                    # quick, somewhat old version
# Or as repository submodules, which is what is advisable in a team (08-04)
$ git submodule add https://github.com/bats-core/bats-core.git tests/bats
$ git submodule add https://github.com/bats-core/bats-support.git tests/lib/bats-support
$ git submodule add https://github.com/bats-core/bats-assert.git tests/lib/bats-assert

The two helper libraries are not essential, but they improve the messages a great deal: bats-support provides the error formatting and bats-assert the checks assert_success, assert_failure, assert_output, assert_line and refute_output, which on failure show what they expected and what they got instead of a terse "it did not pass".

  1. Anatomy of a .bats file

A .bats file is a Bash script with one added piece of syntax: the @test block.

#!/usr/bin/env bats
# tests/common.bats - tests for the toolkit library

setup() {
    load 'lib/bats-support/load'
    load 'lib/bats-assert/load'
    source "${BATS_TEST_DIRNAME}/../lib/common.sh"
}

@test "veloz_percentage computes with one decimal" {
    run veloz_percentage 412 1284
    assert_success
    assert_output "32.1"
}

The pieces, one by one:

  • @test "description" { ... }: each test with a name that reads in the output and that must say which behavior is expected, not "test 1".
  • run command ...: runs the command capturing its output and its exit code without the failure aborting the test. It is Bats' central piece.
  • $status: the exit code of what run executed. $output: the combined output (stdout + stderr). ${lines[@]}: the same output as an array, one line per element.
  • setup() runs before each test and teardown() after it, even if it fails; setup_file()/teardown_file() run once per file, for expensive preparation. load file loads a .bash relative to the test directory, skip "reason" skips a test and BATS_TEST_DIRNAME is the directory of the .bats file, for building paths independent of where it is launched from.

Without bats-assert the same checks are written with [ "$status" -eq 0 ] and [ "$output" = "32.1" ], which is equally valid.

  1. Running the tests

$ bats tests/
common.bats
 ✓ veloz_percentage computes with one decimal
 ✓ veloz_percentage fails if the total is zero
 ✗ veloz_require detects a missing command
   (in test file tests/common.bats, line 41)
   -- command succeeded, but it was expected to fail --
3 tests, 1 failure

Useful options: --filter 'percentage' runs only the tests whose name matches the pattern, -t produces pure TAP output — the standard format CI systems consume — -x traces every command for debugging and -j 4 runs in parallel. The exit code is 0 if everything passes and 1 if something fails, which is exactly what the hook and CI need.

  1. Isolating the test from the environment

The most common mistake when starting out is writing tests that depend on the machine: on the real /srv/veloz/data, on the time zone, on jq being installed. Those tests pass on your laptop, fail in CI and end up disabled. The solution is for each test to build its own world in a temporary directory:

setup() {
    load 'lib/bats-support/load'
    load 'lib/bats-assert/load'
    export TZ=UTC LC_ALL=C                       # no locale-dependent formats
    TEST_DIR="$(mktemp -d "${BATS_TMPDIR}/veloz.XXXXXX")"
    export VELOZ_DATA_DIR="$TEST_DIR/data" VELOZ_LOGS_DIR="$TEST_DIR/logs"
    mkdir -p "$VELOZ_DATA_DIR" "$VELOZ_LOGS_DIR"
    cat > "$VELOZ_DATA_DIR/shipments.csv" <<'EOF'
shipment_id,date,city,courier,status,amount
E000001,2026-08-03,Valencia,alopez,delivered,24.50
E000002,2026-08-03,Sevilla,mgarcia,issue,31.00
E000003,2026-08-03,Valencia,jruiz,in_transit,18.75
EOF
    source "${BATS_TEST_DIRNAME}/../lib/common.sh"
}

teardown() { rm -rf "$TEST_DIR"; }

Three shipments are enough: there is a repeated city, two statuses and three couriers, which is all any aggregation needs. BATS_TMPDIR is the temporary directory Bats manages; in recent versions there is also BATS_TEST_TMPDIR, created and deleted automatically per test.

  1. Test doubles: replacing an external command

This is the technique that makes system scripts testable, and it is simpler than it looks. Bash looks for commands by walking $PATH from left to right; if you put a directory containing a script called curl at the front, that is the curl that runs.

setup() {
    TEST_DIR="$(mktemp -d "${BATS_TMPDIR}/veloz.XXXXXX")"
    mkdir -p "$TEST_DIR/bin"
    cat > "$TEST_DIR/bin/curl" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$CALL_LOG"               # records how it was called
printf '{"status":"ok","shipments":128}\n'
EOF
    chmod +x "$TEST_DIR/bin/curl"
    export CALL_LOG="$TEST_DIR/calls.txt"
    export PATH="$TEST_DIR/bin:$PATH"            # the double takes priority
    source "${BATS_TEST_DIRNAME}/../lib/common.sh"
}

@test "veloz_api_get returns the API JSON" {
    run veloz_api_get /metricas
    assert_success
    assert_output --partial '"shipments":128'
}

@test "veloz_api_get uses --netrc and no credentials on the command line" {
    veloz_api_get /salud >/dev/null
    run grep -c -- '--netrc' "$CALL_LOG"
    assert_output "1"
}

The double achieves three things, and only the first is "not using the network". Determinism: the response is always the same, so the test does not fail because the API is slow. Cases impossible to provoke: by changing the double to return exit 7 or a truncated JSON you test the error handling, which is exactly the code that never gets exercised by hand. And call verification: the log lets you check how it was invoked, which is how the use of --netrc from 08-03 was tested above.

The same technique covers the rest: a systemctl double returning inactive tests the alerting branch of service-status.sh; an ssh one tests fleet.sh without leaving the machine; a mail one verifies that watchdog.sh warns without sending anything to anybody. And backup.sh needs no double: it is enough to point VELOZ_DATA_DIR and VELOZ_BACKUP_DIR at the temporary directory so it works over four fake files instead of over the real disk.

  1. The structure of tests/ in the toolkit

The tests/ directory contains the submodules (bats/, lib/) and one .bats per tested unit: common.bats for the library functions, report.bats for daily-report.sh end to end and backup.bats for retention and restore.

File Normal cases Edge cases Error cases
common.bats veloz_percentage 412 128432.1 numerator 0, total 0, value greater than the total non-numeric arguments → code 1
report.bats 4-row CSV → totals per city CSV with only a header, empty field nonexistent CSV → code 2 and message
backup.bats creates the .tar.gz and its .sha256 0 files; name with spaces destination with no permissions → code ≠ 0

The edge cases are the ones that find the most bugs: a CSV with only the header makes many reports divide by zero, an amount 1,234.50 breaks the sum and a file with spaces takes apart a badly written find. And testing the exit code is not a detail: it is the only thing the systemd timer from 07-05 sees and what decides whether an alert is sent.

  1. Hook, CI and a coverage criterion

The pre-commit hook from 08-04 already invoked bats tests/. In CI it is added alongside the static analysis, with bats --formatter tap tests/. An important detail to make this sustainable: the tests must be fast. If they take forty seconds, the hook becomes annoying and somebody will start using --no-verify; with doubles and fake data, the toolkit's complete suite takes under two seconds, and that is what guarantees it always runs.

You do not have to test everything. The criterion, in priority order: what has already broken at least once — every resolved incident becomes a test, and this is the most valuable rule, because it guarantees the same failure does not come back and it grows the suite precisely where the system is fragile; what decides something (thresholds, validations, computations); what is destructive (retention, deletion, overwriting), because a failure there cannot be undone; and the exit codes of the scripts that govern alerts and timers. Chasing 100% coverage in Bash produces fragile tests that break with every formatting change and end up disabled, which is worse than not having them.

  1. Application: tests/common.bats

#!/usr/bin/env bats
# tests/common.bats - tests for lib/common.sh of the Veloz Envios toolkit
# setup() and teardown() as in section 7, with VELOZ_LOGS_DIR in the temp dir

@test "percentage: normal case with one decimal" {
    run veloz_percentage 412 1284
    assert_success
    assert_output "32.1"
}

@test "percentage: a zero numerator returns 0.0" {
    run veloz_percentage 0 1284
    assert_success
    assert_output "0.0"
}

@test "percentage: a zero total fails and does not divide" {
    run veloz_percentage 5 0
    assert_failure
    assert_output --partial "total"
}

@test "require: fails and names the missing command" {
    run veloz_require command_that_does_not_exist_12345
    assert_failure
    assert_output --partial "command_that_does_not_exist_12345"
}

@test "log: writes level, message and ISO-8601 timestamp" {
    veloz_log INFO "backup completed"
    run cat "$VELOZ_LOGS_DIR/veloz-ops.log"
    assert_output --partial "[INFO] backup completed"
    assert_line --regexp '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}'
}

@test "log: does not interpret the message content" {
    veloz_log INFO 'danger $(id) and *'
    run cat "$VELOZ_LOGS_DIR/veloz-ops.log"
    assert_output --partial 'danger $(id) and *'
}

Five tests, under two seconds, and they cover the three kinds of case (to which it is worth adding the positive case veloz_require bash). The last one deserves attention: it checks that veloz_log treats its argument as text and does not expand it, which is exactly the injection vulnerability from 08-03. A test can watch over a security property just as it watches over a computation.

Common Mistakes and Tips

  • Forgetting run. Without it, a command that fails aborts the test and you cannot check $status.
  • Tests that depend on the machine. Real paths, time zone, jq installed. Isolate everything in setup with a temp dir, TZ and LC_ALL.
  • Not cleaning up in teardown. Tests contaminate each other and fail depending on the execution order.
  • Testing only the happy path. Bugs live in the empty CSV, the missing field and the full disk.
  • Tip: turn every incident into a test. It is the cheapest way for the suite to cover exactly what matters.
  • Tip: bats -x --filter 'name' runs a single test with full tracing; it is the set -x from 05-03 applied to tests.

Exercises

Exercise 1. Write a Bats test verifying that daily-report.sh fails with code 2 and a message on stderr when the input CSV does not exist.

Exercise 2. Explain why this test is wrong and rewrite it.

@test "the backup works" {
    run /home/veloz/veloz-ops/bin/backup.sh
    [ "$status" -eq 0 ]
}

Solutions

Solution 1.

@test "report: a nonexistent CSV fails with code 2 and a message" {
    run "${BATS_TEST_DIRNAME}/../bin/daily-report.sh" -f "$TEST_DIR/does-not-exist.csv"
    assert_failure 2
    assert_output --partial "does-not-exist.csv"
}

assert_failure 2 requires the exact code, not just any failure: it is what distinguishes "usage error" from "internal error" according to the conventional codes from 05-03. Since run captures stdout and stderr together, assert_output sees the message even though it is written to stderr.

Solution 2. It has four problems: it uses an absolute path in the user's home, so it only works on that machine; it runs the backup over the real data, risking overwriting the production backup from a test; it only checks the exit code, not that the backup contains anything; and its name does not say which behavior is expected.

@test "backup: creates the tar.gz and its checksum" {
    export VELOZ_DATA_DIR="$TEST_DIR/data" VELOZ_BACKUP_DIR="$TEST_DIR/backup"
    mkdir -p "$VELOZ_DATA_DIR" "$VELOZ_BACKUP_DIR"
    printf 'content\n' > "$VELOZ_DATA_DIR/shipments.csv"
    run "${BATS_TEST_DIRNAME}/../bin/backup.sh"
    assert_success
    run bash -c 'ls "$VELOZ_BACKUP_DIR"/*.tar.gz "$VELOZ_BACKUP_DIR"/*.sha256'
    assert_success
    run tar -tzf "$(ls "$VELOZ_BACKUP_DIR"/*.tar.gz)"
    assert_output --partial "shipments.csv"
}

Now the test is independent of the machine, works over fake data and verifies the effect: that the archive exists, that its checksum exists (07-03) and that the expected file is inside it.

Conclusion

Testing operations scripts is not a luxury: it is the only early detection available in code that runs at dawn with nobody watching, where an empty backup can go unnoticed for weeks. The best payoff comes from the pure functions in lib/common.sh, followed by each script's end-to-end behavior — exit code, output and the files it creates; plumbing with no logic does not get tested. The idea is the same as the homemade check [[ $(f x) == expected ]] || echo FAIL, and Bats adds what that one lacks: readable names, isolation between cases, a final summary and, above all, run, which captures $status, $output and ${lines[@]} without a failure aborting the test. With setup/teardown each case builds its world in a temporary directory with fake Veloz Envíos data and pins TZ and LC_ALL, so that the test does not depend on the machine — the main reason suites end up disabled. Test doubles are the technique that makes it all possible: a fake script called curl, systemctl, ssh or mail in a directory at the front of PATH lets you test veloz_api_get without a network, fleet.sh without servers and backup.sh without touching the disk, as well as provoking errors you would never see by hand and verifying how the command was invoked. The suite is organized into tests/common.bats, tests/report.bats and tests/backup.bats, each covering normal, edge and error cases with their exit codes, and it runs in the pre-commit hook and in CI (08-04, 08-05), which demands that it be fast: with doubles, under two seconds. And the coverage criterion is not a percentage but a priority: what has already broken once, what decides something and what is destructive.

We now have a readable one, a fast one, an audited one, a versioned one, an analyzed one and a tested one. One decision remains that we have taken for granted all course long: that the interpreter is Bash 5 on Ubuntu. Lesson 08-07 puts it to the test: what happens when a script has to run where /bin/sh is dash, Alpine's ash or ksh, which of the constructs we have been using are bashisms and what their POSIX equivalent is, why the differences between the GNU and BSD tools hurt more than those of the shell itself, how to check portability with checkbashisms, shellcheck -s sh and dash -n, and when pure POSIX is worth it and when it is a burden. That lesson closes the entire module.

Bash Programming Course

Module 1: Introduction to Bash

Module 2: Basic Bash Commands

Module 3: Scripting Fundamentals

Module 4: Intermediate Scripting

Module 5: Advanced Scripting Techniques

Module 6: Working with External Tools

Module 7: Automation and Scheduling

Module 8: Best Practices and Optimization

Module 9: Real-World Projects

© Copyright 2026. All rights reserved