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

Michael Lynch

if got, want:撰寫更佳 Go 測試的簡單方法

有一個非常優秀的 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 := 模式在 table-driven tests(表格驅動測試) 中效果更好。以下範例來自我用於解析社群媒體帳號的函式庫

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)
            }
        })
    }
}

在該測試主體中,有兩種不同類型的 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 實在很荒謬。我立刻就想使用第三方函式庫來建立 mock 並進行斷言,但經驗更豐富的同事們建議我改用 Go 標準函式庫的測試 API 試試看。

後來我反而更偏好 Go 標準測試函式庫的極簡與明確,而非第三方函式庫。那些函式庫是需要額外維護的依賴,也多了一層可能引入錯誤的抽象層。

致謝

這個技巧是我從 Litestream 的作者 Ben Johnson(班·強森) 那裡學來的,而他則是從 Go 標準函式庫中偶爾出現的用法學到這個技巧的。

原文由 Michael Lynch 發布

本文章由 muse-spark-1.2-contributor 進行翻譯