if got, want: A Simple Way to Write Better Go Tests

Michael Lynch

if got, want: より良いGoテストを書くためのシンプルな方法

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

あまり知られていませんが、Goには優れたテストパターンがあります。30秒でお教えしましょう。

このようにGoのテストを書くのではなく、

// The common, unrefined way.
username := GetUser()
if username != "dummyUser" {
  t.Errorf("unexpected username: got %s, want: %s", username, "dummyUser")
}

次のように、各アサーションをif got, want :=で始めて書いてみてください。

// The underused, elegant way.
if got, want := GetUser(), "dummyUser"; got != want {
  t.Errorf("username=%s, want=%s", got, want)
}

if got, want :=パターンは、テーブル駆動テストでさらに威力を発揮します。ソーシャルメディアハンドルをパースする私のライブラリからの例を紹介します。

func TestParseTwitterHandle(t *testing.T) {
  for _, tt := range []struct {
    explanation    string
    input          string
    handleExpected social.TwitterHandle
    errExpected    error
  }{
    {
      "regular handle on its own is valid",
      "jerry",
      social.TwitterHandle("jerry"),
      nil,
    },
    {
      "regular handle in URL is valid",
      "https://twitter.com/jerry",
      social.TwitterHandle("jerry"),
      nil,
    },
    {
      "handle with exactly 15 characters is valid",
      "https://twitter.com/" + strings.Repeat("A", 15),
      social.TwitterHandle(strings.Repeat("A", 15)),
      nil,
    },
    {
      "handle with more than 15 characters is invalid",
      "https://twitter.com/" + strings.Repeat("A", 16),
      social.TwitterHandle(""),
      social.ErrInvalidTwitterHandle,
    },
  } {
    t.Run(fmt.Sprintf("%s [%s]", tt.explanation, tt.input), func(t *testing.T) {
      handle, err := social.ParseTwitterHandle(tt.input)
      if got, want := err, tt.errExpected; got != want {
        t.Fatalf("err=%v, want=%v", got, want)
      }
      if got, want := handle, tt.handleExpected; got != want {
        t.Errorf("handle=%v, want=%v", got, want)
      }
    })
  }
}

このパターンはどのように動くのか?

Goのシンプルなif文は、真偽値を評価します。

// A simple if statement that evaluates a boolean expression.
if volume > maxVolume {
  volume = maxVolume
}

Goには、真偽値を評価する前に文を実行できる、もう一つのifの形があります。

// Execute a statement before evaluating the boolean expression.
if volume := getRequestedVolume(); volume > maxVolume {
  panic("requested volume is too high")
}

巧妙なのは、if文の中で複数の変数を宣言・代入できることです。

// Declare and assign multiple variables within if statement.
if a, b, c := nextScore(), nextScore(), nextScore(); a + b + c == 300 {
  fmt.Println("Congratulations! You got a perfect score!")
}

if文のスコープ内で宣言した変数は、そのif文の中でのみ存在します。そのため、すべてのアサーションでgotwantという変数名を、名前の衝突を気にせず再利用できるのです。

実際、if文の外でgotwantにアクセスしようとすると、Goコンパイラはその変数が存在しないと教えてくれます。

// got and want are only available within the if statement.
if got, want := GetUser(), "dummyUser"; got != want {
  t.Errorf("username=%s, want=%s", got, want)
}
log.Printf("username was %s", got) // This won't compile

このテクニックの何が優れているのか?

重要な情報が一目で分かるようになる

Goのコードは冗長になりがちで、特にテストではその傾向が顕著です。

次のテストスニペットを考えてみましょう。

users := GetAllUsers()
if len(users) != 1 {
  t.Fatalf("expected only a single user, got %d", len(users))
}
if users[0].username != adminUsername {
  t.Errorf("unexpected username: got %s, want: %s", users[0].username, adminUsername)
}

一目見て、どちらが期待する値で、どちらがGetAllUsersが返した値なのか分かるでしょうか。私には分かりません。

上のスニペットをif got, want :=パターンで書き直すと、この曖昧さは解消されます。

users := GetAllUsers()
if got, want := len(users), 1; got != want {
  t.Fatalf("userCount=%d, want=%d", got, want)
}
if got, want := users[0].username, adminUsername; got != want {
  t.Errorf("username=%s, want: %s", got, want)
}

このパターンを知っていれば、テストアサーション中の重要な情報を素早く見つけられるようになります。

このパターンを覚えれば、アサーションの実際の値と期待される値を素早く見つけられるようになります。

コピー&ペーストが簡単になる

変数名が常にgotwantで統一されていれば、アサーションをコピー&ペーストする際に変更する箇所はほとんどありません。通常は、代入部分とt.Errorf内の名前、そして必要に応じてフォーマット指定子(例えば%s%v)を変えるだけで済みます。

