Mocking and testing the exec.Cmd
You used the exec.Cmd
to call an external program. But how to write the code so it is testable?
It was not obvious to me even when I found the answer. In fact I was scratching my head for a while until I fully got it.
The tldr; that floats around the internet is usually something like this:
- Tested function should take
*exec.Cmd
. - To mock the
exec.Cmd
wrap it with function that will invoke the specific test in the same file.func mockCmd() *exec.Cmd { args := []string{"-test.run=TestProcessHelper"} cmd := exec.Command(os.Args[0], args...) cmd.Env = append(cmd.Env, "GO_WANT_HELPER_PROCESS=1") return cmd }
- And trap the execution in that
TestProcessHelper
and perform operations to be tested.func TestProcessHelper(t *testing.T) { if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { return } // Do what you want the mocked process to do. os.Exit(0) }
This approach works. Now it is time to dissasemble the concept with an example. I will show how this techinque works, step by step.
Sample program using exec.Cmd
#
Consider the following example of a code that lists “commands” using the network.
func lsof() *exec.Cmd {
return exec.Command("lsof", "-i", "-n", "-P", "-F", "-nP")
}
func connectedProcesses(cmd *exec.Cmd) ([]string, error) {
commands := make([]string, 0)
out, err := cmd.Output()
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
// command lines start with 'c'
// avoid duplicates
if strings.HasPrefix(line, "c") && !slices.Contains(commands, line[1:]) {
commands = append(commands, line[1:])
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return commands, nil
}
func TestCmdMocking(t *testing.T) {
t.Run("test connectedProcesses", func(t *testing.T) {
result, err := connectedProcesses(lsof())
assert.NoError(t, err)
assert.NotNil(t, result)
fmt.Println(result)
})
}
The program will return a slice of unique commands that use the network.
- Take the
Output()
of thelsof -i -n -P -F -nP
. - Read the output.
- Build an unique slice of lines that start with the
c
character and store incommands
.
A sample result will of course depend on the OS and software you are running:
[loginwindow rapportd identityservicesd ...]
Writing the mock function.#
We need to change the behavior of the lsof()
function. There are 2 requirements.
mockLsof
must still returnexec.Cmd
.- Instead of calling
lsof
tool, output and behavior must be fully controlled by the test.
The mockLsof
function should look like this:
func mockLsof() *exec.Cmd {
args := []string{"-test.run=TestProcessHelper", "lsof"}
cmd := exec.Command(os.Args[0], args...)
cmd.Env = append(cmd.Env, "GO_WANT_HELPER_PROCESS=1")
return cmd
}
Mock still returns exec.Cmd
but it no longer calls the lsof
command but whatever is behind the os.Args[0]
.
On top of that the arguments to the new command are -test.run=TestProcessHelper
and lsof
. By attaching the debugger, we can inspect what command is going to be called in detail (cmd.Args
).
[
"/Users/me/work/blog/__debug_bin1727994439",
"-test.run=TestProcessHelper",
"lsof"
]
Lets disect these arguments before going forward.
“/Users/me/work/blog/__debug_bin1727994439”#
The __debug_bin1727994439
binary is an intermediate binary created by Go during the test process to compile and execute your tests efficiently. Once the tests are done, this binary is usually deleted.
Here’s how it works:
- Compilation: Go compiles your test files and any other necessary files into the __debug_bin binary.
- Execution: Go then runs the __debug_bin binary, executing the test functions and reporting the results.
- Cleanup: After the tests are executed, the __debug_bin binary is typically deleted. It’s a temporary file used solely for the purpose of test execution.
“-test.run=TestProcessHelper”#
You can check yourself what the -test.run
flag does by calling the binary (you need to pause the test with debugger).
./__debug_bin3519903229 -help
[...]
-test.run regexp
run only tests and examples matching regexp
[...]
By calling the temporary binary with the -test.run=TestProcessHelper
we specifically say we are going to execute a single test.
lsof
is an argument passed to the TestProcessHelper
function that we are going to implement now.
TestProcessHelper and calling itself.#
So far we replaced calling lsof
command with execution of the TestProcessHelper
function in the same test suite.
Time to implement TestProcessHelper
func TestProcessHelper(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
// os.Args[0] is the temp file created by go test
// go-by-tests/__debug_bin2203103022
// os.Args[1] -test.run=TestProcessHelper
// os.Args[2] whatever we pass to the command
if len(os.Args) > 2 && os.Args[2] == "lsof" {
fmt.Fprint(os.Stdout, "cCOMMAND1\ncCOMMAND2\ncCOMMAND3")
return
}
os.Exit(0)
}
We set the GO_WANT_HELPER_PROCESS=1
env in the mockLsof
to make sure it executes only when needed. Otherwise, futher logic would execute when running go test
.
The function detects if lsof
is passed as positional argument and mocks it’s behavior.
This allows us to mock the lsof
behavior without mocking the entire exec.Cmd
functionality.
Now it is the time to run the test itself:
t.Run("mocked connectedProcesses", func(t *testing.T) {
result, err := connectedProcesses(mockLsof())
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, []string{"COMMAND1", "COMMAND2", "COMMAND3PASS"}, result)
})
In the TestProcessHelper
we write cCOMMAND1\ncCOMMAND2\ncCOMMAND3
to os.Stdout
. Consequetly we expect the adequate output from the connectedProcesses
function. Note the PASS
at the end that is added at the end of the stdout. This is because we actually have run a test.
Conclusion#
Next time you find yourself needing to test code that interacts with external processes, remember this approach. With a little bit of setup and understanding, you can ensure your code remains testable and maintainable even in the face of external dependencies.
full code example:
package main
import (
"bufio"
"bytes"
"fmt"
"os"
"os/exec"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func lsof() *exec.Cmd {
return exec.Command("lsof", "-i", "-n", "-P", "-F", "-nP")
}
func mockLsof() *exec.Cmd {
args := []string{"-test.run=TestProcessHelper", "lsof"}
cmd := exec.Command(os.Args[0], args...)
cmd.Env = append(cmd.Env, "GO_WANT_HELPER_PROCESS=1")
return cmd
}
func mockCmd() *exec.Cmd {
args := []string{"-test.run=TestProcessHelper"}
cmd := exec.Command(os.Args[0], args...)
cmd.Env = append(cmd.Env, "GO_WANT_HELPER_PROCESS=1")
return cmd
}
func connectedProcesses(cmd *exec.Cmd) ([]string, error) {
commands := make([]string, 0)
out, err := cmd.Output()
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
// command lines start with 'c'
// avoid duplicates
if strings.HasPrefix(line, "c") && !slices.Contains(commands, line[1:]) {
commands = append(commands, line[1:])
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return commands, nil
}
func TestProcessHelper(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
// os.Args[0] is the temp file created by go test
// go-by-tests/__debug_bin2203103022
// os.Args[1] -test.run=TestProcessHelper
// os.Args[2] whatever we pass to the command
if len(os.Args) > 2 && os.Args[2] == "lsof" {
fmt.Fprint(os.Stdout, "cCOMMAND1\ncCOMMAND2\ncCOMMAND3")
return
}
fmt.Fprint(os.Stdout, os.Args[1:])
os.Exit(0)
}
func TestCmdMocking(t *testing.T) {
t.Run("basic test", func(t *testing.T) {
cmd := mockCmd()
output, err := cmd.Output()
assert.NoError(t, err)
assert.Equal(t, "[-test.run=TestProcessHelper]", string(output))
})
t.Run("test connectedProcesses", func(t *testing.T) {
result, err := connectedProcesses(lsof())
assert.NoError(t, err)
assert.NotNil(t, result)
})
t.Run("mocked connectedProcesses", func(t *testing.T) {
result, err := connectedProcesses(mockLsof())
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, []string{"COMMAND1", "COMMAND2", "COMMAND3PASS"}, result)
})
}