更有效的DDD与接口测试套件(二)

97 阅读5分钟

这是我参与2022首次更文挑战的第25天,活动详情查看:2022首次更文挑战

本文为译文,原文链接:www.calhoun.io/more-effect…

在编写接口测试套件时,一个常见的问题是,您会意识到需要额外的数据才能真正编写有用的测试。这通常会带来这样的问题,“但是如果我不知道什么是有效的电子邮件和密码组合该怎么办?”或者,“但是如果我需要在测试之间重置我的认证服务,而接口没有相应的方法,该怎么办?”

在测试时,我们通常需要做一些与生产略有不同的事情。我知道很多人会嘲笑这句话,但这是真的。

当测试任何与数据库相关的内容时,我们可能需要在一些测试之间重置数据库。在生产环境中,您多久重置一次数据库?在您的产品代码中,是否应该有这样的方法呢?

或者,当测试我们在这里讨论的身份验证服务时,我们可能需要一种方法来获得一些有效的电子邮件和密码组合。当然,我们不希望在生产环境中提供这些信息,但是在测试中,我们需要访问这些信息来编写有用的测试用例。

有一些方法可以解决这个问题,但我最喜欢的方法是请求测试套件中的额外数据。

回到我们的身份验证服务示例,我们可能会请求为AuthService实现提供有效和无效凭据的函数。

package apptest

type CredsFn func() (email, pw string)

// valid and invalid are functions we can use to get valid and
// invalid credentials.
func AuthService(t *testing.T, as app.AuthService, valid, invalid CredsFn) {
  t.Run("valid login", func(t *testing.T) {
    validEmail, validPw := valid()
    token, err := as.Login(validEmail, validPw)
    if err != nil {
      t.Errorf("Login() err = %v; want %v", err, nil)
    }
    if len(token) < minTokenLength {
      t.Errorf("len(token) = %v; want >= %v", len(token), minTokenLength)
    }
    // ...
  })
  t.Run("invalid login", func(t *testing.T) {
    invalidEmail, invalidPw := invalid()
    // ...
  })
  // ...
}

然后,我们可以在调用接口测试套件函数的地方提供这些信息。

package jwt_test

func TestAuthService(t *testing.T) {
  validEmail, validPassword := "jon@calhoun.io", "fake-pw"
  us := mockUserStore{
    AuthenticateFn: func(email, password string) (*app.User, error) {
      if email != validEmail || password != validPassword {
        return nil, fmt.Errorf("invalid credentials")
      }
      return &app.User{
        ID: 123,
        Email: validEmail,
      }, nil
    },
  }
  as := jwt.AuthService{
    UserStore: us,
  }
  validFn := func() (email, pw string) {
    return validEmail, validPassword
  }
  invalidFn := func() (email, pw string) {
    return "invalid@email.com", "invalid-pw"
  }
  apptest.AuthService(t, as, validFn, invalidFn)
}

如果这开始成为一种负担,您还可以提供一个结构,以便更容易地构造这些测试套件。例如,我们可能有一些可选的函数来在每个测试用例之前或之后重置我们的认证服务:

type AuthServiceSuite struct {
  AuthService *app.AuthService

  // REQUIRED
  Valid CredsFn
  Invalid CredsFn

  // Optional - useful for resetting a DB perhaps.
  BeforeEach func()
  AfterEach func()
}

func AuthService(t *testing.T, as AuthServiceSuite) {
  // ... use the AuthServiceSuite and its AuthService to run tests
}

这里更重要的一点是,虽然这些数据可能不作为接口定义的一部分提供,但将其作为测试过程的一部分请求是完全可以接受的。只要确保您所请求的信息没有将您锁定在特定的实现中。

在标准库中,我想指出的一个例子是最干净的。TestConn测试套件中。该测试套件旨在测试任何net.Conn实现,但有趣的是,它不接受net.Conn作为参数的一部分:

func TestConn(t *testing.T, mp MakePipe)

相反,TestConn需要一个MakePipe类型,这是一个函数,它将返回两个连接,这样测试可以写入一个连接并读取在第二个连接中写入的内容。此外,MakePipe还需要一个函数来停止用于清理任何可能需要清理的资源的所有操作。

type MakePipe func() (c1, c2 net.Conn, stop func(), err error)

注意:如果您的连接不需要清理,这里的stop函数甚至可以是一个什么都不做的函数。

整个设置允许TestConn函数创建和清理一对全新的net.Conn。每个测试用例的缺点。我们甚至可以在我们的AuthService测试套件中尝试这种模式。

package apptest

type Credentials struct  {
  Email string
  Password string
}

type MakeAuthService func() (as app.AuthService, teardown func(), valid, invalid Credentials)

// valid and invalid are functions we can use to get valid and
// invalid credentials.
func AuthService(t *testing.T, mas MakeAuthService) {
  t.Run("valid login", func(t *testing.T) {
    as, teardown, valid, invalid := mas()
    defer teardown()
    // run the test using as, valid, and invalid
  })
  t.Run("invalid login", func(t *testing.T) {
    as, teardown, valid, invalid := mas()
    defer teardown()
    // run the test using as, valid, and invalid
  })
}

我们甚至可以为这些子测试定义一个自定义类型,使我们的测试看起来更像表驱动测试。

type authTest func(t *testing.T, as app.AuthService, valid, invalid Credentials)

// valid and invalid are functions we can use to get valid and
// invalid credentials.
func AuthService(t *testing.T, mas MakeAuthService) {
  for name, test := map[string]authTest{
    // I try to mimic normal testing naming here minus the exporting part
    // You don't have to do that if you don't want to.
    "valid login": testAuthService_validLogin,
    "invalid login": testAuthService_invalidLogin,
    // ...
  }{
    t.Run(name, func(t *testing.T) {
      as, teardown, valid, invalid := mas()
      defer teardown()
      test(t, as, valid, invalid)
    })
  }
}

结束语

虽然我不期望每个人都能读到这篇文章并立即使用界面测试套件,但我确实认为它们值得提前思考。接口测试套件可以是一个非常强大的工具,特别是与DDD搭配使用时。

也就是说,我对建议您将接口测试套件与TDD结合使用持怀疑态度。这并不是说它不能工作——它绝对可以,特别是如果您已经有了接口的实现,并且已经编写了接口测试套件。不幸的是,经验告诉我,当人们试图用接口测试套件实践TDD时,可能会鼓励一些不好的行为,比如在接口的使用从代码自然演化而来之前编写它。如果您现在还没有明白,我强烈支持让界面从代码中浮现出来,而不是预先定义它们。毕竟,我们不是在编写Java😉。