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

Michael Lynch

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

Goのテストには、あまり知られていない優れたパターンがあります。30秒で説明できます。

Goのテストを、次のように書く代わりに:

// 一般的だが、洗練されていない書き方。
username := GetUser()
if username != "dummyUser" {
  t.Errorf("unexpected username: got %s, want: %s", username, "dummyUser")
}

各アサーションをif got, want :=で始めて、次のように書きます:

// あまり使われていない、エレガントな書き方。
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文は、ブール式を評価します:

// ブール式を評価する単純なif文。
if volume > maxVolume {
  volume = maxVolume
}

Goには、ブール式を評価する前に文を実行できるもう一つの種類のifがあります:

// ブール式を評価する前に文を実行する。
if volume := getRequestedVolume(); volume > maxVolume {
  panic("requested volume is too high")
}

ここで便利なのは、if文の中で複数の変数を宣言して代入できることです:

// if文の中で複数の変数を宣言して代入する。
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とwantはif文の中でしか使えない。
if got, want := GetUser(), "dummyUser"; got != want {
  t.Errorf("username=%s, want=%s", got, want)
}
log.Printf("username was %s", got) // これはコンパイルできない

このテクニックの何がそんなに優れているのでしょうか?

重要な情報を見つける目が鍛えられる

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]" {
  // 上のコードをコピー&ペーストしたものの、エラーメッセージの更新を忘れている。
  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 :=パターンを使えば、この種のエラーから守られます。テストのアサーションをコピー&ペーストした場合、値を更新する場所は1か所だけだからです。

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

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標準ライブラリのtesting APIを試してみるよう言われました。

私は次第に、サードパーティ製ライブラリよりも、Go標準のテストライブラリの簡潔さと明示性を好むようになりました。ライブラリを使えば、保守すべき依存関係が1つ増え、バグを持ち込む可能性のある抽象化の層も1つ増えます。

謝辞

このテクニックは、Litestreamの作者であるBen Johnsonから教わりました。Ben Johnsonは、Go標準ライブラリで時折使われている例から学んだそうです。

原文は Michael Lynch により に公開されました。

この記事は「gpt-5.6-terra」を使用して翻訳されました。