Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Bash & the POSIX Shell  Module 16 – Quality: ShellCheck, tests, style and speed

Testing Bash scripts with Bats

Write Bats tests for Bash scripts. Use run to read $status and $output, setup and teardown for isolation, and a stub to test offline.

  • Intermediate
  • 10 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0, bats 1.14.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Write a Bats test file with @test blocks that print a TAP plan and one ok line per test
  • Use run to capture $status, $output and ${lines[@]} in your assertions, with --separate-stderr for errors
  • Isolate tests with setup, teardown and $BATS_TEST_TMPDIR, and source a script without running its main
  • Replace an external command with a fake first on PATH, so a networked script can be tested offline

Before you start

What you’ll learn · Before you start

What you will learn

  • Write a Bats test file with @test blocks that print a TAP plan and one ok line per test
  • Use run to capture $status, $output and ${lines[@]} in your assertions, with --separate-stderr for errors
  • Isolate tests with setup, teardown and $BATS_TEST_TMPDIR, and source a script without running its main
  • Replace an external command with a fake first on PATH, so a networked script can be tested offline

Before you start

On this page

A Bash script gets edited long after it was written, and one changed line can break it with nothing to say so until it runs for real. Three Bats tests are enough to pin down what a small greet function must do: bats --formatter tap greet.bats prints 1..3, then one ok line for each behaviour that still holds. Bats-core 1.14.0 gives you the parts of a suite worth keeping: @test blocks that read like sentences, a run helper that captures a command’s status and output, setup and teardown around every test, and a fresh process with its own empty folder for each one.

Below you will write a @test block for a greet function, read $status and $output from run, isolate tests with setup, teardown and $BATS_TEST_TMPDIR, and stub curl offline by putting a fake first on PATH. The scripts have a main guard: The test at the end of a script, if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi, which runs main only when the file is executed as a program and skips it when a shell sources the file. More in “Script structure: main, source and libraries” so a test file can sourcing: Running a file's commands in the current shell with source file or . file, so its assignments, functions and cd stay after the file returns; return N inside the file ends it early. More in “Script structure: main, source and libraries” them without main running.

A @test block is one test; a file prints TAP

A Bats: A testing framework for Bash scripts (bats-core, MIT licence). A .bats file is Bash plus @test blocks, which Bats turns into functions and runs one by one; its TAP report has a plan line and one ok or not ok line per test. file is Bash with one extra form, @test "name" { ... }, which marks a test; Bats turns each block into a function before it runs it. Bats-core 1.14.0 reads the file, counts the @test blocks, prints a Test Anything Protocol: A plain-text format for test results that most CI systems read: a plan line of the form 1..N, then one ok or not ok line per test, with optional # diagnostic lines. Bats prints it whenever its output goes to a file, a pipe or a CI log, and with --formatter tap. plan line, runs each block in order and prints ok N name or not ok N name for every one. The suite below tests a tiny greet.sh:

greet.sh: a greeting with a main guard Shell · greet.sh
#!/usr/bin/env bash
# greet.sh: prints "Namaste, NAME!" with "friend" as the default.
# A test file can source this script without main running, because the
# main guard at the bottom only calls main when the file is run directly.

greet() {
  local name=${1:-friend}
  printf 'Namaste, %s!\n' "$name"
}

main() {
  greet "$@"
}

if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  main "$@"
fi
greet.bats: three @test blocks for greet.sh BATS · greet.bats
#!/usr/bin/env bats
# Tests for greet.sh. Each @test block is one test; run executes a command and
# records its exit status, standard output and the lines of that output.

setup() {
  source "${BATS_TEST_DIRNAME}/greet.sh"
}

@test "greet with no name uses friend" {
  run greet
  [ "$status" -eq 0 ]
  [ "$output" = "Namaste, friend!" ]
}

@test "greet with a name uses that name" {
  run greet Priya
  [ "$status" -eq 0 ]
  [ "$output" = "Namaste, Priya!" ]
}

