本記事は、はてなエンジニア - Qiita Advent Calendar 2024 - Qiitaの11日目の記事です。昨日は、
id:Windymelt さんの esbuildでScala.jsをビルドして呼べるようにするプラグインを作った話 - Lambdaカクテル でした。
Goでユニットテストを書くときは、Table Driven Tests が頻繁に使われています。Table Driven Test によって一定程度読みやすいテストコードを書くことができますが、入力の数が多かったり比較項目が複雑になるとアサーション部分で条件分岐が起きてしまい書きにくくなることがあります。 そこで柔軟にテストケースを記述できる script-based test cases を導入したテスト手法を紹介したいです。 script-based test cases とは、 research!rsc: Go Testing By Example で紹介された文字通りスクリプトによってテストケースを書くことです。
実際の例をみたほうが理解しやすいでしょう。次のスクリプトは、go.dev / golang.org のウェブサーバの script-based test cases の例です。
GET https://go.dev/blog/ body contains The Go Blog header Content-Type == text/html; charset=utf-8 GET https://go.dev/blog/ body contains The Go Blog header Content-Type == text/html; charset=utf-8 GET https://golang.google.cn/blog/ body contains The Go Blog header Content-Type == text/html; charset=utf-8 GET https://go.dev/blog/2010/08/defer-panic-and-recover.html redirect == /blog/defer-panic-and-recover
各行にスペース区切りでコマンドと引数が書かれています。
字面から推測できる通り1行目は、HTTPメソッドの種類とリクエストURLを指定してます。つまり、GETメソッドで https://go.dev/blog/ へのリクエストをテストせよという意味です。
続く2~3行目がアサーション部分に当たります。レスポンスボディに The Go Blog の文字列が含まれ、かつヘッダーに Content-Type == text/html; charset=utf-8 があることをチェックせよという意味です。
テストケースは、空行で分けられます。
go test によってテストを実行すると、内部的には、前述のスクリプトに従ってリクエストを組み立て httptest.NewRecorder() と ServeHTTP() を組み合わせてシミュレートします。そして、アサーション部分に沿ってレスポンスのチェックを行います。1
スクリプトを使ってリクエストの組み立てとアサーションを書いていること以外は、一般的なHTTPサーバーのテスト手法と同じです。
リダイレクトのチェックやレスポンスボディの内容チェックなど、テスト内容がケースごとに異なるので Table-Driven Tests では書きにくいテストですが、スクリプトでテストケースを宣言することでシンプルに記述できていると思います。
そろそろ script-based test cases を書きたくなったころではないでしょうか? 実は、script-based test cases を簡単に書けるようにするためのパッケージが rsc.io/script として公開されてます。 github.com
rsc.io/script を使った script-based test cases の例
script-based test cases を試すためサンプルとして1から引数で渡した値まで fizzbuzz を出力するだけのCLIツールを作りました。
$ ./fizzbuzz 5 1 2 Fizz 4 Buzz
このfizzbuzzのコマンドの引数とそれに対する標準出力の結果が期待どおりかを確認するテストをrsc.io/script を使って書きました。 rsc.io/script によるテストは、 scripttest.Test() メソッドに *script.Engine オブジェクトとテストケースの保存場所を指定して呼ぶことで行われます。script Engine オブジェクトがコマンドに応じた処理をする本体です。
func TestAll(t *testing.T) { ctx := context.Background() engine := script.NewEngine() env := os.Environ() scripttest.Test(t, ctx, engine, env, "testdata/*.txt") }
script.NewEngine() は、デフォルト値を埋めた*script.Engineを返すパッケージ関数です。
script.Engine 構造体は、ログ出力の有無を表すQuiet フラグとマップ型のCmdsフィールドとCondsフィールドを持ちます。Cmds はコマンド名、Conds は条件名を文字列型のキーとし、値には、そのキーに対応する処理を実行する関数が入ります。
条件は、[Cond] の形で書かれたもののことです。例えば [!GOOS:windows] stop と書くことで GOOS=Windows 以外のときに、そのファイルに書かれた以降のテストケースの読み込みを中断してテストをスキップできます。
自前のコマンドや条件を書けるようにしたいときは、マップ型のCmdsフィールドやCondsフィールドに要素を追加するだけです。
コードを読んだところ script.Command() や script.Condition() 経由で作るのが想定されているようでした。
デフォルトで定義されるコマンドは DefaultCmds() を、条件は DefaultConds() からコードを追えば理解できます。
rsc.io/script には、デフォルトで Linux コマンドの cat と同等のコマンドやPATHに含まれるバイナリを実行する exec コマンドは定義されていますが、自作の fizzbuzz をテストするためのコマンドは当然提供されていません。そのため、デフォルトのコマンドに加えて独自の run コマンドを定義しました。run コマンドの実態を fizzbuzz/fizzbuzz_test.go at 324dd888c4d7f85b9871f1419675c831d3f6b185 · tomato3713/fizzbuzz · GitHub で定義して、それを engine.Cmds["run"] に代入することで実装しました。
run コマンドは、os.Args に引数をそのまま渡して fizzbuzz のmain関数を実行します。つまり、例えば run 3 と書いたら fizzbuzz 3 を実行した場合と同等の処理が実行されます。
標準出力のテストは、rsc.io/script が cmp コマンドを提供しているので、そのまま利用しました。cmp コマンドはファイルの比較を行います。1番目の引数がテスト対象の処理によって実際の結果、2番目の引数に期待する結果の意味です。1番目の引数に stdout または stderr と書くことで標準出力、標準エラー出力を指定できます。
定義した run コマンドと cmp コマンドを使った script-based test cases の1例を以下に示します。 case.txt は 末尾に txtar 形式で宣言したファイルを表します。
run 3 cmp stdout case.txt -- case.txt -- 1 2 Fizz
テストケース全体は fizzbuzz/testdata/a.txt at main · tomato3713/fizzbuzz · GitHub を見てください。
単純な引数だけだとあまり面白みはありませんが、オプションが増えてくると script-based test cases の柔軟さが活きてくるでしょう。 script-based test cases は滅多に使うことがないかもしれませんが、柔軟性が高くテストケースを記述できる強力な手法です。 頭の片隅に覚えておいても損はないと思います。
はてなエンジニア - Qiita Advent Calendar 2024 - Qiita の明日の担当は、
id:maiyama4 さんです。
- website/internal/webtest/webtest.go at 335437c0d7e4044d277e91edda5208c1d9df4977 · golang/website · GitHub がリクエストをシミュレートしている部分の起点なようでした。↩