Refactoring Go Code to Avoid File I/O in Unit Tests

Matthias Endler

単体テストでファイルI/Oを避けるためのGoコードのリファクタリング

今日、仕事でシンプルなGoのコードをテストしやすくするためにリファクタリングしました。データの入出力とデータの処理を分離することで、モックや一時ファイルを使わずに単体テストでのファイル操作を避けるというアイデアです。

長いコンピューターのプリントアウトを読むGopher
長いコンピューターのプリントアウトを読むGopher
出典: Marcus Olssonによるイラスト CC BY-NC-SA 4.0

Stack Overflowのようなサイトでシンプルな解説が見つからなかったことに驚き、将来ほかの人が参照できるように自分でメモを残すことにしました。

サンプルコード

最初のバージョンは次のようなものでした。

package main

import (
	"bufio"
	"io/ioutil"
	"os"
)

func main() {
	analyze("test.txt")
}

func analyze(file string) error {
	handle, err := os.Open(file)

	if err != nil {
		return err
	}
	defer handle.Close()

	scanner := bufio.NewScanner(handle)
	for scanner.Scan() {
		// Do something with line
		_ = scanner.Text()
	}
	return nil
}

ご覧のとおり、ファイル名を入力として受け取り、analyze関数の中でそのファイルを開いて内容に対して何らかの処理を行っています。

コードに対する最初のテストを書く

このコードに対する典型的なテストハーネスは、次のようになるでしょう。

package main

import "testing"

func Test_analyze(t *testing.T) {
	t.Run("Test something", func(t *testing.T) {
		if err := analyze("test.txt"); (err != nil) != false {
			t.Errorf("analyze() error = %v", err)
		}
	})
}

これで問題なさそうでしょうか?

問題点

この方法でも動作はしますが、テスト実行中にファイルI/Oを行うのは必ずしも最善とは言えません。たとえば、ファイルにアクセスできない制約のある環境で実行しているかもしれません。こうした問題を避けるために一時ファイルを使う方法もあります。

しかし、ディスクI/Oに起因する問題でテストが不安定になったり、フラストレーションの原因になったりすることもあります。
テスト中に別のプロセスがファイルを書き換えてしまう可能性もあります。こうした問題はいずれも、あなたのコード自体とは関係がありません。

さらに、テストを見ただけで何が起きているかを正確に把握できるわけではありません。テキストファイルの中身も併せて確認する必要があります。

代わりにモックを使うことを勧める人も多くいます。この用途にはspf13/aferoのような強力なライブラリがいくつもあります。これらのパッケージはバックグラウンドで一時ファイルを作成し、後でクリーンアップしてくれます。

私の考えでは、テストにおけるモックは最後の手段であるべきです。モックを使う前に、コードで適切な抽象化ができているか確認してみてください。インターフェースに対して実装したり、依存性注入(Dependency Injection)を使ったりすることで、コンポーネントの結合度を下げられるかもしれません。多くの場合、関心の分離を明確にするだけで十分です。

テストしやすくするためのリファクタリング

上記の例では、ファイルI/Oと解析処理を分離することで、モックや一時ファイルを使わずに済ませることができます。そのために、analyze関数をリファクタリングしてio.Readerを受け取るdoSomethingを呼び出すようにします。(今回は文字列の配列を使うこともできます。)

main.goは次のようになります。

package main

import (
	"bufio"
	"io"
	"os"
)

func main() {
	analyze("test.txt")
}

func analyze(file string) error {
	handle, err := os.Open(file)

	if err != nil {
		return err
	}
	defer handle.Close()
	return doSomething(handle)
}

func doSomething(handle io.Reader) error {
	scanner := bufio.NewScanner(handle)
	for scanner.Scan() {
		// Do something with line
		_ = scanner.Text()
	}
	return nil
}

これで、実際の解析処理を分離してテストできるようになります。

package main

import (
	"strings"
	"testing"
)

func Test_analyze(t *testing.T) {
	t.Run("Test something", func(t *testing.T) {
		if err := doSomething(strings.NewReader("This is a test string")); (err != nil) != false {
			t.Errorf("analyze() error = %v", err)
		}
	})
}

analyze("test.txt")doSomething(strings.NewReader("This is a test string"))に置き換えました。(もちろん、analyze()に対しても別途テストを書くべきですが、ここではデータソースに依存しない部分を分離することに焦点を当てています。)

結果

コードを少しリファクタリングするだけで、次のようなメリットが得られました。

  • シンプルなテスト容易性: モックや一時ファイルが不要になります。
  • 関心の分離: 各関数が一つのことだけを行うようになります。
  • コードの再利用が容易に: doSomething()関数はあらゆるio.Readerで動作し、他の場所からも呼び出せます。必要であれば、独自のライブラリに切り出すことも可能です。

Redditでユーザーsoapysops重要な指摘をしています。

一般的に、APIでファイル名を受け取るのは避けたいと思っています。ファイル名ではユーザーに十分な制御が渡せません。たとえば、特殊なエンコーディングや特別なファイルパーミッション、実際のファイルの代わりにbytes.Bufferを使うといったことができません。ファイル名を受け取ることは、コードにファイルシステムという大きな依存関係を、そのOS固有の諸々と一緒に追加することになります。

なので、私ならファイル名ベースのAPIは廃止して、io.Readerベースのものだけを公開するでしょう。そうすれば、完全なコードカバレッジ、高速なテスト、そして考慮すべきエッジケースの大幅な削減が得られます。

私もこの意見には完全に同意します。
ただ、APIが公開されていて既に利用者がいる場合など、ユーザー向けAPIを簡単に変更できないこともよくあります。上記のリファクタリングは、より良いアーキテクチャに向けた第一歩に過ぎません。Goで堅牢で十分にテストされたシステムを書き始めるために、できることはまだまだたくさんあります。

参考資料

興味を持たれた方は、同じトピックを扱っているjustforfunc #29: dependency injection in a code reviewもぜひご覧ください。

おすすめの素晴らしい資料として、Learn Go with Testsがあります。Goによるテスト駆動開発を学べ、TDDの基礎をしっかり身につけることができます。

もう一つは、Unixで知られるBrian W. Kernighan氏が共著のThe Go Programming Languageという書籍です。実世界の問題を解決するための、明確でイディオマティックなGoの書き方を示しています。インターフェースとテストに関する章があり、io.Readerについてもより詳しく解説されています。

Brian W. Kernighan氏共著の書籍『The Go Programming Language』(アフィリエイトリンク)
Brian W. Kernighan氏共著の書籍『The Go Programming Language』(アフィリエイトリンク)

原文は Matthias Endler により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。