kakakakakku blog

Weekly Tech Blog: Keep on Learning!

go-approval-tests: Go で「承認テスト」を実装する

Approval Tests というツールセットを使うと簡単に「Approval Testing(承認テスト)」を実装できる.Approval Testing では,スナップショットテストのように "結果全体を意識した" テストコードを記述する.GitHub には Golden master Verification Library とも書かれていて,BDD (Behavior-Driven Development) で使われることもある.

単体テストを実装するときに一般的に使われる assertEquals のようなアサーションだと値ごとに細かく検証することになるため,ファイル全体やオブジェクト内容を検証するときの選択肢になる.今回紹介する Approval Tests は多くのプログラミング言語をサポートしている💡

approvaltests.com

最近 Go で go-approval-tests (ApprovalTests.go) を使う機会があって,学んだことを簡単にまとめておくことにした❗️ドキュメントを確認すると多くの Verify 関数が実装されている.以下に代表的なものを載せておく📝他にもまだまだある〜

  • VerifyString()
  • VerifyMap()
  • VerifyJSONBytes()
  • VerifyJSONStruct()
  • VerifyAllCombinationsFor1()
  • VerifyAllCombinationsFor2()

github.com

pkg.go.dev

approvals/ApprovalTests.Go.StarterProject リポジトリ

今回は動作確認に使える Starter Project(サンプルプロジェクト)を使って go-approval-tests を試す.まず sample.gosample_test.go を載せておく❗️

github.com

sample.go

  • Person 構造体
package starterpackage

type Person struct {
    Name string
    Age  int
}

sample_test.go

  • TestNormalTest() テスト(一般的な値比較)
  • TestBasicApproval() テスト(go-approval-tests で文字列比較)
  • TestJsonApproval() テスト(go-approval-tests で JSON 比較)
  • TestCombinationsApproval() テスト(go-approval-tests で組み合わせ比較)
package starterpackage

import (
    "fmt"
    approvals "github.com/approvals/go-approval-tests"
    "testing"
)

func TestNormalTest(t *testing.T) {
    if 5 != 5 {
        t.Fatal("5 != 5")
    }
}

func TestBasicApproval(t *testing.T) {
    approvals.VerifyString(t, "Hello Approvals")
}

func TestJsonApproval(t *testing.T) {
    person := Person{
        "John Galt", 100,
    }
    approvals.VerifyJSONStruct(t, person)
}

func TestCombinationsApproval(t *testing.T) {
    arr := []int{10, 20, 30, 40}
    names := []string{"john", "paul", "mary"}
    approvals.VerifyAllCombinationsFor2(t, "File Names", func(a interface{}, b interface{}) string { return fmt.Sprintf("%v_%v.txt", a, b) }, names, arr)
}

VerifyString() 関数

まず VerifyString() 関数を確認する.以下のコードでは Hello Approvals という文字列が返ってくる場合(正確にはビジネスロジックから得られた値)をテストしている.go-approval-tests では「期待値」はファイルに記載する.

func TestBasicApproval(t *testing.T) {
    approvals.VerifyString(t, "Hello Approvals")
}

このままテストを実行すると,自動的に以下の2ファイルが作られる.received.txt には戻り値(いわゆる got)が設定されていて,approved.txt には期待値(いわゆる expected)をこれから設定する.

  • sample_test.TestBasicApproval.received.txt
  • sample_test.TestBasicApproval.approved.txt

そこで approved.txt に期待値として Hello Approvals を設定して再実行するとテストが通って,received.txt は自動的に削除される.なお,macOS で go-approval-tests を実行すると,デフォルトでは Xcode の FileMerge を使って GUI で差分を確認できる.今回の例だと文字列が短いけど,もっと長かったりすると差分を明確に確認できて便利❗️

次に戻り値を Hello Approvals ではなく Bye Approvals のように意図的に間違えて再実行すると,以下のように「どのような差分があるのか」を確認できる.

VerifyJSONStruct() 関数

go-approval-tests の GitHub に以下のように書かれている通り,単純な文字列よりも複雑なオブジェクトなどをテストするときにメリットが出てくると思う.

ApprovalTests allows for easy testing of larger objects, strings and anything else that can be saved to a file (images, sounds, csv, etc...)

VerifyJSONStruct() 関数や VerifyJSONBytes() 関数を使うと JSON オブジェクトをテストできて,サンプルコードでは VerifyJSONStruct() 関数を使って Go の struct(構造体)をテストしている.

