| name | shell-tdd |
| description | Required test-driven development practice for writing shell scripts |
Test-Driven Development (TDD) for Shell Scripts
This rule defines best practices for AI assistants to follow when writing shell scripts using test-driven development (TDD). The AI should create shell scripts that are testable with shunit2, separate concerns, avoid side effects when being sourced, and allow functions to be tested in isolation. Following these guidelines ensures scripts can be reliably tested and maintained.
TDD Workflow for AI Assistants
When asked to create shell scripts, the AI should follow this test-driven workflow:
- Write test first: Before implementing functionality, write tests that define the expected behavior
- Run tests to see them fail: Verify tests correctly identify missing functionality
- Implement minimum code to pass: Create just enough functionality to pass the tests
- Run tests to confirm pass: Verify the implementation satisfies the requirements
- Refactor code: Improve the implementation while maintaining test coverage
- Repeat: Iterate for each new feature or requirement
Example TDD Approach
. "$(dirname "${0}")/../calculator.sh"
test_add() {
result=$(add 5 3)
assertEquals "Addition should work correctly" "8" "$result"
}
test_subtract() {
result=$(subtract 10 4)
assertEquals "Subtraction should work correctly" "6" "$result"
}
. "$(dirname "${0}")/shunit2"
add() {
echo $(($1 + $2))
}
subtract() {
echo $(($1 - $2))
}
if [ "${0##*/}" = "calculator.sh" ]; then
echo "Calculator utility"
fi
Core Principles for Testable AI-Generated Shell Scripts
1. Entry Point Protection
Always protect the main execution code with a conditional that checks if the script is being executed directly:
if [ "${0##*/}" = "script_name.sh" ]; then
main "$@"
fi
Where "script_name.sh" is the actual filename of your script. This works because:
- When executed directly:
$0 contains the script path/name
- When sourced:
$0 contains the name of the parent script doing the sourcing
This allows your script to be sourced for testing without executing its main behavior.
2. Function-Based Design
Structure scripts as collections of well-defined functions:
#!/bin/sh
add_numbers() {
echo $(( $1 + $2 ))
}
main() {
local result
result="$(add_numbers 5 10)"
echo "The result is: ${result}"
}
if [ "${0##*/}" = "example.sh" ]; then
main "$@"
fi
3. Avoid Global Side Effects
- Never use
exit outside of the main execution block
- Don't modify the environment in ways that can't be undone
- Use local variables when possible to avoid leaking state
validate_input() {
if [ -z "${1}" ]; then
echo "Error: Input required" >&2
exit 1
fi
}
validate_input() {
if [ -z "${1}" ]; then
echo "Error: Input required" >&2
return 1
fi
return 0
}
4. Environment Preservation
Save and restore environment state when necessary:
backup_file() {
_original_dir="$(pwd)"
cd "${1}" || return 1
cp "${2}" "${2}.bak" || return 1
cd "${_original_dir}" || return 1
return 0
}
5. Testable I/O Handling
Make input/output operations testable by:
- Allowing output redirection
- Making file paths configurable
- Providing functions that can accept alternate inputs
process_data() {
cat /etc/config.conf | grep "pattern"
}
process_data() {
config_file="${1:-/etc/config.conf}"
grep "pattern" "${config_file}"
}
6. Mock-Friendly External Commands
Design for easy mocking of external commands in tests:
git_sync() {
git clone "${1}" "${2}" || return 1
return 0
}
update_repo() {
repo_url="${1}"
target_dir="${2}"
git_sync "${repo_url}" "${target_dir}"
}
Implementation Guidelines for Testing with shunit2
When the AI generates a shell script, it should:
- Always implement test files alongside production code
- Generate appropriate test directory structure:
project/
├── scripts/
│ └── example.sh
└── tests/
├── common.sh
└── unit/
└── example_test.sh
- Create comprehensive test functions for each unit of functionality
- Build proper test fixtures with setUp and tearDown functions
- Use appropriate assertions from shunit2
- Write high-quality tests that are reliable, fast, and easy to maintain
Example Test Setup
source_script() {
_ORIGINAL_ENV_VARS="$(env)"
. "${1}"
if [ $? -ne 0 ]; then
echo "Error: Failed to source ${1}" >&2
return 1
fi
return 0
}
Example Test File
#!/bin/sh
. "$(dirname "${0}")/common.sh"
source_script "$(dirname "${0}")/../example.sh"
test_add_numbers() {
result="$(add_numbers 5 7)"
assertEquals "Addition should work correctly" "12" "${result}"
}
. "$(dirname "${0}")/shunit2"
Common Pitfalls
-
Unconditional exits: Using exit outside the main execution block will terminate test suites
cleanup() {
if [ ! -f "${1}" ]; then
echo "Error: File not found" >&2
exit 1
fi
}
-
Hardcoded environment assumptions: Making assumptions about the environment
process_files() {
for file in *.txt; do
process_file "${file}"
done
}
process_files() {
dir="${1:-.}"
for file in "${dir}"/*.txt; do
process_file "${file}"
done
}
-
Global state modifications: Modifying global state without restoring it
set_environment() {
set -e
DEBUG=
}
() {
_original_debug=
DEBUG=
() {
DEBUG=
}
}
Implementation Checklist
When implementing shell scripts, always:
- ✅ Write test cases first before implementing functionality
- ✅ Create a main function and protect it with entry point detection
- ✅ Decompose logic into testable functions with single responsibilities
- ✅ Use return codes instead of exit for error handling
- ✅ Parameterize file paths and environment assumptions
- ✅ Include clear function documentation with args and return values
- ✅ Generate complete test structure with common.sh and test files
- ✅ Include examples of test mocking for external dependencies
Real-World Test Example
#!/bin/sh
. "$(dirname "${0}")/../common.sh"
setUp() {
TEST_DIR="$(mktemp -d)"
cd "${TEST_DIR}" || fail "Failed to change to test directory"
echo "test content" > test_file.txt
}
tearDown() {
cd / || fail "Failed to change directory"
rm -rf "${TEST_DIR}"
}
source_script "$(dirname "${0}")/../../my_script.sh"
test_backup_file() {
backup_file "${TEST_DIR}" "test_file.txt"
assertTrue "Backup file should exist" "[ -f 'test_file.txt.bak' ]"
assertEquals "Backup content should match original" \
"$(cat test_file.txt)"
}
.