跪拜 Guibai
← All articles
Shell

Shell Testing Without a Framework: Assert, Mock, and Integrate

By 柒号华仔 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Shell scripts run in CI pipelines, deployment tooling, and infrastructure automation — places where a broken script causes real downtime. A testing discipline that respects shell's constraints (no framework, global state, side-effect-heavy commands) catches regressions without the overhead of porting logic to a general-purpose language.

Summary

Shell testing has unique constraints: functions are rarely pure, global state is the default, and exit codes are a single pass/fail signal. Writing testable shell code means wrapping logic in functions, passing dependencies as explicit parameters, and separating computation from side effects. A hand-written assert library — assert_eq, assert_contains, assert_success, assert_failure, assert_file_exists — provides the core primitives without pulling in a heavy framework.

External dependencies are handled through environment manipulation rather than dependency injection. Replacing PATH so that curl or ssh resolve to mock scripts is the central technique; function-level mocking works by redefining a function after sourcing the original. Time-dependent code becomes testable by accepting an optional timestamp parameter or by overriding the date command via PATH. Network and database calls are mocked by wrapping them in a single function that can be overridden in tests.

Test organization follows a standard tests/ directory with fixtures, a shared assertion library, and a run_all_tests.sh entry point that aggregates results and exits non-zero on failure — exactly what CI systems need. Integration tests run the actual script end-to-end in a temporary environment, verifying that the composed system works. The whole approach fits into GitHub Actions or any CI runner with a single bash invocation.

Takeaways
Shell functions are rarely pure; testing requires isolating side effects rather than assuming referential transparency.
An assert library of six functions — assert_eq, assert_contains, assert_success, assert_failure, assert_true, assert_file_exists — covers the vast majority of shell test assertions.
PATH manipulation is the core mocking technique: prepend a directory of mock scripts so that curl, ssh, or date resolve to controlled replacements.
Time-dependent logic becomes testable by accepting an optional timestamp parameter or by overriding the date command through PATH.
Temporary directories created with mktemp -d and cleaned up via trap EXIT provide per-test file system isolation.
Wrapping external API calls in a single function makes mocking trivial — override one function instead of intercepting every curl invocation.
A run_all_tests.sh entry point that aggregates results and exits non-zero on failure plugs directly into GitHub Actions or any CI system.
Integration tests run the actual script end-to-end in a fake environment, catching edge cases that unit tests miss.
Conclusions

Shell testing's central insight is that environment variables are the dependency-injection mechanism — PATH, LOG_LEVEL, and custom vars replace the constructor injection of OOP languages.

The assert library accumulates results in global variables, which is normally an anti-pattern but is the pragmatic compromise when every test runs in a subshell that can't return structured data.

Mocking by redefining a function after sourcing the original script works but is fragile — it depends on execution order and breaks if the original function is ever made readonly.

Shell testing frameworks like Bats exist but add a dependency; the hand-rolled approach shown here is zero-dependency and fits in a single file, which matters when the script itself must be portable across minimal containers.

Concepts & terms
PATH mocking
Prepending a directory of fake executables to the PATH environment variable so that commands like curl or ssh resolve to controlled mock scripts instead of the real binaries.
Early return
A coding pattern where failure conditions are checked and returned from immediately at the top of a function, avoiding nested if-else blocks and making each failure path independently testable.
Side effect separation
Splitting a function that both computes a value and performs an external action (like an SSH command) into two functions — one pure computation, one side-effect execution — so the logic can be tested without triggering real operations.
Test fixtures
Pre-prepared input files stored in a tests/fixtures/ directory that provide known-good and known-bad data for test cases, kept separate from production configuration.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