SRE/Software Engineer hybrid

Mocking exec.Command in Go

Mocking exec.Command in Go
Photo by David Thielen / Unsplash

Testing in Go is probably one of the hardest parts about Go to get right as a new Go developer. If you haven't thought about testing from the beginning, it's hard to add in. Unlike other languages, such as JS (jest.SpyOn), you can't magically replace executions of function calls. In order to test your code, you have to make sure you have well defined interfaces and plumbing to get them there.

One common pitfall I see is with exec.Command. I rarely ever see any sort of testing in place, and when I do, I normally see something like below:

package main

type execer interface {
  // insert other [exec.Command] methods here...
  Run() error
}

type cmd struct {
  // exec can be swapped out with a real or mock implementation
  exec execer
}

func main() {
  // Run the thing
  (&cmd{}).exec.Run()
}

This is tiring to implement and increases the complexity of the underlying code. While that's a common pattern for mocking in Go, we can do better when it comes to executing binaries. Why?

  • When executing binaries, we're always going to be using the same executor globally. exec.Command or exec.CommandContext – they all operate on the host.
  • Globals can be okay – within reason. You'll see later!

cmdexec

I've written this package about three-four different times at the various places I've worked now. Finally, I sat down and got it outside of one of my open source projects: https://github.com/jaredallard/cmdexec. The goal of cmdexec is to be drop-in compatible (where possible) with exec.Command. Let's look at an example program:

// package.go
package name

func RunACommand() {
  cmd := exec.Command("echo", "hello", "world")
  out, err := cmd.Output()
  if err != nil { // handle }
  fmt.Println(string(out))
}

If we ran this, by importing it in main.go, we'd get the following output:

$ go run main.go
hello world

Swapping in cmdexec, would get us the same thing. Notice the difference:

// ...
import "github.com/jaredallard/cmdexec"
// ...

func RunACommand() {
  // ...
  cmd := cmdexec.Command("echo", "hello", "world")
  // ...
}

Now, if we tried to test this, we could simply do the following:

// package_test.go
package name_test

func ExampleRunACommand() {
    mock := cmdexec.NewMockExecutor(&cmdexec.MockCommand{
        Name:   "echo",
        Args:   []string{"hello", "world"},
        Stdout: []byte("hello world\n"),
    })
    cmdexec.UseMockExecutor(t, mock)

    name.RunACommand()

    // Output:
    // hello world
    //
}

Notice that "hello world" wasn't actually ran, but TestACommand() thinks that it did and would process input as if it did.

This is all done by swapping out the executor at test time only. Normally, the standard exec.Command will be called under the hood. However, using UseMockExecutor() will change it during the execution of this test. This does present some downsides, such as no parallel test support right now (it'll fail if detected to be ran in parallel), but given the nature of command execution – these tradeoffs seem warranted.

That's it! There's a lot more offered by my package, such as Stdin validation, so be sure to check out the pkg.go.dev documentation for it!

Want emails when I post?

No spam, no sharing to third party. Only you and me.

Member discussion