#はじめに
ローカルファイルを加工するような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
に別名保存するケースを紹介します。
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ファイル
今回は便宜上テキストファイルにしました。
testFile0
foo
testFile1
foo
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()
により一行追加されて保存されます。
testFile0
foo
bar //追加行
testFile1
foo
bar //追加行
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
で定義しておくとテストしやすいパターンに持ち込めます。
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/
内に予めテスト用のファイルを用意し、これらのテストファイルも一緒にリポジトリで管理しちゃってます。
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
を導入すると、ファイルシステムのmockを使ってファイルの入出力がテストできます。aferoの公式にも記載の通り、メモリ上で完結する仮想ファイルシステムを使うことで実際のファイル入出力より速く、ファイルの操作権限や出力ファイルの削除を意識せずにテストできるといったメリットがあります。
###main.goの本処理を編集
main.go
のinsertAll()
をafero
用に若干編集します。
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関数から呼び出すと、これまでと同じ結果が得られます。
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)
}
###テストコード
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
のメリットを享受できないケース
- 仮想ファイルシステム上にテストデータを定義するのが難しいケース
- その3:
/spf13/afero
の仮想ファイルシステムを用いたテスト- 上記以外のケース
といったところです。CLIツール製作者の参考になれば幸いです!