見出し画像

Go でファイル操作のユニットテストを効率的に!テクニック集を公開


はじめに

こんにちは、くるふとです。
ナビタイムジャパンで、時刻表 API や地図描画 API の 開発・運用業務を主
担当しています。

今回は Go でファイル操作のユニットテストを書く際に、役立つテクニックを紹介します。

Go で CLI の実装などをする際、テストコードの書き方に悩む時があります。
ユニットテスト上で temp ファイルを作成し、関数の振る舞いをテストするケースもあるでしょう。
ただ、上記のような処理はテストの実装が冗長になったり temp ファイルの削除漏れが起きたりすることも多々あります。

そういった悩みを解決するべく、ファイル操作の振る舞いを効率的にテストできる手法を今回の記事でご紹介します。

インターフェース を io.Reader, io.Writer にする

Go には io.Reader と io.Writer というインターフェースが用意されています。
これらはデータの読み書きを抽象化したもので、ファイルだけではなくネットワークやメモリの処理など、様々なケースで利用できるインターフェースとなっています。
これらを用いることでユニットテストの実装をより効率的なものにすることができます。

src ファイル内の文字列を大文字にして dst ファイルに書き込む関数 toUpper を例にして解説していきます。
以下は関数 toUpper と、そのテストをしている関数 TestToUpper の実装です。

main.go

package main

import (
	"io"
	"os"
	"strings"
)

// toUpper は src 内の文字列を大文字に変換した上で dst に変換後の文字列を書き込む
func toUpper(src *os.File, dst *os.File) error {
	b, err := io.ReadAll(src)
	if err != nil {
		return err
	}

	upperStr := strings.ToUpper(string(b))

	if _, err = dst.WriteString(upperStr); err != nil {
		return err
	}

	return nil
}

main_test.go

package main

import (
	"io"
	"os"
	"testing"
)

func TestToUpper(t *testing.T) {
	// 期待値となる文字列
	want := "HELLO WORLD!"

	// src となる temp ファイルを作成し、そのファイルに文字列を挿入する
	src, err := os.Create("temp_src.txt")
	if err != nil {
		t.Fatal("failed to create temp src file.")
	}
	defer src.Close()
	if err := os.WriteFile(src.Name(), []byte("Hello World!"), 0644); err != nil {
		t.Fatal("failed to write file.")
	}

	// dst となる temp ファイルを作成
	dst, err := os.Create("temp_dst.txt")
	if err != nil {
		t.Fatal("failed to create temp dst file.")
	}
	defer dst.Close()

	// src 内の文字列を大文字に変換し、 dst に書き込む
	err = toUpper(src, dst)
	if err != nil {
		t.Fatal("failed toUpper.")
	}

	// dst となる temp ファイル内の文字列を取得し、期待値( want )の値と比較
	f, err := os.Open(dst.Name())
	if err != nil {
		t.Fatal("failed to open temp dst file.")
	}

	b, err := io.ReadAll(f)
	if err != nil {
		t.Fatal("failed Read.")
	}

	got := string(b)

	if got != want {
		t.Fatalf("there is a difference between got and want. got=%s want=%s", got, want)
	}
}

上記のは問題なく動くコードではあるものの、改善の余地が残るコードとなっています。

TestToUpper 内でテスト用の temp ファイルを作成し返却された os.File を引数に渡していますが、 temp ファイルを操作する実装が冗長になっています。

io.Reader, io.Writer を利用することで、上記のような冗長な実装を解消することができます。

main.go

package main

import (
	"io"
	"strings"
)

// toUpper は src 内の文字列を大文字に変換した上で dst に変換後の文字列を書き込む
func toUpper(src io.Reader, dst io.Writer) error {
	b, err := io.ReadAll(src)
	if err != nil {
		return err
	}

	upperStr := strings.ToUpper(string(b))

	_, err = io.WriteString(dst, upperStr)

	return err
}

main_test.go

package main

import (
	"bytes"
	"io"
	"strings"
	"testing"
)

func TestToUpper(t *testing.T) {
	// 期待値となる文字列
	want := "HELLO WORLD!"

	// src となる strings.Reader を定義する(変換前の文字列も挿入)
	src := strings.NewReader("Hello World!")

	// dst となる bytes.Buffer を定義する
	dst := bytes.NewBufferString("")

	// src 内の文字列を大文字に変換し、 dst に書き込む
	err := toUpper(src, dst)
	if err != nil {
		t.Fatal("failed toUpper.")
	}

	// dst から文字列を抽出し、期待値( want )の値と比較
	b, err := io.ReadAll(dst)
	if err != nil {
		t.Fatal("failed Read.")
	}

	got := string(b)

	if got != want {
		t.Fatalf("there is a difference between got and want. got=%s want=%s", got, want)
	}
}

