ShellCheck and Tests for Bash Scripts
AI generated
ShellCheck · BATS · Testing · CI/CD · Shell
ShellCheck and Tests for Bash Scripts
Linting, BATS Framework, Mocking, and CI Integration

Shell scripts are rarely tested, and that is exactly why they fail without warning in production. ShellCheck finds static errors before the script ever runs. The BATS framework enables real unit tests for Bash functions. Mocking strategies isolate external commands. CI integration ensures that every commit meets this standard.

13 min read ShellCheck · BATS · Mocking · Coverage · CI/CD Bash 4.x · 5.x · GitHub Actions · GitLab CI

1. Why Bash Scripts Need to Be Tested

In many projects, Bash scripts carry a disproportionately high operational responsibility, handling deployments, backups, configuration management, and data migration, while being among the least tested pieces of code. The typical justification is: "It's just a shell script." That is a dangerous underestimation. A shell script that runs incorrectly deletes files, writes wrong configurations, or deploys a broken version to production. The consequence is the same as with any other faulty program, just without the safety nets that are standard in other languages.

ShellCheck and the BATS framework are the two tools that together form a complete quality assurance strategy for Bash scripts. ShellCheck is a static analyzer that detects code patterns leading to faulty behavior, without ever having to run the script. BATS (Bash Automated Testing System) is a test framework that enables real execution of functions and scripts with assertions. The two tools complement each other: ShellCheck finds structural errors, while BATS verifies actual runtime behavior.

Integrating ShellCheck into CI/CD pipelines takes only a few minutes of setup time and delivers immediate value: every commit that introduces a shell script with one of the common error classes fails in CI, before the code is merged into the main line. That is exactly the point at which errors are cheapest to fix. Anyone who has experienced an untested deployment script leaving orphaned files in production because --delete ran without a prior check understands the motivation for ShellCheck and BATS.

2. ShellCheck: Installation, Configuration, and Basic Usage

ShellCheck is available on most systems directly through the package manager: apt install shellcheck, brew install shellcheck, or as a binary download. The simplest usage is shellcheck script.sh: ShellCheck analyzes the file and outputs warnings with categories (error, warning, info, style) and links to documentation. Each warning has a number (e.g. SC2086) and an explanatory text that not only describes the error but also shows the correct pattern.

ShellCheck configuration can be controlled project-wide through a .shellcheckrc file: shell=bash sets the target shell, and disable=SC2034 disables specific rules across the whole project. Rules can also be disabled inline in the script with # shellcheck disable=SC2086 placed above the affected line. This should be used sparingly: if you disable a warning, you need to be able to justify why the code is correct anyway. For CI/CD, the --format=checkstyle output format is well suited, since it can be interpreted as an error report by all common CI systems.


# .shellcheckrc: Project-wide ShellCheck configuration
shell=bash
enable=all
# Disable globally only when justified:
# disable=SC2034   # unused variable (sometimes intentional in lib files)

# --- Minimal Makefile integration ---
# make lint        : runs ShellCheck on all .sh files
# make test        : runs BATS test suite

3. The Most Important ShellCheck Rules in Detail

The most common and most impactful ShellCheck rules are SC2086 (unquoted variable), SC2155 (local combined with command substitution), SC2006 (backtick usage instead of $()), and SC2181 (checking $? instead of a direct condition). SC2086 is by far the most frequent finding: an unquoted $variable leads to word splitting and glob expansion, which causes completely unexpected behavior with strings containing spaces, wildcards, or newlines. ShellCheck reliably finds this pattern in every file.

SC2155 (local result=$(cmd)) is the second most critical finding: the local builtin swallows the exit code of the subshell, which means set -e no longer takes effect. SC2164 (cd ... || exit) warns when a cd is not followed by error handling, a classic mistake where the script continues running in the wrong directory. SC2046 warns about unquoted command substitution. SC2068 and SC2145 expose faulty array expansions. ShellCheck documents every rule at https://www.shellcheck.net/wiki/SCxxxx with a detailed explanation and examples of the correct pattern.

4. BATS Framework: Basics and Test Structure

