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

Matthias Endler

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

原文は Matthias Endler により に公開されました。 このブログを購読する

今日、仕事でシンプルな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に起因する問題でテストが不安定(flaky)になり、フラストレーションの原因になることもあります。
テスト中に別のプロセスがファイルを書き換えてしまう可能性もあります。これらの問題は、どれもあなたのコード自体とは関係がありません。

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

多くの人は代わりにモックを提案します。この目的のための強力なライブラリもいくつかあり、たとえばspf13/aferoなどがそうです。これらのパッケージはバックグラウンドで一時ファイルを作成し、後でクリーンアップしてくれます。

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

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

上記の私のケースでは、ファイル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の基礎を身につけるのに役立ちます。

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

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

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

コメント