つきなみGo

Goで書くテスタブルなCLIツールの作り方

CLIツールをテストする難しさ

ターミナルなどで動作するCLI(コマンドラインインタフェース)ツールは、パッケージを公開して利用してもらうライブラリと比べてテストがしにくいと感じる読者も多いでしょう。

CLIツールは、ファイル/標準入力からの入力や、ファイル/標準出力/標準エラー出力への出力があることが多いです。また、コマンドライン引数やオプション(フラグ)によって変わる挙動のパターンが多いため、網羅的なテストが大変です。

入出力についても単一のファイルを読み書きするだけではなく、ディレクトリごと作成したり、特定のディレクトリ以下を再帰的に読み込むような処理もよくあります。

main関数にすべての処理をすべて書くような作りのCLIツールだと、実際にビルドしてテストスクリプトなどから動かしてテストするしかありません。しかし、せっかくCLIツールをGoで書いているのであれば、テストもGoで書きたいところです。

本稿では、CLIツールをテストしやすい構造にする方法や筆者が作成したテストに便利なライブラリを紹介します。

入出力のテスト

コマンドラインツールは、標準出力や標準エラー出力への出力、標準入力からの入力を行うことが多いです。直接的に、os.Stdoutos.Stderrへ出力したり、os.Stdinから入力を受け付けるとテストがしにくくなります。

os.Stdoutos.Stderrに出力されたものをテストで利用するには、パッケージ変数であるos.Stdoutos.Stderrをテストの時だけ差し替える方法がありますが、テストの並列実行を考えるとあまり良いとはいえません。

また(*testing.T).Prallelメソッドを使ってテストを並列に実行するためには、各テストでパッケージにアクセスするのは避けたいところです。

そこで次のように構造体型を宣言し、出力先や入力元を制御できるようにしておくと良いでしょう。

package mytool

import "io"

type CLI struct {
	Stdout io.Writer
	Stderr io.Writer
	Stdin  io.Reader
}

func (cli *CLI) Run(args []string) error {
	// (略)
}

デフォルトでは次のように設定し、fmt.Fprintln関数やfmt.Fscanln関数などを使えば、標準出力に出力したり、標準入力から入力を受けれます。

package mytool

import (
	"fmt"
	"os"
)

const (
	ExitOK int = 0
	ExitNG int = 1
)

func Main(args []string) int {
	cli := &mytool.CLI{
		Stdout: os.Stdout,
		Stderr: os.Stderr,
		Stdin:  os.Stdin,
	}

	if err := cli.Run(args); err != nil {
		fmt.Fprintln(cli.Stderr, "Error:", err)
		return ExitNG
	}

	return ExitOK
}

mytool.CLI構造体の各フィールドはインタフェースになっているため、次のようにテストケースの作成が容易です。標準入力に任意の文字列を入力したい場合は、strings.Reader型を用いるとテストケースとして分かりやすくなります。改行を入れることで、キーボードでEnterキーを入力したように扱うこともできます。また、標準出力や標準エラー出力への出力を*bytes.Buffer型に行うことで後で期待した値と比較できます。

package mytool_test

import (
	"bytes"
	"path/filepath"
	"strings"
	"testing"
)

func TestCLI_Run(t *testing.T) {
	t.Parallel()

	const (
		noErr  = false
		hasErr = true
	)

	testdata := filepath.Join("testdata", t.Name())
	cases := map[string]struct {
		args    string
		in      string
		wantErr bool
	}{
		"normal": {"", "hello\nworld", noErr},
		"empty":  {"", "", noErr},
	}

	for name, tt := range cases {
		name, tt := name, tt
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			var got bytes.Buffer
			cli := &mytool.CLI{
				Stdout: &got,
				Stderr: &got,
				Stdin:  strings.NewReader(tt.in),
			}

			args := strings.Split(tt.args, " ")
			err := cli.Run(args)

			switch {
			case tt.wantErr && err == nil:
				t.Fatal("expected error did not occur")
			case !tt.wantErr && err != nil:
				t.Fatal("unexpected error:", err)
			}

			if got := got.String(); got != tt.want {
				t.Errorf("want %q, but got %q", tt.want, got)
			}
		})
	}
}

