ローカルファイルをGoで加工するCLIツールのテストパターン3選

#はじめに
ローカルファイルを加工するようなCLIツールを作る際、Goはクロスプラットフォームに優しくて積極的に使っているのですが、加工のロジックやファイル操作が複雑になるとテストコードをどうやって書こうか都度悩んでいました。色々試していく中でおおよそ3つのパターンに落ち着いてきたのでこの記事でご紹介します。他にオススメがあればコメントいただけると非常にありがたいです！

記事中の全てのコードはこちら。
https://github.com/r-tezuka/go-cli-test-samples

#テスト対象のサンプルコード
本題に入る前に、今回サンプルとして作ったテスト対象のコードを紹介します。

##構成
`./src`から`go run main.go`を実行すると`input`のファイルを加工して`output`に別名で保存するようなメソッドに対するテストパターンを考えてみます。

```:ディレクトリ構成
.
├── files                 // I/O対象のファイル
│   ├── input             // 加工前のファイル(サンプルとして3つのファイルを用意)
│   │   ├── testFile0.txt
│   │   ├── testFile1.txt
│   │   └── testFile2.txt
│   └── output            // 加工後のファイル
└── src
    ├── main_test.go
    └── main.go
```

##main.go
main関数の本処理には要件に応じた様々なメソッドが呼び出される想定ですが今回はサンプルとして`input`ファイル全てに一行追記して`output`に別名保存するケースを紹介します。

```go:main.go
package main

import (
	"io"
	"io/ioutil"
	"log"
	"os"
	"path/filepath"
	"strconv"
)

func main() {
	// input/outputディレクトリの定義
	inputDir, err := filepath.Abs("../files/input/")
	if err != nil {
		log.Fatalln(err)
	}
	outputDir, err := filepath.Abs("../files/output/")
	if err != nil {
		log.Fatalln(err)
	}
	os.Mkdir(outputDir, 0777)

	// 本処理（便宜上ファイルに一行追加するメソッドを実行）
	insertAll(inputDir, outputDir)
}

//inputディレクトリ内のファイル全てに一行追加しoutputディレクトリに別名保存
func insertAll(inputDir string, outputDir string) {
	files := dirwalk(inputDir)
	for i, f := range files {

		// 便宜上、outputファイル名に連番を付与
		outputFile := filepath.Join(outputDir, ("testFile" + strconv.Itoa(i) + ".txt"))

		r, err := os.Open(f)
		if err != nil {
			log.Fatalln(err)
		}
		defer r.Close()
		w, err := os.Create(outputFile)
		if err != nil {
			log.Fatalln(err)
		}
		defer w.Close()

		insert(r, w)
	}
}

//ファイルをコピーして一行追加
func insert(r io.Reader, w io.Writer) {
	_, err := io.Copy(w, r)
	if err != nil {
		log.Fatalln(err)
	}
	w.Write([]byte("\nbar"))
}

// 指定したディレクトリ内のファイル一覧を取得
func dirwalk(dir string) []string {
	files, err := ioutil.ReadDir(dir)
	if err != nil {
		log.Fatalln(err)
	}
	var paths []string
	for _, file := range files {
		if file.IsDir() {
			paths = append(paths, dirwalk(filepath.Join(dir, file.Name()))...)
			continue
		}
		paths = append(paths, filepath.Join(dir, file.Name()))
	}

	return paths
}
```
##サンプルで用いたinputファイル
今回は便宜上テキストファイルにしました。

```text:testFile0.txt
testFile0
foo
```
```text:testFile1.txt
testFile1
foo
```
```text:testFile2.txt
testFile2
foo
```

##実行すると
`./src`から`go run main.go`を実行すると`input`ファイルに一行追加し`output`に保存します。

```:ディレクトリ構成
.
├── files                 
│   ├── input             
│   │   ├── testFile0.txt
│   │   ├── testFile1.txt
│   │   └── testFile2.txt
│   └── output 
│   │   ├── testFile0.txt 　// 追加
│   │   ├── testFile1.txt 　// 追加
│   │   └── testFile2.txt 　// 追加
└── src
    ├── main_test.go
    └── main.go
```
`output`のファイルには`insert()`により一行追加されて保存されます。

```text:testFile0.txt
testFile0
foo
bar //追加行
```
```text:testFile1.txt
testFile1
foo
bar //追加行
```
```text:testFile2.txt
testFile2
foo
bar //追加行
```
#テストパターン
さて本題です。この記事では以下の3パターンを紹介します。

