Unit Testing Exec Command in Golang

I’ve been writing unit tests for jthomperoo/custom-pod-autoscaler, and I needed to write unit tests for some functions that used the exec.Command in Go. Outlined below is the method for writing these tests. All code in this article can be found in jthomperoo/test-exec-command-golang .

Inspired by and built on this article https://npf.io/2015/06/testing-exec-command/ .

Take this simple function that we want to test:

package funshell

import (
	"bytes"
)

// ShellCommand prints out a fun shell command!
func ShellCommand() (*bytes.Buffer, error) {
	cmd := exec.Command("echo", "'fun!'")

	// Set up byte buffers to read stdout
	var outb bytes.Buffer
	cmd.Stdout = &outb
	err := cmd.Run()
	if err != nil {
		return nil, err
	}

	return &outb, nil
}

We can look at how exec_test.go works for some inspiration, they use a very clever technique to test their exec commands.
They do two things, they create a fake function to call instead of executing the shell command, for example:

// TestShellProcessSuccess is a method that is called as a substitute for a shell command,
// the GO_TEST_PROCESS flag ensures that if it is called as part of the test suite, it is
// skipped.
func TestShellProcessSuccess(t *testing.T) {
	if os.Getenv("GO_TEST_PROCESS") != "1" {
		return
	}
	fmt.Fprintf(os.Stdout, testStdoutValue)
	os.Exit(0)
}

Then, they create another function that will point to this fake function which will overwrite exec.Command, for example:

// fakeExecCommandSuccess is a function that initialises a new exec.Cmd, one which will
// simply call TestShellProcessSuccess rather than the command it is provided. It will
// also pass through the command and its arguments as an argument to TestShellProcessSuccess
func fakeExecCommandSuccess(command string, args ...string) *exec.Cmd {
	cs := []string{"-test.run=TestShellProcessSuccess", "--", command}
	cs = append(cs, args...)
	cmd := exec.Command(os.Args[0], cs...)
	cmd.Env = []string{"GO_TEST_PROCESS=1"}
	return cmd
}

This is the clever bit, and it can be a bit confusing to wrap your head around. This function creates a new exec.Cmd, but instead of using the command and args parameters provided to build it, it builds it using go test.
So, when this function is called, which will only happen as part of a Go test, it does the following:

  1. Grabs the go test argument that it is being called with, which will ensure it is in the right context - pointing to the correct binary etc.
  2. Sets the -test.run flag, which will mean it will only run the test function defined above, without running any other tests.
  3. Sets up a flag GO_TEST_PROCESS=1 as the commands environment, which will inform the fake process it is calling that it is being called as part of the test.
  4. Appends the actual command and args it is provided to the end of the call it is making to the faked process.

So we can modify our simple function we are testing to make sure it supports these faked functions:

package funshell

import (
	"bytes"
	"os/exec"
)

type execContext = func(name string, arg ...string) *exec.Cmd

// ShellCommand prints out a fun shell command!
func ShellCommand(cmdContext execContext) (*bytes.Buffer, error) {
	cmd := cmdContext("echo", "'fun!'")

	// Set up byte buffers to read stdout
	var outb bytes.Buffer
	cmd.Stdout = &outb
	err := cmd.Run()
	if err != nil {
		return nil, err
	}

	return &outb, nil
}

We’ve added a new type, which is the command context we can provide this function, allowing us to provide either a fake context, or a real one.
We can now write a unit test which will use the faked shell functions we have created:

package funshell_test

import (
	"fmt"
	"os"
	"os/exec"
	"testing"

	"github.com/jthomperoo/test-exec-command-golang/funshell"
)

const (
	testStdoutValue = "test fun value!"
)

func TestShellCommand_Success(t *testing.T) {
	stdout, err := funshell.ShellCommand(fakeExecCommandSuccess)
	if err != nil {
		t.Error(err)
		return
	}
    
    // Check to make sure the stdout is returned properly
	stdoutStr := stdout.String()
	if stdoutStr != testStdoutValue {
		t.Errorf("stdout mismatch:\n%s\n vs \n%s", stdoutStr, testStdoutValue)
	}
}

// TestShellProcessSuccess is a method that is called as a substitute for a shell command,
// the GO_TEST_PROCESS flag ensures that if it is called as part of the test suite, it is
// skipped.
func TestShellProcessSuccess(t *testing.T) {
	if os.Getenv("GO_TEST_PROCESS") != "1" {
		return
    }
    // Print out the test value to stdout
	fmt.Fprintf(os.Stdout, testStdoutValue)
	os.Exit(0)
}

// fakeExecCommandSuccess is a function that initialises a new exec.Cmd, one which will
// simply call TestShellProcessSuccess rather than the command it is provided. It will
// also pass through the command and its arguments as an argument to TestShellProcessSuccess
func fakeExecCommandSuccess(command string, args ...string) *exec.Cmd {
	cs := []string{"-test.run=TestShellProcessSuccess", "--", command}
	cs = append(cs, args...)
	cmd := exec.Command(os.Args[0], cs...)
	cmd.Env = []string{"GO_TEST_PROCESS=1"}
	return cmd
}

And we can now call this function normally outside out tests by providing the real exec.Command context:

package main

import (
	"log"
	"os/exec"

	"github.com/jthomperoo/test-exec-command-golang/funshell"
)

func main() {
	stdout, err := funshell.ShellCommand(exec.Command)
	if err != nil {
		log.Fatal(err)
	}
	log.Print(stdout.String())
}

From Nate Finch’s article , the benefits of this are outlined here:

  • It’s all Go code. No more needing to write shell scripts.
  • The code run in the excutable is compiled with the rest of your test code. No more needing to worry about typos in the strings you’re writing to disk.
  • No need to create new files on disk - the executable is already there and runnable, by definition.

These tests can be extended and made far more complex, you could even check that the correct command is passed through, or that stdin is being used properly.
For a complete example see this Git repository . Hopefully this will help you out!

Written on November 4, 2019