@test "running the script prints the greeting" {
  run bash "${BATS_TEST_DIRNAME}/greet.sh" Meera
  [ "$status" -eq 0 ]
  [ "${lines[0]}" = "Namaste, Meera!" ]
}

Run the file with bats --formatter tap greet.bats. TAP is what Bats prints whenever its output goes to a file, a pipe or a CI log; the flag makes that choice explicit, and bats -p asks for the pretty view instead, a tick per test and a “3 tests, 0 failures” line. The recorded run:

A passing Bats run on greet.bats Python · greet_session.py
from session import files, run

# Runs the Bats suite for greet.sh, the two files shown above. --formatter tap asks for
# TAP whatever the output is connected to, so this record reads the same on every machine
# and in every CI log; bats -p would ask for the pretty view instead.
files("greet.sh", "greet.bats")
run("""
bats --formatter tap greet.bats
""")

Output

$ bats --formatter tap greet.bats
1..3
ok 1 greet with no name uses friend
ok 2 greet with a name uses that name
ok 3 running the script prints the greeting

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 greet_session.py

The plan line 1..3 says how many tests will run, and each ok line is one passing test. A failing test starts with not ok, and the # lines under it give the test file and the line where it stopped, such as (in test file greet.bats, line 18), then the command that failed. When the failure happens inside a shell function: A named group of commands defined with name() { …; } or function name { …; }, called like a command. It runs in the current shell, shares its variables unless local is used, and returns a status with return. More in “Functions: arguments, local and return status”, they name that function and its line first. TAP goes back to t/TEST, the test script that came with Perl 1. Bats writes the older form, without a version line: --formatter tap13 adds TAP version 13 for a reader that wants one, and --formatter junit prints JUnit XML for a CI system that reads that format instead.

One idea per test, named in a sentence

“greet with no name uses friend” is already the sentence an interview answer would start with. If a test needs “and” in its name, split it: one @test per behaviour makes a failure point at exactly one thing.

Checkpoint

Check: @test blocks and TAP

  1. Question 1 of 2 A Bats file prints 1..3 as its first line, then three ok lines. What does the 1..3 tell a reader?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Bats will run 3 tests in this file; expect 3 ok or not ok lines in order

    "1..N" is TAP's plan line: it tells the reader how many tests the file will try to run. It guards against output that was cut short: two ok lines after 1..3 mean a test went missing.

  2. Question 2 of 2 A run of greet.bats prints 1..3, ok 1 greet with no name uses friend, not ok 2 greet with a name uses that name, the line # (in test file greet.bats, line 18), and ok 3 running the script prints the greeting. What does it tell you?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Test 2 failed at line 18 of greet.bats, its output check; tests 1 and 3 passed, and bats exits with status 1

    Read a failing run from the not ok line down: the test's name, then the # lines with the file and line where it stopped and the command that failed. Line 18 of greet.bats is the output check of test 2, so greet printed something other than "Namaste, Priya!".

run captures status and output, so assertions are plain Bash

Bats runs every @test with set -e, so a command that ends with a non-zero exit status: The number from 0 to 255 every command ends with, kept by the shell in $?. 0 means success; Bash uses 126 for found but not executable and 127 for not found. More in “Your first script: shebang, chmod and running it” ends the test with not ok. That is a problem for the commands you are testing, because a command under test may be meant to fail. run cmd ... solves it: Bats runs the command in a subshell, saves its exit status in $status, its combined output in $output and that output split by newline in the array ${lines[@]}, leaving out empty lines unless you add --keep-empty-lines, and then returns 0 itself, so the next test assertion: A check in a test that states what must be true for the test to pass. In Bats it is an ordinary command such as [ "$status" -eq 0 ], and its non-zero exit status ends the test with not ok. runs. The assertions are ordinary [ ... ] tests on those variables. Because the command ran in a subshell, a variable it sets is gone once run returns; call a function directly when that variable is what you want to test.

