POSIX compliant[1] spies and stubs for shell unit testing
Features at a glance:
- Create spies for any command or function in the shell environment
- Stub the stdout, stderr, and return value of spies
- See the call count and check arguments passed to spies
- Integrates with the shunit2 testing framework
- Why Unit Test Shell Scripts?
- Docker Image
- Usage
- Contributing
- API Reference
- A Word On Shell Portability
Like other scripting languages, shell scripts can become complex and difficult to maintain over time. Unit tests help to avoid regressions and verify the correctness of functionality, but where do spies come in?
Spies are useful for limiting the dependencies and scope of a test. Code that utilizes system binaries or shell functions can be tested without running the underlying implementations, allowing tests to focus solely on the system under test. To see this in action, see examples/renamer
The benefits of spies are even greater when testing code that relies on a network. For an example of using spies to stub curl
and make unit tests completely offline, see examples/coverfetch
Shpy is available as shpy/shpy on Docker Hub. The latest master node is published as shpy/shpy:latest
, while tagged releases are available as shpy/shpy:1.0.0
. To use kcov
, append -kcov
to the tag or use the kcov
tag for the latest master node
To use the shpy image, mount your code into /app
and specify the command you want to run. When using kcov, you can also mount /coverage
and output your coverage reports to that directory
docker --rm -v$PWD:/app:ro shpy/shpy:1.0.0 zsh /app/tests/run_my_tests.sh
# ^-your project ^--------your command---------
The following scripts and binaries are provided by this image
Name | Type | Location |
---|---|---|
shpy | script | /shpy/shpy |
shpy-shunit2 | script | /shpy/shpy-shunit2 |
shunit2 | script | /usr/local/bin/shunit2 |
ash | binary | /bin/sh |
bash | binary | /bin/bash |
checkbashisms | binary | /usr/bin/checkbashisms |
dash | binary | /usr/bin/dash |
mksh | binary | /bin/mksh |
shellcheck | binary | /usr/local/bin/shellcheck |
zsh | binary | /bin/zsh |
Let's try out shpy! If you don't want to install shpy locally you can run the official Docker image like so:
docker run -it --rm shpy/shpy:1.0.0
To use shpy, the SHPY_PATH
environment variable must be set as the path to shpy and the shpy script must be sourced. If you're using the Docker image, SHPY_PATH
is already set and shpy is located at /shpy/shpy
SHPY_PATH=path/to/shpy
. path/to/shpy
Let's create a spy for the touch
command and call it!
createSpy touch
touch my-new-file
ls my-new-file # No such file or directory, touch wasn't actually called
The call to touch
was stubbed out with a test dummy in place of the actual implementation. Spies record data about the calls made to them, allowing you to check the call count or call args
getSpyCallCount touch # 1
wasSpyCalledWith touch my-new-file # true
wasSpyCalledWith touch my-old-file # false
getArgsForCall touch 1 # my-new-file
Spies can also simulate successful or unsuccessful calls, like so:
createSpy -o 'call me once, shame on you' -e '' -r 0 \
-e 'call me twice, shame on me' -o '' -r 1 touch
touch my-new-file # outputs "call me once, shame on you" to stdout, returns true
touch my-new-file # outputs "call me twice, shame on me" to stderr, returns false
When developing tests for complex functions with long chained calls, source all of them and use spy with -u flag. The flag will unset declared function, so complex functions can be tested with a mix of spies and original functions.
source my_script.sh # contains complex_function that calls file_check and directory_check
createSpy -o 'spy test' -u file_check
complex_function # complex_function calls file_check, which is mocked to output "spy test"
# Without -u, createSpy would warn that file_check is already declared and wouldn't be mocked
When you're done playing with shpy, it's only polite to clean up after yourself
cleanupSpies
touch my-new-file
ls my-new-file # my-new-file, touch was actually called!
Your shell environment is back to normal, and you've got a new tool at your disposal! 🎓
If you'd like to help with shpy's development, or just gain a better understanding of the internals, check out the contributor guidelines
To use shpy in your tests, set SHPY_PATH
to the location of shpy
and source the script:
SHPY_PATH=path/to/shpy
export SHPY_PATH
. path/to/shpy
When using the Docker image, SHPY_PATH
is preset as /shpy/shpy
for convenience
The SHPY_VERSION
environment variable is provided to get the current shpy version
A summary of functions:
Function | Description |
---|---|
createSpy name |
Create a new spy, or reset an existing spy |
createSpy -r status name |
Sets the status code returned when the spy is invoked Can be passed multiple times to set a return value sequence Once the sequence finishes, the last value is always returned |
createSpy -o output name |
Sets output sent to stdout when the spy is invoked Can be passed multiple times to set an output sequence Once the sequence finishes, the last value is always output When used with -e , standard out is written to first |
createSpy -e output name |
Sets output sent to stderr when the spy is invoked Can be passed multiple times to set an error output sequence Once the sequence finishes, the last value is always output When used with -o , standard out is written to first |
createSpy -u name |
A flag to unset declared function, so created spy can run instead otherwise declared function takes precedence Function is not restored after test runs |
createStub name |
Alias for createSpy |
getSpyCallCount name |
Outputs the number of invocations of a spy |
wasSpyCalledWith name [arg ...] |
Returns 0 if the current spy call under examination has the given args |
getArgsForCall name call |
Prints the arguments from a call to a spy (first call is 1) Single-word arguments are always listed without quotes Multi-word arguments are always listed with double-quotes |
examineNextSpyCall name |
Examine the next spy invocation when calling wasSpyCalledWith This causes wasSpyCalledWith to verify the second invocation, etc |
cleanupSpies |
Clean up any metadata on disk or in the environment for a spy |
To use shpy asserts in your shunit2 tests, you must also source the
shpy-shunit2
script:
. path/to/shpy
. path/to/shpy-shunit2
A summary of asserts:
Function | Description |
---|---|
assertCallCount [message] spy count |
Assert the number of times the spy was invoked |
assertCalledWith spy [arg ...] |
Assert the arguments for the first invocation of the spy Subsequent calls will assert for the second invocation, etc |
assertCalledWith_ message spy [arg ...] |
Same as assertCalledWith , with a specific assertion message |
assertCalledOnceWith spy [arg ...] |
Assert the spy was called once and given the specified arguments |
assertCalledOnceWith_ message spy [arg ...] |
Same as assertCalledOnceWith , with a specific assertion message |
assertNeverCalled [message] spy |
Assert the spy was never invoked |
Use the tearDown
hook provided by shunit2 to remove all spies after each test
tearDown() {
cleanupSpies
}
shpy relies on portable but more modern shell features, such as the local
keyword. To be clear, shpy does not use any Bashisms