- その1：ファイルシステムの操作をスコープから外したテスト
- その2：ファイルシステムの操作もスコープに入れたテスト
- その3：`/spf13/afero`の仮想ファイルシステムを用いたテスト

##その1：ファイルシステムの操作をスコープから外したテスト
1つ目は3つの中で最もシンプルなパターンです。ファイルシステムの操作を含む処理`insertAll()`をテスト対象から外し、ファイルの加工処理`insert()`のみをテスト対象とするパターンです。テスト対象のメソッド引数を`insert(r io.Reader, w io.Writer)`のように`io.Reader`と`io.Writer`で定義しておくとテストしやすいパターンに持ち込めます。

```go:main_test.go
func TestInsert(t *testing.T) {

	// input/outputを初期化
	in := bytes.NewBufferString("foo")
	out := new(bytes.Buffer)

	// テスト対象の処理を実行
	insert(in, out)

	// 出力ファイルが期待通りかチェック
	expected := []byte("foo\nbar")
	if bytes.Compare(expected, out.Bytes()) != 0 {
		t.Fatalf("not matched. expected: %s, actual: %s", expected, out.Bytes())
	}
}
```
ファイルの入出力操作が複雑でなかったり、更新頻度が少ないためテストするまでもないケースではこのようにテストのスコープを絞り込むことでテストコードも少なくできます。

##その2：ファイルシステムの操作もスコープに入れたテスト
複数のファイルを一括で処理したり、ファイルの中身に応じて保存するディレクトリやファイル名を振り分けたり、条件によって拡張子を変えたりファイルを分割したり。。。といった具合にファイルの入出力ロジックが複雑で`その1`で紹介したパターンだけではテスト不十分な場面も度々発生します。

テスト対象の本処理を`insertAll(inputDir string, outputDir string)`といったように`input`と`output`のディレクトリ名を引数に定義することで、テスト用のディレクトリ`../files/ut/`をテストコード内で別途定義することができます。今回の例では`../files/ut/input/`内に予めテスト用のファイルを用意し、これらのテストファイルも一緒にリポジトリで管理しちゃってます。

```go:main_test.go
func TestInsertAll(t *testing.T) {

	// input/outputディレクトリを定義
	inputDir, err := filepath.Abs("../files/ut/input/")
	if err != nil {
		log.Fatalln(err)
	}
	outputDir, err := filepath.Abs("../files/ut/output/")
	if err != nil {
		log.Fatalln(err)
	}
	os.Mkdir(outputDir, 0777)

	// テスト対象の処理を実行
	insertAll(inputDir, outputDir)

	//各出力ファイルが期待通りかチェック
	files := dirwalk(outputDir)
	for i, f := range files {
		file, err := os.Open(f)
		if err != nil {
			log.Fatalln(err)
		}
		defer file.Close()
		out, err := ioutil.ReadAll(file)
		if err != nil {
			log.Fatalln(err)
		}

		// ファイルパスとファイル名が期待通りかテスト
		expFileName := filepath.Join(outputDir, ("testFile" + strconv.Itoa(i) + ".txt"))
		if expFileName != f {
			t.Fatalf("path or name not matched. expected: %s, actual: %s", expFileName, f)
		}

		// ファイルの中身が期待通りかテスト
		expContent := []byte("testFile" + strconv.Itoa(i) + "\nfoo\nbar")
		if bytes.Compare(expContent, out) != 0 {
			t.Fatalf("fileContent not matched. expected: %s, actual: %s", expContent, out)
		}

	}

	// テスト用に出力したファイルを全削除
	if err := os.RemoveAll(outputDir); err != nil {
		log.Fatalln("outputDir could not be deleted. dir path :", outputDir)
	}
}
```
##その3：`/spf13/afero`の仮想ファイルシステムを用いたテスト