The greet.bats tests show the three shapes you need often: [ "$status" -eq 0 ] for the exit code, [ "$output" = "Namaste, friend!" ] for the whole output, and [ "${lines[0]}" = "Namaste, Meera!" ] for one line of many. For a command whose standard error: Descriptor 2, where a command writes its warnings and error messages, kept apart from its results so that they still show when standard output goes to a file or a pipe. Also called stderr. More in “Redirection: stdin, stdout, stderr, /dev/null” is the point, add --separate-stderr: Bats-core 1.5.0 added it, and test 2 of the stub-curl suite below uses it. Step through that test to see what run keeps when the command fails on purpose:

Interactive · message by message What run keeps when a command fails Step through test 2 of stub-curl.bats: a command that is meant to fail, two streams kept apart, and a test that still reaches its checks.
What run keeps when a command fails, step 6 of 6. Messages so far: Your @test to run: run download; run to download: download; download to run: status 1; run to Your @test: returns 0.Your @testtest 2runstderr keptdownloadunder testrun downloaddownloadstatus 1returns 0ok 2

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The test calls run. Test 2 seeds a 500 with MOCK_CURL_STATUS=500 and calls run --separate-stderr download http://example.test/. Called without run, download would return 1 and end the test on that line.
  2. run starts a subshell. run turns off set -e for the call and runs download in a subshell, so a variable that download sets is gone when run returns.
  3. download fails on purpose. The fake curl answers 500, so download writes "download: http://example.test/ returned 500" to standard error, nothing to standard output, and returns 1.
  4. run keeps all three. run saves 1 in $status, the empty standard output in $output and the error line in $stderr. Without --separate-stderr, the error line would be in $output too.
  5. run returns 0. run itself returns 0, so set -e does not stop the test and the next line, the first check, runs.
  6. The checks read the data. [ "$status" -eq 1 ], [ -z "$output" ] and the $stderr comparison all hold, so Bats prints ok 2: the test proved that the failure is the right one.

After run, these are the variables a test reads, with the assertion each one usually feeds:

After run cmd ... Where it comes from What a test asserts
$status the command’s exit status [ "$status" -eq 0 ] or -eq 1
$output the command’s combined stdout and stderr [ "$output" = "Namaste, friend!" ]
${lines[@]} $output split by newline, empty lines left out [ "${lines[0]}" = "..." ], [ "${#lines[@]}" -eq 2 ]
$stderr the error stream, only with --separate-stderr [ "$stderr" = "download: ... returned 500" ]
$BATS_RUN_COMMAND the command and arguments given to run [ "$BATS_RUN_COMMAND" = "greet Priya" ]

Forgetting run and testing $? instead

Wrong: greet "$name"; [ "$?" -eq 0 ]. If greet fails, the test ends at that call, so the [ "$?" -eq 0 ] line never runs, and a test that expects a failure, [ "$?" -eq 1 ], can never pass. Right: run greet "$name", then [ "$status" -eq 0 ]. The point of run is to turn a failure into data the next line can check.

Checkpoint

Check: run captures status and output

  1. Question 1 of 2 A test needs to check that a function prints an error message to standard error and exits 1, without the message landing in $output next to normal lines. Which form of run should it use?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: run --separate-stderr cmd, then check $status, $output and $stderr

    run --separate-stderr (bats-core 1.5.0 and later) gives $output and $stderr separately, so an assertion can test each one. Put bats_require_minimum_version 1.5.0 at the top of the file so an older Bats stops at that line instead of misreading the flag.

  2. Question 2 of 2 A test calls greet Priya directly, without run, and its next line is [ "$status" -eq 0 ]. A bug makes greet print the greeting and then return 1. What does Bats print for this test?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: not ok, because the failing greet call ends the test before the [ ] line runs

    Without run, a failing command is the end of the test. Write run greet Priya and then check $status and $output whenever the status is something the test should decide about.

