1. 什么是单元测试
单元测试是软件开发中的一种测试方法,旨在验证代码中最小可测试单元(如函数、方法、类)的行为是否符合预期,它是开发流程的重要组成部分。单元测试的目标是发现代码中的缺陷和错误,并确保代码的正确性和稳定性。
Go语言中自带有一个轻量级的测试框架testing和自带的go test命令来实现单元测试和性能测试。在包目录内,以_test.go为后缀名的源文件都是go test的一部分,而不是go build的构建部分。
2. Go单元测试命名规范
- 在 Go 中,测试文件的命名规则非常重要。测试文件必须以
_test.go
结尾,否则 Go 的测试框架在执行时将不会识别这些文件。
举个例子,如果你的主程序文件名是 hello.go
,那么对应的测试文件应命名为 hello_test.go
。
注意:
- 测试方法名以
Test
开头,参数要用testing
,例如func TestXxx(t *testing.T)
- 在测试的时候通过
go test
命令进行测试
3. Go语言的测试框架
Go语言有以下几种常见的测试框架:
测试框架 | 推荐指数 |
---|---|
Go原生testing包 | ★★★☆☆ |
GoConvey | ★★★★★ |
testify | ★★★☆☆ |
从测试用例编写的复杂度来看:testify比GoConvey简单;GoConvey比Go自带的testing包简单。然而在测试框架的选择上,我们更推荐使用GoConvey,主要原因有:
- GoConvey与其他Stub/Mock框架的兼容性比Testify更好
- Testify虽然自带Mock功能,但需要手动编写Mock类;而GoMock可以一键自动生成这些重复的代码
接下来也会重点讲一下Go原生testing包的单测写法和GoConvey的主要用法
4. Go自带的testing包
testing
包为Go语言的package提供了自动化测试支持。通过go test
命令,可以自动执行如下形式的任何函数:
go
func TestXxx(*testing.T)
注意:Xxx
可以是任何字母数字字符串,但第一个字母不能是小写字母。在这些测试函数中,可以使用Error
、Fail
等方法来指示测试失败。
要创建一个新的测试套件,需要创建一个名称以_test.go
结尾的文件,该文件包含上述TestXxx
函数。将该文件放在与被测试文件相同的包中。该文件会在正常的程序包构建中被排除,但在运行go test
命令时会被包含。更多详情可执行go help test
和go help testflag
查看。
创建一个新的文件夹hello1,作为项目的根目录。在项目中根目录创建一个文件夹gotest,在gotest目录中创建一个example.go用于编写被测试代码,然后同样在gotest目录中创建一个example_test.go,用于编写测试代码。后续的所有测试用例都是在此基础上进行
4.1 基础示例
被测试代码:
go
package gotest
func Factorial(n int) int {
if n <= 0 {
return 1
}
return n * Factorial(n-1)
}
测试代码:
css
func TestFactorial(t *testing.T) {
var (
input = 5
expected = 120
)
actual := Factorial(input)
if actual != expected {
t.Errorf("Factorial(%d) = %d; expected %d", input, actual, expected)
}
}
在gotest目录下执行go test .
,输出:
bash
➜ gotest go test .
ok hello1/gotest 0.332s
注意:➜ gotest表示是在gotest执行的后面的命令。上述当输入为inut
时,结果实际结果actual
和预期结果expected
相等,表示测试通过。如果我们将Factorial
函数修改为错误的实现:
go
func Factorial(n int) int {
if n <= 0 {
return 1
}
return n * Factorial(n-2) // 错误的递归调用
}
再执行go test .
,将输出:
bash
➜ gotest go test .
--- FAIL: TestFactorial (0.00s)
example_test.go:12: Factorial(5) = 15; expected 120
FAIL
FAIL hello1/gotest 0.334s
FAIL
4.2 Table-Driven测试
Table-Driven方式可以在同一个测试函数中测试多个用例,将TestFactorial
函数改为最初正确的形式,再次测试
go
func TestFactorial(t *testing.T) {
var factorialTests = []struct {
input int // 输入值
expected int // 预期结果
}{
{0, 1},
{1, 1},
{2, 2},
{3, 6},
{4, 24},
{5, 120},
{6, 720},
}
for _, tt := range factorialTests {
actual := Factorial(tt.input)
if actual != tt.expected {
t.Errorf("Factorial(%d) = %d; expected %d", tt.input, actual, tt.expected)
}
}
}
程序输出
bash
➜ gotest go test .
ok hello1/gotest 0.345s
Go自带testing包的更多用法可以参考Go标准库文档。
5. GoConvey
GoConvey适用于编写单元测试用例,并且可以兼容到testing框架中。可以通过go test
命令或使用goconvey
命令访问localhost:8080
的Web测试界面来查看测试结果。GoConvey的基本用法如下:
scss
Convey("测试描述", t, func() {
So(...)
})
GoConvey通常使用So
函数进行断言,断言方式可以传入一个函数,或者使用内置的ShouldBeNil
、ShouldEqual
、ShouldNotBeNil
等函数。
5.1 基本示例
被测试代码:
go
package gotest
func SlicesEqual(a, b []int) bool {
if len(a) != len(b) {
return false
}
if (a == nil) != (b == nil) {
return false
}
for i, v := range a {
if v != b[i] {
return false
}
}
return true
}
测试代码:
go
package gotest
import (
. "github.com/smartystreets/goconvey/convey"
"testing"
)
func TestSlicesEqual(t *testing.T) {
Convey("测试切片相等性函数", t, func() {
a := []int{1, 2, 3, 4}
b := []int{1, 2, 3, 4}
So(SlicesEqual(a, b), ShouldBeTrue) // a和b相等,这个判定应该为true,如果确实相等,则单测绘PASS,否侧不通过
})
}
这次我们不再使用go test .
命令,而用go test -v
命令来查看一下具体的单测执行详情
bash
➜ gotest go test -v
=== RUN TestSlicesEqual
测试切片相等性函数 ✔
1 total assertion
--- PASS: TestSlicesEqual (0.00s)
PASS
ok hello1/gotest 0.231s
总共执行了一个断言,测试结果跟我们预测的结果相同,a和b相等,这个判定应该为true,如果确实相等,则单测会PASS,否侧不通过。测试结果为PASS,表示通过。执行耗时为0.231s
5.2 嵌套测试
测试代码:
go
package gotest
import (
. "github.com/smartystreets/goconvey/convey"
"testing"
)
func TestSlicesEqual(t *testing.T) {
Convey("测试切片相等性函数", t, func() {
Convey("当两个非空切片内容相同时", func() {
a := []int{1, 2, 3, 4}
b := []int{1, 2, 3, 4}
So(SlicesEqual(a, b), ShouldBeTrue)
})
Convey("当两个都是nil切片时", func() {
So(SlicesEqual(nil, nil), ShouldBeTrue)
})
Convey("当两个切片长度不同时", func() {
a := []int{1, 2, 3}
b := []int{1, 2, 3, 4}
So(SlicesEqual(a, b), ShouldBeFalse)
})
})
}
测试结果:
bash
➜ gotest go test -v
=== RUN TestSlicesEqual
测试切片相等性函数
当两个非空切片内容相同时 ✔
当两个都是nil切片时 ✔
当两个切片长度不同时 ✔
3 total assertions
--- PASS: TestSlicesEqual (0.00s)
PASS
ok hello1/gotest 0.235s
内层的Convey不需要再传入t *testing.T
参数,这个例子测试了三种情况,当两个非空切片内容相同时,当两个都是nil切片时当两个切片长度不同时的预期情况和真实的代码测试情况,三种情况都是测试通过的
GoConvey的更多用法可以参考官方文档。
6. Stub/Mock框架
在单元测试中,我们往往需要隔离外部依赖 (如数据库、网络、文件系统、第三方服务等),这时就会用到 Stub 和 Mock 框架。它们帮助我们模拟依赖组件的行为,让测试只聚焦于目标函数的逻辑本身。
6.1 Stub 是什么?
Stub(桩) 是一种最基础的替代品,它通常是你手动实现的函数或对象,用来返回固定的值或行为。
假设你有一个函数 GetUserName(id)
,会从数据库中查询用户姓名。但在测试中你不想真的连数据库:
go
func GetUserNameFromDB(id int) string {
return "RealNameFromDB" // 真实实现,测试中不想调用
}
func GetUserName(id int, dbFunc func(int) string) string {
return dbFunc(id)
}
测试中则可以写一个 Stub:
go
func StubGetUserName(id int) string {
return "StubUser"
}
func TestGetUserName(t *testing.T) {
name := GetUserName(1, StubGetUserName)
if name != "StubUser" {
t.Fail()
}
}
Stub的特点是简单、手动、只模拟"结果"。
6.2 Mock 是什么?
Mock(模拟) 是一种更高级的替代品,通常配合框架使用(如:GoMock、Testify)。除了返回值,它还可以验证调用过程,比如:
- 被调用了几次?
- 参数是否正确?
- 调用顺序对不对?
比如同样,假设我们有一个函数 GetUserName(id)
,它依赖一个数据库查询函数 GetUser(id)
,我们想在测试中 mock 这个函数的行为。
go
package main
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/mock"
)
type DBMock struct {
mock.Mock
}
func (m *DBMock) GetUser(id int) string {
args := m.Called(id)
return args.String(0)
}
func GetUserName(id int, getUser func(int) string) string {
return getUser(id)
}
func TestGetUserName(t *testing.T) {
Convey("给定一个用户ID,应该返回对应的用户名", t, func() {
db := new(DBMock)
db.On("GetUser", 1).Return("MockUser")
result := GetUserName(1, db.GetUser)
So(result, ShouldEqual, "MockUser")
// 验证 mock 调用是否正确
db.AssertExpectations(t)
})
}
Golang有以下Stub/Mock框架:
- GoStub
- GoMock
- Monkey
一般来说,GoConvey可以和GoStub、GoMock、Monkey中的一个或多个搭配使用。
6.3 GoStub
GoStub框架有多种使用场景:
- 基本场景:为全局变量打桩
- 基本场景:为函数打桩
- 基本场景:为过程打桩
- 复合场景:由多个基本场景组合而成
6.3.1 为全局变量打桩
假设在被测函数中使用了一个全局整型变量count,当前测试用例需要将count的值固定为150:
css
stubs := Stub(&count, 150)
defer stubs.Reset()
stubs是GoStub框架函数接口Stub返回的对象,该对象有Reset方法可以将全局变量恢复为原值。
6.3.2 为函数打桩
设我们的代码中有以下函数定义:
go
func Execute(cmd string, args ...string) (string, error) {
// 实际实现...
}
我们可以对Execute函数打桩,代码如下:
go
stubs := StubFunc(&Execute, "command-output", nil)
defer stubs.Reset()
6.3.3 为过程打桩
当函数没有返回值时,我们通常称之为过程。例如,一个资源清理函数:
csharp
func CleanupResources() {
// 清理资源的代码...
}
我们对CleanupResources过程的打桩代码为:
css
stubs := StubFunc(&CleanupResources)
defer stubs.Reset()
GoStub的更多用法可以参考官方文档。
6.4 GoMock
GoMock是由Go官方开发维护的测试框架,提供了基于接口的Mock功能,能够与Go内置的testing包良好集成。GoMock包含两个主要部分:GoMock库和mockgen工具,其中GoMock库管理桩对象的生命周期,mockgen工具用于生成接口对应的Mock类源文件。
6.4.1 定义接口
vbnet
package db
type DataStore interface {
Create(key string, value []byte) error
Retrieve(key string) ([]byte, error)
Update(key string, value []byte) error
Delete(key string) error
}
6.4.2 生成Mock类文件
mockgen工具有两种操作模式:源文件模式和反射模式。
- 源文件模式通过包含接口定义的文件生成Mock类:
ini
mockgen -source=datastore.go [其他选项]
- 反射模式通过构建程序并使用反射理解接口生成Mock类:
bash
mockgen database/sql/driver Conn,Driver
生成的mock_datastore.go文件内容大致如下:
go
// 自动生成的代码 - 请勿手动修改!
// Source: db (interfaces: DataStore)
package mock_db
import (
gomock "github.com/golang/mock/gomock"
)
// MockDataStore 是DataStore接口的模拟实现
type MockDataStore struct {
ctrl *gomock.Controller
recorder *MockDataStoreMockRecorder
}
// MockDataStoreMockRecorder 是MockDataStore的记录器
type MockDataStoreMockRecorder struct {
mock *MockDataStore
}
// NewMockDataStore 创建一个新的模拟实例
func NewMockDataStore(ctrl *gomock.Controller) *MockDataStore {
mock := &MockDataStore{ctrl: ctrl}
mock.recorder = &MockDataStoreMockRecorder{mock}
return mock
}
// EXPECT 返回一个对象,允许调用者指示预期的用法
func (_m *MockDataStore) EXPECT() *MockDataStoreMockRecorder {
return _m.recorder
}
// Create 模拟基础方法
func (_m *MockDataStore) Create(_param0 string, _param1 []byte) error {
ret := _m.ctrl.Call(_m, "Create", _param0, _param1)
ret0, _ := ret[0].(error)
return ret0
}
// ... 其他方法实现
6.4.3 使用Mock对象进行测试
- 导入相关包
go
import (
"testing"
. "github.com/golang/mock/gomock"
"myapp/mock/db"
// 其他导入...
)
- 创建Mock控制器
Mock控制器通过NewController接口生成,是Mock生态系统的顶层控制,它定义了Mock对象的作用域和生命周期,以及期望行为。
go
ctrl := NewController(t)
defer ctrl.Finish()
```
创建Mock对象时需要注入控制器:
```go
ctrl := NewController(t)
defer ctrl.Finish()
mockDB := mock_db.NewMockDataStore(ctrl)
mockAPI := mock_api.NewMockHttpClient(ctrl)
- 定义Mock对象行为
假设有这样一个场景:首先尝试获取数据失败,然后创建数据成功,再次获取就能成功。这个场景的Mock行为设置如下:
scss
mockDB.EXPECT().Retrieve(Any()).Return(nil, errors.New("不存在"))
mockDB.EXPECT().Create(Any(), Any()).Return(nil)
mockDB.EXPECT().Retrieve(Any()).Return(dataBytes, nil)
其中dataBytes是测试数据的序列化结果:
css
data := MyData{Field1: "value", Field2: 123}
dataBytes, _ := json.Marshal(data)
批量操作可以使用Times指定次数:
scss
mockDB.EXPECT().Create(Any(), Any()).Return(nil).Times(5)
多次获取不同数据时,需要设置多个行为:
scss
mockDB.EXPECT().Retrieve(Any()).Return(dataBytes1, nil)
mockDB.EXPECT().Retrieve(Any()).Return(dataBytes2, nil)
mockDB.EXPECT().Retrieve(Any()).Return(dataBytes3, nil)
GoMock的更多用法可以参考官方文档。
6.5 Monkey
前面我们已经了解到:
- 全局变量可通过GoStub框架打桩
- 过程可通过GoStub框架打桩
- 函数可通过GoStub框架打桩
- 接口可通过GoMock框架打桩
但还有两个问题较难解决:
- 方法(成员函数)无法通过GoStub框架打桩,特别是当代码的OO设计较多时
- 通过GoStub框架打桩时,对产品代码有侵入性
Monkey是Go的一个猴子补丁(monkeypatching)框架,通过在运行时重写可执行文件,将待打桩函数或方法的实现重定向到桩实现。原理类似于热补丁技术。但需要注意的是,Monkey不是线程安全的,不应用于并发测试。
Monkey框架的使用场景:
- 基本场景:为函数打桩
- 基本场景:为过程打桩
- 基本场景:为方法打桩
- 复合场景:由多个基本场景组合而成
- 特殊场景:桩中桩的案例
6.5.1 为函数打桩
假设Execute是一个执行命令的函数:
lua
func Execute(cmd string, args ...string) (string, error) {
cmdPath, err := exec.LookPath(cmd)
if err != nil {
log.Printf("exec.LookPath err: %v, cmd: %s", err, cmd)
return "", errors.New("command not found")
}
output, err := exec.Command(cmdPath, args...).CombinedOutput()
if err != nil {
log.Printf("exec.Command.CombinedOutput err: %v, cmd: %s", err, cmd)
return "", errors.New("command execution failed")
}
log.Printf("CMD[%s]ARGS[%v]OUT[%s]", cmdPath, args, string(output))
return string(output), nil
}
使用Monkey打桩的代码:
go
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
. "github.com/bouk/monkey"
"myapp/utils"
)
func TestExecute(t *testing.T) {
Convey("测试命令执行", t, func() {
Convey("成功执行", func() {
expectedOutput := "command output"
guard := Patch(
utils.Execute,
func(_ string, _ ...string) (string, error) {
return expectedOutput, nil
})
defer guard.Unpatch()
output, err := utils.Execute("any", "any")
So(output, ShouldEqual, expectedOutput)
So(err, ShouldBeNil)
})
})
}
Patch
是Monkey
提供的函数打桩API:
- 第一个参数是目标函数
- 第二个参数是桩函数,通常使用匿名函数或闭包
- 返回值是PatchGuard对象指针,用于在测试结束时移除补丁
6.5.2 为过程打桩
对于没有返回值的函数(过程),打桩代码如下:
go
guard := Patch(CleanupResources, func() {
// 空实现或测试所需的行为
})
defer guard.Unpatch()
6.5.3 为方法打桩
假设在分布式系统中,需要模拟从配置中心获取配置的行为:
go
type ConfigCenter struct {
// 字段...
}
func (c *ConfigCenter) GetConfig(key string) (string, error) {
// 实际实现...
return "", nil
}
使用Monkey对方法打桩:
go
var cc *ConfigCenter
guard := PatchInstanceMethod(
reflect.TypeOf(cc),
"GetConfig",
func(_ *ConfigCenter, _ string) (string, error) {
return "{"feature":"enabled","timeout":30}", nil
})
defer guard.Unpatch()
PatchInstanceMethod API是Monkey提供的方法打桩API:
- 首先定义目标类的指针变量x
- 第一个参数是reflect.TypeOf(x)
- 第二个参数是方法名的字符串
- 第三个参数是替换方法
- 返回值是PatchGuard对象指针,用于移除补丁
Monkey的更多用法可以参考官方文档。
7. Mock场景最佳实践
7.1 实例函数Mock:Monkey。
Monkey框架可用于对依赖函数进行替换,完成针对当前模块的单元测试。
有如下例子,helper
包是实际功能实现,mock_helper
包是用于mock的替代实现。
helper.go:
go
package helper
import "fmt"
func FormatSum(a, b int) string {
return fmt.Sprintf("a:%v+b:%v", a, b)
}
type Calculator struct {
}
func (*Calculator) FormatResult(a, b int) string {
return fmt.Sprintf("a:%v+b:%v", a, b)
}
mock_helper.go:
go
package mock_helper
import (
"fmt"
"myapp/helper"
)
func FormatSum(a, b int) string {
return fmt.Sprintf("a:%v+b:%v=%v", a, b, a+b)
}
// 对应helper包中的FormatResult
func FormatResult(_ *helper.Calculator, a, b int) string {
return fmt.Sprintf("a:%v+b:%v=%v", a, b, a+b)
}
测试代码:
scss
func TestFormatting() {
// 替换函数
monkey.Patch(helper.FormatSum, mock_helper.FormatSum)
result := helper.FormatSum(1, 2)
fmt.Println(result)
monkey.UnpatchAll() // 解除所有替换
result = helper.FormatSum(1, 2)
fmt.Println(result)
}
func TestMethodFormatting() {
calc := &helper.Calculator{}
// 参数1: 获取实例的反射类型, 参数2: 被替换的方法名, 参数3: 替换方法
monkey.PatchInstanceMethod(reflect.TypeOf(calc), "FormatResult", mock_helper.FormatResult)
result := calc.FormatResult(1, 2)
fmt.Println(result)
monkey.UnpatchAll() // 解除所有替换
result = calc.FormatResult(1, 2)
fmt.Println(result)
}
7.2 未实现函数Mock:GoMock
假设场景:Company
(公司)和Person
(人)之间的关系:
- 公司可以举行会议
- 公司内部的人实现了
Speaker
接口,拥有SayHello
方法
若所有类都已实现,测试代码如下:
scss
func TestCompany_Meeting(t *testing.T) {
// 直接创建一个Person对象
speaker := NewPerson("小张", "工程师")
company := NewCompany(speaker)
t.Log(company.Meeting("张三", "实习生"))
}
但如果Person
类尚未实现,可以通过GoMock模拟一个符合Speaker
接口的对象
定义Speaker.go
接口:
go
package domain
type Speaker interface {
SayHello(name, role string) (response string)
}
用mockgen
命令生成Mock对象:
go
mockgen -source=Speaker.go -destination=mock_speaker.go -package=mock_domain
测试代码:
scss
func TestCompany_Meeting(t *testing.T) {
// 创建Mock控制器
ctrl := gomock.NewController(t)
// 创建Mock对象
speaker := mock_domain.NewMockSpeaker(ctrl)
// 设置期望行为
speaker.EXPECT().SayHello(gomock.Eq("张三"), gomock.Eq("实习生")).Return(
"你好,张三(角色:实习生),欢迎加入公司会议。我是会议主持人。")
// 将Mock对象传入测试对象
company := NewCompany(speaker)
// 执行测试
t.Log(company.Meeting("张三", "实习生"))
}
7.3 系统内置函数Mock:Monkey
使用Monkey可以mock系统内置函数,例如json.Unmarshal:
go
monkey.Patch(json.Unmarshal, mockUnmarshal)
func mockUnmarshal(b []byte, v interface{}) error {
// 强制设置为指定值,无视输入
*(v.(*models.LoginMessage)) = models.LoginMessage{
UserID: 1,
Username: "admin",
Password: "admin",
}
return nil
}
取消替换:
scss
monkey.Unpatch(json.Unmarshal) // 解除单个Patch
monkey.UnpatchAll() // 解除所有Patch
7.4 数据库行为Mock
使用sqlmock库模拟数据库操作:
go
func TestDatabaseQuery(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
t.Fatalf("创建sqlmock失败: %v", err)
}
defer db.Close()
// 模拟查询结果
rows := sqlmock.NewRows([]string{"id", "username"}).
AddRow(1, "user1").
AddRow(2, "user2")
// 设置期望的SQL查询
mock.ExpectQuery("SELECT id, username FROM users").WillReturnRows(rows)
// 执行查询
result, err := db.Query("SELECT id, username FROM users")
if err != nil {
t.Fatalf("查询执行失败: %v", err)
}
defer result.Close()
// 处理结果
var users []struct {
ID int
Username string
}
for result.Next() {
var id int
var username string
result.Scan(&id, &username)
users = append(users, struct {
ID int
Username string
}{id, username})
t.Logf("查询结果: ID=%d, 用户名=%s", id, username)
}
if result.Err() != nil {
t.Fatalf("结果处理错误: %v", result.Err())
}
// 验证所有期望都已满足
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("有未满足的期望: %v", err)
}
}
7.5 服务器行为Mock
使用net/http/httptest模拟HTTP服务器:
go
func TestHTTPRequest(t *testing.T) {
// 创建处理器
handler := func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"status": "success", "data": {"message": "Hello World"}}`)
}
// 创建请求和响应记录器
req := httptest.NewRequest("GET", "/api/hello", nil)
w := httptest.NewRecorder()
// 处理请求
handler(w, req)
// 获取响应
resp := w.Result()
body, _ := ioutil.ReadAll(resp.Body)
// 验证结果
t.Logf("状态码: %d", resp.StatusCode)
t.Logf("内容类型: %s", resp.Header.Get("Content-Type"))
t.Logf("响应体: %s", string(body))
// 可以进一步解析JSON并断言
var result struct {
Status string `json:"status"`
Data struct {
Message string `json:"message"`
} `json:"data"`
}
json.Unmarshal(body, &result)
assert.Equal(t, "success", result.Status)
assert.Equal(t, "Hello World", result.Data.Message)
}
对于涉及方法的情况,需要使用PatchInstanceMethod
:
go
func TestHTTPClient(t *testing.T) {
var client *http.Client
// 替换http.Client的Do方法
monkey.PatchInstanceMethod(reflect.TypeOf(client), "Do", func(_ *http.Client, _ *http.Request) (*http.Response, error) {
// 创建模拟响应
resp := &http.Response{
StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewBufferString(`{"result": "mocked response"}`)),
Header: make(http.Header),
}
resp.Header.Set("Content-Type", "application/json")
return resp, nil
})
defer monkey.UnpatchAll()
// 测试使用http.Client的函数
result, err := FetchData("https://api.example.com/data")
assert.NoError(t, err)
assert.Equal(t, "mocked response", result.Value)
}
8. 实战案例:消息通讯系统
8.1 项目概览
假设该项目是一个具有用户登录、查看在线用户、私聊、群聊等功能的命令行通讯系统。项目分为Client和Server两个子模块,都采用Model-Controller(Processor)-View(Main)的架构进行功能划分。另外还有一个Common模块存放通用工具类和数据结构。
arduino
├─Client
│ ├─main
│ ├─model
│ ├─processor
│ └─utils
├─Common
└─Server
├─main
├─model
├─processor
└─utils
测试目标:为核心功能模块编写单元测试,确保各模块功能的正确性、完整性和健壮性,并在代码变更后能快速验证。
单元测试应包括:
- 模块接口测试:验证参数传递、处理和返回值
- 模块数据结构测试:确保局部数据在处理过程中的完整性和正确性
- 异常处理测试:验证各种异常情况下的错误处理是否合理
接口测试应全面考察参数合法性、必要性、参数间的冗余性,以及指针引用的正确性等。数据结构测试应关注临时存储在模块内的数据结构的正确性,因为局部数据结构往往是错误的根源。
异常处理测试应关注几种常见问题:
- 错误信息提示不足
- 异常未被处理
- 错误信息与实际不符
- 错误信息未能准确定位问题
在本案例中,假设Model层向服务层提供的接口较少,只有WritePkg
和ReadPkg
两个核心函数,服务层基于这些基础函数封装具体业务逻辑。由于涉及网络连接,需要编写桩函数进行测试。服务层涉及多个网络连接调用和数据库操作,同样需要Mock。
鉴于需要编写Mock和桩函数,我们使用GoStub
和Monkey
包来简化测试,只需要编写替代接口和Mock函数,就能在测试过程中替换系统函数或依赖模块。
8.2 Model层与数据库测试
由于是单元测试,我们需要创建Mock数据库实例,测试CRUD操作的SQL语句执行:
go
const (
sqlSelect = "SELECT id, username FROM users"
sqlDelete = "DELETE FROM users WHERE id > 100 AND id < 200"
sqlUpdate = "UPDATE users SET status = 'active' WHERE id = 1"
sqlInsert = "INSERT INTO users (id, username) VALUES (101, 'newuser')"
)
func TestUserRepository(t *testing.T) {
// 创建sqlmock数据库连接
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
t.Fatalf("创建sqlmock失败: %v", err)
}
defer db.Close()
// 模拟查询结果
rows1 := sqlmock.NewRows([]string{"id", "username"}).
AddRow(1, "admin").
AddRow(2, "user")
rows2 := sqlmock.NewRows([]string{"id", "username"}).
AddRow(101, "temp1").
AddRow(102, "temp2")
rows3 := sqlmock.NewRows([]string{"id", "username"}).
AddRow(1, "admin")
rows4 := sqlmock.NewRows([]string{"id", "username"}).
AddRow(101, "newuser")
// 设置SQL执行预期
mock.ExpectQuery(sqlSelect).WillReturnRows(rows1)
mock.ExpectQuery(sqlDelete).WillReturnRows(rows2)
mock.ExpectQuery(sqlUpdate).WillReturnRows(rows3)
mock.ExpectQuery(sqlInsert).WillReturnRows(rows4)
// 测试用例
var tests = []struct{
querySql string
expected interface{}
}{
{sqlSelect, nil},
{sqlDelete, nil},
{sqlUpdate, nil},
{sqlInsert, nil},
}
for _, test := range tests {
// 执行查询
res, err := db.Query(test.querySql)
assert.Equal(t, err, test.expected) // 验证无错误
// 处理结果
var users []struct {
ID int
Username string
}
for res.Next() {
var id int
var username string
res.Scan(&id, &username)
users = append(users, struct {
ID int
Username string
}{id, username})
t.Logf("查询结果: ID=%d, 用户名=%s", id, username)
}
assert.Equal(t, res.Err(), test.expected) // 验证结果处理无错误
}
}
8.3 私聊功能测试
私聊功能涉及JSON编码和发送消息的底层操作(WritePkg
函数),我们使用Monkey进行Mock:
go
func TestMessageSender_SendPrivateMessage(t *testing.T) {
var conn net.Conn
transfer := &utils.Transfer{
Conn: conn,
}
// Mock WritePkg方法
monkey.PatchInstanceMethod(reflect.TypeOf(transfer), "WritePkg", func(_ *utils.Transfer, _ []byte) error {
return nil
})
convey.Convey("测试发送私聊消息", t, func() {
msg := &models.PrivateMessage{
From: "user1",
To: "user2",
Content: "你好!",
}
sender := &MessageSender{Transfer: transfer}
err := sender.SendPrivateMessage(msg)
convey.So(err, convey.ShouldBeNil)
})
monkey.UnpatchAll()
}
8.4 登录功能测试
登录功能涉及服务器连接和数据处理,我们可以使用多种Mock技术结合测试:
go
func TestMessageSender_SendPrivateMessage(t *testing.T) {
var conn net.Conn
transfer := &utils.Transfer{
Conn: conn,
}
// Mock WritePkg方法
monkey.PatchInstanceMethod(reflect.TypeOf(transfer), "WritePkg", func(_ *utils.Transfer, _ []byte) error {
return nil
})
convey.Convey("测试发送私聊消息", t, func() {
msg := &models.PrivateMessage{
From: "user1",
To: "user2",
Content: "你好!",
}
sender := &MessageSender{Transfer: transfer}
err := sender.SendPrivateMessage(msg)
convey.So(err, convey.ShouldBeNil)
})
monkey.UnpatchAll()
}
```
### 4. 登录功能测试
登录功能涉及服务器连接和数据处理,我们可以使用多种Mock技术结合测试:
```go
func mockJsonUnmarshal(b []byte, v interface{}) error {
// 强制设置登录消息对象的值
*(v.(*models.LoginMessage)) = models.LoginMessage{
UserID: 1,
Username: "admin",
Password: "password123",
}
return nil
}
func mockJsonMarshal(v interface{}) ([]byte, error) {
// 简化的JSON序列化,返回固定内容
return []byte(`{"status":"success"}`), nil
}
func TestUserProcessor_Login(t *testing.T) {
// 创建测试消息
message := &models.Message{
Type: models.LoginMessageType,
Data: "mock_login_data",
}
userProcessor := &UserProcessor{
Conn: nil,
}
// Mock系统函数
monkey.Patch(json.Unmarshal, mockJsonUnmarshal)
monkey.Patch(json.Marshal, mockJsonMarshal)
// Mock用户数据访问对象
var userDao *model.UserDao
monkey.PatchInstanceMethod(reflect.TypeOf(userDao), "Login", func(_ *model.UserDao, _ int, _ string) (*models.User, error) {
return &models.User{
UserID: 1,
Username: "admin",
Password: "password123",
}, nil
})
// Mock传输层
var transfer *utils.Transfer
monkey.PatchInstanceMethod(reflect.TypeOf(transfer), "WritePkg", func(_ *utils.Transfer, _ []byte) error {
return nil
})
// 执行测试
convey.Convey("测试用户登录处理", t, func() {
err := userProcessor.HandleLogin(message)
convey.So(err, convey.ShouldBeNil)
})
// 清理Mock
monkey.UnpatchAll()
}
8.5 工具类测试
测试网络传输工具类:
go
func mockNetRead(conn net.Conn, _ []byte) (int, error) {
// 模拟读取4字节数据
return 4, nil
}
func mockJsonMarshal(v interface{}) ([]byte, error) {
return []byte{1, 2, 3, 4}, nil
}
func mockJsonUnmarshal(data []byte, v interface{}) error {
return nil
}
func TestTransfer_ReadPackage(t *testing.T) {
// Mock网络读取
monkey.Patch(net.Conn.Read, mockNetRead)
monkey.Patch(json.Marshal, mockJsonMarshal)
monkey.Patch(json.Unmarshal, mockJsonUnmarshal)
// 创建测试服务器
listener, _ := net.Listen("tcp", "localhost:9999")
defer listener.Close()
// 创建客户端连接
go net.Dial("tcp", "localhost:9999")
// 接受连接
var conn net.Conn
for {
conn, _ = listener.Accept()
if conn != nil {
break
}
}
// 创建测试对象
transfer := &Transfer{
Conn: conn,
Buf: [8096]byte{1, 2, 3, 4},
}
// 执行测试
convey.Convey("测试数据包读取", t, func() {
message, err := transfer.ReadPackage()
convey.So(err, convey.ShouldBeNil)
convey.So(message, convey.ShouldNotBeNil)
})
// 清理Mock
monkey.UnpatchAll()
}
func TestTransfer_WritePackage(t *testing.T) {
// Mock JSON操作
monkey.Patch(json.Marshal, mockJsonMarshal)
monkey.Patch(json.Unmarshal, mockJsonUnmarshal)
// 创建测试对象
transfer := &Transfer{
Conn: nil,
Buf: [8096]byte{},
}
// 执行测试
convey.Convey("测试数据包写入", t, func() {
err := transfer.WritePackage([]byte{1, 2})
convey.So(err, convey.ShouldBeNil)
})
// 清理Mock
monkey.UnpatchAll()
}
在编写单元测试的时候,推荐使用第三方包来完成,虽然原生包能满足基本需求,但不提供断言语法,导致要写大量重复的错误检查代码,因此引入convey和assert包简化判断逻辑,可以使代码更简洁易读
更多测试实践案例可参考:
9. 基准测试
除了前面提到的单元测试,测试代码单元的正确性之外,Go语言还提供了基准测试框架,可以测试一段程序的性能、CPU消耗,可以对代码做性能分析,测试方法与单元测试类似。
基准测试规则:
- 基准测试以Benchmark为前缀
- 需要一个*testing.B类型的参数b
- 基准测试必须要执行b.N次
常见的基准测试函数写法:
go
func BenchmarkTest(b *testing.B) {
...
}
执行基准测试时,需要添加-bench
参数
ini
go test -bench="."
下面通过一个模拟负载均衡的例子,来看下基准测试:
在gotest包下准备一个Abs
函数作为被测试的代码位于example.go,代码如下:
go
package gotest
import "math"
func Abs(x float64) float64 {
return math.Abs(x)
}
然后在example_test.go文件中为 Abs
函数编写的基准测试,代码如下:
css
package gotest
func BenchmarkAbs(b *testing.B) {
for i := 0; i < b.N; i++ {
Abs(-1)
}
}
注意基准测试的时候参数不再是 *testing.T
,而是 *testing.B
,在测试函数中,我们循环了 b.N
次调用 Abs(-1)
,b.N
的值是一个动态值,我们无需操心,testing
框架会为其分配合理的值,以使测试函数运行足够多的次数,可以准确的计时。
默认情况下,执行 go test
命令时不会自动运行基准测试,需要显式指定 -bench
参数
bash
➜ gotest go test -bench="."
...
3 total assertions
goos: darwin
goarch: arm64
pkg: hello1/gotest
BenchmarkAbs-12 1000000000 0.2954 ns/op
PASS
ok hello1/gotest 1.513s
-bench
的参数接收一个正则表达式,.
匹配所有基准测试。重点看一下执行结果的这一行
bash
BenchmarkAbs-12 1000000000 0.2954 ns/op
BenchmarkAbs-
12 中,BenchmarkAbs
是测试函数名,12 是 GOMAXPROCS
的值,即参与执行的 CPU 核心数。1000000000
表示测试执行了这么多次。0.5096 ns/op
表示每次循环平均消耗的纳秒数。
如果想查看基准测试的内存占用情况,可以通过 -benchmem
参数指定:
bash
➜ gotest go test -bench="BenchmarkAbs$" -benchmem
...
3 total assertions
goos: darwin
goarch: arm64
pkg: hello1/gotest
BenchmarkAbs-12 1000000000 0.2932 ns/op 0 B/op 0 allocs/op
PASS
ok hello1/gotest 0.683s
可以发现,加上-benchmem
参数后,BenchmarkAbs-8
这行打印了更多输出内容:
bash
BenchmarkAbs-12 1000000000 0.2932 ns/op 0 B/op
0 B/op
表示每次执行测试代码分配了多少字节内存。0 allocs/op
表示每次执行测试代码分配了多少次内存。
此外,在执行 go test
命令时,我们可以使用 -benchtime=Ns
参数指定基准测试函数执行时间为 N
秒:
bash
➜ gotest go test -bench="BenchmarkAbs$" -benchtime=0.1s
...
3 total assertions
goos: darwin
goarch: arm64
pkg: hello1/gotest
BenchmarkAbs-12 385589265 0.3081 ns/op
PASS
ok hello1/gotest 0.686s
-benchtime
参数值为 time.Duration
类型支持的时间格式。此外,-benchtime
参数还有一个特殊语法 -benchtime=Nx
参数,可以指定基准测试函数执行次数为 N
次:
bash
➜ gotest go test -bench="BenchmarkAbs$" -benchtime=10x
...
3 total assertions
goos: darwin
goarch: arm64
pkg: hello1/gotest
BenchmarkAbs-12 10 25.00 ns/op
PASS
ok hello1/gotest 0.466s
有时在进行基准测试时,目标函数可能依赖一些预处理步骤,比如数据准备,这些数据准备的时间不应被计入函数本身的性能统计。这时候,我们可以调用 (*testing.B).ResetTimer
来重新开始计时,从而确保测试只衡量核心逻辑的执行时间。
css
func BenchmarkAbsResetTimer(b *testing.B) {
time.Sleep(100 * time.Millisecond) // 模拟数据准备阶段的耗时
b.ResetTimer()
for i := 0; i < b.N; i++ {
Abs(-1)
}
}
这样,在调用 b.ResetTimer()
之前进行的耗时操作将不会被纳入最终的基准测试时间统计中。
另外,还有一种更灵活的做法是:先调用 b.StopTimer()
来暂停计时,等准备工作完成后,再通过 b.StartTimer()
恢复计时,这样也能避免将准备过程的耗时计算在内。
css
func BenchmarkAbsStopTimerStartTimer(b *testing.B) {
b.StopTimer()
time.Sleep(100 * time.Millisecond) // 模拟数据准备阶段的耗时
b.StartTimer()
for i := 0; i < b.N; i++ {
Abs(-1)
}
}
默认情况下,基准测试中的 for
循环是串行方式执行的。如果想要对被测代码进行并发性能测试,可以将其封装在 (*testing.B).RunParallel
方法中,实现并行调用
scss
func BenchmarkAbsParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Abs(-1)
}
})
}
还可以使用 (*testing.B).SetParallelism
控制并发协程数:
scss
func BenchmarkAbsParallel(b *testing.B) {
b.SetParallelism(2) // 设置并发 Goroutines 数量为 2 * GOMAXPROCS
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Abs(-1)
}
})
}
可以通过 -cpu
参数为 go test
指定 GOMAXPROCS 的值,用来控制使用的 CPU 核心数量。如果想了解更多 go test
支持的参数选项,可以执行命令 go help testflag
来获取完整的帮助信息。
10. 小结
单元测试(Unit Test,简称 UT)是高质量软件项目中不可缺少的一个组成部分。它的核心目标是对程序中最小的功能单位进行验证,通常是一个函数或者方法,确保其行为符合预期。Go语言对单元测试提供了很好的支持,其自身就带有一个轻量级的测试框架testing,可以用自带的go test命令来实现单元测试和性能测试。同时也有非常多好用的第三方测试包,比如GoConvey,testify等,可以更加简洁的写测试用例。写好Go程序的单测,不仅仅可以确保代码的完整性和正确性,也是一个Gopher基本功的重要体现
资源分享
为了便于大家学习,秀才已经为大家整理好了相应的PDF 此外,为了避免新手Gopher在开发过程中踩坑,秀才还特意整理了Go语言新手踩坑集合。最后,大家在学完Go开发之后,秀才也准备了一份超级详细的Go语言八股文大全
这份Go的八股大全不仅包含Go语言常用的后端知识,还包含了消息队列,数据库,redis缓存,计算机网络,微服务等全面的后端专项八股,助力大家轻松拿到Go语言的后端offer
关注秀才的公众号:IT杨秀才,回复:Go学习,即可获取
学习交流
如果您觉得文章有帮助,点个关注哦。可以关注公众号:IT杨秀才 ,秀才后面会在公众号分享Go语言:基础 》进阶 》探秘 》实战 》面试的系列知识。也会持续更新更多硬核文章,一起聊聊互联网那些事儿! !
---------------------------历史好文 -------------------------------
《2024年必备的Go语言学习路线(建议收藏🔥)》