ファイルシステムフレームワークの[`afero`](https://github.com/spf13/afero)を導入すると、ファイルシステムのmockを使ってファイルの入出力がテストできます。[aferoの公式](https://github.com/spf13/afero#using-afero-for-testing)にも記載の通り、メモリ上で完結する仮想ファイルシステムを使うことで実際のファイル入出力より速く、ファイルの操作権限や出力ファイルの削除を意識せずにテストできるといったメリットがあります。

###main.goの本処理を編集
`main.go`の`insertAll()`を`afero`用に若干編集します。

```go:main.go
func insertAllWithAfero(appFs afero.Fs, inputDir string, outputDir string) afero.Fs {
	i := 0
	if err := afero.Walk(appFs, inputDir, func(path string, info os.FileInfo, err error) error {
		if !info.IsDir() {
			outputFile := filepath.Join(outputDir, ("testFile" + strconv.Itoa(i) + ".txt"))
			r, err := appFs.Open(path)
			if err != nil {
				log.Fatalln(err)
				return err
			}
			defer r.Close()
			w, err := appFs.Create(outputFile)
			if err != nil {
				log.Fatalln(err)
				return err
			}
			insert(r, w)
			i++
		}
		return nil
	}); err != nil {
		log.Fatalln(err)
	}
	return appFs
}
```
これをmain関数から呼び出すと、これまでと同じ結果が得られます。

```go:main.go
func main() {
	// input/outputディレクトリの定義
	inputDir, err := filepath.Abs("../files/input/")
	if err != nil {
		log.Fatalln(err)
	}
	outputDir, err := filepath.Abs("../files/output/")
	if err != nil {
		log.Fatalln(err)
	}
	os.Mkdir(outputDir, 0777)

	// 本処理（Afero ver.）
	var appFs = afero.NewOsFs()
	insertAllWithAfero(appFs, inputDir, outputDir)
}
```

###テストコード

```go:main_test.go
func TestInsertAllWithAfero(t *testing.T) {

	// mockを定義
	appFs := afero.NewMemMapFs()

	// inputファイル/outputディレクトリをmock内に作成
	inputDir := "../files/input/"
	outputDir := "../files/output/"
	afero.WriteFile(appFs, filepath.Join(inputDir, "testFile0.txt"), []byte("testFile0\nfoo"), 0644)
	afero.WriteFile(appFs, filepath.Join(inputDir, "testFile1.txt"), []byte("testFile1\nfoo"), 0644)
	afero.WriteFile(appFs, filepath.Join(inputDir, "testFile2.txt"), []byte("testFile2\nfoo"), 0644)
	appFs.Mkdir(outputDir, 0777)

	// テスト対象の処理を実行
	appFs = insertAllWithAfero(appFs, inputDir, outputDir)

	// 実行結果をactualに格納
	var actualFileNames []string
	var actualContents [][]byte
	if err := afero.Walk(appFs, outputDir, func(path string, info os.FileInfo, err error) error {
		if !info.IsDir() {
			actualFileNames = append(actualFileNames, path)
			f, err := appFs.Open(path)
			if err != nil {
				log.Fatalln(err)
			}
			defer f.Close()
			actualContent, err := afero.ReadAll(f)
			if err != nil {
				log.Fatalln(err)
			}
			actualContents = append(actualContents, actualContent)
		}
		return nil
	}); err != nil {
		log.Fatalln(err)
	}

	// ファイルパスとファイル名が期待通りかテスト
	var expFileNames []string
	expFileNames = append(expFileNames, filepath.Join(outputDir, ("testFile0.txt")))
	expFileNames = append(expFileNames, filepath.Join(outputDir, ("testFile1.txt")))
	expFileNames = append(expFileNames, filepath.Join(outputDir, ("testFile2.txt")))
	if !reflect.DeepEqual(expFileNames, actualFileNames) {
		t.Fatalf("path or name not matched. expected: %s, actual: %s", expFileNames, actualFileNames)
	}

	// ファイルの中身が期待通りかテスト
	var expContents [][]byte
	expContents = append(expContents, []byte("testFile0\nfoo\nbar"))
	expContents = append(expContents, []byte("testFile1\nfoo\nbar"))
	expContents = append(expContents, []byte("testFile2\nfoo\nbar"))
	if !reflect.DeepEqual(expContents, actualContents) {
		t.Fatalf("fileContent not matched. expected: %s, actual: %s", expContents, actualContents)
	}
}
```

`var appFs = afero.NewMemMapFs()`で仮想ファイルシステム`MemMapFs`をNewすることで、`appFs`へのファイル操作はOSのファイルシステムを使わずメモリ上で完結します。出力したファイルを消し忘れる心配はなくなりました。
#まとめ
以上、テストパターン3選でした。個人的には適材適所で使い分けていて、

- その1：ファイルシステムの操作をスコープから外したテスト
    - テストのスコープがこれでよければこれ一択
- その2：ファイルシステムの操作もスコープに入れたテスト
    - 仮想ファイルシステム上にテストデータを定義するのが難しいケース
        - 例えばCADファイル(設計図面)等の特殊なフォーマットを扱う場合
    - 制約によりフレームワークが導入できないケース
    - `afero`の[メリット](https://github.com/spf13/afero#using-afero-for-testing)を享受できないケース
- その3：`/spf13/afero`の仮想ファイルシステムを用いたテスト
    - 上記以外のケース

といったところです。CLIツール製作者の参考になれば幸いです！