【Go】テーブル駆動テストのエラーチェックは関数パターンがおすすめ

記事公開時点ではSREの市川です。

というのも2024年の大晦日を以て退職となるのですが、実は【カヤック】面白法人グループ Advent Calendar 2024の7日目の記事をすっぽかしていたので、Go におけるテストの話を書いて置き土産といたします。

ケーススタディ

以下のようなSUT(テスト対象)があるとします。

package foo

func DoSomething(input string) int {
    // 何かしらの処理
}

この限りでは、SUTがエラーを返さないのでエラーチェックの必要はありません。つまり、以下のようなテストコードを書くことができます。

package foo_test

import (
    "testing"

    "foo" // your SUT package

    "github.com/stretchr/testify/require"
)

func TestDoSomething(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  int
    }{
        {
            name:  "when input is foo",
            input: "foo",
            want:  3,
        },
        // 他のテストケースを列挙
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := foo.DoSomething(tt.input)
            require.Equal(t, tt.want, got)
        })
    }
}

stretchr/testify についても、かなり普及しているモジュールだと思うので詳しい解説は割愛しますが、Go のテストにおけるアサーションを行うためのモジュールです。

require パッケージの諸関数は、要求を満たさなかった場合に当該テストを失敗としてゴルーチンを即時終了させます。 t.Run のコールバックは個別のゴルーチンで実行されるので、上記コードにおいて require が失敗時に中断する検証処理は個々のテストケースに閉じます。

エラーを返す関数のテスト

さて、本題に戻って、エラーを返す関数のテストをどう書くかを考えてみましょう。

先ほどの例を以下のように変更した場合を考えます。

package foo

func DoSomething(input string) (int, error) {
    // 何かしらの処理
}

この場合、テストコードはどう書けばよいでしょうか?

書き方① require.ErrorIs で比較

割とベーシックなのはこのパターンかなと思います。もちろんこれも下の例のようにSUTが返すエラーが変数として定義されていれば過不足なく検証可能です。

 func TestDoSomething(t *testing.T) {
        tests := []struct {
                name  string
                input string
                want  int
+               wantErr error
        }{
                {
                        name:  "when input is foo",
                        input: "foo",
                        want:  3,
                },
+               {
+                       name:    "invalid input",
+                       input:   "bar",
+                       wantErr: foo.ErrInvalidInput,
+               },
                // 他のテストケースを列挙
        }

        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
-                       got := foo.DoSomething(tt.input)
-                       require.Equal(t, tt.want, got)
+                       got, err := foo.DoSomething(tt.input)
+                       require.ErrorIs(t, err, tt.wantErr)
+                       if err == nil {
+                               require.Equal(t, tt.want, got)
+                       }
                })
        }
 }

なお、wantErr が暗黙で nil になっている箇所もありますが、errors.Is(nil, nil)true になるので問題ありません。

stretchr/testify は内部的に Go の標準パッケージの errors を使っており、errors.Is() の挙動はPlaygroundで確かめることが可能です。

書き方② 関数で比較

これに対して、今回おすすめしたいのは、テストケースにエラーチェック用の関数を追加する方法です。

この方法のメリットは、とにかく柔軟にテストケースを記述できることです。エラーが単純な変数ではなく独自の型として定義されている場合の詳細な比較もできますし、「諸般の事情から文字列チェックをするしかない」みたいなケースにも簡単に対応できます。

 func TestDoSomething(t *testing.T) {
        tests := []struct {
                name  string
                input string
                want  int
+               errorCheck func(*testing.T, error)
        }{
                {
                        name:  "when input is foo",
                        input: "foo",
                        want:  3,
+                       errorCheck: func(t *testing.T, err error) {
+                               require.NoError(t, err)
+                       },
+               },
+               {
+                       name:  "invalid input",
+                       input: "bar",
+                       errorCheck: func(t *testing.T, err error) {
+                               require.ErrorIs(t, err, foo.ErrInvalidInput)
+                       },
+               },
+               {
+                       name:  "empty input",
+                       input: "",
+                       errorCheck: func(t *testing.T, err error) {
+                               if !strings.Contains(err.Error(), "empty input") {
+                                       t.Errorf("unexpected error message: %v", err)
+                               }
+                       },
                },
                // 他のテストケースを列挙
        }

        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
-                       got := foo.DoSomething(tt.input)
-                       require.Equal(t, tt.want, got)
+                       got, err := foo.DoSomething(tt.input)
+                       tt.errorCheck(t, err)
+                       if err == nil {
+                               require.Equal(t, tt.want, got)
+                       }
                })
        }
 }

