原文地址:https://betterprogramming.pub/5-and-a-half-techniques-for-effectively-writing-unit-tests-in-go-1b87b94abd21
我们将涵盖的内容
为了节省您阅读整篇文章的时间,您可以直接查看这些主题:
-
单元测试和模拟(一般) -
生成模拟接口的部分 -
模拟函数 -
模拟套件和断言 -
模拟 HTTP 服务器 -
模拟 SQL 数据库
单元测试和模拟(一般)
正如我们在 Martin Fowler 的文章中看到的,我们可以区分两种类型的单元测试:
-
Sociable unit tests: 测试是我们测试依赖于其他对象的单元的测试。如果您想测试 UserController,您将使用与数据库通信的 UserRepository 对其进行测试。 -
Solitary unit tests: 是我们测试完全隔离的单元的测试。在这里,您将测试 UserController,它与受控的、模拟的 UserRepository 交互,您可以为它提供它在没有数据库的情况下的准确行为。
这两种方法在一个项目中都是合法的,我总是使用它们。 如果我编写社交单元测试,过程很简单——使用我已经从我的模块中使用的任何东西,并一起测试逻辑。但是,当谈到在 Go 中进行模拟时,它并不是一个标准过程。 Go 不支持继承,但支持组合。这意味着一个结构不扩展第二个结构但包含它。因此,Go 不支持结构级别的多态性,而是支持接口。 因此,一旦您的结构直接依赖于一个结构的实例,或者某个函数需要一个特定的结构作为参数——祝您模拟该结构好运。 在下面的代码示例中,我们有一个使用 UserDBRepository 和 AdminController 的简单案例。AdminController 直接依赖于 UserDBRepository 的实例,它表示负责与数据库“对话”的 Repository 的实现。
type UserDBRepository struct {
connection *sql.DB
}
func (r *UserDBRepository) FilterByLastname(ctx context.Context, lastname string) ([]User, error) {
var users []User
//
// do something with users
//
return users, nil
}
type AdminController struct {
repository *UserDBRepository
}
func (c *AdminController) FilterByLastname(ctx *gin.Context) {
lastname := ctx.Param("name")
c.repository.FilterByLastname(ctx, lastname)
//
// do something with users
//
}
如果我们想为 AdminController 编写单元测试,看看它是否会创建正确的 JSON 响应,我们有两种可能性:
-
提供 UserDBRepository 的新实例以及到 AdminController 的数据库连接,并希望它是您需要随着时间的推移传递的唯一依赖项。 -
不要提供任何东西,一旦开始运行测试就只期望一个 nil 指针异常。
为了避免这种情况并能够正确测试我们的单元,我们需要确保我们的代码遵守以下原则:
-
Programming to the interface(面向接口编程OOP) -
Dependency inversion principle(依赖倒置原则)
一旦我们应用这两个原则,我们重构的代码就会得到如下例所示的形状,其中实际的 AdminController 取决于接口 UserRepository,而不是指定它是数据库的 Repository 还是其他。
type UserRepository interface {
GetByID(ctx context.Context, ID string) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
FilterByLastname(ctx context.Context, lastname string) ([]User, error)
Create(ctx context.Context, user User) (*User, error)
Update(ctx context.Context, user User) (*User, error)
Delete(ctx context.Context, user User) (*User, error)
}
type AdminController struct {
repository UserRepository
}
func (c *AdminController) FilterByLastname(ctx *gin.Context) {
lastname := ctx.Param("name")
c.repository.FilterByLastname(ctx, lastname)
//
// do something with users
//
}
所以,现在我们有了一个起点,让我们看看如何最有效地模拟。
生成mock
有许多用于生成模拟的库,而且,如果您愿意的话,您可以创建自己的生成器。我喜欢 Vektra 的 Mockery 包。它提供由 Stretchr, Inc 的 Testify 包支持的模拟,这已经是一个足够好的理由继续使用它。
type User struct {
//
// some fields
//
}
type UserRepository interface {
GetByID(ctx context.Context, ID string) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
FilterByLastname(ctx context.Context, lastname string) ([]User, error)
Create(ctx context.Context, user User) (*User, error)
Update(ctx context.Context, user User) (*User, error)
Delete(ctx context.Context, user User) (*User, error)
}
type AdminController struct {
repository UserRepository
}
func NewAdminController(repository UserRepository) *AdminController {
return &AdminController{
repository: repository,
}
}
func (c *AdminController) FilterByLastname(ctx *gin.Context) {
lastname := ctx.Param("name")
c.repository.FilterByLastname(ctx, lastname)
//
// do something with users
//
}
让我们回到前面的 UserRepository 和 AdminController 示例。AdminController 期望 UserRepository 接口在有人向 /users 端点发送请求时按姓氏过滤用户。 严格来说,AdminController 并不关心 UserRepository 是如何找到结果的。根据它是否获得用户或错误的切片,只需要将正确的响应附加到 Gin 包中的上下文。
func main() {
var repository UserRepository
//
// initialize repository
//
controller := NewAdminController(repository)
router := gin.Default()
router.GET("/users/:lastname", controller.FilterByLastname)
//
// do something with router
//
}
在这个例子中,我使用了 Gin-Gonic 的 Gin 包来进行路由,但是我们想使用哪个包来实现这个目的并不重要。我们将首先初始化 UserRepository 的实际实现,将其传递给 AdminController 并在运行我们的服务器之前定义端点。此时,我们的文件夹结构可能是这样的:
user-service
| cmd
| main.go
| pkg
| user
| user.go
| admin_controller.go
| admin_controller_test.go
现在,在用户文件夹中,我们可以执行用于生成模拟对象的 Mockery 命令。$ mockery --all --case=underscore
它检查包内的所有接口(您可以进一步调整此选项),并创建一个新文件夹 mocks,其中放置所有生成的文件。
user-service
| cmd
| main.go
| pkg
| user
| mocks
| user_repository.go
| user.go
| admin_controller.go
| admin_controller_test.go
生成文件的内容如下例所示:
// Code generated by mockery v1.0.0. DO NOT EDIT.
package mocks
import (
//
// some imports
//
mock "github.com/stretchr/testify/mock"
)
// UserRepository is an autogenerated mock type for the UserRepository type
type UserRepository struct {
mock.Mock
}
// Create provides a mock function with given fields: ctx, _a1
func (_m *UserRepository) Create(ctx context.Context, _a1 user.User) (*user.User, error) {
ret := _m.Called(ctx, _a1)
var r0 *user.User
if rf, ok := ret.Get(0).(func(context.Context, user.User) *user.User); ok {
r0 = rf(ctx, _a1)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*user.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, user.User) error); ok {
r1 = rf(ctx, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// and so on....
当我在一个项目上工作时,我喜欢将所有命令都写在项目内部的某个地方。有时,它可以是 Makefile 或 bash 脚本。但是在这里,我们可以在用户文件夹中添加额外的 generate.go 文件,并将以下代码放入其中:
package user
//go:generate go run github.com/vektra/mockery/cmd/mockery -all -case=underscore
目录结构:
user-service
| cmd
| main.go
| pkg
| user
| mocks
| user_repository.go
| user.go
| admin_controller.go
| admin_controller_test.go
| generate.go
该文件包含一个特定的注释,以 //go:generate 开头。它包括一个用于执行其后代码的标志,一旦您从项目根文件夹中的波纹管运行命令,它将生成所有文件:
$ go generate ./...
这两种方法最终都会给出相同的结果——生成带有模拟对象的文件。因此,编写单独的单元测试不再是问题:
func TestAdminController(t *testing.T) {
var ctx *gin.Context
//
// setup context
//
repository := &mocks.UserRepository{}
repository.
On("FilterByLastname", ctx, "some last name").
Return(nil, errors.New("some error")).
Once()
controller := NewAdminController(repository)
controller.FilterByLastname(ctx)
//
// do some checking for ctx
//
}
接口的部分模拟
有时,不需要模拟接口中的所有方法。或者,包不属于我们所有,因此我们无法生成文件。在我们的库中创建和保存文件也没有意义。 但是,有时候,接口可以有很多方法,我们需要其中的一些方法。对于这种情况,我们仍然可以使用 UserRepository 的示例。AdminController 只使用 Repository 中的一个函数,称为 FilterByLastname。 这意味着我们不需要任何其他方法来测试 AdminController。为此,让我们提供一些名为 MockedUserRepository 的结构,在下面的示例中可见:
type MockedUserRepository struct {
UserRepository
filterByLastnameFunc func(ctx context.Context, lastname string) ([]User, error)
}
func (r *MockedUserRepository) FilterByLastname(ctx context.Context, lastname string) ([]User, error) {
return r.filterByLastnameFunc(ctx, lastname)
}
MockedUserRepository 实现接口 UserRepository。我们确保在将 UserRepository 接口嵌入 MockedUserRepository 时就是这种情况。 我们的模拟对象期望在其中包含一些 UserRepository 接口的实例。如果未定义该实例,默认情况下将为 nill 。除此之外,它还有一个字段,这是一种函数。 此函数与 FilterByLastname 具有相同的签名。FilterByLastname 方法附加到模拟结构,它只是代理对私有字段的调用。现在,如果我们按以下方式重写我们的测试,它可能看起来更直观:
func TestAdminController(t *testing.T) {
var gCtx *gin.Context
//
// setup context
//
repository := &MockedUserRepository{}
repository.filterByLastnameFunc = func(ctx context.Context, lastname string) ([]User, error) {
if ctx != gCtx {
t.Error("expected other context")
}
if lastname != "some last name" {
t.Error("expected other lastname")
}
return nil, errors.New("error")
}
controller := NewAdminController(repository)
controller.FilterByLastname(gCtx)
//
// do some checking for ctx
//
}
当我们使用 AWS SDK 测试我们的代码与 AWS 服务(如 SQS)的集成时,这种技术可能会很有用。在这种情况下,我们的 SQSReceiver 依赖于 SQSAPI 接口,它有……。嗯,很多功能:
import (
//
// some imports
//
"github.com/aws/aws-sdk-go/service/sqs/sqsiface"
)
type SQSReceiver struct {
sqsAPI sqsiface.SQSAPI
}
func (r *SQSReceiver) Run() {
//
// wait for SQS message
//
}
在这里我们可以使用相同的技术并提供我们自己的模拟结构:
type MockedSQSAPI struct {
sqsiface.SQSAPI
sendMessageFunc func(input *sqs.SendMessageInput) (*sqs.SendMessageOutput, error)
}
func (m *MockedSQSAPI) SendMessage(input *sqs.SendMessageInput) (*sqs.SendMessageOutput, error) {
return m.sendMessageFunc(input)
}
func TestSQSReceiver(t *testing.T) {
//
// setup context
//
sqsAPI := &MockedSQSAPI{}
sqsAPI.sendMessageFunc = func(input *sqs.SendMessageInput) (*sqs.SendMessageOutput, error) {
if input.MessageBody == nil || *input.MessageBody != "content" {
t.Error("expected other message")
}
return nil, errors.New("error")
}
receiver := &SQSReceiver{
sqsAPI: sqsAPI,
}
receiver.Run()
//
// do some checking for ctx
//
}
通常,我不会测试负责与数据库或外部服务建立连接的基础设施对象。为此,我在更高级别的测试金字塔上编写测试。尽管如此,如果确实需要测试此类代码,这种方法对我还是有帮助的。
函数模拟
在核心 Go 代码或任何其他包中,有许多有用的函数。我们可以直接在代码中使用这些函数,例如下面的 ConfigurationRepository 中。 该结构体负责读取 config.yml 文件并返回应用程序各处使用的配置。ConfigurationRepository 从核心 Go 包 IOutil 中调用 ReadFile 方法:
type ConfigurationRepository struct {
//
// some fields
//
}
func (r *ConfigurationRepository) GetConfiguration() (map[string]string, error) {
config := map[string]string{}
data, err := ioutil.ReadFile("config.yml")
//
// do something with data
//
return config, nil
}
在这样的代码中,如果我们要测试GetConfiguration,那么每次测试执行都不可避免地要依赖config.yml文件的存在。 我们再次依赖技术细节,比如从文件中读取。当这样的事情发生时,我想为这段代码提供单元测试,我过去使用了两种变体。
变体 1:简单类型别名
第一个变体是为我们要模拟的方法类型提供类型别名。新类型表示我们要在代码中使用的函数签名。ConfigurationRepository 应该依赖于这个新类型 FileReaderFunc 而不是我们想要模拟的方法:
type FileReaderFunc func(filename string) ([]byte, error)
type ConfigurationRepository struct {
fileReaderFunc FileReaderFunc
//
// some fields
//
}
func NewConfigurationRepository(fileReaderFunc FileReaderFunc) ConfigurationRepository{
return ConfigurationRepository{
fileReaderFunc: fileReaderFunc,
}
}
func (r *ConfigurationRepository) GetConfiguration() (map[string]string, error) {
config := map[string]string{}
data, err := r.fileReaderFunc("config.yml")
//
// do something with data
//
return config, nil
}
在这种情况下,在初始化您的应用程序时,我们将在创建配置存储库期间将 Go 核心包中的实际方法作为参数传递:
package main
func main() {
repository := NewConfigurationRepository(ioutil.ReadFile)
config, err := repository.GetConfiguration()
//
// do something with configuration
//
}
最后,我们可以像下面的代码示例一样编写单元测试。我们在那里定义了一个新的读取器函数,它返回我们在每种情况下控制的结果。
func TestGetConfiguration(t *testing.T) {
var readerFunc FileReaderFunc
// we want to have error from reader
readerFunc = func(filename string) ([]byte, error) {
return nil, errors.New("error")
}
repository := NewConfigurationRepository(readerFunc)
_, err := repository.GetConfiguration()
if err == nil {
t.Error("error is expected")
}
// we want to have concrete result from reader
readerFunc = func(filename string) ([]byte, error) {
return []byte("content"), nil
}
repository = NewConfigurationRepository(readerFunc)
_, err = repository.GetConfiguration()
if err != nil {
t.Error("error is not expected")
}
//
// do something with config
//
}
变体 2:使用接口的复杂类型别名
第二种变体使用相同的想法,但将接口作为 ConfigurationRepository 中的依赖项。它不依赖于函数类型,而是依赖于接口 FileReader,该接口的方法与我们要模拟的 ReadFile 方法具有相同的签名。
type FileReader interface {
ReadFile(filename string) ([]byte, error)
}
type ConfigurationRepository struct {
fileReader FileReader
//
// some fields
//
}
func NewConfigurationRepository(fileReader FileReader) *ConfigurationRepository {
return &ConfigurationRepository{
fileReader: fileReader,
}
}
func (r *ConfigurationRepository) GetConfiguration() (map[string]string, error) {
config := map[string]string{}
data, err := r.fileReader.ReadFile("config.yml")
//
// do something with data
//
return config, nil
}
此时,我们应该再次添加相同的类型别名 FileReaderFunc,但这次我们应该为该类型附加一个函数。是的,我们需要在一个方法中添加一个方法——我无法表达我有多喜欢 Go 中的这一部分。
type FileReaderFunc func(filename string) ([]byte, error)
func (f FileReaderFunc) ReadFile(filename string) ([]byte, error) {
return f(filename)
}
从这一点来看,FileReaderFunc 类型实现了 FileReader 接口。它拥有的唯一方法代理调用该类型的实例,即原始方法。当我们想要初始化应用程序时,它带来了最小的变化:
func main() {
repository := NewConfigurationRepository(FileReaderFunc(ioutil.ReadFile))
config, err := repository.GetConfiguration()
//
// do something with configuration
//
}
而且,它不对单元测试进行任何更改:
func TestGetConfiguration(t *testing.T) {
var readerFunc FileReaderFunc
// we want to have error from reader
readerFunc = func(filename string) ([]byte, error) {
return nil, errors.New("error")
}
repository := NewConfigurationRepository(readerFunc)
_, err := repository.GetConfiguration()
if err == nil {
t.Error("error is expected")
}
// we want to have concrete result from reader
readerFunc = func(filename string) ([]byte, error) {
return []byte("content"), nil
}
repository = NewConfigurationRepository(readerFunc)
config, err := repository.GetConfiguration()
if err != nil {
t.Error("error is not expected")
}
//
// do something with config
//
}
我更喜欢第二种变体,因为比起独立函数,我更喜欢接口和结构。但是,这两种解决方案中的任何一种都是好的。
Suites and Assertions
我需要再次提到 Testify 包的伟大之处。除了模拟之外,这个库还提供对套件和断言的支持。 只要测试的重点是检查一个简单的函数或没有任何依赖项(或至少不是模拟的)的结构,我就会对简单的单元测试使用断言。我发现对于未来的代码读者来说,理解测试用例的想法是有帮助的,而且更明确。 我唯一一次使用纯 Go 原生代码进行测试是在我的库还没有依赖其他包的时候,所以保持它“干净”。否则,如果没有这种包的帮助,覆盖所有检查太累人了。
import (
//
// some imports
//
"github.com/stretchr/testify/assert"
)
func TestUser_HasAccess(t *testing.T) {
assert.False(t, User{}.HasAccess("admin"))
assert.True(t, User{
Roles; []string{"admin"}
}.HasAccess("admin"))
}
当应该测试一些复杂的结构时,我使用套件,至少有一个模拟依赖项。这使我可以定义一个代码,该代码应在整个套件开始之前、每次测试之前、每次测试之后等执行。 在大多数情况下,在每个 Suite 运行之前,我都会初始化对整个过程来说都是静态的变量。 例如,如果 Context 或 Request 不包含任何特定于测试用例的数据,我会在 Suite 启动之前定义它们。如果测试可以改变它们的状态,我会在每次测试前用所有模拟对象和主要结构初始化它们。 最后,在每次测试之后,有时我应该放置一个代码来关闭一些通道,销毁一些变量,或者对发送到结构函数的调用次数进行断言。
import (
//
// some imports
//
"github.com/stretchr/testify/suite"
)
type AdminControllerTestSuite struct {
suite.Suite
controller *AdminController
repository *mocks.UserRepository
ctx *gin.Context
}
func TestAdminControllerTestSuite(t *testing.T) {
suite.Run(t, &AdminControllerTestSuite{})
}
func (s *AdminControllerTestSuite) SetupSuite() {
s.ctx = &gin.Context()
}
func (s *AdminControllerTestSuite) SetupTest() {
s.repository = &mocks.UserRepository{}
s.controller = NewAdminController(s.repository)
}
func (s *AdminControllerTestSuite) TearDownTest() {
s.repository.AssertExpectations(s.T())
}
func (s *AdminControllerTestSuite) TestFind_ProfessionError() {
s.repository.
On("FilterByLastname", s.ctx, "some last name").
Return(nil, errors.New("some error")).
Once()
s.controller.FilterByLastname(s.ctx)
//
// do some checking for s.ctx
//
}
模拟 HTTP 服务器
当谈到模拟 HTTP 服务器时,我认为它不属于单元测试。尽管如此,有时某些人的代码结构可能依赖于某些 HTTP 请求,本节给出了这些情况下的一些想法。 让我们假设我们有一些 UserAPIRepository,它通过与外部 API 而不是数据库通信来发送和获取数据。这个结构可能看起来像这样:
type UserAPIRepository struct {
host string
}
func NewUserAPIRepository(host string) *UserAPIRepository {
return &UserAPIRepository{
host: host,
}
}
func (r *UserAPIRepository) FilterByLastname(ctx context.Context, lastname string) ([]User, error) {
var users []User
url := path.Join(r.host, "/users/", lastname)
response, err := http.Get(url)
//
// do somethinf with users
//
return users, nil
}
当然,我们也可以用 mocking 函数接近这里,但让我们继续玩游戏。为了对 UserAPIRepository 进行单元测试,我们可以使用核心 Go HTTPtest 包中的 Server 实例。 这个包为我们提供了一个简单的小型服务器,在本地的一些端口上工作,我们可以快速适应我们的测试用例并向它发送请求:
import (
//
// some imports
//
"net/http/httptest"
)
func TestUserAPIRepository(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/users/") {
var content string
//
// do something
//
io.WriteString(w, content)
return
}
http.NotFound(w, r)
}))
repository := NewUserAPIRepository(server.URL)
users, err := repository.FilterByLastname(context.Background(), "some last name")
//
// do some checking for users and err
//
}
模拟 SQL 数据库
同样,与 HTTP 请求一样,我并不是特别渴望编写用于测试 SQL 查询的单元测试。我总是质疑自己是在那里测试存储库还是测试模拟工具。 尽管如此,当我想检查一些 SQL 查询时,它可能包含在一些结构中,就像这里的 UserDBRepository:
type UserDBRepository struct {
connection *sql.DB
}
func NewUserDBRepository(connection *sql.DB) *UserDBRepository {
return &UserDBRepository{
connection: connection,
}
}
func (r *UserDBRepository) FilterByLastname(ctx context.Context, lastname string) ([]User, error) {
var users []User
rows, err := r.connection.Query("SELECT * FROM users WHERE lastname = ?", lastname)
//
// do something with users
//
return users, nil
}
当我决定为这种存储库编写单元测试时,我喜欢使用 DATA-DOG 的 Sqlmock 包。它很简单并且有很好的文档:
import (
//
// some imports
//
"github.com/DATA-DOG/go-sqlmock"
)
func TestUserDBRepository(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Error("expected not to have error")
}
mock.
ExpectQuery("SELECT * FROM users WHERE lastname = ?").
WithArgs("some last name").
WillReturnError(errors.New("error"))
repository := NewUserDBRepository(db)
users, err := repository.FilterByLastname(context.Background(), "some last name")
//
// do some checking for users and err
//
}
当模拟实际的 SQL 查询太累时,另一种方法是使用一个带有测试数据的小 SQLite 文件。它应该与我们的常规 SQL 数据库具有相同的表结构。 当然,这也不是一个理想的解决方案,因为我们在不同的数据库引擎上测试我们的查询,我们可能应该依赖 ORM 来避免双重集成。 在本例中,我创建了一个临时文件,并在每次测试执行之前将数据从 SQLite 文件复制到其中。它比较慢,但像这样,我不能破坏我的测试数据。
import (
//
// some imports
//
_ "github.com/mattn/go-sqlite3"
)
func getSqliteDBWithTestData() (*sql.DB, error) {
// read all from sqlite file
data, err := ioutil.ReadFile("test_data.sqlite")
if err != nil {
return nil, err
}
// create temporary file
tmpFile, err := ioutil.TempFile("", "db*.sqlite")
if err != nil {
return nil, err
}
// store test data into temporary file
_, err = tmpFile.Write(data)
if err != nil {
return nil, err
}
err = tmpFile.Close()
if err != nil {
return nil, err
}
// make connection to temporary file
db, err := sql.Open("sqlite3", tmpFile.Name())
if err != nil {
return nil, err
}
return db, nil
}
最后,单元测试现在看起来更简单了:
func TestUserDBRepository(t *testing.T) {
db, err := getSqliteDBWithTestData()
if err != nil {
t.Error("expected not to have error")
}
repository := NewUserDBRepository(db)
users, err := repository.FilterByLastname(context.Background(), "some last name")
//
// do some checking for users and err
//
}
结论
用 Go 编写单元测试仍然比用其他语言更具挑战性,至少对我来说是这样。它需要准备代码以支持测试策略。 这在某种程度上是一个令人愉快的部分——它比任何其他语言都更能帮助我塑造我编写代码的架构方法。 它从不乏味,即使经过一千次单元测试,它也给人一种持续的愉悦感。您在 Go 中进行单元测试和模拟的经验如何?
原文始发于微信公众号(小唐云原生):【翻译】在 Go 中有效编写单元测试的 5 种半技巧
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/270888.html