BATS (Bash Automated Testing System) is a test framework for Bash scripts that follows the principle of "one test per @test block." Each test is a function named after @test "description". The test is considered passed when all commands finish with exit code 0. BATS provides helpers like run (execute a command and store its output in $output and its exit code in $status) and the helper library bats-assert for readable assertions such as assert_success, assert_failure, and assert_output.

The ShellCheck configuration also applies to BATS test files. BATS tests are stored as .bats files, typically in a test/ directory. Installation happens as a git submodule or via a package manager. For library testing, shell functions from the file under test are loaded with source, then called and verified individually. The setup() hook runs before every test and teardown() runs after, ideal for temporary directories and cleanup. BATS adds the dimension of runtime behavior to ShellCheck: what ShellCheck checks statically, BATS verifies dynamically.


#!/usr/bin/env bats
# test/deploy.bats: BATS tests for deployment functions
# Install: git submodule add https://github.com/bats-core/bats-core test/bats
# Run: ./test/bats/bin/bats test/

# Load helpers and the function under test
load 'bats-support/load'
load 'bats-assert/load'
# shellcheck source=../lib/deploy.sh
load '../lib/deploy.sh'

setup() {
  # Create isolated temp dir for each test
  TEST_TMPDIR="$(mktemp -d)"
  export TEST_TMPDIR
}

teardown() {
  rm -rf "$TEST_TMPDIR"
}

@test "get_release_name returns timestamp-based name" {
  run get_release_name
  assert_success
  assert_output --regexp '^[0-9]{8}-[0-9]{6}$'
}

@test "validate_config fails when DEPLOY_HOST is unset" {
  unset DEPLOY_HOST
  run validate_config
  assert_failure
  assert_output --partial "DEPLOY_HOST"
}

@test "create_release_dir creates directory structure" {
  run create_release_dir "$TEST_TMPDIR/releases" "20260509-120000"
  assert_success
  assert [ -d "$TEST_TMPDIR/releases/20260509-120000" ]
}

@test "prune_releases keeps only last N releases" {
  # Create 7 fake release directories
  for i in $(seq 1 7); do
    mkdir -p "$TEST_TMPDIR/releases/2026050${i}-120000"
  done
  run prune_releases "$TEST_TMPDIR/releases" 5
  assert_success
  local count
  count=$(find "$TEST_TMPDIR/releases" -maxdepth 1 -type d | wc -l)
  assert [ "$count" -eq 6 ]  # 5 releases + parent dir
}

5. Mocking: Isolating External Commands and System Calls

The biggest obstacle when testing shell scripts is external commands: ssh, rsync, curl, aws, database clients. In unit tests, these commands should not actually be executed: the goal is to test the script's behavior for specific outputs and exit codes without real network calls or file operations taking place. Mocking in Bash tests works by overriding external commands with shell functions defined in the current scope. The function has the same name as the external command and returns defined output and exit codes.

In BATS tests, mock functions are defined in setup() and removed again in teardown() with unset -f commandname. The script under test must be structured so that external commands are encapsulated in functions that can be mocked. This is also a quality criterion for the design of shell scripts: anyone who wants to write a testable script structures it so that external dependencies are isolated, which also makes the script more maintainable in other contexts. ShellCheck and tests together lead to better structured Bash scripts.


#!/usr/bin/env bats
# test/deploy-mocking.bats: Mocking external commands in BATS tests
load 'bats-support/load'
load 'bats-assert/load'
load '../lib/deploy.sh'

setup() {
  TEST_TMPDIR="$(mktemp -d)"
  export TEST_TMPDIR
  export DEPLOY_LOG="$TEST_TMPDIR/deploy.log"
  export DRY_RUN=0
}

teardown() {
  # Remove mock functions: unset restores real commands
  unset -f rsync ssh curl
  rm -rf "$TEST_TMPDIR"
}

# Mock rsync: simulate successful transfer with stats output
rsync() {
  echo "sent 1,234 bytes  received 56 bytes"
  echo "Number of files: 10"
  return 0
}
export -f rsync

# Mock ssh: capture command that would be executed
ssh() {
  echo "MOCK SSH: $*" >> "$TEST_TMPDIR/ssh_calls.log"
  return 0
}
export -f ssh