ゴールデンファイルテスト

出力された内容が複雑な場合、テストケースに期待する値を記述しても正しくテストできているか分かりづらい場合があります。そういう場合には、ゴールデンファイルテストが有効です。

ゴールデンファイルテストとは、図のようにテスト対象となる処理の結果をファイルに出力しておき、1回目は人力で確認し、2回目以降はそのファイルとの差分をチェックするテスト方法です。出力されるファイルのことをゴールデンファイルと呼び、.goldenという拡張子をつけます。

ゴールデンファイルテスト
図

ゴールデンファイルは、正しく動いてる場合の出力であるため、Gitなどのバージョン管理システムの管理下に置くことが多いです。バージョン管理を行うことで、ゴールデンファイルに更新があった場合に差分が確認できます。

たとえば、標準出力および標準エラー出力の出力結果に対して、ゴールデンファイルテストを行うには次のように書きます。

package mytool_test

import (
	"bytes"
	"flag"
	"path/filepath"
	"strings"
	"testing"

	"github.com/tenntenn/golden"
)

var flagUpdate bool

func init() {
	flag.BoolVar(&flagUpdate, "update", false, "update golden files")
}

func TestCLI_Main(t *testing.T) {
	t.Parallel()

	const (
		exitOK = 0
		exitNG = 1
	)

	testdata := filepath.Join("testdata", t.Name())
	cases := map[string]struct {
		args         string
		in           string
		wantExitCode int
	}{
		"normal": {"", "hello\nworld", exitOK},
		"empty":  {"", "", exitOK},
	}

	for name, tt := range cases {
		name, tt := name, tt
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			var stdout, stderr bytes.Buffer
			cli := &mytool.CLI{
				Stdout: &stdout,
				Stderr: &stderr,
				Stdin:  strings.NewReader(tt.in),
			}

			args := strings.Split(tt.args, " ")
			gotExit := cli.Main(args)

			if gotExit != tt.wantExit {
				t.Errorf("exit code: want=%d but got %d", tt.wantExit, gotExit)
			}

			c := golden.New(t, flagUpdate, "testdata", name)

			if diff := c.Check("_stdout", &stdout); diff != "" {
				t.Error("stdout\n", diff)
			}

			if diff := c.Check("_stderr", &stderr); diff != "" {
				t.Error("stderr\n", diff)
			}
		})
	}
}

ゴールデンファイルの出力および既存のゴールデンファイルとテストで得られた値の比較は、筆者が作成しているgoldenを使用しています。golden.New関数は、ゴールデンテストを行うための*golden.Checker型の値を生成します。第2引数がtrueの場合、ゴールデンファイルをアップデートします。falseの場合は、すでに存在しているゴールデンファイルと与えられた値を比較します。実際に比較を行うのは(*golden.Checker).Checkメソッドを用いています。

Checkメソッドの引数には、サフィックスとテスト対象のデータを指定します。golden.New関数の第3引数で渡したディレクトリへのパスと第4引数で渡した名前の後ろにサフィックスが追加され、さらに拡張子の.goldenが付加されてゴールデンファイルのパスとなります。Checkメソッドは、ゴールデンファイルの更新の場合は、第2引数で指定されたデータでゴールデンファイルを更新し、比較の場合はすでに保存されているゴールデンファイルとgo-cmpを使用し比較します。

ゴールデンファイルに出力される値は型によって異なります。io.Readerインタフェースを実装している値の場合には、Readerから読み出された値がそのままゴールデンファイルに書き込まれます。string型、[]byte型、encoding.TextMarshallerインタフェースにも対応しています。その他の値はencoding/jsonパッケージによってJSONエンコードされます。

1つのテストケースにて単一のゴールデンファイルを扱いたい場合は、golden.Check関数を用いると良いでしょう。また、更新と差分のチェックを分けたい場合には、golden.Update関数とgolden.Diff関数を使用できます。

ゴールデンファイルを生成するかどうかは、go testコマンドのオプション(フラグ)で指定すると便利です。通常のコマンドラインツールと同じように、flagパッケージによってオプションを設定しておくと、go test -updateのようにフラグを渡せます。この場合は、golden.New関数の第2引数に指定するなどして、-updateオプションを指定したときのみ、ゴールデンファイルを更新するようにします。