setup, teardown and a fresh folder isolate every test

Bats runs each @test block in a fresh Bash process of its own, so variables, the current folder and traps from one test cannot leak into the next: a cd / in test 1 still leaves test 2 in the folder bats was started from. setup runs before every test and teardown runs after, in that same process; setup_file and teardown_file run once around the whole file, in a process of their own, so only what setup_file exports reaches the tests. The environment variable: A variable that has been exported, so every program the shell starts receives a copy of it in its environment, the list of name=value pairs a process begins with. More in “Variables, export and the environment” $BATS_TEST_TMPDIR names a new, empty folder made for the current test: what other tests wrote in their own folders is not in it, and Bats removes all those folders when the run ends. test isolation: The property that one test's actions cannot change another test's result. Bats runs each @test in a fresh process of its own with a new, empty $BATS_TEST_TMPDIR, so variables, the current folder and traps do not carry over from one test to the next. in Bats covers the process and that folder; a file written anywhere else is still there for the next test.

Walk through the lifecycle for the first @test of greet.bats:

Interactive · message by message Running one @test, start to finish Step through the Bats lifecycle: setup_file, a fresh process per @test, run, the assertions, teardown.
Running one @test, start to finish, step 8 of 8. Messages so far: Bats to Your @test: new process; Your @test to greet.sh: source; Your @test to greet.sh: run greet; Your @test to Bats: ok 1.Batsthe runnerYour @testown processgreet.shunder testnew processsourcerun greetok 1ok 1 of 3

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. The file starts. Bats parses greet.bats, counts the @test blocks and prints its plan line, 1..3, so a reader knows how many tests will run.
  2. setup_file runs once. If the file defines setup_file, Bats runs it before any @test, and only what it exports reaches the tests. teardown_file runs after the last @test the same way. greet.bats defines neither.
  3. A fresh process per @test. Bats starts a new Bash process for every @test, so variables, the current folder and traps from one test cannot leak into the next. $BATS_TEST_TMPDIR points at a new, empty folder of its own.
  4. setup runs. In greet.bats, setup sources greet.sh so the test can call its functions. The main guard inside greet.sh sees that BASH_SOURCE[0] is greet.sh but $0 is the Bats program bats-exec-test, so main does not run.
  5. The @test body runs. "greet with no name uses friend" runs. run greet calls greet with no arguments, captures its exit status in $status and its printed text in $output, and leaves $? at 0 so the next command is not interrupted.
  6. The assertions decide. [ "$status" -eq 0 ] and [ "$output" = "Namaste, friend!" ] decide whether the test passes. Each is an ordinary command: a non-zero status would end the @test with "not ok".
  7. teardown runs, the process ends. teardown runs if it is defined, then the process exits. The next test gets a different $BATS_TEST_TMPDIR; Bats keeps every test folder until the run is over and then removes them all.
  8. Bats prints the TAP line. "ok 1 greet with no name uses friend" goes to standard output. The next @test starts another fresh process; the three ok lines of greet.bats are three repeats of steps 3 to 7.

The source line in step 4 is what the main guard is for. source "${BATS_TEST_DIRNAME}/greet.sh" reads the file into the test’s shell so you can call greet directly, but greet.sh’s own [[ ${BASH_SOURCE[0]} == "$0" ]] is false there ($0 is Bats’s own bats-exec-test program), so main does not run. See sourcing and script structure for the template every script in this module uses.

Key idea

A @test block runs in a fresh process with setup and teardown around it, $BATS_TEST_TMPDIR is a new, empty folder for that test alone, and only what setup_file exports reaches the tests.

Checkpoint

