在Go语言Web编程进阶中,我们使用到了单元测试的概念,来对编写的程序进行测试。单元测试是软件开发过程中的一种质量保证方法,它通过对软件中的最小可测试单元进行检查和验证,确保每个单元都能按照预期工作。
Go语言在设计时就非常注重简洁和高效,这同样体现在其单元测试的支持上。Go语言单元测试的优势有
在Go语言中,testing包是编写单元测试的基础。这个包提供了测试框架和断言函数,使得编写和运行测试变得非常简单。
测试函数
在Go中,测试代码通常位于与被测试代码相同的包中,测试文件通常以_test.go结尾(如hello_test.go)。测试函数的命名规则是以Test开头,后跟被测试函数的名字,例如TestAdd用于测试Add函数。
func TestAdd(t *testing.T) {
sum := Add(1, 2)
if sum != 3 {
t.Errorf("Add(1, 2) = %d; want 3", sum)
}
}
**说明:***testing.T是一个类型,它代表着一个测试的上下文。当一个测试函数被执行时,它会被传递给测试函数,以便测试函数可以使用它来报告测试的状态、记录日志、报告错误或者失败
测试组
测试组允许你将相关的测试函数组合在一起,通常用于共享相同的设置代码
func TestGroup(t *testing.T) {
tests := []struct {
name string
x int
y int
want int
}{
{"Positive", 1, 2, 3},
{"Negative", -1, -1, -2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.x, tt.y)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d", tt.x, tt.y, got, tt.want)
}
})
}
}
在这段代码中,我们定义了一个名为TestGroup的测试函数,它包含了一个结构体切片tests,这个切片包含了多个测试用例。每个测试用例都有一个名字(name)和对应的输入值(x和y)以及期望的输出值(want)。
接着,我们遍历这个tests切片,并使用t.Run方法来执行每个测试用例。t.Run的第一个参数是子测试的名字,它将显示在测试输出中;第二个参数是一个函数,这个函数包含了实际的测试代码
在t.Run的函数参数中,我们调用了Add函数,并将实际的结果(got)与期望的结果(tt.want)进行比较。如果它们不相等,我们使用t.Errorf来报告测试失败,并输出错误信息。
通过这种方式,我们可以将一组相关的测试用例组织在一起,每个测试用例都可以独立运行,同时共享TestGroup函数中的设置代码。这使得测试更加模块化,易于管理和维护。
testing包中,测试辅助函数是一组用于在测试过程中报告状态、错误、日志等的方法。这些方法都是*testing.T类型的一部分,下面是一些常用的测试辅助函数及其例子
Error和Errorf
Error和Errorf方法用于在测试中报告错误。它们会标记测试为失败,但不会停止测试的执行。
func TestAdd(t *testing.T) {
sum := Add(1, 2)
if sum != 3 {
t.Error("Add(1, 2) failed") // 使用Error
}
}
func TestSubtract(t *testing.T) {
result := Subtract(5, 3)
if result != 2 {
t.Errorf("Subtract(5, 3) = %d; want 2", result) // 使用Errorf
}
}
Fail和FailNow
Fail方法用于标记测试为失败,但不提供错误信息。FailNow方法会立即停止当前测试的执行,并标记测试为失败
func TestFail(t *testing.T) {
t.Fail() // 标记测试为失败,但继续执行
// 更多代码...
}
func TestFailNow(t *testing.T) {
t.FailNow() // 立即停止测试
// 此行代码不会被执行
}
Fatal和Fatalf
Fatal和Fatalf方法用于在测试中报告致命错误。它们会标记测试为失败,并立即停止测试的执行。
func TestFatal(t *testing.T) {
if _, err := os.Stat("nonexistentfile"); err == nil {
t.Fatal("os.Stat returned no error for nonexistent file") // 使用Fatal
}
}
func TestFatalf(t *testing.T) {
if _, err := os.Open("nonexistentfile"); err != nil {
t.Fatalf("os.Open returned an error: %v", err) // 使用Fatalf
}
}
Log和Logf
Log和Logf方法用于记录测试日志。这些日志信息仅在详细输出时可见,不会影响测试的状态。
func TestLog(t *testing.T) {
t.Log("This is a log message") // 使用Log
}
func TestLogf(t *testing.T) {
t.Logf("This is a formatted log message: %v", 42) // 使用Logf
}
Skip和Skipf
Skip和Skipf方法用于跳过当前测试。这些方法通常用于条件测试,当某些前提条件不满足时,可以跳过测试
func TestSkip(t *testing.T) {
if os.Getenv("SKIP_TEST") == "true" {
t.Skip("Skipping this test due to environment variable") // 使用Skip
}
// 测试代码...
}
func TestSkipf(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skipf("Skipping this test on Windows") // 使用Skipf
}
// 测试代码...
}
运行单元测试通常可以通过IDE工具或者命令行的方式来触发,下面展示一些运行的例子