ゴールデンファイルの更新は、新しいゴールデンファイルを作成する場合やゴールデンファイルの出力結果が変わるような変更がテスト対象に加わった場合です。それ以外の場合はfalseに設定(つまり、-updateフラグを指定しない)しておくことで、すでに出力されているゴールデンファイルと比較されます。

ディレクトリ作成のテスト

筆者が作成しているskeletonは、Goの静的解析ツールのひな形を作るためのCLIツールです。このような複数ファイルの生成を行うCLIツールのテストではひと工夫が必要です。skeletonで生成するファイルは1つだけではなく、go.modやtestdataディレクトリなども生成します。出力されるファイルは複雑なものが多いため、ゴールデンファイルテストを行いたくなりますが、ファイルもたくさんあるため、ファイルごとに行うのは大変です。

そこで、ディレクトリを含む複数のファイルを1つのファイル扱えるtxtar形式を利用するとテストしやすくなります。txtar形式は、Goのコンパイラをテストする際に生まれた形式で、golang.org/x/tools/txtarパッケージを使ってパースできます。

txtar形式とは、The Go Playgroundでも扱える次のようなファイル形式です。--ファイルパス--でファイルを区切り、1つのテキストファイルに複数のテキストファイルをまとめます。----の間にはファイルパスが書け、その下の部分(次の--まで部分)がファイルの中身となり、そのパスにファイルがあるという形で扱います。次の例では、ルートディレクトリ以下に、main.goとgo.modがあり、aというディレクトリの下にmsg.goがあるということを表しています。

-- main.go --
package main

import "example.com/txtar/a"

func main() {
	println(a.Msg())
}
-- a/msg.go --
package a

func Msg() string {
	return "hello"
}
-- go.mod --
module example.com/txtar

go 1.19

コマンドラインツールが生成するファイルの出力先は次のように変更できるようにしておくと良いでしょう。テスト時に(*testing.T).TempDirメソッドが生成する一時ディレクトリを指定でき、テストケースごとに出力用の一時ディレクトリが生成されます。TempDirメソッドは、筆者のZennの記事でも解説しているとおり、テスト終了時に削除される一時ディレクトリを生成するメソッドです。

type CLI struct {
	OutputDir string
	// (略)
}

出力されたファイル群をtxtar形式に変換し、ゴールデンファイルとして扱うには、いくつか手順を踏む必要があります。os.DirFS関数によって指定したディレクトリをio/fs.FS型に変換し、抽象的なファイルシステム型とします。そして、txtarfsというライブラリを用いて、fs.FS型から*txtar.Archive型に変換します。そして、(*txtar.Archive).Formatメソッドを用いて[]byte型でtxtar形式のテキストを取得し、ゴールデンファイルとして出力します。

golden.Txtar関数はこれらの手順を自動で行ってくれます。次のように、ディレクトリを指定するだけで、txtar形式のテキストがstring型で取得できます。そうすると、golden.Check関数や(*golden.Checker).Checkメソッドの引数に指定して簡単にゴールデンファイルテストが行えます。

package mytool_test

import (
	"bytes"
	"flag"
	"path/filepath"
	"strings"
	"testing"

	"github.com/tenntenn/golden"
)

var flagUpdate bool

func init() {
	flag.BoolVar(&flagUpdate, "update", false, "update golden files")
}

func TestCLI_Main(t *testing.T) {
	// (略)

	testdata := filepath.Join("testdata", t.Name())

	for name, tt := range cases {
		name, tt := name, tt
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			var stdout, stderr bytes.Buffer
			cli := &mytool.CLI{
				OutputDir: t.TempDir(), // 一時ディレクトリ
				// (略)
			}

			args := strings.Split(tt.args, " ")
			gotExit := cli.Main(args)

			// (略)

			// 出力ファイル群をtxtar形式にする
			got := golden.Txtar(t, cli.OutputDir)
			if diff := c.Check("_files", got); diff != "" {
				t.Error("files\n", diff)
			}
		})
	}
}

ディレクトリの初期化