Check: Setup, teardown and a fresh folder

  1. Question 1 of 2 A test writes a few files into $BATS_TEST_TMPDIR to feed the function under test. Why can the next test rely on finding its own $BATS_TEST_TMPDIR empty?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Bats makes a new folder for each @test and sets $BATS_TEST_TMPDIR to point at it, so no two tests share one

    $BATS_TEST_TMPDIR is a per-test folder made by Bats; a folder shared by the tests of one file is $BATS_FILE_TMPDIR and one shared by the whole run is $BATS_SUITE_TMPDIR. Bats removes all of them when the run ends.

  2. Question 2 of 2 setup in greet.bats sources greet.sh, which ends with if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi. Why does main not run while the tests run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In the test's process $0 is the Bats program bats-exec-test, while BASH_SOURCE[0] is greet.sh, so the test is false

    Run directly, bash greet.sh makes $0 and BASH_SOURCE[0] the same file, so main runs. Sourced from a test, $0 stays bats-exec-test, so the guard is false and the test can call greet on its own.

Stub an external command with a fake first on PATH

A command stub: A fake program with a real command's name, placed first on PATH so the shell finds it instead of the real one. A stub lets a test decide what the script sees from curl, date or any external tool, so the suite needs no network and gives the same result every run. is a fake program with the real one’s name, placed first on PATH so the shell finds it instead: for a name that is not a function or a builtin, Bash searches the folders of PATH in order and runs the first executable file with that name. For a script that calls curl, the stub writes a seeded body to the file it was told to and prints a seeded status code, so the test proves the script’s own logic without touching the network. The stub lives in $BATS_TEST_TMPDIR/bin so each test’s PATH is clean when the next one starts; the real curl is still on PATH, behind the stub.

download.sh: a tiny HTTP client that uses curl Shell · download.sh
#!/usr/bin/env bash
# download.sh URL: fetches URL with curl. Prints the body on a 2xx response.
# On any other HTTP status it writes a message to standard error and exits 1.
# A transport error from curl exits 2.

download() {
  local url=$1 tmp http
  tmp=$(mktemp)
  http=$(curl --silent --output "$tmp" --write-out '%{http_code}' "$url") || {
    rm -f "$tmp"
    return 2
  }
  if [[ $http != 2* ]]; then
    rm -f "$tmp"
    printf 'download: %s returned %s\n' "$url" "$http" >&2
    return 1
  fi
  cat "$tmp"
  rm -f "$tmp"
}

main() {
  download "$@"
}

if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  main "$@"
fi
stub-curl.bats: three tests, no network BATS · stub-curl.bats
#!/usr/bin/env bats
# Tests download.sh offline. A fake curl, first on PATH, writes a seeded body
# and status, so the suite runs without touching the network.

bats_require_minimum_version 1.5.0

setup() {
  bindir="${BATS_TEST_TMPDIR}/bin"
  mkdir -p "$bindir"
  cat > "$bindir/curl" <<'STUB'
#!/usr/bin/env bash
# Fake curl: --output writes MOCK_CURL_BODY to that file; --write-out prints
# MOCK_CURL_STATUS. Only the arguments download.sh uses are read.
set -euo pipefail
out=
while [ $# -gt 0 ]; do
  case $1 in
    --silent)    shift ;;
    --output)    out=$2; shift 2 ;;
    --write-out) shift 2 ;;
    *)           shift ;;
  esac
done
printf '%s' "${MOCK_CURL_BODY-OK}" > "$out"
printf '%s' "${MOCK_CURL_STATUS-200}"
STUB
  chmod +x "$bindir/curl"
  PATH=$bindir:$PATH
  source "${BATS_TEST_DIRNAME}/download.sh"
}

@test "a 2xx response prints the body" {
  MOCK_CURL_BODY=hello MOCK_CURL_STATUS=200 run download http://example.test/
  [ "$status" -eq 0 ]
  [ "$output" = "hello" ]
}

@test "a 500 goes to standard error and the exit status is 1" {
  MOCK_CURL_STATUS=500 run --separate-stderr download http://example.test/
  [ "$status" -eq 1 ]
  [ -z "$output" ]
  [ "$stderr" = "download: http://example.test/ returned 500" ]
}

