Appearance
断言与 testify
上一篇我们用标准库 testing 包写了第一份表驱动测试,断言部分都是手写的 if got != want { t.Errorf(...) }。这种写法虽然清晰,但用例一多就显得啰嗦,错误信息也常常不够友好。本篇将介绍 Go 生态中最流行的断言库 testify,看看它如何让测试代码更简洁、错误信息更可读,并介绍测试套件(Suite)这一组织复杂测试的高级模式。
一、为什么需要断言库
先回顾一下原生写法:
go
func TestAdd(t *testing.T) {
got := Add(1, 2)
if got != 3 {
t.Errorf("Add(1, 2) = %d, want 3", got)
}
}短短几行,但要写出有意义的失败信息,需要手写 t.Errorf 并构造格式串。考虑以下几个场景:
- 比较两个结构体是否相等:要写一大段格式化代码才能看到差异在哪。
- 比较两个切片顺序无关:原生
!=直接不行,得手动排序或循环。 - 断言一个错误为 nil:
if err != nil失败时只看到「err != nil」,看不到错误内容。 - 断言一个 map 包含某个 key:要写循环。
- 断言一个 panic 发生:得手写
defer/recover。
断言库的核心价值就是把这些重复劳动封装成一行函数调用,并提供可读的失败信息。
二、原生断言方式的问题
来看一段典型的「原生断言」测试,断言点比较多时容易变成长篇格式串:
go
package user_test
import "testing"
type User struct {
ID int
Name string
Email string
Age int
}
func GetUser(id int) User {
return User{ID: id, Name: "Alice", Email: "alice@example.com", Age: 28}
}
func TestGetUser(t *testing.T) {
got := GetUser(1)
if got.ID != 1 {
t.Errorf("ID = %d, want 1", got.ID)
}
if got.Name != "Alice" {
t.Errorf("Name = %q, want \"Alice\"", got.Name)
}
if got.Email != "alice@example.com" {
t.Errorf("Email = %q, want \"alice@example.com\"", got.Email)
}
if got.Age != 28 {
t.Errorf("Age = %d, want 28", got.Age)
}
}四个字段,四段 if,四段格式串。如果断言失败,需要分别查看每条信息。换成 testify 之后,整段可以压缩成一行断言。
原生断言的另一个痛点是比较复杂类型:
go
// 比较两个切片(顺序无关)需要手动实现
func sliceEqualUnordered(a, b []int) bool {
if len(a) != len(b) {
return false
}
counts := make(map[int]int)
for _, v := range a {
counts[v]++
}
for _, v := range b {
counts[v]--
if counts[v] < 0 {
return false
}
}
return true
}这种工具函数每个项目都要写一遍,但用 testify 一行 assert.ElementsMatch(t, a, b) 就能搞定。
三、testify 简介
testify 是 stretchr 团队开发的测试工具库,是 Go 社区使用最广泛的测试辅助库(GitHub Star 数 20k+)。它由四个子包组成:
| 子包 | 用途 | 失败行为 |
|---|---|---|
testify/assert | 提供断言函数 | 标记失败,继续执行 |
testify/require | 提供断言函数(同 assert API) | 失败立即停止 |
testify/suite | 测试套件,支持 Setup/TearDown | — |
testify/mock | Mock 框架(下一篇详述) | — |
安装:
bash
go get github.com/stretchr/testifyimport 习惯:
go
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)assert 还是 require? 一个简单的判断:如果后续断言依赖前面(比如先断言 err==nil,再断言 result),用
require;如果想一次看到所有失败点,用assert。日常实践中require.NoError用得最频繁,因为前置检查失败后继续断言通常毫无意义。
四、testify/assert:常用断言函数
assert 包提供了上百个断言函数,下面分类介绍最常用的。
1. 相等性:Equal、NotEqual、EqualValues
go
package assertdemo_test
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestEqual(t *testing.T) {
// 基本类型相等
assert.Equal(t, 3, Add(1, 2), "Add(1,2) 应该等于 3")
// 字符串相等
assert.Equal(t, "hello", "hello")
// 切片相等(顺序敏感,长度与每个元素都相同)
assert.Equal(t, []int{1, 2, 3}, []int{1, 2, 3})
// map 相等
assert.Equal(t, map[string]int{"a": 1}, map[string]int{"a": 1})
// 结构体相等(字段顺序与类型都必须一致)
type Point struct{ X, Y int }
assert.Equal(t, Point{1, 2}, Point{1, 2})
// 不等
assert.NotEqual(t, 1, 2)
// EqualValues:类型不同的比较(会做类型转换)
assert.EqualValues(t, int32(1), int64(1)) // 通过
}
func Add(a, b int) int { return a + b }Equal 失败时的输出非常友好:
text
Error Trace: assertdemo_test.go:14
Error: Not equal:
expected: 3
actual : 4
Test: TestEqual
Messages: Add(1,2) 应该等于 32. 布尔:True、False、Condition
go
func TestBool(t *testing.T) {
assert.True(t, 1+1 == 2, "1+1 应该等于 2")
assert.False(t, 1+1 == 3)
assert.Condition(t, func() bool { return len("abc") == 3 }, "长度应为 3")
}3. Nil 与 NotNil
go
func TestNil(t *testing.T) {
var p *int
assert.Nil(t, p)
var s []int
assert.Nil(t, s) // nil 切片
var m map[string]int
assert.Nil(t, m) // nil map
v := 42
assert.NotNil(t, &v)
}4. 错误:Error、NoError、EqualError、IsError
go
package errdemo_test
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
var ErrNotFound = errors.New("not found")
func FindUser(id int) error {
if id <= 0 {
return ErrNotFound
}
return nil
}
func TestError(t *testing.T) {
// 断言返回了某种错误
err := FindUser(0)
assert.Error(t, err)
// 断言错误信息字符串完全匹配
assert.EqualError(t, err, "not found")
// 断言返回 nil(最常用)
assert.NoError(t, FindUser(1))
// 断言错误是某个特定错误(Go 1.13+ errors.Is)
assert.ErrorIs(t, FindUser(0), ErrNotFound)
}5. 包含与子串:Contains、NotContains
go
func TestContains(t *testing.T) {
// 字符串子串
assert.Contains(t, "hello world", "world")
// 切片包含元素
assert.Contains(t, []int{1, 2, 3}, 2)
// map 包含 key
assert.Contains(t, map[string]int{"a": 1}, "a")
// 不包含
assert.NotContains(t, "hello", "xyz")
}6. 元素匹配:ElementsMatch
ElementsMatch 比较两个切片的元素是否相同(顺序无关,长度必须相同),这是表驱动测试里特别有用的断言:
go
func TestElementsMatch(t *testing.T) {
// 顺序无关的比较
assert.ElementsMatch(t,
[]int{1, 2, 3, 4},
[]int{4, 2, 1, 3},
)
// 字符串切片
assert.ElementsMatch(t,
[]string{"a", "b", "c"},
[]string{"c", "a", "b"},
)
// 重复元素也算
assert.ElementsMatch(t,
[]int{1, 1, 2, 2},
[]int{2, 1, 2, 1},
)
}7. 长度与空:Len、Empty、NotEmpty
go
func TestLenEmpty(t *testing.T) {
assert.Len(t, []int{1, 2, 3}, 3)
assert.Len(t, "hello", 5)
assert.Len(t, map[string]int{"a": 1}, 1)
assert.Empty(t, []int{})
assert.Empty(t, "")
assert.Empty(t, map[string]int{})
assert.NotEmpty(t, []int{1})
}8. Panic 检测:Panics、NotPanics
go
func TestPanic(t *testing.T) {
assert.Panics(t, func() {
panic("boom")
})
assert.NotPanics(t, func() {
_ = 1 + 1
})
// 断言 panic 值
assert.PanicsWithValue(t, "specific error", func() {
panic("specific error")
})
}9. 数值比较:Greater、Less、InDelta
go
func TestNumeric(t *testing.T) {
assert.Greater(t, 5, 3)
assert.GreaterOrEqual(t, 5, 5)
assert.Less(t, 3, 5)
assert.LessOrEqual(t, 3, 3)
// 浮点数近似比较(避免精度问题)
assert.InDelta(t, 0.333, 1.0/3.0, 0.01)
assert.InEpsilon(t, 0.333, 1.0/3.0, 0.05)
}10. 时间相关:WithinDuration
go
func TestTime(t *testing.T) {
t1 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
t2 := time.Date(2024, 1, 1, 12, 0, 5, 0, time.UTC)
assert.WithinDuration(t, t1, t2, 10*time.Second)
}11. 文件与目录:FileExists、DirExists
go
func TestFile(t *testing.T) {
assert.FileExists(t, "go.mod")
assert.DirExists(t, ".")
}12. JSON 与列表比较
go
func TestJSON(t *testing.T) {
// JSONEqual:比较原始 JSON 字符串
assert.JSONEq(t, `{"a": 1, "b": 2}`, `{"b": 2, "a": 1}`)
// Subset:subset 检查
assert.Subset(t, []int{1, 2, 3, 4}, []int{2, 3})
// IsType:类型断言
var i interface{} = 42
assert.IsType(t, 0, i) // i 必须是 int 类型
}五、testify/require:失败立即停止
require 包与 assert 拥有完全相同的 API,唯一区别是失败时立即调用 t.FailNow() 终止当前测试函数。它的典型用途是前置检查:
go
package require_test
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRequire(t *testing.T) {
// 假设 LoadConfig 是被测函数
cfg, err := LoadConfig("config.yaml")
// 前置检查:如果失败,后续断言毫无意义,必须立即停止
require.NoError(t, err, "加载配置失败")
require.NotNil(t, cfg, "配置对象不应为空")
// 只有上面都通过,才会执行到这里
require.Equal(t, "production", cfg.Env, "环境应为 production")
require.Equal(t, 8080, cfg.Port, "端口应为 8080")
}
// LoadConfig 模拟实现,供上面测试调用。
type Config struct {
Env string
Port int
}
func LoadConfig(path string) (*Config, error) {
return &Config{Env: "production", Port: 8080}, nil
}如果用 assert,当 LoadConfig 失败时,下面会继续访问 cfg.Env,可能直接 nil panic 导致测试崩溃。require 保证了「前置不通过就停」,是更安全的选择。
一个常用组合:在表驱动测试里用
require.NoError做前置,用assert.Equal做主断言。这样既安全又能看到多个失败点。
六、testify/suite:测试套件
当一个测试对象需要多个步骤、共享 setup/teardown 时,使用顶层 TestXxx 函数会让重复逻辑散落各处。testify/suite 提供「测试套件」模式,把一组相关的测试方法组织在一个 struct 里,并支持:
SetupSuite()/TearDownSuite():套件级别的初始化与清理。SetupTest()/TearDownTest():每个测试方法前后执行。BeforeTest(suiteName, testName string)/AfterTest(suiteName, testName string):更细粒度的钩子。
下面是一个完整示例:
go
package suite_test
import (
"testing"
"github.com/stretchr/testify/suite"
)
// Counter 是被测对象。
type Counter struct {
value int
}
func (c *Counter) Inc() { c.value++ }
func (c *Counter) Dec() { c.value-- }
func (c *Counter) Get() int { return c.value }
func (c *Counter) Reset() { c.value = 0 }
// CounterSuite 是测试套件。
type CounterSuite struct {
suite.Suite
counter *Counter
}
// SetupSuite 在整个套件开始前执行一次。
func (s *CounterSuite) SetupSuite() {
s.T().Log("SetupSuite: 初始化套件资源")
}
// TearDownSuite 在整个套件结束后执行一次。
func (s *CounterSuite) TearDownSuite() {
s.T().Log("TearDownSuite: 清理套件资源")
}
// SetupTest 在每个测试方法前执行。
func (s *CounterSuite) SetupTest() {
s.counter = &Counter{value: 0}
s.T().Log("SetupTest: 每个 Test 方法前创建新 Counter")
}
// TearDownTest 在每个测试方法后执行。
func (s *CounterSuite) TearDownTest() {
s.counter = nil
s.T().Log("TearDownTest: 每个 Test 方法后清理")
}
// TestInc 是一个测试方法,会被自动识别并执行。
func (s *CounterSuite) TestInc() {
s.Equal(0, s.counter.Get())
s.counter.Inc()
s.Equal(1, s.counter.Get())
s.counter.Inc()
s.Equal(2, s.counter.Get())
}
// TestDec 是另一个测试方法。
func (s *CounterSuite) TestDec() {
s.counter.Inc()
s.counter.Inc()
s.counter.Inc()
s.Equal(3, s.counter.Get())
s.counter.Dec()
s.Equal(2, s.counter.Get())
}
// TestReset 测试重置功能。
func (s *CounterSuite) TestReset() {
s.counter.Inc()
s.counter.Inc()
s.counter.Reset()
s.Equal(0, s.counter.Get())
}
// TestMultipleOperations 测试组合操作。
func (s *CounterSuite) TestMultipleOperations() {
for i := 0; i < 10; i++ {
s.counter.Inc()
}
s.Equal(10, s.counter.Get())
for i := 0; i < 5; i++ {
s.counter.Dec()
}
s.Equal(5, s.counter.Get())
}
// TestEntryPoint 是套件的入口,必须在普通 TestXxx 函数里调用 suite.Run。
func TestCounterSuite(t *testing.T) {
suite.Run(t, new(CounterSuite))
}执行 go test -v 可以看到 setup/teardown 的执行顺序:
text
=== RUN TestCounterSuite
=== RUN TestCounterSuite/TestInc
suite_test.go: SetupTest: 每个 Test 方法前创建新 Counter
=== RUN TestCounterSuite/TestDec
=== RUN TestCounterSuite/TestReset
=== RUN TestCounterSuite/TestMultipleOperations
--- PASS: TestCounterSuite (0.00s)
PASSSuite 的成员方法
通过嵌入 suite.Suite,测试套件获得了所有 assert 与 require 的方法(以 s.Equal、s.Require().NoError 形式调用)。常用方法包括:
s.Assert():返回*Assertions,等同assert包函数。s.Require():返回*Assertions,等同require包函数。s.T():获取*testing.T。s.SetT(t):设置内部*testing.T。
Suite 与子测试结合
Suite 也可以与 t.Run 子测试结合,方法名即子测试名:
go
func (s *CounterSuite) TestIncMany() {
s.Run("从 0 开始加 1", func() {
s.counter.Inc()
s.Equal(1, s.counter.Get())
})
s.Run("从 0 开始加 5", func() {
for i := 0; i < 5; i++ {
s.counter.Inc()
}
s.Equal(5, s.counter.Get())
})
}七、SetupTest 和 TearDownTest
SetupTest 与 TearDownTest 是 Suite 最常用的钩子,它们在每个测试方法执行前后各跑一次。适用场景:
- 数据库事务回滚:每个测试前开启事务,测试后回滚。
- 临时文件创建与清理:每个测试用独立目录,避免互相干扰。
- Mock 重置:每个测试前重置 Mock 期望。
下面是一个使用临时文件的例子:
go
package fileutil_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/suite"
)
// FileWriter 是被测对象。
type FileWriter struct {
dir string
}
func NewFileWriter(dir string) *FileWriter {
return &FileWriter{dir: dir}
}
func (w *FileWriter) Write(name, content string) error {
return os.WriteFile(filepath.Join(w.dir, name), []byte(content), 0644)
}
func (w *FileWriter) Read(name string) (string, error) {
data, err := os.ReadFile(filepath.Join(w.dir, name))
if err != nil {
return "", err
}
return string(data), nil
}
// FileWriterSuite 是 FileWriter 的测试套件。
type FileWriterSuite struct {
suite.Suite
writer *FileWriter
tmpDir string
}
func (s *FileWriterSuite) SetupTest() {
dir, err := os.MkdirTemp("", "filewriter-test-*")
s.Require().NoError(err)
s.tmpDir = dir
s.writer = NewFileWriter(dir)
}
func (s *FileWriterSuite) TearDownTest() {
// 每个测试结束后清理目录
_ = os.RemoveAll(s.tmpDir)
}
func (s *FileWriterSuite) TestWriteAndRead() {
err := s.writer.Write("hello.txt", "hello world")
s.Require().NoError(err)
got, err := s.writer.Read("hello.txt")
s.Require().NoError(err)
s.Equal("hello world", got)
}
func (s *FileWriterSuite) TestReadNonExist() {
_, err := s.writer.Read("nonexist.txt")
s.Require().Error(err)
s.True(os.IsNotExist(err))
}
func (s *FileWriterSuite) TestOverwrite() {
s.Require().NoError(s.writer.Write("f.txt", "first"))
s.Require().NoError(s.writer.Write("f.txt", "second"))
got, err := s.writer.Read("f.txt")
s.Require().NoError(err)
s.Equal("second", got)
}
func TestFileWriterSuite(t *testing.T) {
suite.Run(t, new(FileWriterSuite))
}八、自定义断言
testify/assert 提供了 Assertion 类型和 Collect 机制,可以组合断言。但更常见的「自定义断言」是写一个返回布尔值与失败信息的普通函数:
go
package customassert_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
// User 业务对象。
type User struct {
ID int
Name string
Email string
Age int
}
// IsValid 是一个校验函数。
func (u User) IsValid() bool {
return u.ID > 0 && u.Name != "" && u.Email != "" && u.Age > 0
}
// assertUserValid 自定义断言:判断 User 是否有效。
func assertUserValid(t *testing.T, u User, msgAndArgs ...interface{}) {
t.Helper()
if !u.IsValid() {
msg := fmt.Sprintf("User 应该有效,但不是: %+v", u)
if len(msgAndArgs) > 0 {
msg = fmt.Sprintf(msgAndArgs[0].(string), msgAndArgs[1:]...) + " | " + msg
}
t.Errorf(msg)
}
}
func TestUserValid(t *testing.T) {
u := User{ID: 1, Name: "Alice", Email: "alice@example.com", Age: 18}
assertUserValid(t, u)
u2 := User{ID: 0, Name: "Bob", Email: "", Age: 20}
assertUserValid(t, u2, "用户 Bob 校验失败")
}注意 t.Helper() 的使用:标记该函数为「辅助函数」,错误信息中会指向调用方而不是辅助函数本身。
另一种做法是利用 assert.Collect 或 assert.Assertion,将多个断言组合:
go
package customassert2_test
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCollectAssertions(t *testing.T) {
// Collect 返回一个新的 Assertions,所有断言结果会收集起来
c := assert.New(t)
u := User{ID: 1, Name: "Alice", Email: "alice@example.com", Age: 18}
c.True(u.IsValid())
c.Equal(1, u.ID)
c.Equal("Alice", u.Name)
c.Contains(u.Email, "@")
}assert.New(t) 创建的 *Assertions 对象拥有所有断言方法(无需每次传 t),链式调用更顺畅。
九、完整示例:用户验证逻辑测试
下面用一个完整的「用户验证逻辑」示例把 testify 的常用功能串起来。
user.go:
go
package user
import (
"errors"
"fmt"
"regexp"
"strings"
"time"
)
var (
ErrInvalidName = errors.New("invalid name")
ErrInvalidEmail = errors.New("invalid email")
ErrInvalidAge = errors.New("invalid age")
)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
type User struct {
ID int
Name string
Email string
Age int
CreatedAt time.Time
}
func (u User) Validate() error {
if strings.TrimSpace(u.Name) == "" || len(u.Name) > 50 {
return fmt.Errorf("%w: name=%q", ErrInvalidName, u.Name)
}
if !emailRegex.MatchString(u.Email) {
return fmt.Errorf("%w: email=%q", ErrInvalidEmail, u.Email)
}
if u.Age < 0 || u.Age > 150 {
return fmt.Errorf("%w: age=%d", ErrInvalidAge, u.Age)
}
return nil
}
type UserService struct {
users map[int]User
}
func NewUserService() *UserService {
return &UserService{users: make(map[int]User)}
}
func (s *UserService) Create(u User) (User, error) {
if err := u.Validate(); err != nil {
return User{}, err
}
u.ID = len(s.users) + 1
u.CreatedAt = time.Now()
s.users[u.ID] = u
return u, nil
}
func (s *UserService) Get(id int) (User, error) {
u, ok := s.users[id]
if !ok {
return User{}, fmt.Errorf("user not found: id=%d", id)
}
return u, nil
}
func (s *UserService) List() []User {
list := make([]User, 0, len(s.users))
for _, u := range s.users {
list = append(list, u)
}
return list
}user_test.go:
go
package user_test
import (
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"example.com/user"
)
var _ = errors.New // 占位,保留 errors 包以便后续扩展
func TestUser_Validate(t *testing.T) {
cases := []struct {
name string
user user.User
wantErr error
}{
{
name: "合法用户",
user: user.User{Name: "Alice", Email: "alice@example.com", Age: 28},
wantErr: nil,
},
{
name: "空名字",
user: user.User{Name: "", Email: "a@b.com", Age: 20},
wantErr: user.ErrInvalidName,
},
{
name: "名字过长",
user: user.User{Name: strings.Repeat("a", 51), Email: "a@b.com", Age: 20},
wantErr: user.ErrInvalidName,
},
{
name: "邮箱格式错误",
user: user.User{Name: "Bob", Email: "not-an-email", Age: 20},
wantErr: user.ErrInvalidEmail,
},
{
name: "邮箱缺少域名",
user: user.User{Name: "Bob", Email: "a@b", Age: 20},
wantErr: user.ErrInvalidEmail,
},
{
name: "年龄为负",
user: user.User{Name: "Bob", Email: "a@b.com", Age: -1},
wantErr: user.ErrInvalidAge,
},
{
name: "年龄过大",
user: user.User{Name: "Bob", Email: "a@b.com", Age: 200},
wantErr: user.ErrInvalidAge,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.user.Validate()
if c.wantErr == nil {
assert.NoError(t, err)
} else {
require.Error(t, err)
assert.ErrorIs(t, err, c.wantErr)
}
})
}
}
// UserServiceSuite 测试用户服务。
type UserServiceSuite struct {
suite.Suite
svc *user.UserService
}
func (s *UserServiceSuite) SetupTest() {
s.svc = user.NewUserService()
}
func (s *UserServiceSuite) TestCreateValid() {
u := user.User{Name: "Alice", Email: "alice@example.com", Age: 28}
created, err := s.svc.Create(u)
s.Require().NoError(err)
s.Equal(1, created.ID)
s.Equal("Alice", created.Name)
s.False(created.CreatedAt.IsZero())
}
func (s *UserServiceSuite) TestCreateInvalid() {
u := user.User{Name: "", Email: "bad", Age: -1}
_, err := s.svc.Create(u)
s.Require().Error(err)
s.ErrorIs(err, user.ErrInvalidName)
}
func (s *UserServiceSuite) TestGetExisting() {
created, _ := s.svc.Create(user.User{Name: "Alice", Email: "a@b.com", Age: 28})
got, err := s.svc.Get(created.ID)
s.Require().NoError(err)
s.Equal(created.Name, got.Name)
}
func (s *UserServiceSuite) TestGetNonExist() {
_, err := s.svc.Get(999)
s.Require().Error(err)
}
func (s *UserServiceSuite) TestList() {
_, _ = s.svc.Create(user.User{Name: "Alice", Email: "a@b.com", Age: 28})
_, _ = s.svc.Create(user.User{Name: "Bob", Email: "c@d.com", Age: 30})
_, _ = s.svc.Create(user.User{Name: "Charlie", Email: "e@f.com", Age: 25})
list := s.svc.List()
s.Len(list, 3)
names := []string{}
for _, u := range list {
names = append(names, u.Name)
}
s.ElementsMatch(names, []string{"Alice", "Bob", "Charlie"})
}
func (s *UserServiceSuite) TestListEmpty() {
list := s.svc.List()
s.Empty(list)
}
func TestUserServiceSuite(t *testing.T) {
suite.Run(t, new(UserServiceSuite))
}执行测试:
bash
go test -v -cover预期输出(节选):
text
=== RUN TestUser_Validate
=== RUN TestUser_Validate/合法用户
=== RUN TestUser_Validate/空名字
=== RUN TestUser_Validate/邮箱格式错误
=== RUN TestUser_Validate/年龄过大
--- PASS: TestUser_Validate (0.00s)
=== RUN TestUserServiceSuite
=== RUN TestUserServiceSuite/TestCreateValid
=== RUN TestUserServiceSuite/TestList
--- PASS: TestUserServiceSuite (0.00s)
PASS
coverage: 95.0% of statements
ok example.com/user 0.010s十、testify 使用注意事项
1. assert vs require 的选择
错误的选择会导致测试「崩溃式失败」而非「断言式失败」:
go
// ❌ 错误:用 assert 做前置检查,err 为 nil 时下面直接 panic
result, err := DoSomething()
assert.NoError(t, err) // 失败仍继续
assert.Equal(t, "expected", result.Field) // nil pointer panic
// ✅ 正确:用 require 做前置,确保下面是安全的
result, err := DoSomething()
require.NoError(t, err)
assert.Equal(t, "expected", result.Field)2. 不要滥用 assertion 链式调用
assert.New(t) 创建的 *Assertions 可以链式调用,但不要为了「简洁」强行使用,必要时分多行更易读。
3. 不要在 helper 里隐藏断言
把断言包装在辅助函数里时,记得调用 t.Helper(),否则错误信息会指向辅助函数,失去定位价值。
4. testify 不是必需的
很多团队(包括 Go 标准库自身)也使用纯 if/t.Errorf 写法,特别是简单的断言场景。testify 是「锦上添花」而非「必需品」。引入它前先评估团队偏好与依赖成本。
十一、小结
本篇介绍了 testify 这个最常用的 Go 测试辅助库,核心要点:
- assert 包:提供上百个断言函数,覆盖相等、布尔、nil、错误、包含、元素匹配、panic、数值、时间、文件等场景,失败时继续执行。
- require 包:与 assert 同 API,失败时立即停止,用于前置检查。
- suite 包:测试套件,支持 SetupSuite/TearDownSuite、SetupTest/TearDownTest,适合需要共享初始化的复杂测试。
- 常用断言:
Equal、NotEqual、True/False、Nil/NotNil、NoError/Error、Contains、ElementsMatch、Len/Empty、Panics、InDelta、JSONEq、ErrorIs。 - 自定义断言:用普通函数 +
t.Helper()封装项目特定的断言逻辑。 - assert 与 require 的搭配:前置检查用 require,主断言用 assert,是大多数项目的标准做法。
下一篇我们将进入更深入的领域:如何使用 Mock 来测试依赖外部资源的代码,包括手动 Mock、gomock、mockery、testify/mock 等多种方案。
掌握了 testify 之后,你会发现写测试的体验明显提升——失败信息更友好、代码更紧凑、组织更清晰。但请记住:工具是手段,不是目的。一份好的测试,最关键的还是覆盖到了正确的边界条件、保持了独立性、易于维护。testify 只是把这件事变得更顺手。