设计可扩展的验证API

风格指南中关于测试的大部分建议都是关于测试你自己的代码。本节是关于如何为其他人提供设施来测试他们编写的代码,以确保它符合你的库的要求。

验收测试 #

这种测试被称为验收测试。这种测试的前提是,使用测试的人不知道测试中发生的每一个细节;他们只是把输入交给测试机构来完成。这可以被认为是一种控制反转的形式。

在典型的Go测试中,测试函数控制着程序流程,无断言测试函数指南鼓励你保持这种方式。本节解释了如何以符合 Go 风格的方式来编写对这些测试的支持。

在深入探讨如何做之前,请看下面摘录的io/fs中的一个例子:

type FS interface {
    Open(name string) (File, error)
}

虽然存在众所周知的fs.FS的实现,但 Go 开发者可能会被期望编写一个。为了帮助验证用户实现的fs.FS是否正确,在testing/fstest中提供了一个通用库,名为fstest.TestFS。这个API将实现作为一个黑箱来处理,以确保它维护了io/fs契约的最基本部分。

撰写验收测试 #

现在我们知道了什么是验收测试以及为什么要使用验收测试,让我们来探讨为package chess建立一个验收测试,这是一个用于模拟国际象棋游戏的包。chess 的用户应该实现 chess.Player 接口。这些实现是我们要验证的主要内容。我们的验收测试关注的是棋手的实现是否走了合法的棋,而不是这些棋是否聪明。

1.为验证行为创建一个新的包,习惯上命名为,在包名后面加上 “test “一词(例如:chesstest)。

2.创建执行验证的函数,接受被测试的实现作为参数,并对其进行练习:

// ExercisePlayer tests a Player implementation in a single turn on a board.
// The board itself is spot checked for sensibility and correctness.
//
// It returns a nil error if the player makes a correct move in the context
// of the provided board. Otherwise ExercisePlayer returns one of this
// package's errors to indicate how and why the player failed the
// validation.
func ExercisePlayer(b *chess.Board, p chess.Player) error

测试应该注意哪些不变式被破坏,以及如何破坏。你的设计可以选择两种失败报告的原则:

  • 快速失败:一旦实现违反了一个不变式,就返回一个错误。

    这是最简单的方法,如果预计验收测试会快速执行,那么它的效果很好。简单的错误 sentinels自定义类型在这里可以很容易地使用,反过来说,这使得测试验收测试变得很容易。

    for color, army := range b.Armies {
      // The king should never leave the board, because the game ends at
      // checkmate.
      if army.King == nil {
          return &MissingPieceError{Color: color, Piece: chess.King}
      }
    }
  • 集合所有的失败:收集所有的失败,并报告它们。 这种方法类似于keep going的指导,如果验收测试预计会执行得很慢,这种方法可能更可取。

    你如何聚集故障,应该由你是否想让用户或你自己有能力询问个别故障(例如,为你测试你的验收测试)来决定的。下面演示了使用一个自定义错误类型聚合错误

    var badMoves []error
    
    move := p.Move()
    if putsOwnKingIntoCheck(b, move) {
      badMoves = append(badMoves, PutsSelfIntoCheckError{Move: move})
    }
    
    if len(badMoves) > 0 {
      return SimulationError{BadMoves: badMoves}
    }
    return nil

验收测试应该遵守 keep going 的指导,不调用t.Fatal,除非测试检测到被测试系统中的不变量损坏。 例如,t.Fatal应该保留给特殊情况,如设置失败,像往常一样:

func ExerciseGame(t *testing.T, cfg *Config, p chess.Player) error {
    t.Helper()

    if cfg.Simulation == Modem {
        conn, err := modempool.Allocate()
        if err != nil {
            t.Fatalf("no modem for the opponent could be provisioned: %v", err)
        }
        t.Cleanup(func() { modempool.Return(conn) })
    }
    // Run acceptance test (a whole game).
}

这种技术可以帮助你创建简明、规范的验证。但不要试图用它来绕过断言指南。 最终产品应该以类似这样的形式提供给终端用户:

// Good:
package deepblue_test

import (
    "chesstest"
    "deepblue"
)

func TestAcceptance(t *testing.T) {
    player := deepblue.New()
    err := chesstest.ExerciseGame(t, chesstest.SimpleGame, player)
    if err != nil {
        t.Errorf("deepblue player failed acceptance test: %v", err)
    }
}