@test "the curl the script finds is this test's stub" {
  [ "$(command -v curl)" = "$BATS_TEST_TMPDIR/bin/curl" ]
}
The stub lets the suite run offline Python · stub-curl_session.py
from session import files, run

# Runs stub-curl.bats, shown above, against download.sh. The fake curl it writes is the
# only curl the tests reach, so the run needs no network.
files("download.sh", "stub-curl.bats")
run("""
bats --formatter tap stub-curl.bats
""")

Output

$ bats --formatter tap stub-curl.bats
1..3
ok 1 a 2xx response prints the body
ok 2 a 500 goes to standard error and the exit status is 1
ok 3 the curl the script finds is this test's stub

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stub-curl_session.py

The three tests show what offline testing is for: the body test proves download returns a 2xx body as it is; the 500 test proves the error message goes to standard error and the exit status is 1; the third test is a test fixture: A known starting state built before a test and torn down after it: a seeded file, a stub command on PATH or an environment variable. In Bats, setup builds a per-test fixture and setup_file a per-file one. check, in which command -v curl must name the stub in this test’s own $BATS_TEST_TMPDIR/bin, so a broken PATH line fails a test whose name says what went wrong, while the other two would only show the symptom. The bats_require_minimum_version 1.5.0 line at the top says the file needs Bats 1.5.0 or later, the release that added run --separate-stderr. The function itself arrived in Bats 1.7.0, which also warns about a run flag used without it; an older Bats does not know the function, so the call fails instead of letting the suite pass by mistake.

Prove the suite catches a regression with mutation testing: An audit of a test suite's strength: change one small thing in the code under test and see which tests fail. A mutant that no test catches is a hole in the suite; a stronger assertion or a new test is needed.: change one character of download.sh, run bats stub-curl.bats, and watch a test fail. Flip the comparison from 2* to 3*: the 2xx test goes from ok to not ok on its [ "$status" -eq 0 ] line, because the function now treats success as an error, while the other two stay ok. A mutant that leaves every test ok shows a hole in the suite: add the test, or the assertion, that fails on it.

Run Bats in CI next to the static checks already on the file, so a broken test and a shell bug are both found before the next pull request: shellcheck download.sh && shfmt -i 2 -ci -d download.sh && bats stub-curl.bats. The -i 2 -ci flags match the two-space layout of these files; a bare shfmt -d would ask for tabs and fail the job. The lessons on ShellCheck and style with shfmt set those up; the performance lesson times code with the shell’s time, and time bats stub-curl.bats times the whole suite the same way.

Checkpoint

Check: Stubbing a command on PATH

  1. Question 1 of 2 The test wants a fake curl to run instead of the real one, so the suite can run offline. Which one line in setup makes that happen?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: PATH=$BATS_TEST_TMPDIR/bin:$PATH, after writing an executable curl into that folder

    Put the fake first on PATH, in a per-test folder, so each test's PATH is clean when the next starts and the real curl is still there behind it. The third test of stub-curl.bats checks exactly this with command -v curl.

  2. Question 2 of 2 A teammate deletes the first test of stub-curl.bats, the one for a 2xx response, and then flips 2* to 3* in download.sh as a mutation check. Both remaining tests still print ok. What does that tell them?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No test sends a 2xx answer any more, so the suite has a hole: put back a 2xx test that checks the status and the body

    Mutation testing asks which test turns red when the code changes. Here none can, because no test sends a 2xx answer: the hole is a missing test, not a weak assertion.

Practise: predict output, pick the fix, read a TAP line

Each question shows a Bats fragment, a TAP line or a scenario you have met in a shell test. A wrong choice names the rule it broke:

Practice that adapts to you

Practise this lesson