Go のテーブル駆動テストのコードは、とにかく縦長になる傾向があり、個々のテストケースと t.Run 内の検証処理を行ったり来たりすると疲れが溜まります。

そのため、できれば個々のテストケースだけを見た時に受ける直感を、完全に頼り切れるように設計したいと常々思っています。

ebi-yade/gotest/cases のご紹介

今どきLLMに入力補助をしてもらったり様々なソリューションがあるので「タイプ数が多くて面倒臭い」という気持ちとは折り合いをつけやすいですが、それもケースバイケースです。

好みによっては ebi-yade/gotest/cases というパッケージが助けになるかもしれません。

以下のように記述することで、エラーチェック関数の記述量を削減することが可能です。

package foo_test

import (
    // 略
    "github.com/ebi-yade/gotest/cases"
)

func TestDoSomething(t *testing.T) {
    tests := []struct {
        name       string
        input      string
        want       int
        errorCheck func(*testing.T, error)
    }{
        {
            name:       "when input is foo",
            input:      "foo",
            want:       3,
            errorCheck: cases.NoError,
        },
        {
            name:       "invalid input",
            input:      "bar",
            errorCheck: cases.ErrorIs(foo.ErrInvalidInput),
        },
    // 

ソースコードとしても t.Helper() を呼んで require をラップしている程度なので、もしインポートすることに抵抗があれば、プロジェクト内のユーティリティパッケージにコピペしていただいても構いません。

まとめ

テストは書くのも読むのも少なからず負担がかかりますが、テストの品質はソフトウェアの品質に大きな影響を与えます。 また、新たにチームに参加したエンジニアの幸福度にも直結する項目でもあると思います。

少しだけ厄介な問題に足を踏み入れることで、自信を持ってチームメンバーに仕事を任せられるようになると良いですね。

カヤックではテストで愛を表現できるエンジニアも募集しています❤️

hubspot.kayac.com

ありがとうを、実装で

この記事はTech KAYAC Advent Calendar 2024の10日目の記事です。

皆さん、2024も終わりますね。メリークリスマス。良いお年を!

「ぼくらの甲子園!ポケット 高校野球ゲーム」はご存知でしょうか? 略して「ぼくポケ」は、株式会社カヤックのソシャールゲームのオリジナルIPです。 来年をもってサービス終了させていただきますが、運営開始するからなんと長い10年間もサービスを提供しております。

その長い10年間の5分の1、ゲームの終盤に、私ikkakukuku、Unityエンジニアーとして勤めさせていただきました。 優秀な先輩たちから色々優しく教えていただいて、その短い間の中、この未熟者の私でもリーダーを託されてお役に立てて光栄でした。


自分には特有な要素やチャレンジがあると思うので、少し自分のことを書かせていただきたいと思います。 日本に来てから2年半になりまして、母国のインドネシアに大学通っていた時は日本でゲーム開発できることなど想像もできなかったです。 とにかくゲームにはハマってました。詰まらない授業中にはこっそりとゲームしてました。(よくない笑)

勉強中の日本語でコミュニケーションを取るのは勘違いでミスってやらかしたことなどもちろんありましたが、チームにいた時は1回もそれで責められたことがなくて、トラブルあったら全員が解決思考です。成長にばっちりな環境に恵まれただと日々仕事して感謝の気持ちで溢れています。


これから後述する記事はそのありがたい気持ちを自分ができること、実装、に注ぐ話です!

よろしければ読んでいただけたら幸いです。

TL;DR

デバッグツールを元にしたアバター編集機能をブラッシュアップしてゲームに組み込みました!

作ったもの: プロフィールカード機能

まずはできたものをご覧いただきたいと思います!

プロフィールカード機能

限られた時間と人手の中で作ったプロフィールカードです! プレイヤー様が頑張って集めたやきゅう道具、着せ替え、スタンプ、マイキャラ、とアニメーションを選択してmix-and-matchできて、 野球カードのようにアレンジして、思い出になるような一枚が撮れる機能です。

プロフィール機能を作ろうとしたきっかけ

プロフィールカードを作ろうと思った理由は、3つあります。

1. これまでに制作した綺麗な背景をプレイヤー様にもっと見せたい

大まかにUnityエンジニアの課題は2つに分かれています。改善と運営です。一時期、改善タスクが忙しくて運営に手が回らない時がありまして、その大変な中、UIデザイナーさんたち(ありがとうございます。)がチャレンジの気持ちでUnityも操作して、Unityエンジニアの運営タスクを軽くしていただいた事がありました。その運営タスクのものの中に、自分がいつも気になっていたことがあります。ガラポン(ガチャ)訴求一覧に、綺麗な背景が付いてるのに、ボタンやコピー文言でちょっとしか見えなかったことです。私が制作者でしたら、頑張って作った背景をもっとプレイヤー様に見ていただけたら嬉しいでしょうと勝手に思って、この画像が使える新機能を模索しました。

ガラポン背景

2. デバッグツールからのひらめき

  1. デバッグツールからのひらめき 新しい着せ替えややきゅう道具を作った時、グラフィッカーさん(ありがとうございます。)がデバッグツールを使って画像に問題がないとしっかり確認してからリリースします。このデバッグツールは作りが良くて、操作も快適です。一緒に仕事したことはなかったんですが、当時自分のメンターであったまーだーさん(ありがとうございます。)が作ったそうです。これをちょっとブラッシュアップすれば、プレイヤー様にも触っていただける楽しい機能としてリリースできるのではないかと思いました。

マイキャラのデバッグツール

3. プレイヤー様の思い出になるものを作りたい

ゲームがサービス終了するということは、プレイヤー様(ありがとうございます!!)が毎日楽しんでログインして、チームメイトのみんなと話して、同じゴールを目指すことがなくなってしまいます。寂しいですよね。数年後にぼくポケのことを振り返って残っているのは、出会った仲間たちと頑張っていた思い出だと思います。それで、その思い出を豊かにする機能があればいいんじゃないかと思いました。個々の自分らしさをプロフィールカードで表して、「このように私を思い出に留めてください」とカード交換するような機能をイメージしました。

その他のチャレンジとありがとう

  • 企画を提案するのが言わば未経験で、最初はまともな提案じゃなく、漠然としたものでしたが、担当のUIさん(ありがとうございます。)とPDさん(ありがとうございます。)と壁打ちして、より分かりやすい機能になりました。
  • 開発期間が想定よりもかかってしまったので、次の配属先のチームのPDさん(ありがとうございます。)とぼくポケのPMさん(ありがとうございます。)と相談して、リリースできるようにチーム配置を調整していただきました。
  • いつも雰囲気作りをしてくださるSNSチーム(ありがとうございます。)にこの機能を使ったプレイヤー様の投稿をピックアップしていただき嬉しいです。
  • できれば、サーバーさん(ありがとうございます。)と連携して、編集したプロフィールカードをサーバーに保存してゲーム内で見せ合えるようにしたかったんですが、時間の制限で間に合わなかったです。
  • この記事を訂正していただいた編集者たち、macopyさんとcommojunさん、(ありがとうございます。)にも感謝です。

結論

個人情報を守るためにここには載せないですが、SNSで「#ぼくポケ」を検索したらこの機能を楽しんでいただく方がいらっしゃいますので、やり甲斐があったと分かって喜んでおります。 作りたいものを作れたのが幸せですね。皆さんも自分ができることに「ありがとう」を込めてちょっとでも世界を明るくしましょう!

お世話になりました!


カヤックでは感謝で溢れているエンジニアも募集しています!

hubspot.kayac.com