如何在Go lang中模拟exec.Command进行多个单元测试?

如何在Go lang中模拟exec.Command进行多个单元测试?

问题描述:

I just learnt unit testing functions that uses exec.Command() i.e., mocking exec.Command(). I went ahead to added more unit cases, but running into issues of not able to mock the output for different scenarios.

Here is a sample code hello.go I'm trying to test...

package main

import (
    "fmt"
    "os/exec"
)

var execCommand = exec.Command

func printDate() ([]byte, error) {
    cmd := execCommand("date")
    out, err := cmd.CombinedOutput()
    return out, err
}

func main() {
    fmt.Printf("hello, world
")
    fmt.Println(printDate())
}

Below is the test code hello_test.go...

package main

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

var mockedExitStatus = 1
var mockedDate = "Sun Aug 20"
var expDate = "Sun Aug 20"

func fakeExecCommand(command string, args ...string) *exec.Cmd {
    cs := []string{"-test.run=TestHelperProcess", "--", command}
    cs = append(cs, args...)
    cmd := exec.Command(os.Args[0], cs...)
    cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"}
    return cmd
}

func TestHelperProcess(t *testing.T) {
    if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
        return
    }

    // println("Mocked Data:", mockedDate)
    fmt.Fprintf(os.Stdout, mockedDate)
    os.Exit(mockedExitStatus)
}

func TestPrintDate(t *testing.T) {
    execCommand = fakeExecCommand
    defer func() { execCommand = exec.Command }()

    out, err := printDate()
    print("Std out: ", string(out))
    if err != nil {
        t.Errorf("Expected nil error, got %#v", err)
    }
    if string(out) != expDate {
        t.Errorf("Expected %q, got %q", expDate, string(out))
    }
}

func TestPrintDateUnableToRunError(t *testing.T) {
    execCommand = fakeExecCommand
    defer func() { execCommand = exec.Command }()

    mockedExitStatus = 1
    mockedDate = "Unable to run date command"
    expDate = "Unable to run date command"

    out, err := printDate()
    print("Std out: ", string(out))
    if err != nil {
        t.Errorf("Expected nil error, got %#v", err)
    }
    if string(out) != expDate {
        t.Errorf("Expected %q, got %q", expDate, string(out))
    }
}

go test fails for the second test TestPrintDateUnableToRunError...

$ go test hello
Std out: Sun Aug 20Std out: Sun Aug 20--- FAIL: TestPrintDateTomorrow (0.01s)
    hello_test.go:62: Expected "Unable to run date command", got "Sun Aug 20"
FAIL
FAIL    hello   0.017s

Even though I'm trying to set the global mockedDate value inside the test case, it's still getting the global value that it was initialized with. Is the global value not getting set? Or the changes to that global var is not getting updated in TestHelperProcess?

I got the solution for this...

Is the global value not getting set? Or the changes to that global var is not getting updated in TestHelperProcess?

Since in TestPrintDate(), fakeExecCommand is called instead of exec.Command, and calling fakeExecCommand runs go test to run only TestHelperProcess(), it's altogether a new invocation where only TestHelperProcess() will be executed. Since only TestHelperProcess() is called, the global variables aren't being set.

The solution would be to set the Env in the fakeExecCommand, and retrieve that in TestHelperProcess() and return those values.

PS> TestHelperProcess is renamed to TestExecCommandHelper, And few variables are renamed.

package main

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

var mockedExitStatus = 0
var mockedStdout string

func fakeExecCommand(command string, args ...string) *exec.Cmd {
    cs := []string{"-test.run=TestExecCommandHelper", "--", command}
    cs = append(cs, args...)
    cmd := exec.Command(os.Args[0], cs...)
    es := strconv.Itoa(mockedExitStatus)
    cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1",
        "STDOUT=" + mockedStdout,
        "EXIT_STATUS=" + es}
    return cmd
}

func TestExecCommandHelper(t *testing.T) {
    if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
        return
    }

    // println("Mocked stdout:", os.Getenv("STDOUT"))
    fmt.Fprintf(os.Stdout, os.Getenv("STDOUT"))
    i, _ := strconv.Atoi(os.Getenv("EXIT_STATUS"))
    os.Exit(i)
}

func TestPrintDate(t *testing.T) {
    mockedExitStatus = 1
    mockedStdout = "Sun Aug 201"
    execCommand = fakeExecCommand
    defer func() { execCommand = exec.Command }()
    expDate := "Sun Aug 20"

    out, _ := printDate()
    if string(out) != expDate {
        t.Errorf("Expected %q, got %q", expDate, string(out))
    }
}

func TestPrintDateUnableToRunError(t *testing.T) {
    mockedExitStatus = 1
    mockedStdout = "Unable to run date command"
    execCommand = fakeExecCommand
    defer func() { execCommand = exec.Command }()

    expDate := "Unable to run date command"

    out, _ := printDate()
    // println("Stdout: ", string(out))
    if string(out) != expDate {
        t.Errorf("Expected %q, got %q", expDate, string(out))
    }
}

go test results as below... (Purposely failing one test to show that the mock is working properly).

 go test hello
--- FAIL: TestPrintDate (0.01s)
        hello_test.go:45: Expected "Sun Aug 20", got "Sun Aug 201"
FAIL
FAIL    hello   0.018s

Based on the code you've posted, the mockedDate variable doesn't do anything. Neither the test, nor the call to printDate() are utilizing it, so the TestPrintDateUnableToRunError() test performs just like the tests before it.

If you were to add functionality to the printDate() function to return string of "Unable to run date command" (when that is the case), then your condition on line 62 would pass. That said, such checks should be unnecessary, when you have an error in the return values from printDate(). If the returned error is non-nil, the returned output string should be expected to be invalid (or empty, "").

I can't tell how you really want printDate() to fail, but as it stands, there's no way for it to return the values you're expecting in TestPrintDateUnableToRunError().