在没有断言库的情况下,一个断言通常需要三行样板代码:if got != want { t.Errorf("want %v, got %v", want, got) }。一个测试包含十几个断言时,失败消息的拼接占据一半篇幅,输出格式因人而异,代码评审时难以阅读。testify 将这些样板收敛为一行调用,失败时直接标出期望值与实际值的差异;它成为 Go 生态中的事实断言标准,正是基于这一能力。

testify 不替代 testing 包:所有测试仍以 *testing.T 为入口,testify 只负责让断言可读、失败信息可理解。测试的目标是约束行为、让失败自解释,而非追求覆盖率,该前提在单元测试规范中已展开。

testify 共包含四块能力:assert/require(断言)、mock(打桩隔离)、suite(生命周期分组),外加社区配套的 testifylint 检查器。本文只介绍前两块:它们覆盖日常绝大多数使用场景,也是误用最集中的部分。

assert 与 require

两者的断言函数签名完全一致,区别只有一个:失败之后如何处理。assert 记录失败后继续执行,一次运行即可看到所有失败点;require 直接调用 t.FailNow(),立即终止当前测试。配置加载、数据库连接这类 setup 步骤失败后应立即停止——后续每一步都建立在其结果之上,继续执行只会产生一连串无意义的连锁报错。

**验证类断言应使用 assert,前置条件应使用 require,两者不应混用。**前置条件指失败后继续执行会触发 panic 或产生误导结果的检查。最典型的情况是错误检查之后还要解引用结果:若使用 assert.NoError,当 err 非 nil 时,下一行将发生 nil 解引用 panic,真正的失败原因反而被掩盖。

推荐写法是用 assert.New(t) / require.New(t) 各封装一个实例,固定命名为 is 和 must。这比在每个断言中传递 t 更简洁,哪些是前置断言、哪些是验证断言,在代码中清晰可辨:

func TestParseConfig(t *testing.T) {
    is := assert.New(t)   // 非致命:继续收集所有失败
    must := require.New(t) // 致命:前置条件不满足立即终止

    cfg, err := ParseConfig("testdata/valid.yaml")
    must.NoError(err)  // 解析失败时 cfg 为 nil,必须用 require
    must.NotNil(cfg)

    is.Equal("production", cfg.Environment) // 验证类断言:全部执行完
    is.Equal(8080, cfg.Port)
    is.True(cfg.TLS.Enabled)
}

两类高频错误

一类是参数顺序,一类是 error 比较。

**参数顺序必须为 (expected, actual),期望值在前。**testify 按此约定生成 diff,顺序颠倒时输出即为镜像结果,排查时极易误导。此类错误依靠人工检查难以发现,应启用 testifylint 作为最终防线。

error 断言应使用 ErrorIs / ErrorAs / ErrorContains,不应使用 Equal。is.Equal(ErrNotFound, err) 在错误被 fmt.Errorf("%w", ...) 包装时必然失败,ErrorIs 会沿错误链查找:

is := assert.New(t)

// ❌ 包装过的 error 会比较失败,diff 也难以定位原因
is.Equal(ErrNotFound, err)

// ✅ 沿错误链匹配哨兵错误,兼容 %w 包装
is.ErrorIs(err, ErrNotFound)

// ✅ 需要断言错误上附带的字段时,使用 ErrorAs 取出具体类型
var perr *PathError
is.ErrorAs(err, &perr)
is.Equal("config.yaml", perr.Path)

相等比较默认使用 Equal,它基于 DeepEqual 且要求类型一致;仅当类型不同但值可比较时才应使用 EqualValues。Nil/NotNil、True/False、Empty/NotEmpty、Len、Contains(字符串、切片、map key 都支持)这批常用断言,应在使用中熟悉其语义。

哨兵错误与错误包装的设计取舍,见错误处理规范。

常用的专用断言

  • 无序比较切片使用 ElementsMatch;浮点数应保留容差,使用 InDelta;断言 JSON 输出使用 JSONEq,它忽略空白和 key 顺序。
  • 时间戳不应比较相等,应使用 WithinDuration 给定一个时间窗口;自动生成的 ID 这类没有固定值的字段,使用 Regexp 约束格式即可。
  • 等待异步结果不应使用 time.Sleep,应使用 Eventually(fn, 等待时长, 轮询间隔) 轮询状态变化;需要在一次等待内做多处断言时,使用 EventuallyWithT,在回调中针对 *assert.CollectT 编写断言。
is := assert.New(t)

is.ElementsMatch([]string{"b", "a", "c"}, result)      // 无序比较
is.InDelta(3.14, computedPi, 0.01)                     // 浮点容差

// 轮询等待异步任务完成:最多 5 秒,每 100ms 试一次
is.Eventually(func() bool {
    status, _ := client.GetJobStatus(jobID)
    return status == "completed"
}, 5*time.Second, 100*time.Millisecond)

mock 的编写方式

编写 mock 包含固定的三步:结构体内嵌 mock.Mock,方法实现中调用 m.Called(),测试结尾调用 AssertExpectations(t)。Called 返回的 mock.Arguments 按位置取值,例如 .Error(0)、.Int(0)。手写样板没有必要,mockery 能从接口自动生成 mock,生成的代码同样基于 mock.Mock,用法完全一致;mock 生成在接口定义所在之处,方法签名无需重复书写。

