Shell Testing Without a Framework: Assert, Mock, and Integrate
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.
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.
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.