Go
golang

GoのテストでOSコマンド実行だけをモックする(変態編)

More than 1 year has passed since last update.

記事「Testing os/exec.Command」が割とイカれているのですが、日本語では読んだことがない内容だったので紹介します。

OSのコマンド実行を含むGoコード、どうテストする?

Goをシェルスクリプト代わりくらいのつもりで書いていると、OSコマンドをバンバン実行するようなGoコードが作られたりします。それ自体は悪いことではないと思うのですが、テストを書きたいと思ったときに途方に暮れたりしませんか?(僕がGo初心者なせいかもしれませんが)

コマンド実行部分を構造体のメソッドにして構造体の埋め込みで差し替えられるようにしておく、みたいな方針が綺麗な実装なのかもしれませんが、こっちはシェルスクリプトのノリで書きたいんであって、そんなオシャレな書き方がしたいわけじゃないんですよ!(←意識が低い)

意識低くコードを書きたいけどテストはしたい!そんなことを考えていた私は上記URLの記事を見て衝撃を受けました。その方針を紹介します。

OSコマンド実行だけをモックするトリック

上記の記事のアイデアを一言で言えば、OSコマンド実行自体を差し替えるのではなく、実行するバイナリを自分自身に差し替えて任意の結果を返せるようにする、というものです。

概要としては次のような処理になります。

  • 実コード上で exec.Command() をグローバル変数 execCommand 経由で実行するようにする(モック可能にするため)
  • テストコードに exec.Command() のモック関数 fakeExecCommand を準備
    • 内部的に exec.Command()を呼び出し、その第一引数にos.Args[0](=テストバイナリ)を指定する。
    • 第1引数として -test.run=TestHelperProcess をつけて関数 TestHelperProcess を明示的に呼び出す
    • 本来のコマンドライン引数をexec.Command()引数末尾に追加する
    • 環境変数 'GO_WANT_HELPER_PROCESS' をセットする
  • テスト関数内で execCommand = fakeExecCommand として exec.Command() をモックする
  • TestHelperProcessというモック用ヘルパー関数を作る。
    • 環境変数 GO_WANT_HELPER_PROCESS が未定義なら常に成功するテストとして振る舞う
    • 環境変数 GO_WANT_HELPER_PROCESS が定義されていたらモック用関数の意味、テストに即した値を返すようにする。また、 os.Exit(0) で即座に終了する

go testの裏側ではテストコードを含むテストバイナリがビルドされています。また、コマンドラインオプションを受け取って特定のテストだけ実行するようなことも可能です。これを利用すれば、任意の結果を返すコマンドが組み立てられるというわけです。

邪道すぎる方針のような気がしますが、個人的には面白いハックだと思いました。

コード例

上記ページで紹介されているものをベースに、もう少し現実的な例を作ってみました。

ifconfig.go
package ifconfig

import (
    "os/exec"
    "regexp"
    "strings"
)

var execCommand = exec.Command

var ifConfigHeaderPattern = regexp.MustCompile(
    `\A(\w+):`,
)

func GetInterfaces() ([]string, error) {
    cmd := execCommand("ifconfig")
    out, err := cmd.CombinedOutput()
    if err != nil {
        return nil, err
    }
    interfaces := make([]string, 0)
    for _, line := range strings.Split(string(out), "\n") {
        if matches := ifConfigHeaderPattern.FindStringSubmatch(line); matches != nil {
            interfaces = append(interfaces, matches[1])
        }
    }
    return interfaces, nil
}

ifconfig_test.go
package ifconfig

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

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
}

const stub = `lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
    options=3<RXCSUM,TXCSUM>
    inet6 ::1 prefixlen 128
    inet 127.0.0.1 netmask 0xff000000
    inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1
    nd6 options=1<PERFORMNUD>
gif0: flags=8010<POINTOPOINT,MULTICAST> mtu 1280
stf0: flags=0<> mtu 1280`

func TestGetInterfaces(t *testing.T) {
    execCommand = fakeExecCommand
    defer func() { execCommand = exec.Command }()
    interfaces, err := GetInterfaces()
    if err != nil {
        t.Errorf("Expected nil error, got %#v", err)
    }
    expected := []string{"lo0", "gif0", "stf0"}
    if !reflect.DeepEqual(expected, interfaces) {
        t.Errorf("Expected %#v, got %#v", expected, interfaces)
    }
}

func TestHelperProcess(t *testing.T) {
    if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
        return
    }
    fmt.Fprint(os.Stdout, stub)
    os.Exit(0)
}

ifconfigコマンドの実行結果を加工する関数のテストを行う例です。コマンド実行の結果をstubで置き換えることで、それ以降の処理のテストができるというわけです。

実用的か?

この方法、実コード側の変更量が非常に少ないのは良い点だと思います。一方で、テストコード側が冗長で何をしているかパッと見で理解できないので、そこは大きな減点になるでしょう。僕がコードレビュアーなら却下すると思います。

上記のコードなら、コマンド実行の結果をparseする関数を作って、parse関数のテストを行うくらいが現実的な落としどころではないでしょうか。

ただ、テスト中にテストバイナリ自体を利用するという発想が独創的で面白いと思ったので紹介してみました。