CLIツールは既存のディレクトリ以下のファイルを読み込んで処理を行うものも多いでしょう。ファイルが1つであればio.Reader型で抽象化すれば十分ですが、複数のファイルであったり、実行するまでディレクトリ構造が分からない場合はテストが難しくなります。

testdataディレクトリ以下にテストで使用したいディレクトリ構造を用意しておく方法もありますが、テストケースが増えるとともにディレクトリの数も増えていきます。また、ファイルに分かれていると、コードレビューにおいて確認作業が大変になります。

このような場合もtxtar形式を使用すると便利です。CLIツールが動作するディレクトリを次のようにフィールドにしておくことで、テスト時に(*testing.T).TempDirメソッドで取得した一時ディレクトリを指定できます。

type CLI struct {
	Dir string
	// (略)
}

txtar形式通りにディレクトリを初期化するには、golden.DirInit関数が便利です。次のように、testdataディレクトリ内にある初期化用のディレクトリをgolden.Txtar関数でtxtar形式にし、それを(*testing.T).TempDirメソッドで生成した一時ディレクトリにgolden.DirInit関数によって展開します。

func Test_Main(t *testing.T) {
	// (略)

	testdata := filepath.Join("testdata", t.Name(), "init")
	txtarStr := golden.Txtar(testdata)

	for name, tt := range cases {
		name, tt := name, tt
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			// (略)

			cli := &mytool.CLI{
				Dir: t.TempDir(), // 一時ディレクトリ
			}

			golden.DirInit(t, cli.Dir, txtarStr)

			// (略)
		})
	}
}

テスト関数ではなく、テストケース(サブテスト)ごとにディレクトリの状態を変えたい場合は、golden.TxtarWith関数を使うと便利です。golden.TxtarWith関数は第2引数以降が可変長引数となっており、ファイルパスとファイルの中身を並べて記述できます。たとえば、次のようなtxtar形式を生成したい場合を考えます。

-- main.go --
package main

import "example.com/txtar/a"

func main() {
	println(a.Msg())
}
-- a/msg.go --
package a

func Msg() string {
	return "hello"
}
-- go.mod --
module example.com/txtar

go 1.19

この場合、次のようにgolden.TxtarWith関数を呼び出せば良いでしょう。

golden.TxtarWith(t,
	"main.go", `package main
	
import "example.com/txtar/a"

func main() {
	println(a.Msg())
}
`,
	"a/msg.go", `package a

func Msg() string {
	return "hello"
}
`,
	"go.mod", `module "example.com/txtar"

go 1.19
`)

複雑なテストの場合は、次のようにテスト関数ごとにベースとなるディレクトリ構造を作っておき、txtar形式を生成します。そして、テストケースごとにgolden.TxtarWith関数でテストケースごとの差分のtxtar形式の値を生成し、golden.TxtarJoin関数でベースのtxtar形式の値とマージさせると良いでしょう。それぞれのテストケースで差分だけを考えればすむようになります。

func Test_Main(t *testing.T) {
	// (略)

	testdata := filepath.Join("testdata", t.Name(), "init")
	initBase := golden.Txtar(testdata)

	for name, tt := range cases {
		name, tt := name, tt
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			// (略)

			cli := &mytool.CLI{
				Dir: t.TempDir(), // 一時ディレクトリ
			}

			// テストケースごとに追加されるファイル
			initByCase := golden.TxtarWith(t, tt.filename, tt.file)
			txtarStr := golden.TxtarJoin(initBase, initByCase)
			golden.DirInit(t, cli.Dir, txtarStr)

			// (略)
		})
	}
}

まとめ

本稿では、テストが作りづらいCLIツールについてテストを作りやすくする方法とゴールデンファイルテストというテスト手法を紹介しました。また、ゴールデンファイルテストは、筆者が作成したgoldenを使うとより簡単に行えることを解説しました。読者のみなさんもCLIツールを作る場合には、実行してみてテストを行うのではなく、ここで紹介した方法を用いてテストコードを書いてみてください。

なお、Goのテストについては筆者のZennの記事や筆者が行っているGopher道場 自習室の講義動画、また有料(学生は無料)にはなりますがGopher塾でも解説を行っていますので参考にしてください。

おすすめ記事

記事・ニュース一覧