文章目录
- golang 单元测试
- 引言
- 单元测试的重要性
- Go语言单元测试的优势
- Go语言单元测试基础
- testing包
- 辅助测试函数
- 运行单元测试
- Mock技术
- Mock简介
- GoMock框架介绍
- 编写Mock代码
- 使用Mock进行单元测试
- 面向测试编程(TDD)
- TDD简介
- TDD的优势
- TDD的基本步骤
- Go语言中的TDD实践
golang 单元测试
引言
在Go语言Web编程进阶中,我们使用到了单元测试的概念,来对编写的程序进行测试。单元测试是软件开发过程中的一种质量保证方法,它通过对软件中的最小可测试单元进行检查和验证,确保每个单元都能按照预期工作。
单元测试的重要性
- 提高代码质量:单元测试能够及时发现代码中的错误和缺陷,减少软件发布后出现问题的概率。
- 简化调试过程:当发现代码异常时,单元测试可以帮助开发者快速定位问题所在,减少调试时间。
- 促进代码重构:有了单元测试的保护,开发者可以更加自信地对代码进行重构,提高代码的可读性和可维护性。
- 文档化代码:单元测试可以看作是代码的一种文档形式,它展示了函数或方法预期的行为和功能。
- 支持持续集成:单元测试可以自动化运行,与持续集成工具结合使用,确保代码库的稳定性。
Go语言单元测试的优势
Go语言在设计时就非常注重简洁和高效,这同样体现在其单元测试的支持上。Go语言单元测试的优势有
- 内置测试框架:Go语言的标准库中包含了完整的测试框架,无需额外安装第三方库。
- 简洁的测试语法:Go语言的测试代码编写简单,易于理解,即使是初学者也能快速上手。
- 并行测试:Go语言的测试框架支持并行执行测试,提高测试效率。
- 测试覆盖率工具:Go语言提供了内置的测试覆盖率工具,可以方便地分析测试的全面性。
- 集成到标准工作流:Go语言的测试与构建、部署等环节紧密集成,支持统一的命令行操作。
- 社区支持:Go语言有着活跃的社区,提供了大量的文档、教程和工具,方便开发者学习和使用单元测试。
Go语言单元测试基础
testing包
在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 stringx inty intwant 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工具或者命令行的方式来触发,下面展示一些运行的例子
- IDE的以vscode为例子
可以点击run test(直接运行)或者debug test(debug代码)的方式,来对测试代码进行验证 - 命令行
-
测试全部测试文件
最基础的运行单元测试的方法是使用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技术是在单元测试中用来模拟外部依赖项(如数据库、网络服务、文件系统等)的技术。在编写单元测试时,我们通常希望测试案例能够独立于外部系统,以便能够快速、可靠地运行测试,并确保测试结果只受被测试代码的影响。Mock技术允许我们创建模拟对象(mock objects),这些对象的行为和真实的外部依赖项相似,但在测试环境中是可控的。
其优势主要体现在
- 隔离测试:通过模拟外部依赖,我们可以确保测试只针对当前单元,而不是整个系统。
- 可重复性:Mock对象提供一致的行为,使得测试结果可预测和重复。
- 速度:Mock对象通常比真实的外部系统更快,可以加快测试执行速度。
- 安全性:Mock技术可以避免在测试过程中对真实数据库或服务进行不必要的操作,减少风险。
- 灵活性:可以模拟各种异常情况,测试边缘情况和错误处理逻辑。
Go中手动mock的示例
假设我们有一个简单的用户服务,该服务负责从数据库中获取用户信息。我们将使用Mock技术来模拟数据库服务,以便在不需要实际数据库的情况下测试用户服务
首先,我们定义一个数据库接口和它的实现:
// db/interface.go
package dbtype User struct {ID intName string
}type Database interface {GetUser(id int) (*User, error)
}
// db/mock_db.go
package dbtype 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 serviceimport ("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 serviceimport ("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框架介绍
GoMock 是一个用于 Go 语言的开源 Mock 框架,它允许开发者创建 Mock 对象来模拟接口的行为。GoMock 是由 Go 社区维护的,并且与 Go 的标准测试包 testing 集成得很好。使用 GoMock,可以轻松地为接口创建 Mock 实现,并定义这些 Mock 对象在调用时的行为和返回值
特点
- 基于接口的 Mocking:GoMock 允许你为任何接口生成 Mock 实现。
- 强大的断言:可以精确地断言 Mock 方法被调用的次数、参数和返回值。
- 灵活的行为定义:可以定义 Mock 方法在每次调用时的不同行为。
- 与 Go 测试包集成:GoMock 与 Go 的标准测试包 testing 完美集成,可以很容易地在测试中使用 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
编写Mock代码
使用 GoMock 的基本步骤
-
定义接口:首先,你需要定义一个接口,你想要为这个接口创建 Mock 实现
// my_interface.go package mypackagetype 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 mypackageimport ("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 对象进行测试 }
- 代码首先创建了一个新的 Mock 控制器
ctrl
。控制器负责管理 Mock 对象和 Mock 期望(expectations)。 - d
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
。
- 代码首先创建了一个新的 Mock 控制器
使用Mock进行单元测试
我们看一个具体的例子,创建一个简单的 HTTP 客户端接口和它的 Mock,然后编写一个测试用例来使用这个 Mock
- 定义接口:首先,定义一个 HTTP 客户端接口
// http_client/interface.gopackage httpclienttype HTTPClient interface {Get(url string) ([]byte, error)}
- 生成 Mock 实现:使用 mockgen 命令生成 Mock 实现。
mockgen -source=interface.go -destination=interface_mock.go -package=mypackage
有兴趣的同学可以对比一下,看看
interface_mock.go
与之前手动编写的mock_db.go
有什么区别,来加深对于mock的设计理念的认识 - 编写一个使用 HTTP 客户端的服务
// service/http_service.go package serviceimport ("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 Responseif err := json.Unmarshal(data, &resp); err != nil {return "", err}return resp.Message, nil }
- 编写一个单元测试,使用 GoMock Mock 对象来模拟 HTTP 客户端的行为
可以看到我们并没有直接调用我们进行mock的接口,而是通过// service/http_service_test.gopackage serviceimport ("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)}}
FetchMessage
方法,来间接调用httpclient.Get
的;我们并未对FetchMessage
进行mock操作,但最后返回的依然是我们设定的返回结果,而不是实际去请求http://example.com/message
。由此可见,mock控制器创建后,就会直接在测试函数域内生效,只要使用httpclient.Get
接口,并且输入参数为http://example.com/message
,都会返回我们预设的{Message: "Hello, World!"}
面向测试编程(TDD)
TDD简介
面向测试编程(Test-Driven Development,TDD)是一种软件开发过程,它强调在编写实际的代码实现之前先编写测试代码。这种开发方式的核心思想是通过测试来推动软件设计,确保代码的质量和可维护性
TDD的优势
- 提高代码质量:通过持续的测试,可以及早发现和修复错误。
- 更好的设计:测试驱动的方式鼓励更好的设计和模块化,因为代码必须易于测试。
- 文档化:测试本身可以作为代码行为的文档。
- 勇气重构:因为有测试作为安全保障,开发者在进行重构时更加大胆。
TDD的基本步骤
TDD通常遵循以下步骤:
- 编写测试(Red):开发人员首先编写一个会失败的测试用例,这个测试用例用来描述代码的一个小的、特定的行为。
- 编写最小量的代码(Green):接着,开发人员编写足够的代码使测试通过,这个过程要求开发者只编写必要的代码,而不是一开始就实现所有的功能。
- 重构(Refactor):最后,开发人员对已经通过测试的代码进行重构,改进代码结构,提高代码的可读性和可维护性,同时确保所有的测试仍然通过。
Go语言中的TDD实践
golang中的实践主要遵循以下步骤
- 编写测试:在.go文件中编写测试代码。Go语言的测试文件通常与源代码文件在同一目录下,文件名以_test.go结尾。测试函数以Test开头,并且接受一个类型为*testing.T的参数。
- 运行测试:使用go test命令运行测试。如果测试失败,go test会输出失败的测试信息。
- 编写最小量的代码:根据测试失败的原因,编写最少的代码使测试通过。
- 重构:一旦测试通过,对代码进行重构,以提高代码质量和可读性。
- 重复:重复以上步骤,每次添加一个新的测试,然后编写代码使测试通过,最后重构。
看一个简单例子,首先,创建一个名为calculator_test.go的测试文件,并编写一个测试用例
package calculatorimport "testing"func TestAdd(t *testing.T) {result := Add(1, 2)expected := 3if 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的基本流程。通过不断地重复这个过程,你可以逐步构建起一个健壮的代码库。