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:

  1. Tested function should take *exec.Cmd.
  2. 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
    }
    
  3. 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.

  1. Take the Output() of the lsof -i -n -P -F -nP.
  2. Read the output.
  3. Build an unique slice of lines that start with the c character and store in commands.

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.

  1. mockLsof must still return exec.Cmd.
  2. 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:

  1. Compilation: Go compiles your test files and any other necessary files into the __debug_bin binary.
  2. Execution: Go then runs the __debug_bin binary, executing the test functions and reporting the results.
  3. 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)
	})

}