# Mock curl: simulate network timeout
curl_timeout() {
  return 28  # curl exit code for timeout
}

@test "deploy runs rsync and logs transfer stats" {
  run deploy_files "/tmp/src/" "server:/var/www/"
  assert_success
  assert_output --partial "sent 1,234 bytes"
}

@test "deploy aborts when network check fails" {
  # Override curl with timeout mock for this test
  curl() { return 28; }
  export -f curl
  run check_network_connectivity "https://example.com"
  assert_failure
  assert_output --partial "timeout"
}

@test "symlink_swap creates correct link on remote" {
  run symlink_swap "server" "/var/www/releases/20260509" "/var/www/current"
  assert_success
  grep -q "ln -sfn" "$TEST_TMPDIR/ssh_calls.log"
}

6. Assertions and Helpers in BATS

The standard BATS assertions are minimalistic, offering only exit code and string matching. The helper library bats-assert extends them with readable assertions: assert_success, assert_failure, assert_output "expected text", assert_output --regexp 'pattern', assert_line --index 0 "first line", refute_output "unexpected text". With bats-file, file assertions are added as well: assert_file_exists, assert_file_contains, assert_symlink_to. These assertions make test output on failure far more readable than raw assert [ ... ] comparisons.

The ShellCheck-compatible test structure for maximum readability consists of setup functions for complex environments, helper functions for frequently used assertion combinations, and clear test names that describe the expected behavior. Test names like "deploy_files aborts when source directory is missing" are self-documenting: when the test fails, it is immediately clear what is not working. This convention is especially important when tests run in CI/CD pipelines and developers have to read the failures in the pipeline log without local access to the environment.

7. Coverage: Measuring Test Coverage for Shell Scripts

Coverage for shell scripts is less established than in other languages, but it can be measured with bashcov (based on SimpleCov) or the approach using set -x and trace evaluation. ShellCheck exposes structural weaknesses, but coverage shows which code paths are executed under which conditions. For critical scripts, such as deployments, backup rotations, and data migrations, coverage measurement is worthwhile to ensure that error paths, cleanup functions, and alternative execution branches are also tested.

The pragmatic approach to coverage in practice is to list the critical code paths explicitly instead of using percentage metrics, and write a BATS test for each one. "The ERR trap is called when rsync fails." "The cleanup function deletes the lockfile even when the script is terminated by SIGTERM." "The backup rotation leaves exactly 7 releases in place." This structured approach replaces the percentage coverage metric and is more practical for shell scripts. ShellCheck combined with this checklist forms the complete quality assurance system.

8. CI Integration: GitHub Actions and GitLab CI

Integrating ShellCheck into CI pipelines is one of the simplest and most effective steps for the quality of shell scripts. GitHub Actions offers the official shellcheck-action, which automatically analyzes all .sh files in the repository. In GitLab CI, a simple job with image: koalaman/shellcheck-alpine is the fastest solution. The job fails when ShellCheck finds errors in the "error" or "warning" category, and it outputs the affected lines to every developer in the merge request comment.

The recommended CI configuration for maximum effectiveness is ShellCheck with --severity=warning for mandatory checks (which fails CI) and BATS as a separate job running in parallel with the ShellCheck job. This way the pipeline always has two levels of checking: static analysis by ShellCheck and dynamic tests by BATS. For projects with many shell scripts, a pre-commit hook that runs ShellCheck locally is also worthwhile, catching errors even earlier, before they ever reach the repository.

9. ShellCheck vs. BATS: Which Tool When?

The strengths of ShellCheck and BATS lie in different dimensions of quality assurance. Together they form the complete toolset for professional Bash script development.

Dimension ShellCheck BATS Recommendation
Error type Static code patterns Runtime behavior Both in parallel in CI
Setup effort Minimal (apt install) Moderate (submodules, helpers) ShellCheck first, BATS for critical scripts
Execution time Seconds Seconds to minutes Parallel as separate CI jobs
Mocking needed? No Yes, for external commands Define mocking strategy early
Suitable for pre-commit hook Ideal, very fast Possible for quick unit tests ShellCheck as pre-commit, BATS in CI

