LoginSignup
9
4

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-15

はじめに

ローカルファイルを加工するような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に別名保存するケースを紹介します。

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ファイル

今回は便宜上テキストファイルにしました。

testFile0.txt
testFile0
foo
testFile1.txt
testFile1
foo
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()により一行追加されて保存されます。

testFile0.txt
testFile0
foo
bar //追加行
testFile1.txt
testFile1
foo
bar //追加行
testFile2.txt
testFile2
foo
bar //追加行

テストパターン

さて本題です。この記事では以下の3パターンを紹介します。

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

その1:ファイルシステムの操作をスコープから外したテスト

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

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)といったようにinputoutputのディレクトリ名を引数に定義することで、テスト用のディレクトリ../files/ut/をテストコード内で別途定義することができます。今回の例では../files/ut/input/内に予めテスト用のファイルを用意し、これらのテストファイルも一緒にリポジトリで管理しちゃってます。

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を導入すると、ファイルシステムのmockを使ってファイルの入出力がテストできます。aferoの公式にも記載の通り、メモリ上で完結する仮想ファイルシステムを使うことで実際のファイル入出力より速く、ファイルの操作権限や出力ファイルの削除を意識せずにテストできるといったメリットがあります。

main.goの本処理を編集

main.goinsertAll()afero用に若干編集します。

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関数から呼び出すと、これまでと同じ結果が得られます。

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)
}

テストコード

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メリットを享受できないケース
  • その3:/spf13/aferoの仮想ファイルシステムを用いたテスト
    • 上記以外のケース

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

9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4