Skip to content

断言与 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 并构造格式串。考虑以下几个场景:

  1. 比较两个结构体是否相等:要写一大段格式化代码才能看到差异在哪。
  2. 比较两个切片顺序无关:原生 != 直接不行,得手动排序或循环。
  3. 断言一个错误为 nil:if err != nil 失败时只看到「err != nil」,看不到错误内容。
  4. 断言一个 map 包含某个 key:要写循环。
  5. 断言一个 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/mockMock 框架(下一篇详述)

安装:

bash
go get github.com/stretchr/testify

import 习惯:

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) 应该等于 3

2. 布尔: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)
PASS

Suite 的成员方法

通过嵌入 suite.Suite,测试套件获得了所有 assertrequire 的方法(以 s.Equals.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

SetupTestTearDownTest 是 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.Collectassert.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 测试辅助库,核心要点:

  1. assert 包:提供上百个断言函数,覆盖相等、布尔、nil、错误、包含、元素匹配、panic、数值、时间、文件等场景,失败时继续执行。
  2. require 包:与 assert 同 API,失败时立即停止,用于前置检查。
  3. suite 包:测试套件,支持 SetupSuite/TearDownSuite、SetupTest/TearDownTest,适合需要共享初始化的复杂测试。
  4. 常用断言EqualNotEqualTrue/FalseNil/NotNilNoError/ErrorContainsElementsMatchLen/EmptyPanicsInDeltaJSONEqErrorIs
  5. 自定义断言:用普通函数 + t.Helper() 封装项目特定的断言逻辑。
  6. assert 与 require 的搭配:前置检查用 require,主断言用 assert,是大多数项目的标准做法。

下一篇我们将进入更深入的领域:如何使用 Mock 来测试依赖外部资源的代码,包括手动 Mock、gomock、mockery、testify/mock 等多种方案。


掌握了 testify 之后,你会发现写测试的体验明显提升——失败信息更友好、代码更紧凑、组织更清晰。但请记住:工具是手段,不是目的。一份好的测试,最关键的还是覆盖到了正确的边界条件、保持了独立性、易于维护。testify 只是把这件事变得更顺手。