ShellCheck should be active in every project with shell scripts. There is no good reason to forgo a free static analyzer. BATS is the next level of investment for scripts with significant business logic or high risk potential. A deployment script that manages production data deserves BATS tests. A one-line wrapper script for a frequently used command is fine with just ShellCheck.

Mironsoft

Shell automation, DevOps tooling, and deployment infrastructure

Shell scripts with real tests and automatic linting?

We integrate ShellCheck and BATS into your CI/CD pipeline, write tests for critical deployment scripts, and implement mocking strategies for external dependencies.

ShellCheck Setup

Configure CI integration, .shellcheckrc, and pre-commit hooks

BATS Test Suite

Build unit tests for critical Bash functions with a mocking framework

CI/CD Pipeline

ShellCheck + BATS as parallel CI jobs in GitHub Actions or GitLab CI

10. Summary

ShellCheck and BATS are the two tools that lift shell scripts from "mostly works" to "works reliably." ShellCheck analyzes statically and finds the most common error classes: unquoted variables (SC2086), swallowed exit codes with local (SC2155), missing error handling after cd (SC2164). BATS tests actual runtime behavior: functions, exit codes, outputs, and side effects. Mocking strategies isolate external commands for reproducible unit tests.

CI integration is the multiplier: ShellCheck as a mandatory CI job prevents merging scripts with known error patterns. BATS as a parallel test job verifies that functions show the expected behavior under defined conditions. Pre-commit hooks with ShellCheck catch problems even earlier. Anyone who applies both levels consistently has a quality assurance strategy for shell scripts that is comparable to professional software development in other languages.

ShellCheck and Tests for Bash Scripts: The Key Points at a Glance

ShellCheck

Static analyzer for shell scripts. SC2086, SC2155, and SC2164 are the most common findings. .shellcheckrc for project-wide configuration. CI integration as a mandatory job.

BATS Framework

Unit tests with @test blocks, the run helper, bats-assert for readable assertions. Setup/teardown for isolation. For critical deployment scripts and function libraries.

Mocking

Shell functions override external commands. export -f for subshells. Define in setup(), remove in teardown() with unset -f.

CI Integration

ShellCheck + BATS as parallel jobs. Pre-commit hook for local ShellCheck. GitHub Actions shellcheck-action or GitLab CI with koalaman/shellcheck-alpine.

11. FAQ: ShellCheck and Tests for Bash Scripts

1What is ShellCheck and how do I install it?
Static analyzer for shell scripts. apt install shellcheck or brew install shellcheck. Usage: shellcheck script.sh, which outputs categorized warnings with documentation links.
2BATS vs. ShellCheck: what is the difference?
ShellCheck equals static analysis. BATS equals runtime tests. The two complement each other: ShellCheck finds structural errors, BATS verifies actual behavior.
3Mocking external commands in BATS?
Define a shell function with an identical name, export -f for subshells. Remove it in teardown() with unset -f so other tests are not affected.
4Which ShellCheck rule is the most important?
SC2086 (unquoted variable) is the most common. SC2155 (local with command substitution) is the most dangerous, since it swallows exit codes. SC2164 (cd without error handling) is critical in deployment scripts.
5Integrating ShellCheck into GitHub Actions?
uses: ludeeus/action-shellcheck@master with severity: warning. All .sh files are checked automatically, no further setup needed.
6Disabling ShellCheck warnings inline?
# shellcheck disable=SC2086 above the affected line. Project-wide in .shellcheckrc. Use sparingly and always justify it.
7Structuring BATS tests for deployment scripts?
Load functions with source. Setup: temp directory plus environment variables. Teardown: clean up plus remove mocks. One assertion per test, test error cases explicitly.
8What does bats-assert bring?
Readable assertions: assert_success, assert_failure, assert_output, assert_output --regexp, assert_line. Failures show the actual output and expected value.
9Measuring coverage for Bash scripts?
bashcov as a dedicated tool. Pragmatically: define critical paths as a checklist and write a BATS test for each one.
10Is BATS worth it for small scripts?
For wrapper scripts, ShellCheck is enough. BATS is worth it for decision logic, critical resources (deployments, backups), or multi-environment operation.