Shell Script Testing & CI with BATS
Test a deploy script with BATS, mock curl, and automate CI with GitHub Actions.
Introduction
30 Second Summary
Shell scripts are often the glue holding critical infrastructure together. They are also the code most likely to break silently when someone makes a change, because they rarely have automated tests.
In this project, you will build a professional test suite for a deploy script using BATS (Bash Automated Testing System), mock external dependencies so tests run offline, and wire everything into GitHub Actions so every push is automatically validated.
What You'll Build
When you push code to GitHub, your CI pipeline automatically runs ShellCheck for static analysis and BATS for behavioral tests, catching regressions before they ever reach production.
By the end of this project, you'll have:
- A BATS test suite that validates your deploy script's flag parsing, error handling, and rollback logic with green checkmarks on every run.
- PATH-stubbed mocks replacing curl with fake responses, so your tests run offline and produce deterministic results every time.
- A GitHub Actions workflow that runs ShellCheck and BATS on every push, blocking merges when tests fail.
- Secret Mission: Add flock-based concurrency protection to your deploy script and prove it works with a BATS test exercising parallel execution.
Are there any prerequisites?
This is Part 4 of the Advanced Shell Scripting series. You should have completed Parts 1-3 (modular scripting, getopts CLIs, and the deploy.sh capstone) before starting.
You need a Linux environment (Debian-based) with Bash, Git, Docker, ShellCheck, and a GitHub account.
Before We Start
Before writing any tests, let's lock in what you're about to build and why it matters for your DevOps toolkit.
This project gives you a BATS test suite that validates your deploy script, PATH-stubbed mocks for offline testing, and a GitHub Actions workflow that catches regressions on every push before code reaches production.
Set Up the Test Infrastructure
Your deploy script from Part 3 works today. Tomorrow someone changes a flag name, and it silently breaks in production. Tests prevent that by catching regressions the moment code changes.
Before writing any tests, you need a test harness. BATS (Bash Automated Testing System) is the standard testing framework for shell scripts. In this step, you'll install it as Git submodules so the test infrastructure lives inside your repo and works identically in CI.
In this step, get ready to:
- Create the project directory structure with bin/, lib/, and test/ directories.
- Install bats-core, bats-support, and bats-assert as git submodules.
- Run a smoke test to confirm the harness works.
Create the project directory structure
Your project needs a clean separation between the script you'll test, the library it sources, and the test files themselves. This mirrors the layout from Part 3.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project directory with the required subdirectories, then move into it:
mkdir -p deploy-testing/bin deploy-testing/lib deploy-testing/test/test_helper
cd deploy-testing
- Confirm the directories exist by running:
ls
You should see bin, lib, and test listed in the output.
Not seeing all three directories?
Make sure you ran the mkdir -p command exactly as shown. The -p flag creates parent directories as needed.
If you see a different set of folders, help me figure out why my directory structure is wrong.
Initialize a git repo and install BATS as submodules
BATS needs to be installed inside your project so tests run without any system-wide dependencies. Git submodules pin the exact version and make CI checkout automatic.
- Initialize a git repository by running:
git init
You should see a message starting with Initialized empty Git repository.
- Add bats-core, bats-support, and bats-assert as git submodules by running these commands:
git submodule add https://github.com/bats-core/bats-core.git test/bats
git submodule add https://github.com/bats-core/bats-support.git test/test_helper/bats-support
git submodule add https://github.com/bats-core/bats-assert.git test/test_helper/bats-assert
What are git submodules doing here?
Each git submodule add command clones a repository into your project at the specified path. Your project now carries its own copy of the test framework.
When you push this repo and CI checks it out with submodules: recursive, the exact same versions are pulled automatically. No system install required.
- Verify BATS installed correctly by running:
./test/bats/bin/bats --version
You should see Bats 1.14.0 printed (or whichever version was cloned).
Seeing permission denied or command not found?
If you get a permission error, make sure you're running from inside the deploy-testing directory. Run pwd to confirm your location.
If the submodule add commands failed, check your internet connection. Each command clones a GitHub repository.
help me troubleshoot my BATS submodule installation.
Write a smoke test to confirm the harness works
A smoke test is a minimal sanity check that proves your test infrastructure is wired up correctly. If this passes, you know BATS, bats-support, and bats-assert are all loading properly.
- Create test/smoke.bats in your editor and add the following code:
#!/usr/bin/env bats
setup() {
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
}
@test "bats harness is working" {
assert_equal "hello" "hello"
}
What does this test do?
The setup() function runs before every test in the file. It loads the bats-support and bats-assert helper libraries, which give you assertions like assert_equal.
The @test block defines a single test case. assert_equal "hello" "hello" trivially passes, proving that the assertion library loaded without errors.
- Save test/smoke.bats.
Before you run this, what do you expect to see? A passing test, a syntax error, or a file-not-found failure?
- Run the smoke test by executing:
./test/bats/bin/bats test/smoke.bats
You should see a green checkmark next to bats harness is working and a summary showing 1 test passed, 0 failures.
Test failing or helpers not found?
If you see an error about load failing, double-check that the submodules cloned into the correct paths. Run ls test/test_helper/ and confirm you see bats-support and bats-assert directories.
If you see a syntax error, make sure the @test line uses double quotes around the test name and curly braces around the body.
help me debug my BATS smoke test failure.
✔️ Awesome, I've got everything!
Great. Double-check that you've saved test/smoke.bats and your test passed with a green checkmark.
ⓧ I'd like to double check the full code
Here is the complete test/smoke.bats file:
#!/usr/bin/env bats
setup() {
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
}
@test "bats harness is working" {
assert_equal "hello" "hello"
}
Why BATS over other shell test frameworks?
BATS is used by Docker, Homebrew, and Kubernetes tooling. Its TAP-compliant output integrates natively with CI systems, and the run keyword captures exit codes and output cleanly for assertions.
Since your Part 3 project already uses ShellCheck for static analysis, BATS complements it by testing runtime behavior. ShellCheck finds bugs in syntax. BATS finds bugs in logic.
Your test harness is live and confirmed working. Next up, you'll write a real deploy script with testable structure and put it under test with assertions that catch real bugs.
Write a Testable Deploy Script and Your First Real Tests
Your test harness is ready, but there is nothing to test yet. A script without tests works until someone changes it.
Your Part 3 deploy.sh mixed logic with side effects, making it hard to test in isolation. In this step you will build a simplified but realistic deploy script that separates testable logic from execution, then write BATS tests that validate flag parsing and error handling.
In this step, get ready to:
- Create a deploy library and script with cleanly separated concerns.
- Add a shared test helper that sets up and tears down each test run.
- Write and run 5+ BATS tests covering flags, errors, and dry-run.
Create the deploy library and script
The key design choice is separating reusable logic (functions) into a library file that the CLI script sources. This lets BATS tests source the same library without triggering side effects.
- Create the library file by pasting this code into lib/deploy-lib.sh:
#!/usr/bin/env bash
# Deploy library - sourced by bin/deploy.sh
# Separates testable logic from CLI handling
DEPLOY_ENDPOINT="${DEPLOY_ENDPOINT:-https://api.example.com}"
HEALTH_CHECK_URL="${HEALTH_CHECK_URL:-https://api.example.com/health}"
ROLLBACK_MARKER_DIR="${ROLLBACK_MARKER_DIR:-/tmp}"
usage() {
cat <<EOF
Usage: deploy.sh --env ENV --version VERSION [--dry-run] [--help]
Options:
--env ENV Target environment (staging, production)
--version VER Version to deploy (e.g., v1.2.3)
--dry-run Print actions without executing
--help Show this help
EOF
}
What does this code do?
- The three environment variables (DEPLOY_ENDPOINT, HEALTH_CHECK_URL, ROLLBACK_MARKER_DIR) default to safe values but can be overridden in tests.
- The usage() function prints a help message. Placing it here means both the script and tests can call it.
- Add the action functions below the usage() function in the same file:
deploy_version() {
local env="$1" version="$2"
local response
response=$(curl -sf -X POST "${DEPLOY_ENDPOINT}/deploy" \
-H "Content-Type: application/json" \
-d "{\"env\": \"${env}\", \"version\": \"${version}\"}" 2>&1) || {
echo "ERROR: Deploy API call failed: ${response}" >&2
return 1
}
echo "Deploy API response: ${response}"
}
health_check() {
local env="$1"
local status
status=$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_CHECK_URL}?env=${env}") || return 1
if [[ "${status}" -ge 200 && "${status}" -lt 300 ]]; then
return 0
else
echo "Health check returned HTTP ${status}" >&2
return 1
fi
}
rollback() {
local env="$1" version="$2"
echo "Rolling back ${version} on ${env}..."
touch "${ROLLBACK_MARKER_DIR}/rollback-${env}-${version}.marker"
echo "Rollback complete (marker written)"
}
What do these functions do?
- deploy_version() POSTs a JSON payload to the deploy endpoint using curl. If the call fails, it returns 1.
- health_check() fetches the health URL and checks the HTTP status code. Anything outside 2xx is a failure.
- rollback() writes a marker file to disk. In production this would call an API, but a file is easy to assert on in tests.
- Save lib/deploy-lib.sh.
- Verify the syntax is valid by running:
bash -n lib/deploy-lib.sh
No output means no syntax errors. If you see an error, check for unmatched quotes or missing fi keywords.
Seeing a syntax error?
- The heredoc delimiter EOF in usage() must start at column 0 with no leading spaces.
- Each function's curly braces must be balanced. Count your opening { and closing } for each function.
- help me debug a syntax error in my deploy-lib.sh
Now create the CLI script that sources this library.
- Paste the following into bin/deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/deploy-lib.sh
source "${SCRIPT_DIR}/../lib/deploy-lib.sh"
readonly LOCK_FILE="/tmp/deploy-${USER:-nouser}.lock"
acquire_lock() {
exec 200>"${LOCK_FILE}"
if ! flock -n 200; then
echo "ERROR: deploy already in progress (lock: ${LOCK_FILE})" >&2
exit 75
fi
}
main() {
local env="" version="" dry_run=false
while [[ $# -gt 0 ]]; do
case "$1" in
--env) env="${2:-}"; shift 2 ;;
--version) version="${2:-}"; shift 2 ;;
--dry-run) dry_run=true; shift ;;
--help) usage; exit 0 ;;
*) echo "ERROR: unknown option: $1" >&2; usage >&2; exit 1 ;;
esac
done
What does this code do?
- set -euo pipefail makes the script fail fast on errors, unset variables, and broken pipes.
- SCRIPT_DIR resolves the script's real location so the source path works regardless of where you run it from.
- acquire_lock() uses flock to grab an exclusive file lock. If another deploy is running, it exits with code 75 (EX_TEMPFAIL).
- The while/case loop parses flags without depending on external getopt binaries.
- Continue adding to bin/deploy.sh immediately below the done keyword:
if [[ -z "${env}" ]]; then
echo "ERROR: --env is required" >&2
usage >&2
exit 1
fi
if [[ -z "${version}" ]]; then
echo "ERROR: --version is required" >&2
usage >&2
exit 1
fi
acquire_lock
if [[ "${dry_run}" == true ]]; then
echo "[DRY-RUN] Would deploy version ${version} to ${env}"
echo "[DRY-RUN] Would run health check against ${env}"
return 0
fi
echo "Deploying version ${version} to ${env}..."
deploy_version "${env}" "${version}"
echo "Running health check..."
if ! health_check "${env}"; then
echo "ERROR: Health check failed, triggering rollback" >&2
rollback "${env}" "${version}"
exit 1
fi
echo "Deploy complete: ${version} is live on ${env}"
}
main "$@"
What does this second half do?
- The -z checks ensure both --env and --version were supplied. Missing either exits with code 1.
- When --dry-run is set, the script prints what it would do and returns early without calling curl.
- On a real run, if health_check fails, rollback() is triggered automatically.
- main "$@" at the bottom invokes the function, passing all CLI arguments through.
- Save bin/deploy.sh.
- Make it executable and test the help flag by running:
chmod +x bin/deploy.sh
./bin/deploy.sh --help
You should see the usage text printed, starting with Usage: deploy.sh --env ENV --version VERSION.
- Now test the dry-run flag by running:
./bin/deploy.sh --env staging --version v1.2.3 --dry-run
You should see two lines starting with [DRY-RUN] confirming the script skipped real HTTP calls.
Seeing 'permission denied' or 'source: not found'?
- Confirm you ran chmod +x bin/deploy.sh. Without it, the script cannot execute.
- Check that lib/deploy-lib.sh exists in the correct path relative to bin/deploy.sh. The source line expects one directory up.
- help me debug my deploy.sh script
✔️ Awesome, I've got everything!
Great. Confirm both files are saved and move on to the test helper.
ⓧ I'd like to double check the full code
Both files exceed 30 lines. Scroll up and confirm each chunk is present in order:
- lib/deploy-lib.sh: shebang and env vars, then usage(), then deploy_version(), health_check(), rollback().
- bin/deploy.sh: shebang through acquire_lock(), then main() flag parsing, then validation, lock, dry-run, deploy, health check, rollback, and main "$@".
Add the common test helper
Your smoke test loaded bats-support and bats-assert directly. A shared helper centralizes that setup and adds PATH manipulation so tests can call deploy.sh by name.
- Create test/test_helper/common-setup.bash with this content:
#!/usr/bin/env bash
_common_setup() {
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
PROJECT_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME}")/.." >/dev/null 2>&1 && pwd)"
PATH="${PROJECT_ROOT}/bin:${PATH}"
TEST_TEMP_DIR="$(mktemp -d)"
export ROLLBACK_MARKER_DIR="${TEST_TEMP_DIR}"
}
_common_teardown() {
rm -rf "${TEST_TEMP_DIR:-}"
}
What does this helper do?
- _common_setup() loads both assertion libraries, prepends bin/ to PATH so run deploy.sh works in tests, and creates a temp directory for test artifacts.
- Exporting ROLLBACK_MARKER_DIR redirects rollback markers into the temp directory so tests stay isolated.
- _common_teardown() deletes the temp directory after each test, preventing cross-test contamination.
- Save test/test_helper/common-setup.bash.
✔️ Awesome, I've got everything!
Great. Double check you saved the file, then move on to writing the tests.
ⓧ I'd like to double check the full code
#!/usr/bin/env bash
_common_setup() {
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
PROJECT_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME}")/.." >/dev/null 2>&1 && pwd)"
PATH="${PROJECT_ROOT}/bin:${PATH}"
TEST_TEMP_DIR="$(mktemp -d)"
export ROLLBACK_MARKER_DIR="${TEST_TEMP_DIR}"
}
_common_teardown() {
rm -rf "${TEST_TEMP_DIR:-}"
}
Write and run your first real tests
Each @test block in BATS uses run to capture exit status and output, then asserts against them. You will cover the five core behaviors: help, missing arguments, unknown flags, and dry-run.
- Create test/deploy.bats with the test harness and the first two tests:
#!/usr/bin/env bats
setup() {
load 'test_helper/common-setup'
_common_setup
}
teardown() {
_common_teardown
rm -f "/tmp/deploy-${USER:-nouser}.lock"
}
@test "deploy.sh prints usage with --help" {
run deploy.sh --help
assert_success
assert_output --partial "Usage: deploy.sh"
}
@test "deploy.sh fails without --env" {
run deploy.sh --version v1.0.0
assert_failure 1
assert_output --partial "ERROR: --env is required"
}
What does this code do?
- setup() runs before every test. It calls _common_setup to load libraries and configure PATH.
- teardown() runs after every test even if it fails. It cleans temp files and removes the lock file to avoid test pollution.
- run deploy.sh --help captures both $status (exit code) and $output (stdout+stderr) for assertion.
- assert_failure 1 checks the exit code was exactly 1. assert_output --partial checks the output contains the expected substring.
- Add the remaining three tests below the existing ones in the same file:
@test "deploy.sh fails without --version" {
run deploy.sh --env staging
assert_failure 1
assert_output --partial "ERROR: --version is required"
}
@test "deploy.sh rejects unknown options" {
run deploy.sh --bogus
assert_failure 1
assert_output --partial "ERROR: unknown option: --bogus"
}
@test "deploy.sh --dry-run prints actions without executing" {
run deploy.sh --env staging --version v1.2.3 --dry-run
assert_success
assert_output --partial "[DRY-RUN] Would deploy version v1.2.3 to staging"
assert_output --partial "[DRY-RUN] Would run health check"
}
What do these tests cover?
- The missing --version test proves both required flags are independently validated.
- The unknown option test confirms the catch-all * case in the while loop works and gives a helpful error.
- The dry-run test is crucial. It proves --dry-run skips real HTTP calls, which means this test works without mocking curl.
- Save test/deploy.bats.
✔️ Awesome, I've got everything!
Great. Confirm the file is saved, then run the suite below.
ⓧ I'd like to double check the full code
Scroll up and confirm your test file contains in order:
- setup() and teardown() functions.
- Five @test blocks: help, missing env, missing version, unknown option, dry-run.
Before you run the suite, what do you expect to see? Five tests all passing, or will something fail?
- Run the full test suite by executing:
./test/bats/bin/bats test/deploy.bats
You should see 5 green checkmarks, one for each @test block, with output like:
1..5
ok 1 deploy.sh prints usage with --help
ok 2 deploy.sh fails without --env
ok 3 deploy.sh fails without --version
ok 4 deploy.sh rejects unknown options
ok 5 deploy.sh --dry-run prints actions without executing
Tests failing or command not found?
- If you see deploy.sh: command not found, check that common-setup.bash adds bin/ to PATH. The PROJECT_ROOT variable must resolve to your project root.
- If you see flock: command not found, install it with sudo apt-get install -y util-linux. The flock command is part of the util-linux package.
- If assert functions are not found, verify your test/test_helper/bats-support and test/test_helper/bats-assert submodule directories are not empty.
- help me debug failing BATS tests
Why separate lib/ from bin/?
If all logic lived in bin/deploy.sh, every test would execute the full script (including main "$@") just to check one function. By placing functions in lib/deploy-lib.sh, a test can source the library and call a single function in isolation. This pattern is exactly how production teams structure testable shell projects.
You now have a tested deploy script with clean separation of concerns. Next up, you will mock curl so your tests can verify health-check and rollback behavior without touching the network.
Mock External Commands and Test Failure Paths
Your 5 passing tests prove that flag parsing works, but deploy.sh does more than parse flags. It calls curl to hit a health-check endpoint and triggers rollback when that check fails.
Testing those paths against a real HTTP endpoint makes your suite slow, flaky, and network-dependent. PATH stubbing solves this by placing a fake curl script earlier on PATH so your deploy script calls the fake instead of the real binary.
In this step, get ready to:
- Create a mock curl that returns controlled HTTP responses via environment variables.
- Write tests covering the health-check success path and the rollback failure path.
- Test that the concurrency lock prevents two simultaneous deploys.
Create the mock curl script
PATH stubbing works because Bash resolves commands by searching directories in PATH left to right. If you prepend a directory containing your own curl script, Bash finds it before the system curl binary.
- Create the mocks directory by running:
mkdir -p test/mocks
- Create the mock curl script by pasting the following into test/mocks/curl:
#!/usr/bin/env bash
# Mock curl: returns controlled responses based on environment variables
# MOCK_CURL_STATUS - HTTP status code to simulate (default: 200)
# MOCK_CURL_BODY - response body to echo (default: empty)
status="${MOCK_CURL_STATUS:-200}"
body="${MOCK_CURL_BODY:-}"
for arg in "$@"; do
if [[ "${arg}" == "-w" ]]; then
# curl -w '%{http_code}' mode: print status code
echo -n "${status}"
exit 0
fi
done
if [[ "${status}" -ge 200 && "${status}" -lt 300 ]]; then
echo "${body}"
exit 0
else
echo "${body}" >&2
exit 22
fi
What does this mock do?
The mock reads MOCK_CURL_STATUS and MOCK_CURL_BODY from the environment. It handles two modes that deploy-lib.sh uses:
- When -w is in the arguments (the health_check function's mode), it prints just the status code.
- Otherwise (the deploy_version function's mode), it echoes the body and exits 0 for success or 22 for errors, matching real curl -f behavior.
- Make the mock executable by running:
chmod +x test/mocks/curl
- Confirm the file exists by running:
ls test/mocks/
You should see curl listed in the output.
Don't see the file?
Make sure you ran mkdir -p test/mocks from your project root (the directory containing bin/, lib/, and test/). help me find my project root directory
Wire the mock into your tests and hit the export wall
The mock exists on disk, but your tests do not use it yet. You need to set the mock's environment variables and prepend test/mocks/ to PATH in the test setup() function.
- In test/deploy.bats, find the existing setup() function:
setup() {
load 'test_helper/common-setup'
_common_setup
}
- Replace it with this version that configures the mock (notice the variables are set without export):
setup() {
load 'test_helper/common-setup'
_common_setup
MOCK_CURL_STATUS="200"
MOCK_CURL_BODY='{"status":"ok"}'
PATH="${BATS_TEST_DIRNAME}/mocks:${PATH}"
}
- Add these two new test blocks at the bottom of test/deploy.bats, after the existing --dry-run test:
@test "deploy.sh succeeds when health check returns 200" {
MOCK_CURL_STATUS="200"
run deploy.sh --env staging --version v1.2.3
assert_success
assert_output --partial "Deploy complete: v1.2.3 is live on staging"
}
@test "deploy.sh triggers rollback when health check returns 503" {
MOCK_CURL_STATUS="503"
run deploy.sh --env staging --version v1.2.3
assert_failure 1
assert_output --partial "Health check failed, triggering rollback"
assert [ -f "${ROLLBACK_MARKER_DIR}/rollback-staging-v1.2.3.marker" ]
}
What do these tests check?
The 200 test confirms that a successful health check lets the deploy complete normally. The 503 test confirms that a failing health check triggers the rollback() function, which writes a marker file to ROLLBACK_MARKER_DIR. Asserting on the marker file proves rollback actually executed, not just that an error message printed.
Before you run the tests, what do you think will happen? The 200 test should pass since the mock defaults to 200 anyway. But the 503 test sets MOCK_CURL_STATUS="503" without export. Will the mock see that value?
- Run the test suite with the --print-output-on-failure flag to see what went wrong on any failure:
./test/bats/bin/bats --print-output-on-failure test/deploy.bats
The 503 test fails. BATS prints the captured $output and you see "Deploy complete" instead of "Health check failed". The mock never received the 503 value.
Why did the mock ignore 503?
BATS run executes the command as a subprocess. Subprocesses only inherit variables that are exported. Without export, MOCK_CURL_STATUS stays local to the BATS process, and the mock script falls back to its default of 200.
- In test/deploy.bats, find this line in setup():
MOCK_CURL_STATUS="200"
MOCK_CURL_BODY='{"status":"ok"}'
- Add export to both lines so they look like this:
export MOCK_CURL_STATUS="200"
export MOCK_CURL_BODY='{"status":"ok"}'
- Now find the 503 test and change its MOCK_CURL_STATUS line from:
MOCK_CURL_STATUS="503"
- To:
export MOCK_CURL_STATUS="503"
- Also add export to the 200 test's MOCK_CURL_STATUS line so it reads export MOCK_CURL_STATUS="200".
- Save test/deploy.bats and run the suite again:
./test/bats/bin/bats test/deploy.bats
All 7 tests pass with green checkmarks. The 503 test now correctly triggers rollback and finds the marker file.
503 test still failing?
Double-check that test/mocks/curl is executable (ls -l test/mocks/curl should show -rwxr-xr-x permissions). Also confirm the PATH line in setup() uses ${BATS_TEST_DIRNAME}/mocks (not a relative path like ./test/mocks). help me debug my failing BATS mock
Test the concurrency lock
Your deploy.sh already has acquire_lock() using flock to prevent two simultaneous deploys. You can test this by launching one deploy in the background (holding the lock), then immediately attempting a second.
- First, update teardown() in test/deploy.bats to clean up background processes. Find the existing teardown():
teardown() {
_common_teardown
rm -f "/tmp/deploy-${USER:-nouser}.lock"
}
- Replace it with this version that also kills any background deploy process:
teardown() {
_common_teardown
if [[ -n "${DEPLOY_BG_PID:-}" ]]; then
kill "${DEPLOY_BG_PID}" 2>/dev/null || true
wait "${DEPLOY_BG_PID}" 2>/dev/null || true
fi
rm -f "/tmp/deploy-${USER:-nouser}.lock"
}
Why clean up background processes?
Without killing DEPLOY_BG_PID in teardown, a background deploy could outlive its test and hold the lock file open. That would cause subsequent tests to fail with spurious lock conflicts.
- Add this concurrency test at the bottom of test/deploy.bats:
@test "deploy.sh exits 75 when another instance holds the lock" {
deploy.sh --env staging --version v1.0.0 --dry-run &
DEPLOY_BG_PID=$!
sleep 0.2
run deploy.sh --env staging --version v2.0.0
assert_failure 75
assert_output --partial "deploy already in progress"
}
How does this test work?
The & launches the first deploy in the background. It acquires the flock and holds it while --dry-run prints its output. The sleep 0.2 gives the background process time to grab the lock before the second instance tries.
The second deploy.sh hits flock -n 200 which fails immediately (non-blocking), so it exits with code 75 (EX_TEMPFAIL from sysexits.h, meaning "try again later").
Before you run, do you expect all 8 tests to pass? The concurrency test exercises a completely different code path from the mock tests.
- Save test/deploy.bats and run the full suite:
./test/bats/bin/bats test/
You should see 8 passing tests with green checkmarks covering flag parsing, dry-run, health-check success, rollback on failure, and concurrency locking.
Concurrency test failing or hanging?
If the test hangs, the background process may not have acquired the lock in time. Try increasing sleep 0.2 to sleep 0.5. If the test fails with a different exit code, confirm that acquire_lock() in bin/deploy.sh uses exit 75 (not exit 1). help me debug my concurrency lock test
✔️ Awesome, I've got everything!
Great. Double check you have saved test/mocks/curl and test/deploy.bats.
ⓧ I'd like to double check the full code
Your test/mocks/curl should contain the full mock script (22 lines) you created in the first substep. Confirm it starts with #!/usr/bin/env bash and ends with exit 22.
Your test/deploy.bats should contain these sections in order:
- setup() with export MOCK_CURL_STATUS, export MOCK_CURL_BODY, and the PATH prepend.
- teardown() with _common_teardown, DEPLOY_BG_PID cleanup, and lock file removal.
- 5 original tests: --help, missing --env, missing --version, unknown option, --dry-run.
- Health check 200 test with export MOCK_CURL_STATUS="200".
- Health check 503 test with export MOCK_CURL_STATUS="503" and the marker file assertion.
- Concurrency test launching a background deploy and asserting exit code 75.
The files bin/deploy.sh, lib/deploy-lib.sh, and test/test_helper/common-setup.bash are unchanged from the previous step.
Your test suite now covers the full deploy lifecycle: flags, dry-run, HTTP success, HTTP failure with rollback, and concurrency safety. Next up, you will wire ShellCheck and BATS into GitHub Actions so every push runs these tests automatically.
Wire ShellCheck and BATS into GitHub Actions
Your test suite catches logic errors locally, and your mock curl keeps tests deterministic. But tests only protect you if they run automatically.
In this step, you will create a GitHub Actions workflow that runs both ShellCheck and BATS on every push. ShellCheck catches bugs BATS cannot (unused variables, quoting issues, portability warnings). BATS catches bugs ShellCheck cannot (logic errors, wrong exit codes, broken rollback).
In this step, get ready to:
- Create a GitHub Actions workflow with lint and test jobs.
- Push to GitHub and verify CI catches a deliberate ShellCheck violation.
- Configure branch protection to block merges on CI failure.
Create the CI workflow file
GitHub Actions workflows live in a special directory at the root of your repository. Each YAML file in that directory defines a pipeline that GitHub triggers automatically on events you specify.
- Create the workflow directory by running these commands:
mkdir -p .github/workflows
- Confirm the directory exists by running:
ls .github/workflows
You should see an empty listing with no errors. The directory is ready for your workflow file.
- Create the workflow file .github/workflows/ci.yml and paste the following content:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install ShellCheck
run: sudo apt-get install -y shellcheck
- name: Run ShellCheck
run: |
find bin/ lib/ -name '*.sh' -print0 | xargs -0 shellcheck --shell=bash
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Run BATS tests
run: ./test/bats/bin/bats test/
What does this workflow do?
The on: block triggers this pipeline on every push to main and on every pull request targeting main.
- The lint job installs ShellCheck and runs it against every .sh file in bin/ and lib/. It catches quoting bugs, unused variables, and portability issues.
- The test job checks out the repo with submodules: recursive so BATS and its helpers are available, then runs the full test suite.
- The two jobs run in parallel. If either fails, the workflow reports a red X on the commit.
Why submodules: recursive?
Your BATS libraries (bats-core, bats-support, bats-assert) are git submodules. By default, actions/checkout@v4 only clones the main repo. Without submodules: recursive, the test/bats/ directory would be empty in CI and every test would fail.
- Save .github/workflows/ci.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file, then move on to pushing it to GitHub.
ⓧ I'd like to double check the full code
Here is the complete .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install ShellCheck
run: sudo apt-get install -y shellcheck
- name: Run ShellCheck
run: |
find bin/ lib/ -name '*.sh' -print0 | xargs -0 shellcheck --shell=bash
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Run BATS tests
run: ./test/bats/bin/bats test/
Push to GitHub and verify CI passes
The workflow only runs when code lands on GitHub. You need to create a remote repository and push your project.
- Go to github.com/new in your browser.
- Name the repository (for example, deploy-testing) and leave it public.
- Click Create repository.
- Back in your terminal, stage everything, commit, add the remote, and push by running these commands:
git add -A
git commit -m "Add CI workflow with ShellCheck and BATS"
git remote add origin git@github.com:[[GITHUB_USER="your-github-username"]]/[[REPO_NAME="deploy-testing"]].git
git branch -M main
git push -u origin main
HTTPS instead of SSH
If you use HTTPS instead of SSH, replace the remote URL with https://github.com/USERNAME/REPO.git. GitHub will prompt for your credentials or personal access token.
Before you check the Actions tab, what do you predict will happen? Both jobs should pass since your scripts already pass ShellCheck and your BATS tests pass locally.
- Open your repository on GitHub in your browser.
- Click the Actions tab at the top of the repository page.
- Click on the latest workflow run (it will be named after your commit message).
You should see two jobs (lint and test) both showing green checkmarks. Your CI pipeline is live.
Seeing a red X on the test job?
The most common cause is missing submodules. Click the failed job, expand the Run BATS tests step log, and look for errors about missing files. Confirm your ci.yml includes submodules: recursive under the checkout step.
If the lint job fails, check for unintended quoting warnings in your scripts. Run shellcheck --shell=bash bin/deploy.sh lib/deploy-lib.sh locally to see the same output CI sees.
Why is my CI workflow failing?
Break something and watch CI catch it
A CI pipeline you never see fail might as well not exist. You need to prove it actually catches problems. Right now you will introduce a deliberate ShellCheck violation, push it, and watch the red X appear.
- In lib/deploy-lib.sh, find this line inside the health_check function:
local env="$1"
- Remove the quotes around $1 so the line looks like this:
local env=$1
Why is this a bug?
Without quotes, if $1 contains spaces or glob characters, Bash splits it into multiple words. ShellCheck flags this as SC2086 (double-quote to prevent globbing and word splitting). BATS would never catch this because your test inputs have no spaces.
- Save lib/deploy-lib.sh, commit, and push by running:
git add lib/deploy-lib.sh
git commit -m "Introduce unquoted variable (deliberate)"
git push
What do you predict will happen this time? The lint job should fail because ShellCheck detects the unquoted variable. The test job should still pass because BATS tests do not exercise inputs with spaces.
- Return to the Actions tab in your browser and click the latest run.
You should see a red X on the lint job and a green checkmark on the test job. Click into the failed lint job to see ShellCheck's error output pointing at the unquoted variable.
Why run both tools?
This is exactly why you need both ShellCheck and BATS in CI. ShellCheck caught a quoting bug that would never surface in your test suite (because test inputs have no spaces). BATS catches logic errors (wrong exit codes, broken rollback) that ShellCheck cannot reason about. Together they cover different classes of defects.
- Revert the violation by restoring the quotes. In lib/deploy-lib.sh, change the line back to:
local env="$1"
- Save, commit, and push the fix by running:
git add lib/deploy-lib.sh
git commit -m "Fix unquoted variable"
git push
- Confirm the Actions tab shows a green run again.
Still seeing a red X after reverting?
Double check that you restored the double quotes exactly: local env="$1". Run shellcheck --shell=bash lib/deploy-lib.sh locally to verify no warnings remain before pushing.
Help me fix my ShellCheck violation.
Add a branch protection rule
CI catching failures is only half the story. Without branch protection, a developer can still merge broken code by pushing directly to main. Adding a protection rule makes passing CI a hard requirement.
- On your repository page in GitHub, click Settings.
- In the left sidebar under Code and automation, click Branches.
- Next to Branch protection rules, click Add rule.
- Under Branch name pattern, type main.
- Check the box labelled Require status checks to pass before merging.
- In the search field that appears, search for and select both lint and test (your workflow job names).
- Click Create at the bottom of the page to save the rule.
What does this protect?
With this rule active, any pull request targeting main must have both the lint and test jobs pass before GitHub allows the merge. No one can bypass a failing pipeline.
This is the same workflow professional DevOps teams use. Code gets pushed, CI validates it, and the branch stays green. You can refactor with confidence because your safety net runs on every change.
Status checks not appearing in the search?
GitHub only shows status checks that have reported at least once in the last week. If your checks do not appear, confirm you have at least one successful workflow run on main (which you do from the green run earlier). Refresh the page and try searching again.
Your CI pipeline is complete. Every push to this repository now runs static analysis and behavioral tests automatically. You can refactor deploy.sh fearlessly because any regression shows up as a red X before it reaches production.
Secret mission
Add Concurrency Safety and Test It
In production, cron jobs or operators can accidentally launch two deploys at the same time. Can you build a reliable lock-holding helper that makes your concurrency test bulletproof, even in slow CI environments?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a free GitHub repository. There are no ongoing costs or paid resources to worry about. Decide whether to keep your resources for Parts 5-6, pause them, or delete them entirely.
Resources you used:
- Local project directory on your Desktop containing bin/, lib/, test/, and .github/ directories.
- Git submodules (bats-core, bats-support, bats-assert) inside test/.
- GitHub repository (remote) with the CI workflow.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with Parts 5-6 of the series.
- Your local project folder stays on your Desktop.
- Your GitHub repository remains available with the CI workflow active on every push.
- GitHub Actions is free for public repositories and includes 2000 minutes per month for private repositories.
Pause - I'll come back to this later
There are no running processes to stop. Your local files and GitHub repository sit idle until you return.
- No services are running locally. The project only executes when you run commands manually.
- GitHub Actions only runs when you push new commits. No pushes means no workflow runs and no usage.
- Everything is ready to pick up exactly where you left off whenever you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to fully clean up.
- Delete your local project folder by running this command (replace the folder name with your actual project directory name):
- Delete any leftover lock files:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]
rm -f /tmp/deploy-${USER:-nouser}.lock
- Delete your GitHub repository:
- Navigate to your repository on GitHub.
- Click Settings in the repository header.
- Scroll down to the Danger Zone section and click Delete this repository.
- Click I want to delete this repository.
- Click I have read and understand these effects.
- Type your repository name to confirm, then click Delete this repository.
Nice Work!
Nice Work!
Nice work! You have built a professional shell testing workflow from scratch. Your deploy script now has automated tests, mocked dependencies, and a CI pipeline that catches regressions before they reach production.
You've learned how to:
- Build a testable deploy script with modular structure (separating logic in lib/ from execution in bin/) and write a BATS test suite covering flag parsing, error paths, and rollback behavior.
- Mock external commands via PATH stubbing so tests run offline and deterministically without hitting the network.
- Wire ShellCheck and BATS into GitHub Actions so every push is automatically validated and regressions are caught before merging.
- Secret Mission: Added flock-based concurrency protection to prevent simultaneous deploys and proved it works with a BATS test exercising parallel execution.
Ready to quiz yourself?