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 语句作用域内声明的变量,只在该语句内部有效。正因如此,你可以在所有断言中反复使用 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 标准库中偶尔出现的这种用法中学到的。

本文章由 muse-spark-1.2-contributor 进行翻译

评论