func TestJsonApproval(t *testing.T) {
    person := Person{
        "John Galt", 100,
    }
    approvals.VerifyJSONStruct(t, person)
}

実行すると,自動的に以下の2ファイルが作られる.

  • sample_test.TestJsonApproval.received.json
  • sample_test.TestJsonApproval.approved.json

approved.txt に期待値として以下の JSON(Person 構造体に値を設定して JSON 化した)を設定してテストを再実行するとテストが通る❗️

{
  "Name": "John Galt",
  "Age": 100
}

次に戻り値の Age 「意図的に 1000 にして再実行すると,以下のように差分を確認できる.

VerifyAllCombinationsFor2() 関数

VerifyAllCombinations ではオブジェクト(コレクション)の組み合わせを検証できる.引数に与えるコレクション数によって VerifyAllCombinationsFor1() から VerifyAllCombinationsFor9() まである.サンプルコードでは VerifyAllCombinationsFor2() 関数が使われていて,id と name の配列を組み合わせて "ファイル名" を生成するロジックの検証に go-approval-tests を使っている.

func TestCombinationsApproval(t *testing.T) {
    arr := []int{10, 20, 30, 40}
    names := []string{"john", "paul", "mary"}
    approvals.VerifyAllCombinationsFor2(t, "File Names", func(a interface{}, b interface{}) string { return fmt.Sprintf("%v_%v.txt", a, b) }, names, arr)
}

流れは同じで,実行すると,自動的に以下の2ファイルが作られる.

  • sample_test.TestCombinationsApproval.received.txt
  • sample_test.TestCombinationsApproval.approved.txt

approved.txt に以下のような期待値(記述は received.txt をコピーすれば OK)を設定してテストを再実行するとテストが通る❗️

File Names


[john,10] => john_10.txt
[john,20] => john_20.txt
[john,30] => john_30.txt
[john,40] => john_40.txt
[paul,10] => paul_10.txt
[paul,20] => paul_20.txt
[paul,30] => paul_30.txt
[paul,40] => paul_40.txt
[mary,10] => mary_10.txt
[mary,20] => mary_20.txt
[mary,30] => mary_30.txt
[mary,40] => mary_40.txt

⛏️ UseFolder() 関数

デフォルトだと received.txtapproved.txt はテストコードと同じディレクトリ階層に並ぶ.テストコードが増えるほどファイルが増えて気になるようになると思う.そのときは UseFolder() 関数を使って,特定のディレクトリにファイルをまとめられる.設定する場合は Go testing を実行するときのエントリーポイントになる TestMain に以下のように実装する.

func TestMain(m *testing.M) {
    approvals.UseFolder("testdata")
    os.Exit(m.Run())
}

⛏️ UseReporter() 関数

macOS のデフォルトだと,差分確認には Xcode の FileMerge が使われる.UseReporter() 関数を使うと任意のツールに変更できる.サポートされているツールは以下など.

  • VS Code (Visual Studio Code)
  • IntelliJ
  • GoLand
  • diff コマンド

例えば VS Code を使う場合は以下のように実装する.UseFolder() 関数と組み合わせて TestMain に実装できる.

func TestMain(m *testing.M) {
    r := approvals.UseReporter(reporters.NewVSCodeReporter())
    defer r.Close()
    approvals.UseFolder("testdata")
    os.Exit(m.Run())
}

実行すると以下のように VS Code で差分を確認できた.

もし GitHub Actions など CI/CD パイプラインの実行中に差分が出てしまうこともあると思う.その場合は単純に diff コマンドを使った差分として NewRealDiffReporter() 関数も使える.env 環境変数などによって挙動を切り替えておくと良さそう.

func TestMain(m *testing.M) {
    r := approvals.UseReporter(reporters.NewRealDiffReporter())
    defer r.Close()
    approvals.UseFolder("testdata")
    os.Exit(m.Run())
}

実行すると,以下のようにテキストで差分を確認できた.real_diff_reporter.go で実装を確認したところ exec.Command("diff", "-u", approved, received) のようになっていた.

--- testdata/sample_test.TestJsonApproval.approved.json  2023-03-03 15:50:00
+++ testdata/sample_test.TestJsonApproval.received.json   2023-03-03 16:00:00
@@ -1,4 +1,4 @@
 {
   "Name": "John Galt",
-  "Age": 100
+  "Age": 1000
 }