【翻译】在 Go 中有效编写单元测试的 5 种半技巧

原文地址: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 指针异常。

为了避免这种情况并能够正确测试我们的单元,我们需要确保我们的代码遵守以下原则:

  1. Programming to the interface(面向接口编程OOP)
  2. 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

(0)
葫芦侠五楼的头像葫芦侠五楼

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!