先ほどのコードから toUpper の引数を io.Reader, io.Writer に変更しています。
os.File は io.Reader と io.Writer としての実装を持っており、ファイルの読み書きの際に io.Reader もしくは io.Writer として扱うことができます。

テストコードも上記に合わせて変更を加えることができます。
strings.NewReader が特定の文字列を引数に strings.Reader を返却します。strings.Reader は io.Reader を実装しているので、toUpper の引数 src として渡すことができます。
bytes.NewBufferString は特定の文字列を引数に bytes.Buffer を返却します。
bytes.Buffer は io.Writer の実装を持っているので、 toUpper の引数 dst として渡すことができます。

これによって、 temp ファイルを作成することなく toUpper の振る舞いをテストすることができます。
(実際に、 TestToUpper 内で Hello World! という文字列が HELLO WORLD! に変換できていることをテストできているはずです)

このように、io.Reader, io.Writer をインターフェースとして利用することによりファイル操作のユニットテストを効率的に実装できます。

iotest パッケージの利用

io.Reader, io.Writer を用いた関数の振る舞いのテストをする際、 nil ではない err が返却されるケース、いわゆる異常系のテストをすることが難しいです。

ファイルシステムの異常系をユニットテスト上で意図的に起こすのはなかなか難しく、テストの実装が悩ましい箇所であります。

上記のような異常系のテストを実装できるよう、 Go では iotest パッケージが提供されています。
iotest はテストに役立つ io.Reader, io.Writer を提供するパッケージとなっています。

以下は先ほども登場した関数 toUpper の実装と、 iotest を用いたテストの実装例です。

main.go

package main

import (
	"io"
	"log"
	"os"
	"strings"
)

// toUpper は src 内の文字列を大文字に変換した上で dst に変換後の文字列を書き込む
func toUpper(src io.Reader, dst io.Writer) error {
	b, err := io.ReadAll(src)
	if err != nil {
		return err
	}

	upperStr := strings.ToUpper(string(b))

	_, err = io.WriteString(dst, upperStr)

	return err
}

main_test.go

package main

import (
	"bytes"
	"errors"
	"testing"
	"testing/iotest"
)

func TestToUpperWithErrReader(t *testing.T) {
	// 読み込み処理が失敗する io.Reader を定義する
	src := iotest.ErrReader(errors.New("failed read file."))
	dst := bytes.NewBufferString("")

	err := toUpper(src, dst)
	if err == nil {
		t.Fatal("no error returned from toUpper.")
	}
}

func TestToUpperWithTimeoutReader(t *testing.T) {
	// タイムアウトのエラーを出力する io.Reader を定義する
	src := iotest.NewReadLogger("read log", iotest.TimeoutReader(strings.NewReader("Hello World!")))
	dst := bytes.NewBufferString("")

	err := toUpper(src, dst)
	if err == nil {
		t.Fatal("no error returned from toUpper.")
	}
}

iotest.ErrReader は引数に渡した error を返却する io.Reader を返却してくれるため、自前で error を実装して異常系のテストをすることができます。
また、特定のエラーを想定した関数も複数提供されており、一例として上のコードでは iotest.TimeoutReader を利用しています。iotest.TimeoutReader はタイムアウトの error を返却する io.Reader を返却します。

ちなみに、iotest.NewReadLogger を使用することで go test -v コマンド実行時に io.Reader のログを出力できます。以下はその実行例です。

実行ログ

$ go test -v
=== RUN   TestToUpperErrReader
--- PASS: TestToUpperErrReader (0.00s)
=== RUN   TestToUpperTimeoutReader
2024/05/20 14:54:42 read log 48656c6c6f20576f726c6421
2024/05/20 14:54:42 read log : timeout
--- PASS: TestToUpperTimeoutReader (0.00s)
PASS
ok      sandbox 0.288s

testing.T.TempDir, os.CreateTemp の利用

前述の項目で io iotest パッケージを用いたユニットテストの書き方について解説しました。
しかし、場合によってはどうしてもユニットテスト内で temp ファイルが必要なケースが出てきます。
その際は、 testing.T.TempDir, os.CreateTemp が役に立ちます。