测试全部测试文件
最基础的运行单元测试的方法是使用go test命令。在你的项目根目录或包含测试文件的目录下,运行以下命令
go test
这会编译并运行所有命名符合TestXXX格式的测试函数。
测试单个文件
go test -run TestFileName
其中TestFileName是你的测试文件名(不包括.go扩展名)
测试单个测试函数
可以通过指定测试函数的全名来运行单个测试函数
go test -run TestFunctionName
这里TestFunctionName是你要运行的测试函数的名称
并行运行测试
如果你的测试是并行安全的,你可以使用-parallel标志来并行运行测试,以提高效率
go test -parallel 4
这里4是并行运行的测试数量。你可以根据你的CPU核心数来调整这个值。
显示测试覆盖率
可以使用-cover标志来查看测试覆盖率
go test -cover
这将输出每个包的测试覆盖率摘要
输出详细信息
如果你想看到更多的测试输出,可以使用-v标志
go test -v
这将显示每个测试函数的运行结果和日志信息。
测试当前包
如果你想测试当前包,而不管它是否是测试主包,可以使用-run标志和.模式
go test -run .
通过这些命令和选项,你可以灵活地运行和管理你的Go语言单元测试
Mock技术是在单元测试中用来模拟外部依赖项(如数据库、网络服务、文件系统等)的技术。在编写单元测试时,我们通常希望测试案例能够独立于外部系统,以便能够快速、可靠地运行测试,并确保测试结果只受被测试代码的影响。Mock技术允许我们创建模拟对象(mock objects),这些对象的行为和真实的外部依赖项相似,但在测试环境中是可控的。
其优势主要体现在
Go中手动mock的示例
假设我们有一个简单的用户服务,该服务负责从数据库中获取用户信息。我们将使用Mock技术来模拟数据库服务,以便在不需要实际数据库的情况下测试用户服务
首先,我们定义一个数据库接口和它的实现:
// db/interface.go
package db
type User struct {
ID int
Name string
}
type Database interface {
GetUser(id int) (*User, error)
}
// db/mock_db.go
package db
type MockDB struct {
data map[int]*User
}
func (m *MockDB) GetUser(id int) (*User, error) {
user, ok := m.data[id]
if !ok {
return nil, fmt.Errorf("user not found")
}
return user, nil
}
func NewMockDB() *MockDB {
return &MockDB{
data: make(map[int]*User),
}
}
现在,我们有一个用户服务,它使用这个数据库接口来获取用户信息:
// service/user_service.go
package service
import (
"fmt"
"myproject/db"
)
type UserService struct {
db db.Database
}
func NewUserService(db db.Database) *UserService {
return &UserService{
db: db,
}
}
func (us *UserService) GetUser(id int) (string, error) {
user, err := us.db.GetUser(id)
if err != nil {
return "", err
}
return fmt.Sprintf("User: %s", user.Name), nil
}
最后,我们编写一个单元测试来测试UserService,使用Mock数据库代替真实的数据库:
// service/user_service_test.go
package service
import (
"myproject/db"
"testing"
)
func TestGetUser(t *testing.T) {
// 创建Mock数据库
mockDB := db.NewMockDB()
// 设置Mock数据
mockDB.data[1] = &db.User{
ID: 1,
Name: "John Doe",
}
// 创建UserService实例,注入Mock数据库
userService := NewUserService(mockDB)
// 调用GetUser方法
name, err := userService.GetUser(1)
// 断言结果
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if name != "User: John Doe" {
t.Errorf("Expected name 'John Doe', got %s", name)
}
}
在这个例子中,我们创建了一个MockDB实例,并设置了期望的数据。然后,我们创建了UserService实例,并将MockDB注入到其中。最后,我们调用了GetUser方法,并使用断言来验证结果是否符合预期
不过这样手动mock的方法比较冗长,也不好批量编写,我们可以用GoMock框架来轻松编写mock的单元测试
GoMock 是一个用于 Go 语言的开源 Mock 框架,它允许开发者创建 Mock 对象来模拟接口的行为。GoMock 是由 Go 社区维护的,并且与 Go 的标准测试包 testing 集成得很好。使用 GoMock,可以轻松地为接口创建 Mock 实现,并定义这些 Mock 对象在调用时的行为和返回值
特点
安装 GoMock
要使用 GoMock,首先需要安装 GoMock 命令行工具。可以通过以下命令安装:
go get github.com/golang/mock/gomock
go install github.com/golang/mock/mockgen@latest
google在2023年已经停止了对gomock的更新和支持,以上的命令也还能获取,如果要获取更新的版本,可以用下面的
go get github.com/uber-go/mock/gomock
go install go.uber.org/mock/mockgen@latest
使用 GoMock 的基本步骤
定义接口:首先,你需要定义一个接口,你想要为这个接口创建 Mock 实现
// my_interface.go
package mypackage
type MyInterface interface {
DoSomething(arg int) (int, error)
}
生成 Mock 代码:使用 GoMock 命令行工具mockgen生成 Mock 代码
mockgen -source=my_interface.go -destination=my_interface_mock.go -package=mypackage
这将生成一个名为 my_interface_mock.go 的文件,其中包含 Mock 实现的代码。
编写测试:在你的测试代码中,创建 Mock 控制器并定义 Mock 对象的行为
// my_interface_test.go
package mypackage
import (
"github.com/golang/mock/gomock"
"testing"
)
func TestMyFunction(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mock := NewMockMyInterface(ctrl)
mock.EXPECT().DoSomething(gomock.Eq(123)).Return(456, nil)
// 使用 Mock 对象进行测试
}
ctrl。控制器负责管理 Mock 对象和 Mock 期望(expectations)。efer ctrl.Finish() 是一个延迟调用的声明,它将在当前函数执行完毕后执行。Finish 方法是 Mock 控制器的一个方法,它用于释放控制器占用的资源,比如在测试结束后关闭 Mock 对象可能打开的文件描述符。mock := NewMockMyInterface(ctrl) 创建了一个新的 Mock 对象,它实现了我们之前定义的 MyInterface 接口。NewMockMyInterface 是 GoMock 框架提供的一个方法,它创建了一个新的 Mock 对象,并将其与当前的 Mock 控制器关联起来。mock.EXPECT().DoSomething(gomock.Eq(123)).Return(456, nil) 定义了一个期望的行为。EXPECT() 方法用于告诉 GoMock,我们应该期望 DoSomething 方法被调用,并且它应该被调用的方式。gomock.Eq(123) 是一个断言,它告诉 GoMock,我们期望 DoSomething 方法的第一个参数等于 123。Return(456, nil) 定义了当 DoSomething 方法被调用时,我们应该返回的值。在这个例子中,我们期望 DoSomething 方法被调用一次,并且它的第一个参数是 123,然后我们应该返回 456 和 nil。我们看一个具体的例子,创建一个简单的 HTTP 客户端接口和它的 Mock,然后编写一个测试用例来使用这个 Mock
// http_client/interface.go
package httpclient
type HTTPClient interface {
Get(url string) ([]byte, error)
}
mockgen -source=interface.go -destination=interface_mock.go -package=mypackage
有兴趣的同学可以对比一下,看看
interface_mock.go与之前手动编写的mock_db.go有什么区别,来加深对于mock的设计理念的认识
// service/http_service.go
package service
import (
"encoding/json"
httpclient "golang-30-days/Day13-15/code/unit_test/http_client"
)
type Response struct {
Message string `json:"message"`
}
func FetchMessage(client httpclient.HTTPClient, url string) (string, error) {
data, err := client.Get(url)
if err != nil {
return "", err
}
var resp Response
if err := json.Unmarshal(data, &resp); err != nil {
return "", err
}
return resp.Message, nil
}
// service/http_service_test.go
package service
import (
"encoding/json"
httpclient "golang-30-days/Day13-15/code/unit_test/http_client"
"testing"
"github.com/golang/mock/gomock"
)
func TestFetchMessage(t *testing.T) {
// 创建 Mock 控制器
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// 创建 Mock HTTP 客户端
mockClient := httpclient.NewMockHTTPClient(ctrl)
// 设置 Mock 行为:当调用 Get 方法时,返回预定义的响应
mockResponse := Response{Message: "Hello, World!"}
mockData, _ := json.Marshal(mockResponse)
mockClient.EXPECT().Get("http://example.com/message").Return(mockData, nil)
// 调用服务函数
message, err := FetchMessage(mockClient, "http://example.com/message")
// 断言结果
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if message != mockResponse.Message {
t.Errorf("Expected message '%s', got '%s'", mockResponse.Message, message)
}
}
可以看到我们并没有直接调用我们进行mock的接口,而是通过FetchMessage方法,来间接调用httpclient.Get的;我们并未对FetchMessage进行mock操作,但最后返回的依然是我们设定的返回结果,而不是实际去请求http://example.com/message。由此可见,mock控制器创建后,就会直接在测试函数域内生效,只要使用httpclient.Get接口,并且输入参数为http://example.com/message,都会返回我们预设的{Message: "Hello, World!"}面向测试编程(Test-Driven Development,TDD)是一种软件开发过程,它强调在编写实际的代码实现之前先编写测试代码。这种开发方式的核心思想是通过测试来推动软件设计,确保代码的质量和可维护性
TDD通常遵循以下步骤:
golang中的实践主要遵循以下步骤
看一个简单例子,首先,创建一个名为calculator_test.go的测试文件,并编写一个测试用例
package calculator
import "testing"
func TestAdd(t *testing.T) {
result := Add(1, 2)
expected := 3
if result != expected {
t.Errorf("Add(1, 2) = %d; expected %d", result, expected)
}
}
接着,创建一个名为calculator.go的文件,并编写一个空的Add函数:
package calculator
// Add takes two numbers and returns their sum.
func Add(a, b int) int {
return 0
}
运行go test命令,测试将会失败,因为我们的Add函数还没有实现加法功能
go test
接下来,我们回到calculator.go文件,实现Add函数
package calculator
// Add takes two numbers and returns their sum.
func Add(a, b int) int {
return a + b
}
再次运行go test命令,测试会通过。之后我们可以继续添加更多的测试用例,比如测试负数相加、零相加等,然后根据测试结果逐步完善Add函数。
这就是在Go语言中实践TDD的基本流程。通过不断地重复这个过程,你可以逐步构建起一个健壮的代码库。