期望通过 On(方法名, 参数...) 声明,Return 指定返回值。参数可以不写死为精确值:mock.Anything 匹配任意值,mock.AnythingOfType("string") 按类型匹配,mock.MatchedBy(func) 支持自定义谓词;调用次数用 .Once() / .Times(n) 约束,可选调用的期望标记 .Maybe()。

type MockSender struct{ mock.Mock }

func (m *MockSender) Send(ctx context.Context, to string, msg Message) error {
    return m.Called(ctx, to, msg).Error(0)
}

func TestOrderService_Place(t *testing.T) {
    is := assert.New(t)
    m := new(MockSender)
    // ctx 使用任意匹配器,收件人精确匹配,消息体按类型匹配
    m.On("Send", mock.Anything, "buyer@example.com",
        mock.AnythingOfType("Message")).Return(nil).Once()

    err := NewOrderService(m).Place(context.Background(), order)

    is.NoError(err)
    m.AssertExpectations(t) // 缺少该调用时,声明的期望不会被校验
}

调用验证有四个粒度,应按需选择:AssertExpectations 校验全部期望,AssertCalled / AssertNotCalled 检查具体调用,AssertNumberOfCalls 检查精确次数。"重试场景第一次失败第二次成功"这类需求,编写两段 On(...).Once() 并各自配置 Return 即可实现;需要替换已声明的期望时,先对返回的 call.Unset(),再重新 On。

需要检查 mock 实际收到的参数时,使用 .Run(func(args mock.Arguments)) 注册副作用,在回调中取出参数做记录或断言。这比使用过宽的匹配器后再依靠推测定位更可靠。

只 mock 自有接口

**只应 mock 自己拥有的接口,不应 mock 第三方具体类型。**接口定义在被测包一侧(消费者定义接口)时,mock 才能保持稳定。依赖第三方库时,应先用薄适配器把它的具体类型包装成自有接口,再 mock 这个接口——依赖倒置在测试侧就是这个用法,展开见依赖注入实践。Rob Pike 的 Go 谚语"接口越大,抽象越弱",同样适用于 mock:违反这一原则的代价很直接,第三方库一升级,mock 全部随之修改。

**不应过度 mock。**mock 的目的是隔离被测单元,而非替代真实实现。一个测试里 mock 了五六个依赖,通常表明该单元职责过多,应先拆分;纯内存计算、简单值对象应直接使用真实实现,比大量 On/Return 声明更易读。

每个测试用例应新建 mock 实例,期望不跨用例共享。mock.Mock 的调用记录是可变状态,复用实例会让 AssertExpectations 的结果互相污染。

与表驱动测试搭配

应把期望配置放进用例结构体,每个子测试独立建 mock、独立断言:在 t.Run 内新建 mock 实例,期望字段在子测试开头声明,结尾统一调用 AssertExpectations,失败信息自带用例名,便于定位。新增场景只需追加一行用例,无需复制整个测试函数。

tests := []struct {
    name    string
    sendErr error // mock 返回的错误
    wantErr bool
}{
    {"发送成功", nil, false},
    {"发送失败", errors.New("timeout"), true},
}

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        m := new(MockSender) // 每个用例独立 mock
        m.On("Send", mock.Anything, mock.Anything, mock.Anything).
            Return(tt.sendErr).Once()

        err := NewOrderService(m).Place(context.Background(), order)

        if tt.wantErr {
            assert.Error(t, err)
        } else {
            assert.NoError(t, err)
        }
        m.AssertExpectations(t)
    })
}

关于 suite 包:它用 SetupTest/TearDownTest 把公共搭建收敛到结构体方法中,s.Equal 等方法行为等同 assert,需要致命失败时使用 s.Require()。注意 suite 必须提供 suite.Run(t, new(XxxSuite)) 入口函数,否则所有测试静默不执行——不报错、不跳过、也不运行,这一错误没有任何提示。

常见误用

  • 忘写 AssertExpectations(t)。这是最常见的错误:mock 期望未被校验,该调未调的依赖不会导致测试失败,隔离测试因此无法证明任何行为。
  • 用 Equal 比较 error 或指针。error 应使用 ErrorIs/ErrorAs;指针用 Equal 判的是内容(DeepEqual 会解引用),断言同一对象应使用 Same;结构体含未导出字段导致比较失败时,应改用 EqualExportedValues。
  • 参数顺序写反。(expected, actual) 是强制约定,人工检查难以发现,应启用 testifylint 作为最终防线。
  • 该用 require 的位置使用了 assert。前置条件失败后继续执行,极易触发 nil panic,原始失败原因被掩盖。

速查清单

  • assert 非致命,收集全部失败;require 致命,立即终止。前置条件一律 require,验证断言一律 assert。
  • 推荐写法:is := assert.New(t),must := require.New(t)。
  • 参数顺序必须为 (expected, actual),启用 testifylint 作为最终防线。
  • 错误断言使用 ErrorIs / ErrorAs / ErrorContains,不应使用 Equal。
  • 无序比较用 ElementsMatch,浮点用 InDelta,JSON 用 JSONEq,时间用 WithinDuration,异步用 Eventually / EventuallyWithT。
  • mock 三要素:内嵌 mock.Mock、m.Called()、结尾 AssertExpectations(t)。
  • 匹配器:mock.Anything、mock.AnythingOfType("T")、mock.MatchedBy(func);次数:.Once() / .Times(n) / .Maybe();副作用:.Run(func)。
  • 只 mock 自己的接口,避免过度 mock,每个用例使用独立实例。
  • suite 必须提供 suite.Run 入口,否则测试静默不执行。