🧪

go test: 外部リソースを使用した安全で効率的なテストの話

2023/09/14に公開

これまでGoを使ったバックエンド開発に携わってきて、安定したテストが小気味よく回ることの重要性を感じ、少しずつ改善に取り組んできました。事前準備なくgo testでテストが実行できるだけでもう嬉しいのですが、テストの活用が開発スピードに与える影響は大きく、いろいろと欲がでてきますよね。

特に外部リソース(RDBMSなど)を使った動作をテストする際の苦労とその改善方法を考えているチームはやはり多いようで、これについて書いた記事もいくつか見たことがあります。
この問題に対し、自分が考えたことや課題解決のために作成したツールなどを紹介することでGoコミュニティに貢献し、あわよくばフィードバックが得られると嬉しいなと思って書いています。

この記事に書いてあること

  • 外部リソースを使用したテストの並列化では、"状態"をうまく扱わなければならない
  • コンテナは便利だが、それにも課題がある
  • RDBMSではその仕組みを使って工夫ができる
  • 問題解決のために作ったライブラリの紹介と反省

外部リソースを使うということは、テストに"状態"を持ち込んでしまうこと

いうまでもないことですが、RDBMS等の外部リソースを使ったテストの根本的な課題は、テストに状態を持ち込んでしまうことにあります。

状態をもつということは、状態の管理や排他制御を考えなければならないということです。
複数のテストが同時にリソースにアクセスした際に問題が起きないかどうか、もう一度テストを回した際に同じ条件でテストができているかどうかなどを考える必要があります。面倒です。

これをせずに目的が達成できるのであれば、その方向に向かった方がよいでしょう。

go test高速化の基本

よく知られていることですが、テストを高速に回していくためには二つのポイントがあります。

  1. go testの並列実行機能
  2. t.Parallel()を使用したマーキング

詳細については、以下の記事がとても参考になります。
https://engineering.mercari.com/blog/entry/how_to_use_t_parallel/

また、先日拝見したLTで、既存のテストケースに自動的にt.Parallel()を挿入するツールを公開されている方がいらっしゃいました。並列化を後回しにしているケースではとても便利そうです。
https://github.com/sho-hata/tparagen
https://speakerdeck.com/shohata/yunitutotesutobing-xing-hua-nodao-ru-toyun-yong-godenoshi-li-shao-jie

先述の1つ目のポイントではテスト対象となるパッケージを複数のプロセスに分配することで高速化が見込めるわけですが、これらのプロセスは引き受けたパッケージを順番に一つずつテストしていきます。

ということは、外部リソースAを使うのがただ一つのパッケージのみである場合、各テストケースでsync.Mutex等を共有することで、リソースへのアクセスが競合しないように簡単に制御することができます。

package_1/sample_test.go
var m sync.Mutex

func TestA(t *testing.T) {
	t.Parallel()
	m.Lock()
	t.Cleanup(m.Unlock)
	// use resource A...
}

func TestB(t *testing.T) {
	t.Parallel()
	m.Lock()
	t.Cleanup(m.Unlock)
	// use resource A...
}

リポジトリパターンを使ってDBアクセスの責務を一つのパッケージに集中させている場合、排他制御の問題は比較的簡単にクリアできます。
複数のパッケージからDBアクセスがある場合でも、go test -p=1 ./...として実行プロセスを1つに絞ることによってテストの安定性の課題についてはクリアできるかもしれません。ですがこれによって、関係のないその他諸々のパッケージのテスト実行も直列化してしまい、好ましくないわけですね。

外部リソースのライフサイクル

外部リソースを使い回すことで問題が発生するのであれば、「必要になった時に立ち上げれば良いのでは…!」という考え方もあります。

Goでは、testcontainers/testcontainers-goory/dockertestといったパッケージを使うことで、テストケースから必要なDockerコンテナを簡単に起動させることができます。
https://github.com/testcontainers/testcontainers-go
https://github.com/ory/dockertest

そのパッケージのテスト専用のリソースを用意することでパッケージをまたいだ状態管理から解放されます。
ただし、同じリソースを使うパッケージが複数ある場合、その都度コンテナを立ち上げることによってコンピューティングリソースと起動時間の増加が無視できなくなり、効率の悪さを感じることもあります。

開発環境やCIの環境がコンテナ上にある場合

devcontainerで開発環境を整備している場合など、テストコードからのコンテナ起動には一工夫が必要です。DinD(Docker in Docker)やDooD(Docker outside of Docker)を駆使する必要があります。
場合によってはDocker networkの使い方までおさえる必要もあります。

devcontainerでは、Docker composeと組み合わせることで開発用コンテナ以外のコンテナを同時に起動させることができます。また、GitHub ActionsやGitLab CIでは、CIのジョブ単位でコンテナをセットアップすることができます。仕組みが用意されているのであれば、これに乗っかるのが良さそうです。
https://code.visualstudio.com/docs/devcontainers/create-dev-container#_use-docker-compose
https://docs.github.com/ja/actions/using-containerized-services/about-service-containers
https://docs.gitlab.com/ee/ci/services/

とはいっても、テストの並列化に対応するためには、各リソースを、少なくともそのリソースを使用するパッケージ数分用意する必要があり、場合によっては現実的でない可能性があります。

やはり、一つのリソースを安全に使い回す仕組みを構築できれば、開発者がよりハッピーになるのでは…という考え方に辿り着きます。

RDBMSを使う場合にできる工夫

