Skip to content
Switch branches/tags

Latest commit


Git stats


Failed to load latest commit information.
Latest commit message
Commit time


Build Status Coverage MIT License

POSIX compliant* spies and stubs for shell unit testing

ash bash dash mksh zsh
Ash Build Status Bash Build Status Dash Build Status Mksh Build Status Zsh Build Status

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

Table of Contents

Why Unit Test Shell Scripts?

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

Docker Image

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/
#           ^-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

. 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 # 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

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

API Reference

To use shpy in your tests, set SHPY_PATH to the location of shpy and source the script:

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

shunit2 Integration

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() {

A Word On Shell Portability

shpy relies on portable but more modern shell features, such as the local keyword. To be clear, shpy does not use any Bashisms