Sitemap
Jahangir

Jahangir

Cloud | DevOps Engineer | AWS & Azure Solution Architect | VMware EsXi | Linux | DevOps | Git | Dockers | kubernetes | Python | Bash Scripting

ShellCheck and Bats: A Safety Net for Linux Shell Scripts

7 min readSep 21, 2026

--

Press enter or click to view image in full size

Shell scripts often begin as a few convenient commands. Over time, they grow into deployment tools, backup jobs, health checks, installers, and production automation. At that point, a quoting mistake or an incorrect exit code can cause far more damage than expected.

Two tools solve different parts of this problem:

  • ShellCheck examines shell code without running it and identifies suspicious syntax, unsafe patterns, portability problems, and common mistakes.
  • Bats, short for Bash Automated Testing System, runs shell commands and verifies that their real behavior matches your expectations.

They are most effective when used together. ShellCheck asks, “Does this code look unsafe or incorrect?” Bats asks, “Does this code actually behave correctly?”

Why shell scripts need automated checks

Bash is flexible, but that flexibility creates traps. Consider this cleanup command:

rm -rf $backup_dir/*

If backup_dir is empty or contains spaces, the command may behave very differently from what the author intended. Quoting the variable is safer:

rm -rf -- "${backup_dir:?}"/*

The quotes prevent word splitting, -- ends option processing, and ${backup_dir:?} stops the command if the variable is empty or unset. A reviewer may catch the original problem, but an automated tool can catch it on every commit.

Testing is also necessary because syntactically valid code can still produce the wrong result. A backup script might run without errors but omit hidden files. A service check might print “healthy” while returning a failure exit code. Static analysis alone cannot validate these behaviors.

ShellCheck: static analysis for shell code

ShellCheck analyzes shell scripts and reports diagnostics identified by codes such as SC2086. It supports common shells including sh, Bash, Dash, and KornShell.

On Ubuntu or Debian, install it with:

sudo apt update
sudo apt install shellcheck

Then check a script:

shellcheck backup.sh

Consider this example:

#!/usr/bin/env bash
name=$1
for file in $(find /var/log -name "*.log"); do
echo Processing $file for $name
done

ShellCheck will warn about issues such as unquoted variables and iterating over the output of find. A safer version is:

#!/usr/bin/env bash
name=${1:?Usage: backup.sh NAME}
while IFS= read -r -d '' file; do
printf 'Processing %s for %s\n' "$file" "$name"
done < <(find /var/log -type f -name '*.log' -print0)

This version correctly handles filenames containing spaces, tabs, and newlines.

What ShellCheck can catch

ShellCheck is especially useful for detecting:

  • unquoted variable expansion;
  • accidental word splitting and glob expansion;
  • ineffective assignments in subshells;
  • incorrect tests and conditionals;
  • suspicious use of cd, find, grep, and pipelines;
  • Bash features used in scripts declared as POSIX sh;
  • variables that appear unused or incorrectly referenced.

You can check every shell script in a repository with:

find . -type f -name '*.sh' -print0 | xargs -0 shellcheck

If executable scripts do not use the .sh extension, target them explicitly or maintain a list in your build configuration.

Suppressing a warning carefully

Occasionally, a flagged construct is intentional. A specific warning can be disabled near the relevant line:

# shellcheck disable=SC2086
command $intentionally_split_arguments

A suppression should be narrow and accompanied by a short explanation. Disabling checks globally can hide unrelated defects.

Bats: behavioral testing for Bash and Unix commands

Bats-core is a TAP-compliant testing framework for Bash. A .bats file contains named test cases built from normal shell commands. Bats can test Bash scripts as well as other Unix command-line programs.

On Ubuntu, a simple installation option is:

sudo apt update
sudo apt install bats

Package versions vary by Linux distribution. The official Bats installation guide also documents npm, Homebrew, source, and other installation methods.

Verify the installation:

bats --version

Your first Bats test

Suppose we have a script named greet.sh:

#!/usr/bin/env bash
set -euo pipefail
if (( $# != 1 )); then
printf 'Usage: %s NAME\n' "$0" >&2
exit 64
fi
printf 'Hello, %s!\n' "$1"

Make it executable:

chmod +x greet.sh

Create test/greet.bats:

#!/usr/bin/env bats
setup() {
PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
GREET="$PROJECT_ROOT/greet.sh"
}
@test "prints a greeting for a supplied name" {
run "$GREET" "Jagz"
[ "$status" -eq 0 ]
[ "$output" = "Hello, Jagz!" ]
}
@test "fails with usage information when the name is missing" {
run "$GREET"
[ "$status" -eq 64 ]
[[ "$output" == *"Usage:"* ]]
}

Run the tests:

bats test/greet.bats

In a Bats test, run executes a command and captures its result. The most commonly used values are:

  • $status: the command's exit status;
  • $output: combined standard output and standard error;
  • ${lines[0]}, ${lines[1]}, and so on: individual output lines.

The official Bats documentation notes that run executes the command in a subshell. Therefore, environment changes made inside that command do not remain after run finishes.

Setup, teardown, and isolated temporary files

Tests should not depend on files left behind by earlier runs. Bats provides lifecycle functions and a temporary directory for isolation:

setup() {
config_file="$BATS_TEST_TMPDIR/app.conf"
printf 'PORT=8080\n' > "$config_file"
}
teardown() {
# Optional cleanup for resources outside BATS_TEST_TMPDIR.
true
}
@test "configuration contains the expected port" {
run grep '^PORT=8080$' "$config_file"
[ "$status" -eq 0 ]
[ "$output" = "PORT=8080" ]
}

Use setup() before each test and teardown() after each test. BATS_TEST_TMPDIR gives each test a suitable place for disposable files.

Get Jahangir’s stories in your inbox

Join Medium for free to get updates from this writer.

For suite-wide preparation, Bats also supports setup_file() and teardown_file() in a test file. Per-test setup is usually safer because it reduces hidden dependencies between tests.

Testing failure paths, not only success paths

Operational scripts must fail safely. Test invalid arguments, unavailable dependencies, permission errors, malformed configuration, and repeated execution.

For example:

@test "rejects an unsupported environment" {
run -2 ./deploy.sh productionn
[[ "$output" == *"Unsupported environment"* ]]
}

Here, run -2 tells Bats that exit status 2 is expected. You can also use run ! command when any nonzero status is acceptable. These forms make the intended failure explicit.

A useful test suite should answer questions such as:

  • Does the command return zero when it succeeds?
  • Does it return nonzero when it fails?
  • Is the error message understandable?
  • Are existing files preserved when validation fails?
  • Can the script run twice without corrupting state?
  • Does it handle paths containing spaces?
  • What happens when a required command is missing?

Be careful when testing pipelines

This test does not behave as many people expect:

run generate_report | grep 'healthy'

The shell constructs the pipeline outside run. Current Bats documentation recommends bats_pipe when a pipeline must execute inside the captured command:

run bats_pipe generate_report \| grep 'healthy'
[ "$status" -eq 0 ]

For simpler cases, invoking Bash explicitly can also make the pipeline a single command:

run bash -o pipefail -c './generate_report | grep "healthy"'
[ "$status" -eq 0 ]

Avoid inserting untrusted data into a bash -c string.

ShellCheck and Bats in one workflow

A small project might use this structure:

project/
├── bin/
│ └── greet.sh
└── test/
└── greet.bats

Run both checks locally:

shellcheck bin/*.sh
bats test

You can wrap them in a Makefile:

.PHONY: lint test check
lint:
shellcheck bin/*.sh
test:
bats test
check: lint test

Now one command performs the complete validation:

make check

The same command can run in CI, ensuring that developers and the pipeline use identical checks.

Example GitHub Actions workflow

Create .github/workflows/shell-tests.yml:

name: Shell tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install test tools
run: |
sudo apt-get update
sudo apt-get install --yes shellcheck bats
- name: Run ShellCheck
run: shellcheck bin/*.sh
- name: Run Bats tests
run: bats test

For reproducible CI, pin the runner and tool versions according to your organization’s dependency policy. Distribution packages are convenient, while a container or explicitly pinned release provides tighter version control.

What each tool can and cannot prove

QuestionShellCheckBatsIs there a likely quoting or expansion mistake?YesOnly if a test exposes itDoes the script return the correct exit status?NoYesIs the script using syntax incompatible with its declared shell?OftenPossiblyDoes the program create the correct file?NoYesDoes an error path preserve existing data?NoYesCan it identify suspicious code without executing it?YesNo

Neither tool proves that a script is completely safe. ShellCheck uses rules and cannot understand every operational intention. Bats validates only the scenarios you write. Together, however, they eliminate a large class of preventable failures.

Practical best practices

  1. Start scripts with an accurate shebang, such as #!/usr/bin/env bash for Bash-specific code.
  2. Quote variable expansions unless splitting or globbing is deliberate.
  3. Treat exit codes as part of the command’s interface.
  4. Test both successful and unsuccessful paths.
  5. Use temporary directories rather than real production paths.
  6. Mock destructive or external commands such as rm, curl, ssh, and cloud CLIs when unit testing.
  7. Keep each test independent.
  8. Run ShellCheck and Bats locally and in CI.
  9. Add a regression test whenever a bug is fixed.
  10. Keep scripts small enough that their behavior is easy to test.

Never run destructive integration tests against a production host. Use disposable containers, virtual machines, temporary directories, or dedicated test accounts.

A simple memory trick

Remember: ShellCheck checks the script; Bats checks the behavior.

Or use the shorter phrase: Lint before launch, test before trust.

ShellCheck is the code reviewer looking for dangerous patterns. Bats is the operator executing controlled scenarios and confirming the results.

Conclusion

Shell scripting deserves the same engineering discipline as application code. A script may be only 30 lines long, but if it manages backups, deployments, users, or servers, its operational impact can be enormous.

ShellCheck gives immediate feedback about risky shell constructs. Bats turns expected command behavior into executable documentation. Adding both to a repository creates a fast safety net: lint the code, test its behavior, and reject regressions before they reach a live Linux system.

Further reading

--

--

Jahangir
Jahangir

Cloud | DevOps Engineer | AWS & Azure Solution Architect | VMware EsXi | Linux | DevOps | Git | Dockers | kubernetes | Python | Bash Scripting