外部リソースにもいろいろありますが、RDBMSの場合はいくつか工夫が考えられます。

  1. 全てのDB操作をトランザクションに閉じ込めて、テストケースの最後にロールバックする
  2. パッケージやテストケースごとに専用のデータベース/スキーマを作成する

1. 全てのDB操作をトランザクションに閉じ込めて、テストケースの最後にロールバックする

先に紹介したLTのスライドでは、achiku/pgtxdbを使う例が紹介されています。
https://github.com/achiku/pgtxdb

使用しているデータベースやそのドライバとなるパッケージで使えるライブラリがある、あるいは自分で実装できるという場合には良いかもしれません(この方法は検討したことがないのであまり知識がありません)。

2. パッケージやテストケースごとに専用のデータベース/スキーマを作成する

私が参加したプロジェクトでは、トランザクションでもRDBMSのインスタンスでもなく、データベースやスキーマを分離する方法を採用しました。
パッケージやテストケースごとにデータベースを作成し、DDLを流しこんだ上で、テストコードから使用します。

以下の例では、DatabaseNameForTest()関数の呼び出し元の関数名(${package}.${function})から、データベース/スキーマ名を作成します。

func DatabaseNameForTest(t *testing.T) {
	t.Helper()
	
	pc, _, _, ok := runtime.Caller(1)
	if !ok {
		t.Fatal("failed to get caller")
	}
	packageName := runtime.FuncForPC(pc).Name()
	
	// データベース/スキーマ名として使用できない文字を置換する。
	return strings.NewReplacer(
		"/", "_",
		".", "_",
		"-", "_",
	).Replace(packageName)
}

// example.com/schema-nameパッケージのTestA関数からの呼び出しの場合
// ↓
// "example_com_schema_name_TestA"

これにより、各テストでユニークなデータベース名を考える必要なしに、専用のデータベースを用意してあげることが可能になります。
作成した名前のデータベースがすでに存在していれば削除し、改めてDDLを流してあげることで、前回のテスト実行の影響を受けないようにすることができます。

devcontainerで立ち上げたコンテナは、テストの他に開発中の動作確認のためにも使用されることが想定されますが、データベースを分離することでテスト実行の影響が及ぶことを避けることができます。

ですがこれらは、RDBMSにトランザクションと任意のデータベースを作成する機能があるからこそ実現できるものであり、たとえばRedisを使う場合などにはそううまくいきません。

拙作パッケージの話

以前、Dockerコンテナの起動と、プロセスを跨いでコンテナ利用を制御するためのパッケージを作成し、紹介記事を書きました。

https://github.com/daichitakahashi/confort
https://zenn.dev/daichitakahashi/articles/9e091863f158b7

これは、以下を実現するために作ったものでした。

  • テストコードからのコンテナ起動
  • すでにコンテナが存在している場合の再利用
  • 「コンテナに対する初期化処理」と「コンテナ利用」の排他制御
  • プロセスを跨いでユニークな値の生成

そう、盛り込みすぎです。これをメンテしていく中で課題や気付きがありました。

一つは、Docker関連パッケージの更新についてです。
Dockerの特性上仕方がないのですが、依存しているパッケージにCVEが発表されることがあり、それに伴ってセキュリティフィックスが発生します。confortではDocker APIを介してDockerとインタラクションしているため影響がないものが多いはずですが、依存しているパッケージに脆弱性が存在する状態は好ましくなく、confortのメンテナーとその利用者側の両方でアップデート対応が必要になります。

こういったメンテナンスは、すでに広く使用されているtestcontainers/testcontainers-goory/dockertestをあてにした方がよいと感じています。

もう一つ、「プロセスを跨いでユニークな値の生成」。これについては自分が経験したユースケースがIDやそれに準ずる文字列の生成のみだったので、「UUIDを使えばよい」という結論に至っています。衝突したらそれはその時。

daichitakahashi/rsmap

これらの反省を生かして、プロセスを跨いだ排他制御のみを担うパッケージを開発しています。
https://github.com/daichitakahashi/rsmap

rsmapでは外部リソースの用意はサービスや環境にまかせ、排他制御だけにフォーカスしています。confortではプロセス間の排他制御を実現するために専用のコマンド=プロセスが必要でしたが、それも必要としない作りになっています。
testcontainers/testcontainers-goと組み合わせてコンテナを起動してもよし、起動済みのリソースを使うもよし、です。

以下のように使います。

sample_test.go
func TestA(t *testing.T) {
	t.Parallel()
	
	m, _ := rsmap.New(".rsmap")
	t.Cleanup(m.Close)
	
	r, _ := m.Resource("resource_A")
	_ = r.Lock(ctx)
	t.Cleanup(func() {
		_ = r.UnlockAny()
	})
	// use resource A...
}
package_1/sample_test.go
func TestB(t *testing.T) {
	t.Parallel()
	
	m, _ := rsmap.New("../.rsmap")
	t.Cleanup(m.Close)
	
	r, _ := m.Resource("resource_A")
	_ = r.Lock(ctx)
	t.Cleanup(func() {
		_ = r.UnlockAny()
	})
	// use resource A...
}

現在、絶賛開発中でして、安定して動くように検証と調整が必要です。
もっとブラッシュアップしたら紹介記事でも書くかもしれません。

最後に

これまで書いてきた課題を拙作のパッケージが根本的に解決します!と持っていければ良いのですが、残念ながらここまでです。
ここで書いてきた課題に取り組んでいるチームは少なくないと思いますので、ベストプラクティスがもっと出回るようになればいいなと思います。

長文に目を通していただきありがとうございました🙇

Discussion