8 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 8 Which line is the TAP plan that Bats prints before any test runs?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: 1..3

    1..N is the plan; one ok or not ok line follows for each test. If fewer lines follow than the plan promised, the output was cut short, which is how a reader notices a suite that crashed.

  2. Question 2 of 8 A greet.bats file has three @test blocks, and the second one fails. Run with bats --formatter tap, which lines does Bats print for the plan and the tests, in order?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: 1..3; ok 1 ...; not ok 2 ...; ok 3 ...

    Bats keeps running after a failure, so it prints one TAP line per @test. The # lines under not ok 2 show the file and the line where the test stopped.

  3. Question 3 of 8 Why does run greet Priya exist, when the @test block is a Bash function and could call greet Priya directly?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: run saves the exit status in $status and the output in $output, then returns 0, so a failing command does not end the @test before the assertion runs

    Use run when a non-zero status is possible or when you want to assert on the output. For a command that must simply succeed, calling it directly does the same job, because a failure ends the test anyway.

  4. Question 4 of 8 download.sh prints "download: URL returned 500" to standard error on a 500 response. A test uses run download ... and then [ -z "$output" ]. The assertion fails. What is the most likely cause?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: run merges stdout and stderr into $output by default, so the error message lands in $output

    Use run --separate-stderr. Then $output is standard output, $stderr is the error text, and the "nothing on stdout, this message on stderr" split is a plain [ -z "$output" ] and [ "$stderr" = "..." ].

  5. Question 5 of 8 Which statement is true about every @test block in a Bats file?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: It runs in a fresh process of its own, with its own $BATS_TEST_TMPDIR, after setup and before teardown

    A fresh process is why a variable, cd or trap set inside a @test does not leak into the next test. setup_file and teardown_file run once per file, and only what setup_file exports reaches the tests.

  6. Question 6 of 8 A fixture is a 50 MB sample database that is slow to build, and every test of the file only reads it. Where should the suite build it, and where should it clean up?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: Build it in setup_file, once, under $BATS_FILE_TMPDIR; clean up in teardown_file

    Per test: setup, teardown, $BATS_TEST_TMPDIR. Per file: setup_file, teardown_file, $BATS_FILE_TMPDIR. Per run: $BATS_SUITE_TMPDIR. Build an expensive fixture once for the file, and let the tests only read it.

  7. Question 7 of 8 Why does a Bats test stub curl instead of calling the real one?

    Choose one answer.

    Show the answer: Question 7 of 8

    Answer: So the test is deterministic and offline: it decides what response the script sees, which no real server can guarantee

    A stub is a controlled replacement for an external command. For curl, it writes a seeded body to the file the script asks for and prints a seeded status, so one test proves the 200 path and another the 500 path.

  8. Question 8 of 8 A 2xx test seeds MOCK_CURL_BODY=hello and asserts only [ "$output" = "hello" ]. A mutant makes download print the body and then return 1. The test still prints ok. What is the fix?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: Add [ "$status" -eq 0 ] next to the output check

    Mutation testing is a quick, honest audit: change a small thing and see which tests fail. A 2xx test should check both the exit status and the body, because each check catches mutants the other misses.

Interview questions

Warm-up (fresher to 2–5 years): What do $status and $output hold after run, and why not just call the command directly in a @test?

run CMD... tells Bats to execute the command, save its exit status in $status, its combined standard output and standard error in $output, and that output split by newline in the array ${lines[@]}, with empty lines left out, and then return 0 itself. The return 0 is the point: Bats runs a @test with set -e, so a bare greet Priya that returns 1 would end the test on that line with “not ok” before any assertion ran, and the test could never prove that a failure is the right answer for bad input. With run, the status is data: [ "$status" -eq 1 ] or [ "$status" -eq 0 ] is an ordinary [ ] test like any other. For a command whose standard error is the thing you want to check, run --separate-stderr (Bats 1.5 and up) gives $output and $stderr separately.