以下は指定したディレクトリ内のファイルをカウントする関数 countFiles とそのテスト関数 TestCountFiles です。

main.go

package main

import (
	"io/fs"
	"log"
	"path/filepath"
)

// countFiles は dir 配下のファイルの数をカウントする
func countFiles(dir string) (int, error) {
	count := 0
	err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		if !d.IsDir() {
			count++
		}

		return nil
	})

	return count, err
}

main_test.go

package main

import (
	"os"
	"testing"
)

func TestCountFiles(t *testing.T) {
	// temp ディレクトリと temp ファイルを作成する
	tempDir := t.TempDir()
	for range 3 {
		_, err := os.CreateTemp(tempDir, "temp")
		if err != nil {
			t.Fatal("failed create temp file.")
		}
	}

	// 期待値の定義( temp ディレクトリにファイルが3つあることを想定)
	want := 3

	got, err := countFiles(tempDir)
	if err != nil {
		t.Fatal("failed countFiles.")
	}

	if got != want {
		t.Fatalf("there is a difference between got and want. got=%d want=%d", got, want)
	}
}

t.TempDir はテスト関数内で利用するための temp ディレクトリを作成するための関数です。
テスト関数が終了すると作成された temp ディレクトリも自動で削除されるため削除漏れ等を気にしなくて良いのが特徴です。

os.CreateTemp は temp ファイル作成用の関数です。t.TempDir 内で作成されたディレクトリ内に temp ファイルを作成することで、テスト関数内でのみ参照可能なファイルを用意することができます。

ファイルシステムモックツール afero の利用

最後にファイル操作のテストに便利なテストライブラリ、 afero のご紹介します。

afero はあらゆるファイルシステムを抽象化し、統一されたインターフェースを提供することを可能としています。これにより、ファイルシステムのモックを簡単に用意することができます。ファイル操作のユニットテストをより容易にしたい場合、導入を検討してみるのが良いと思います。

以下は指定されたファイルの行数をカウントする関数 countLines と、そのテスト関数 TestCountFiles となります。

main.go

package main

import (
	"bufio"
	"fmt"
	"log"

	"github.com/spf13/afero"
)

// countLines は filePath の行数を返却する
func countLines(fs afero.Fs, filePath string) (int, error) {
	f, err := fs.Open(filePath)
	if err != nil {
		return 0, err
	}

	scanner := bufio.NewScanner(f)
	count := 0

	for scanner.Scan() {
		count++
	}

	if err := scanner.Err(); err != nil {
		return 0, err
	}

	return count, nil
}

func main() {
	// ローカル上のファイルシステムを使用する
	fs := afero.NewOsFs()

	n, err := countLines(fs, "sample.txt")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(n)
}

main_test.go

package main

import (
	"testing"

	"github.com/spf13/afero"
)

func TestCountLines(t *testing.T) {
	// メモリ上のファイルシステムを使用する(ディスクに依存しない)
	fs := afero.NewMemMapFs()

	filePath := "temp.txt"

	if err := afero.WriteFile(fs, filePath, []byte("This is sample file.\nUsed in sample code.\nGood Luck!!"), 0644); err != nil {
		t.Fatal("failed write file.")
	}

	// 期待値の定義(ファイルの行数が3であることを想定)
	want := 3

	got, err := countLines(fs, filePath)
	if err != nil {
		t.Fatal("failed countLines.")
	}

	if got != want {
		t.Fatalf("there is a difference between got and want. got=%d want=%d", got, want)
	}
}

本来、ファイル操作は os パッケージで提供される機能を用いて処理しますが、上記のコードでは afero.Fs を用いてファイルの操作を実施しています。

この afero.Fs はファイルシステムのインターフェースとして機能しており、 main 関数とテスト関数とで関数の引数に渡すファイルシステムを切り替えることができます。

main 関数では afero.NewOsFs を用いることでローカル上のファイルシステムを用いることができます。
一方で、テスト関数では afero.NewMemMapFs を用いることで、ディスクに依存しないテストコードを実装することが可能です。

まとめ

いかがでしたでしょうか?

Go は CLI の開発等でも採用実績が多く、こういったファイル操作のコードは書く機会が多いと思います。
今回の記事の内容が皆様の開発現場で役立つ機会があると嬉しいです。

参考資料