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

735 阅读6分钟

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

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

在本系列的前面,我们开始探讨领域驱动设计(DDD)及其优点。作为这个探索的一部分,我们看到在Go中实现DDD的一种方法是在你的领域中定义接口,这样我们就可以编写与实现细节无关的代码。这让我们可以很容易地把github换成gitlab

拥有这样的接口的另一个好处是,我们可以编写针对这些接口运行的测试。这并不鼓励我们编写完全专注于行为的测试,但它也有助于确保任何时候我们将一个实现转换为另一个实现时不会出现意想不到的问题。这些测试通常称为接口测试套件。

What are interface test suites?什么是接口测试套件

如前所述,接口测试套件是接受接口并对其运行测试的测试。但它实际上是什么样的呢?

假设我们有一个需要验证用户的应用程序。为此,我们可以定义一个AuthService接口,该接口有两个方法:LoginAuthenticate。当我们不知道用户是谁,需要他们的电子邮件地址和密码时,使用第一种方法登录用户。之后,我们为用户生成一个令牌token,并使用该令牌和Authenticate方法继续对用户进行身份验证。

域代码最终可能看起来像这样:

package app

type Token string

type User struct {
  ID    string
  Email string
}

type AuthService interface {
  Login(email, password string) (Token, error)
  Authenticate(Token) (*User, error)
}

现在我们不能在没有提供实现的情况下测试这个接口,但是我们仍然可以定义一些表示所需行为的测试。例如,我们可能想验证在登录用户之后,我们可以使用令牌来验证相同的用户。我们可能还想验证无效的电子邮件地址和密码组合是否以某种方式导致返回令牌。我们可能还需要核实一些其他的案例。

为此,我们将编写一个助手函数,给定AuthService的实现,该函数可以运行一系列测试,以验证该实现是否符合我们的预期。

package apptest

func AuthService(t *testing.T, as app.AuthService) {
  t.Run("valid login", func(t *testing.T) {
    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) {
    // ...
  })
  // ...
}

一种常见的方法是将其放置到子文件夹中,例如应用程序包的apptest,这样包名就来源于此。稍后我们将看到如何在AuthService实现的测试文件中调用它。

我称它为测试套件,不是因为它需要很多东西,而是因为它实际上不是一个测试用例。它是一个用来生成测试用例集合的辅助函数,这些测试用例通常被称为测试套件。

鉴于这是一个测试套件,这意味着这些测试不会奇迹般地自行运行。每当我们实现身份验证服务时,都需要调用这个助手函数。例如,如果我们有一个实现认证服务的jwt包,我们可以将以下代码添加到它的jwt_test包中。

package jwt_test

func TestAuthService(t *testing.T) {
  as := jwt.AuthService(...)
  apptest.AuthService(t, as)
}

在这个特定的例子中,我们只运行测试套件,但是如果您愿意,您也可以编写特定于实现的测试。我绝不是鼓励您只使用接口测试套件,但是为了简洁起见,我们在这里看到的就是这些。

接口测试套件的优势是什么?

当用户登录到我们的应用程序时,我们使用Login方法创建一个令牌。这个令牌可以是JWT、记忆令牌或其他完全不同的东西。只要令牌能够对用户进行身份验证,我们并不关心这些。

这意味着我们可以从一个记忆令牌实现开始:

package token

type AuthService struct {
  UserStore interface {
    Authenticate(email, password string) (*app.User, error)
  }
  TokenStore interface {
    User(app.Token) (*app.User, error)
    Create(userID string) (app.Token, error)
  }
}

func (a *AuthService) Login(email, password string) (app.Token, error) {
  user, err := a.UserStore.Authenticate(email, password)
  if err != nil {
    return "", err
  }
  token, err := a.TokenStore.Create(user.ID)
  if err != nil {
    return "", err
  }
  return token, nil
}

func (a *AuthService) Authenticate(t app.Token) (*app.User, error) {
  user, err := a.TokenStore.User(t)
  if err != nil {
    return nil, err
  }
  return user, nil
}

然后我们可能会读到黑客新闻的一篇文章,告诉我们jwt有多酷,并决定给他们一个机会:

package jwt

type AuthService struct {
  UserStore interface {
    Authenticate(email, password string) (*app.User, error)
  }
}

func (a *AuthService) Login(email, password string) (app.Token, error) {
  user, err := a.UserStore.Authenticate(email, password)
  if err != nil {
    return "", err
  }
  token := // build the JWT
  return token, nil
}

func (a *AuthService) Authenticate(t app.Token) (*app.User, error) {
  token, err := Parse(t) // not implemented here
  if err != nil {
    // eg if we couldn't parse the token for any reason - such as an invalid
    // signature
    return nil, err
  }
  // Otherwise pull this data from the JWT. You may need to use fields like
  // "Claims" depending on how you implement this.
  return &app.User{
    ID: token.UserID,
    Email: token.Email,
  }, nil
}

为了简洁起见,我有意省略或注释掉了这两种实现中的大多数逻辑,但我认为这一点仍然成立;原来的AuthService可以通过多种方式实现。

虽然我们可能不关心每个令牌是如何生成的,但我们仍然可能关心AuthService接口的其他方面,并希望在所有实现中进行测试。例如,我们可能期望令牌在多次调用Authenticate时工作,而不是在一次使用后过期。或者,当用户提供了一个不存在的电子邮件地址时,我们可能会看到一种特定类型的错误,这样我们就可以通知用户他们没有帐户。

通过使用接口测试套件,我们能够在domain域级别表达所有这些需求,让任何实现接口的人都清楚地知道对他们的期望是什么。

我们也能够编写这些测试一次,然后有足够的信心——只要使用了测试套件——我们就可以验证每个实现是否符合我们的需求,并且很可能在我们的应用程序中工作。

此外,当您在某个实现中发现bug时,您通常可以在接口级编写测试,以确保该bug不会出现在任何现有或未来的实现中。

简而言之,接口测试套件很好地与领域驱动设计模式结合在一起,因为我们通常已经将接口定义为构建块。它们也是非常适合的,因为接口测试只能测试行为,而不能测试实现细节,因此,如果我们的应用程序需要根据不断变化的需求发展,那么以这种方式编写的任何测试都不太可能中断。

重申一下,使用接口测试套件并不意味着您只能使用DDD接口测试,但我确实发现,许多在实现级定义的单元测试通常可以替换为接口测试,以提供更持久的好处。

但是还存在一个问题,问题是什么呢?请听下回分解。