Two harder questions follow, one about tests that pass alone but fail in the suite, one about testing a script that calls the network. Explain your reasoning before you open each first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A Bats test "writes the report" passes when it is the only @test in the file, but fails whenever another test runs before it. The failing message is "report.csv: No such file or directory". Explain what shared state is hiding here, and how you would fix it.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A release script gets a token with curl, then posts the release; after a 503 it runs sleep 5 and posts again, three tries in all. Design an offline Bats suite that proves the retry limit and that the token is sent, without ever sleeping, and show that it would catch a broken retry loop.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What is a Bats test file, and what does it print?

    Bash with @test "name" { ... } blocks. bats --formatter tap FILE prints a plan line and one ok or not ok line per test, in the Test Anything Protocol that CI systems read.

  2. What do $status, $output and ${lines[@]} hold after run CMD?

    The command's exit status, its combined standard output and standard error, and that output split into lines with empty ones left out. run --separate-stderr adds $stderr.

  3. Why call a command through run inside a @test instead of directly?

    Bats runs each test with set -e, so a command that fails directly ends the test at once. run captures the status and returns 0, so the test can assert on the status it expected.

  4. How does Bats keep tests from affecting each other?

    Every @test runs in a fresh process with a new, empty $BATS_TEST_TMPDIR; setup and teardown run around each test, and setup_file and teardown_file once per file.

  5. What must a script have before a Bats file can source it to test its functions?

    A main guard, so main does not run when the file is sourced under a different $0; to test the whole command, run the script itself.

  6. How do you test a script that calls curl without touching the network?

    Put a fake curl first on PATH, in $BATS_TEST_TMPDIR/bin, so the script calls the stub. Then prove the suite with a mutant that must turn at least one test to not ok.

Key takeaways

  • A Bats file is Bash with @test "name" { ... } blocks; bats --formatter tap FILE prints a plan line and one ok or not ok line per test, in the Test Anything Protocol that CI systems read.
  • run CMD... runs the command in a subshell and keeps its exit status in $status, its combined output in $output and its non-empty lines in ${lines[@]}, so an assertion is an ordinary [ ... ] test; run --separate-stderr adds $stderr for error messages.
  • Every @test runs in a fresh process with a new, empty $BATS_TEST_TMPDIR; setup and teardown run around each test, and setup_file and teardown_file run once per file.
  • Test a function by sourcing its script, which needs a main guard that stops main from running under a different $0; test a command by running the script itself.
  • Stub a command by putting a fake first on PATH in $BATS_TEST_TMPDIR/bin, so a script that calls curl can be tested offline, and prove the suite with a mutant that must turn at least one test not ok.

Terms in this lesson

Bats
A testing framework for Bash scripts (bats-core, MIT licence). A .bats file is Bash plus @test blocks, which Bats turns into functions and runs one by one; its TAP report has a plan line and one ok or not ok line per test.
command stub
A fake program with a real command's name, placed first on PATH so the shell finds it instead of the real one. A stub lets a test decide what the script sees from curl, date or any external tool, so the suite needs no network and gives the same result every run.
mutation testing
An audit of a test suite's strength: change one small thing in the code under test and see which tests fail. A mutant that no test catches is a hole in the suite; a stronger assertion or a new test is needed.
Test Anything Protocol
A plain-text format for test results that most CI systems read: a plan line of the form 1..N, then one ok or not ok line per test, with optional # diagnostic lines. Bats prints it whenever its output goes to a file, a pipe or a CI log, and with --formatter tap.
test assertion
A check in a test that states what must be true for the test to pass. In Bats it is an ordinary command such as [ "$status" -eq 0 ], and its non-zero exit status ends the test with not ok.
test fixture
A known starting state built before a test and torn down after it: a seeded file, a stub command on PATH or an environment variable. In Bats, setup builds a per-test fixture and setup_file a per-file one.
test isolation
The property that one test's actions cannot change another test's result. Bats runs each @test in a fresh process of its own with a new, empty $BATS_TEST_TMPDIR, so variables, the current folder and traps do not carry over from one test to the next.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.