Shmock: Easy Testing of Javascript Code Calling Shell Commands

Bun recently shipped it's new Bun Shell feature, an easy and ergonomic way to call into shell programs from Bun.

This came at a perfect time, since I was looking to migrate from Bash scripts for a micro VPN service I am building. I decided to give Bun and Bun Shell a try.

But how do you test code calling into Bun Shell? Bun Shell doesn't come with any hooks to inject or mock out specific functions or commands. And while mocking the entire $ function might be possible, it would mock out too much. I just wanted to mock -- and verify the invocation of -- specific command line tools iptables, ip or wg.

Introducing Shmock. Shmock is a unit testing framework for easy mocking of shell commands.

 1
 2import { mount, unmountAll } from 'shmock';
 3import { test, mock, afterAll, expect } from 'bun:test';
 4import { $ } from 'bun';
 5
 6const commandMock = mount('some-command', mock());
 7
 8test('shmock can verify command invocation', async () => {
 9  await $`some-command`;
10  expect(commandMock).toHaveBeenCalled();
11});
12
13afterAll(async () => {
14  await unmountAll();
15});

If you would like to try it out for yourself, you can get it from GitHub right now:

1bun add --dev git+https://github.com/DanielBaulig/shmock.git

How it works

Shmock has two primary components: a server and a client. The server runs as part of you test runner and gets spun up as part of your first call to mount.

The server will listen for incoming connections on a unix domain socket and once it receives a connection will await messages informing it about command invocations.

The server will cross reference these command invocation messages with mounted mocks and call the corresponding mock implementations. The return value as well as any stdout and stderr IO is sent back to the invoking client over the unix domain socket connection.

The client on the other hand is just a small script that connects to the servers unix domain socket at a well known location and informs the server about it's invocation. It provides a list of the arguments to the server and waits for stderr and stdout IO as well as the final exit code to terminate it's process with.

A call to shmocks mount function "mounts" the client script with a given command name by linking it into a mock directory that was previously prepended to the PATH environment variable.
Any invocations to the command will then look for the command in that prepended directory first, find the client link and invoke the client instead of the actual command.

Cleaning up all mounted mocks is important so shmock knows it can spin down it's server and doesn't prevent the test runner from exiting.

Considerations

Compatebility

Shmock was written and tested with Bun in mind, but should work with other JS runtimes supporting the node fs and net APIs, too. Shmock itself currently does not have any dependencies on Bun specific modules.

PATH

As of the time of writing, calling $.env({ PATH: someChangedPath }) will not actually change the PATH variable that Bun Shell uses for looking up commands in it's environment. This means that Shmock can't programmitcally and automatically update Bun Shell's PATH for it's mock mounting directory.

A bug was filed with the Bun project, but until that is resolved it is necesary to provide the amended PATH to the bun test invocation:

1PATH=.shmocks:$PATH bun test

Add this to your package.json as a script for convenience

1{
2  "scripts": {
3    "test": "PATH=shmocks:$PATH bun test"
4  }
5}

And then call the script like follows:

1bun run test

Performance

Shmock invocations are slow. At the time of writing, the shmock client is a Typescript script that upon invocation spins up a whole new bun process for execution. Even with all it's performance optimizations spinning up a bun process takes dozens of milliseconds. So this is how long every single shmock invocation will take. For my needs, this is currently good enough.

One way to improve on this should this become necesary would be to rewrite the client in a language that compiles to a binary like C or Rust. This would significantly improve invocation times.

Conclusion

Please let me know if you run into any issues. If you would like to support my work, consider sponsoring me on GitHub.