このパターンは、私が過去によくやっていたミスも防いでくれます。テストアサーションをコピー&ペーストした際に、エラーメッセージの一部を更新し忘れるというミスです。例えば次のようなケースです。

username := GetUser()
if username != "admin" {
  t.Errorf("wrong username: got %s, want %s", username, "admin")
}

email := GetEmail()
if email != "[email protected]" {
  // Whoops, copy/pasted from above but forgot to update the error message.
  t.Errorf("wrong username: got %s, want %s", username, "admin")
}

このミスにはテストが失敗するまで気づかず、次のような混乱を招くエラーメッセージが出力されることになります。

--- FAIL: TestUserProperties (0.00s)
    users_test.go:24: wrong username: got admin, want admin

if got, want :=パターンなら、テストアサーションをコピー&ペーストしても一箇所だけ値を更新すればよいので、この種のエラーを防ぐことができます。

テストのアサーションとテストのロジックを区別できる

GoでHTTPサーバーを実装する際、私はよく次のような単体テストを書きます。

func TestUserHandler(t *testing.T) {
    for _, tt := range []struct {
        explanation      string
        payload          string
        statusExpected   int
        responseExpected string
    }{
        {
            "valid request returns success",
            "username=doug",
            http.StatusOK,
            "created user doug",
        },
        {
            "reject username with angle brackets",
            "username=d<script>oug",
            http.StatusBadRequest,
            "",
        },
        {
            "reject empty username",
            "username=",
            http.StatusBadRequest,
            "",
        },
    } {
        t.Run(tt.explanation, func(t *testing.T) {
            req, err := http.NewRequest("POST", "/user", strings.NewReader(tt.payload))
            if err != nil {
                t.Fatal(err)
            }

            s := NewServer()
            rec := httptest.NewRecorder()
            s.Router().ServeHTTP(rec, req)
            res := rec.Result()

            if got, want := res.StatusCode, tt.statusExpected; got != want {
                t.Fatalf("httpStatus=%v, want=%v", got, want)
            }

            // If this is not a test for valid input, ignore the rest of the
            // server's response.
            if tt.statusExpected != http.StatusOK {
                return
            }

            body, err := io.ReadAll(res.Body)
            if err != nil {
                t.Fatal(err)
            }
            defer res.Body.Close()

            if got, want := string(body), tt.responseExpected; got != want {
                t.Fatalf("response=%s, want=%s", got, want)
            }
        })
    }
}

このテスト本体には、2種類のif文があります。テストのアサーションと、テストのロジック分岐です。

if got, want :=パターンを使ったif文はすべて、テスト対象のコードに対するアサーションです。それ以外のif文は単にコードの流れを制御しているだけで、自分のコードに対するアサーションではありません。

例えば、テスト内の最初のif文は、HTTPリクエストオブジェクトを正しく構築できたかを確認するためのものです。

req, err := http.NewRequest("POST", "/user", strings.NewReader(tt.payload))
if err != nil {
    t.Fatal(err)
}

これは私のコードに対するアサーションではありません。まだサーバーすら呼び出していないからです。もしここで失敗するなら、Goの標準ライブラリで何かおかしなことが起きているということになります。

一方で、読者はif got, want :=を見かけるたびに、そこがテスト対象のコードに対するアサーションであると確信できます。

if got, want := res.StatusCode, tt.statusExpected; got != want {
    t.Fatalf("httpStatus=%v, want=%v", got, want)
}

サードパーティのテストアサーションライブラリを使わないのはなぜか?

もしあなたがtestifyisのようなサードパーティ製テストライブラリの熱心なユーザーなら、この記事は馬鹿げているように聞こえるかもしれません。そうしたライブラリは表現力豊かなテスト出力と明確なアサーションを提供してくれます。では、なぜ私はそれらを使わないのでしょうか。

私はPythonからGoに移ってきたので、GoにPythonのunittest.assertEqualのようなAPIがないのは不合理だと思っていました。すぐにモックの作成やアサーションのためにサードパーティのライブラリに手を伸ばしましたが、より経験豊富なチームメイトから、Go標準ライブラリのテストAPIを試してみるように勧められたのです。

やがて私は、サードパーティのライブラリよりも、Go標準のテストライブラリのミニマリズムと明示性を好むようになりました。ライブラリは管理すべき依存関係が一つ増えることになり、バグを招きかねない抽象化レイヤーが一つ増えることにもなります。

謝辞

このテクニックはLitestreamの作者であるBen Johnson氏から学びました。Ben氏自身は、Go標準ライブラリで時折使われているのを見てこの手法を知ったそうです。

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

コメント