Mocking sourced zsh functions
Lately I had to write a test for a zsh function. I wanted to mock some of the heavy calls inside it, and it got a bit more complicated than expected. So as to not fall into the same trap (pun intended, you'll see why shortly), I decided to document it, for future me.
The setup
For the sake of example, imagine I have two functions in my shell (not my real ones, but close enough).
get-weatherdoes an HTTP call to a public endpoint and prints the raw JSON it gets back.get-temperaturecalls it and usesjqto extract the temperature and convert it from Fahrenheit to Celsius.
get-temperature and get-weather are zsh autoloaded function; this means they
are always available in my CLI, but only loaded on demand. You don't need to
know that to understand the rest of the post, but zsh autoloaded functions are
so neat I wanted to share the info.
# /usr/local/share/zsh/site-functions/get-temperature
local fahrenheit=$(get-weather | jq -r .temperature)
printf '%.0f\n' $(( (fahrenheit - 32) * 5 / 9 ))
I'm using bats as the test runner, so
a naive test for get-temperature would be:
@test "get Celsius temperature" {
run zsh -c 'get-temperature'
[[ "$output" == "21" ]]
}
Of course, this test would probably fail, as I'm not fixing the output of
get-weather and thus depends on real live weather data.
What I really want to test is the Fahrenheit to Celsius conversion. So I want to
mock get-weather so it returns a fixed temperature in Fahrenheit, so I can
assert that get-temperature gives me the right Celsius value.
The naive mock
The easiest way to mock a method is to re-define a method with the exact same
name but a fixed return value. I created a dedicated mock.zsh file to store
it, and sourced it before the call.
# mock.zsh
function get-weather() {
echo '{"temperature": 64.4}';
}
@test "get Celsius temperature" {
run zsh -c 'source ./mock.zsh; get-temperature'
[[ "$output" == "18" ]]
}
✅ This pass.
The original temperature is now fixed, and my test actually tests the conversion. Perfect!
The refactor
Later on, I realized I don't really need get-weather in every shell. I only
need it in get-temperature. All I need in my path is get-temperature.
I moved get-weather to a weather.zsh file, and get-temperature now sources
it when it runs.
# /usr/local/share/zsh/site-functions/get-temperature
source ./weather.zsh
local fahrenheit=$(get-weather | jq -r .temperature)
printf '%.0f\n' $(( (fahrenheit - 32) * 5 / 9 ))
Now get-temperature, the function I want to test, loads its own dependencies.
What about the test?
@test "get Celsius temperature" {
run zsh -c 'source ./mock.zsh; get-temperature'
[[ "$output" == "18" ]]
}
❌ This fails.
Sure, the code is cleaner, but the tests are now broken. As get-temperature
now sources weather.zsh after I mocked it, my mock is now useless.
This is where I realized this is going to be a bit more complex than I anticipated.
Attempt A: a guard in the lib
The first fix I found is to update the weather.zsh to only define the real
function if it isn't already defined.
# weather.zsh
whence get-weather >/dev/null && return 0
function get-weather() {
# ...
}
It works, but I don't like it.
It clutters my code files with instructions that are only relevant when in a test runner. That's poor separation of responsibilities. Worse, it forces me to keep only one function per file, if I want to be able to bail out of executing the file early.
I need a better solution.
Attempt B: mocking source()
What if I mocked source itself? It would source what it is asked, then
sources my mocks on top.
function source() {
# `builtin` calls the real `source`, not my wrapper, which would loop forever.
builtin source "$@"
builtin source ./mock.zsh
}
✅ It works!
Surely, this is future-proof and willalways work, right?
…
…
Right?
Well, it didn't survive another refactor. Let's imagine I now want to add a
constant in weather.zsh because I plan to use it in get-temperature later.
# weather.zsh
typeset FAHRENHEIT_OFFSET=32
function get-weather() {
# ...
}
# /usr/local/share/zsh/site-functions/get-temperature
source ./weather.zsh
local fahrenheit=$(get-weather | jq -r .temperature)
printf '%.0f\n' $(( (fahrenheit - FAHRENHEIT_OFFSET) * 5 / 9 ))
❌ Tests fails now.
Why? Because my redefining source I created a scope around source, and any
variable defined in the scope stays in this scope. The builtin source command
doesn't have this limitation, but my cheap wrapper does.
We need another solution.
Attempt C: a DEBUG trap
The final solution, at least the one I found and am using now, is not to mock
source but to use a zsh feature called a debug trap.
A DEBUG trap is a piece of code that zsh runs before each command:
# Let's define a DEBUG trap
trap 'echo debug' DEBUG
# Now execute some commands
echo one
echo two
The output would be:
debug
one
debug
two
The code passed to the debug trap would get executed before each command. It means in my case I could force zsh to reload my mocks before every command like this:
trap 'source ./mock.zsh' DEBUG
That way, no matter how many times the function under test sources another
file, I will always source my mocks right before executing any command.
@test "get Celsius temperature" {
run zsh -c 'trap "builtin source ./mock.zsh" DEBUG; get-temperature'
[[ "$output" == "18" ]]
}
✅ This works!
As we are using the builtin source, my FAHRENHEIT_OFFSET is still available.
What it costs
Surely, re-mocking everything between every single command adds some overhead. From my calculation, it only added ~4% to the total time of the test file, something I ca definitely live with.
Where I ended up
At the end of the day, I managed to test my function, mock the underlying heavy calls and keep my code clean. There might be an even better solution, but I haven't found it yet.






