在没有断言库的情况下,一个断言通常需要三行样板代码: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入口,否则测试静默不执行。