Skip to content

Files

Latest commit

00d6687 · Oct 25, 2021

History

History
967 lines (724 loc) · 38.3 KB

File metadata and controls

967 lines (724 loc) · 38.3 KB

五、依赖注入与猴子补丁

是否有依赖于全局变量的代码?您是否有依赖于文件系统的代码?您是否尝试过测试数据库错误处理代码?

在本章中,我们将研究 monkey 补丁作为一种在我们的测试和测试过程中交换依赖关系的方法,而这种方式在其他方面是不可能的。这些依赖关系是对象还是函数并不重要。我们将对我们的示例服务应用 monkey 补丁,这样我们就可以将测试与数据库分离;将不同的层彼此分离,而无需进行重大重构。

在继续我们务实的怀疑态度的过程中,我们还将讨论猴子修补的优缺点。

本章将介绍以下主题:

  • 猴子魔术猴子修补术简介
  • 猴子修补术的优点
  • 应用猴子修补术
  • 猴子修补术的缺点

技术要求

熟悉我们在第 4 章ACME 注册服务简介中介绍的我们服务的代码会有所帮助。您可能还发现阅读并运行本章代码的完整版本很有用,可在上找到 https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch05

获取代码和配置示例服务的说明可在此处的自述文件中找到 https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/

您可以在ch05/acme中找到我们服务的代码,其中已经应用了本章的更改。

猴子魔术!

Monkey patching 是在运行时更改程序,通常是通过替换函数或变量。

虽然这不是传统的依赖注入DI)形式,但它可以用于 Go 以方便测试。事实上,猴子补丁可以用其他不可能的方法进行测试。

让我们先来考虑一个真实的类比。假设你想测试车祸对人体的影响。你可能不会自愿成为测试时车里的那个人。也不允许您更改车辆以方便测试。但是你可以在你的测试中用一个碰撞测试假人来替换(猴子补丁)。

同样的过程也适用于代码中的猴子补丁;这些更改只在测试期间存在,并且在许多情况下可以在对生产代码影响很小的情况下应用

对于那些熟悉 Ruby、Python 和 JavaScript 等动态语言的人来说,一个简短的提示是:可以对单个类方法进行修补,在某些情况下,还可以修补标准库。Go 只为我们提供了修补变量的功能,这些变量可以是对象或函数,我们将在本章中看到。

猴子修补术的优点

猴子补丁作为 DI 的一种形式,在实现和效果上都与本书中介绍的其他方法非常不同。因此,在某些情况下,monkey patching 要么是唯一的选择,要么是唯一简洁的选择。monkey patching 的其他优势将在本节中详细介绍。

通过 monkey patching 实现 DI 的成本很低-在这本书中,我们已经讨论了很多关于解耦的问题,这是一种想法,即我们的代码的各个部分应该保持独立,即使它们相互使用/依赖。我们引入抽象并将它们相互注入。让我们后退一步,考虑一下为什么我们希望代码解耦。这不仅仅是为了让它更容易测试。它还允许代码单独演化,并为我们提供小团体和心理箱(如果您愿意的话),我们可以使用它们分别思考代码的不同部分。正是这种解耦或分离可以应用猴子补丁。

考虑这个功能:

func SaveConfig(filename string, cfg *Config) error {
   // convert to JSON
   data, err := json.Marshal(cfg)
   if err != nil {
      return err
   }

   // save file
   err = ioutil.WriteFile(filename, data, 0666)
   if err != nil {
      log.Printf("failed to save file '%s' with err: %s", filename, err)
      return err
   }

   return nil
}

我们如何将此功能与操作系统分离?让我换一种说法:当文件丢失时,我们如何测试此函数的行为?

我们可以将文件名替换为*os.Fileio.Writer,但这只会将问题推到其他地方。我们可以将此函数重构为一个结构,将对ioutil.WriteFile的调用更改为一个抽象,然后模拟它。但这听起来好像需要做很多工作。

使用猴子补丁,有一个更便宜的选择:

func SaveConfig(filename string, cfg *Config) error {
   // convert to JSON
   data, err := json.Marshal(cfg)
   if err != nil {
      return err
   }

   // save file
   err = writeFile(filename, data, 0666)
   if err != nil {
      log.Printf("failed to save file '%s' with err: %s", filename, err)
      return err
   }

   return nil
}

// Custom type that allows us to Monkey Patch
var writeFile = ioutil.WriteFile

有了一行代码,我们就可以用一个 mock 来替换writeFile(),这将使我们能够轻松地测试愉快路径和错误场景。

允许我们在不完全理解其内部结构的情况下模拟其他包-在前面的示例中,您可能已经注意到我们模拟的是标准库函数。你知道如何让ioutil.WriteFile()失败吗?当然,我们可以在标准图书馆里寻根;虽然这是提高你的围棋技能的好方法,但这并不是我们得到报酬的原因。在这种情况下,ioutil.WriteFile()如何失败甚至不重要。真正重要的是我们的代码如何对错误做出反应。

猴子补丁和其他形式的嘲弄一样,让我们能够不关心依赖的内部,但能够让它按照我们需要的方式运行。

我建议不管怎样,从外部测试是一条可行之路。解耦我们对依赖性的思考方式确保了任何测试对内部的了解都较少,因此不易受到实现或环境变化的影响。如果io.WriteFile()的内部实现细节发生任何变化,它们不能破坏我们的测试。我们的测试只依赖于我们的代码,所以它们的可靠性完全取决于我们。

*通过 monkey 补丁的 DI 对现有代码的影响最小-在前面的示例中,我们定义了外部依赖,如下所示:

var writeFile = ioutil.WriteFile

让我们稍微改变一下:

type fileWriter func(filename string, data []byte, perm os.FileMode) error

var writeFile fileWriter = ioutil.WriteFile

这让你想起什么了吗?在这个版本中,我们明确定义了我们的需求,就像我们在第 2 章Go依赖倒置原则一节中所做的一样。虽然这种改变完全是多余的,但它确实提出了一些有趣的问题。

让我们回头看看,在不使用猴子补丁的情况下,为了测试我们的方法,我们需要做哪些更改。第一个选项是向函数中注入io.WriteFile,如下代码所示:

func SaveConfig(writer fileWriter, filename string, cfg *Config) error {
   // convert to JSON
   data, err := json.Marshal(cfg)
   if err != nil {
      return err
   }

   // save file
   err = writer(filename, data, 0666)
   if err != nil {
      log.Printf("failed to save file '%s' with err: %s", filename, err)
      return err
   }

   return nil
}

// This custom type is not strictly needed but it does make the function 
// signature a little cleaner
type fileWriter func(filename string, data []byte, perm os.FileMode) error

这有什么不对?就我个人而言,我有三个问题。首先,这是一个小而简单的函数,只有一个依赖项;如果我们有更多的依赖项,函数会很快变得非常丑陋。换句话说,代码 UX 很糟糕。

其次,它打破了函数实现的封装(信息隐藏)。这可能让人觉得我在进行一场狂热分子式的辩论,但我不这么认为。想象一下,如果我们重构SaveConfig()的实现,从而需要将io.WriteFile更改为其他内容,会发生什么。在这种情况下,我们必须改变我们功能的每一个用途,可能会有很多变化,因此也会有很多风险。

最后,正如我们在第 3 章用户体验编码测试诱导损伤一节中所讨论的,这种变化可以说是测试诱导损伤,因为它只是用于改进测试,而不会增强非测试代码。

另一个可能想到的选择是将函数重构为对象,然后使用更传统的 DI 形式,如以下代码所示:

type ConfigSaver struct {
   FileWriter func(filename string, data []byte, perm os.FileMode) error
}

func (c ConfigSaver) Save(filename string, cfg *Config) error {
   // convert to JSON
   data, err := json.Marshal(cfg)
   if err != nil {
      return err
   }

   // save file
   err = c.FileWriter(filename, data, 0666)
   if err != nil {
      log.Printf("failed to save file '%s' with err: %s", filename, err)
      return err
   }

   return nil
}

遗憾的是,这个重构与前一个重构有着相似的问题,其中最重要的一点是它可能会有大量的更改。正如您所看到的,与传统方法相比,猴子补丁需要的更改要少得多。

通过猴子补丁的 DI 允许测试全局和单例-你可能认为我疯了,Go 没有单例。也许不是严格意义上的,但是您是否阅读过math/rand标准库包(的代码 https://godoc.org/math/rand ?在其中,您将发现以下内容:

// A Rand is a source of random numbers.
type Rand struct {
   src Source

   // code removed
}

// Int returns a non-negative pseudo-random int.
func (r *Rand) Int() int {
   // code changed for brevity
   value := r.src.Int63()
   return int(value)
}

/*
 * Top-level convenience functions
 */

var globalRand = New(&lockedSource{})

// Int returns a non-negative pseudo-random int from the default Source.
func Int() int { return globalRand.Int() }

// A Source represents a source of uniformly-distributed
// pseudo-random int64 values in the range [0, 1<<63).
type Source interface {
   Int63() int64

   // code removed
}

您将如何测试Rand结构?您可以将Source与返回可预测、非随机结果的模拟进行交换,这很容易。

现在,您将如何测试便利功能Int()?这并不容易。根据定义,此方法返回一个随机值。但是,使用 monkey 补丁,我们可以,如下代码所示:

func TestInt(t *testing.T) {
   // monkey patch
   defer func(original *Rand) {
      // restore patch after use
      globalRand = original
   }(globalRand)

   // swap out for a predictable outcome
   globalRand = New(&stubSource{})
   // end monkey patch

   // call the function
   result := Int()
   assert.Equal(t, 234, result)
}

// this is a stubbed implementation of Source that returns a 
// predictable value
type stubSource struct {
}

func (s *stubSource) Int63() int64 {
   return 234
}

通过 monkey 补丁,我们可以测试 singleton 的使用情况,而无需对客户端代码进行任何更改。要使用其他方法实现这一点,我们必须引入一层间接寻址,这反过来又需要更改客户机代码。

应用猴子修补术

让我们将猴子补丁应用到我们在第 4 章ACME 注册服务简介中介绍的 ACME 注册服务中。我们希望通过我们的服务改进的许多事情之一是测试可靠性和覆盖率。在这种情况下,我们将处理data包。目前,我们只有一个测试,如下所示:

func TestData_happyPath(t *testing.T) {
   in := &Person{
      FullName: "Jake Blues",
      Phone:    "01234567890",
      Currency: "AUD",
      Price:    123.45,
   }

   // save
   resultID, err := Save(in)
   require.Nil(t, err)
   assert.True(t, resultID > 0)

   // load
   returned, err := Load(resultID)
   require.NoError(t, err)

   in.ID = resultID
   assert.Equal(t, in, returned)

   // load all
   all, err := LoadAll()
   require.NoError(t, err)
   assert.True(t, len(all) > 0)
}

在这个测试中,我们正在执行保存,然后使用Load()LoadAll()方法加载新保存的注册

此代码至少有三个主要问题。

首先,我们只是在测试快乐路径;我们根本没有测试我们的错误处理。

其次,测试依赖于数据库。有些人会说这没问题,我不想在这场辩论中添加任何内容。在这种特殊情况下,使用实时数据库会导致我们对LoadAll()的测试不是很具体,这使得我们的测试不如可能的那么彻底。

最后,我们在一起测试所有的函数,而不是孤立的。

returned, err := Load(resultID)
require.NoError(t, err)

问题在哪里?Load()坏了还是Save()坏了?这是关于隔离测试的争论的基础。

data包中的所有函数都依赖于*sql.DB的全局实例,它表示数据库连接池。因此,我们将对该全局变量进行修补,并引入一个模拟版本。

介绍 SQLMock

SQLMock 包(https://github.com/DATA-DOG/go-sqlmock 描述如下:

实现 sql/driver 的模拟库。它只有一个目的——在测试中模拟任何 sql 驱动程序行为,而不需要真正的数据库连接

我发现 SQLMock 很有用,但通常比直接使用数据库做更多的工作。作为一名实用主义程序员,我很乐意使用这两种方法。通常,使用哪种测试取决于我希望测试如何工作。如果我希望非常精确,不存在与表的现有内容相关的潜在问题,并且不可能由于表的并发使用而导致数据争用,那么我将花费额外的精力来使用 SQLMock。

A data race occurs when two or more goroutines access a variable at the same time, and at least one of the goroutines is writing to the variable.

让我们看看如何使用 SQLMock 进行测试。考虑以下功能:

func SavePerson(db *sql.DB, in *Person) (int, error) {
   // perform DB insert
   query := "INSERT INTO person (fullname, phone, currency, price) VALUES (?, ?, ?, ?)"
   result, err := db.Exec(query, in.FullName, in.Phone, in.Currency, in.Price)
   if err != nil {
      return 0, err
   }

   // retrieve and return the ID of the person created
   id, err := result.LastInsertId()
   if err != nil {
      return 0, err
   }
   return int(id), nil
}

此函数以*Person*sql.DB为输入,将人员保存到提供的数据库中,然后返回新创建记录的 ID。此函数使用传统形式的 DI 将数据库连接池传递到函数中。这使我们可以用一种简单的方法将真实的数据库连接换成假的数据库连接。现在,让我们构建测试。首先,我们使用 SQLMock 创建一个模拟数据库:

testDb, dbMock, err := sqlmock.New()
require.NoError(t, err)

然后,我们将期望的查询定义为正则表达式,并使用它来配置模拟数据库。在这种情况下,我们期望一个db.Exec调用返回2(新创建记录的 ID)和1(受影响的行):

queryRegex := `\QINSERT INTO person (fullname, phone, currency, price) VALUES (?, ?, ?, ?)\E`

dbMock.ExpectExec(queryRegex).WillReturnResult(sqlmock.NewResult(2, 1))

现在我们调用函数:

resultID, err := SavePerson(testDb, person)

然后,我们验证结果和模拟的预期:

require.NoError(t, err)
assert.Equal(t, 2, resultID)
assert.NoError(t, dbMock.ExpectationsWereMet())

现在我们已经了解了如何利用 SQLMock 来测试数据库交互,让我们将其应用到 ACME 注册代码中。

用 SQLMock 修补猴子

首先,快速复习:目前,data包不使用 DI,因此我们不能像前面的示例中那样传入*sql.DB。该函数当前看起来如以下代码所示:

// Save will save the supplied person and return the ID of the newly 
// created person or an error.
// Errors returned are caused by the underlying database or our connection
// to it.
func Save(in *Person) (int, error) {
   db, err := getDB()
   if err != nil {
      logging.L.Error("failed to get DB connection. err: %s", err)
      return defaultPersonID, err
   }

   // perform DB insert
   query := "INSERT INTO person (fullname, phone, currency, price) VALUES (?, ?, ?, ?)"
   result, err := db.Exec(query, in.FullName, in.Phone, in.Currency, in.Price)
   if err != nil {
      logging.L.Error("failed to save person into DB. err: %s", err)
      return defaultPersonID, err
   }

   // retrieve and return the ID of the person created
   id, err := result.LastInsertId()
   if err != nil {
      logging.L.Error("failed to retrieve id of last saved person. err: %s", err)
      return defaultPersonID, err
   }
   return int(id), nil
}

我们可以重构到这一点,也许将来我们也可以,但目前我们几乎没有对这段代码进行测试,没有测试的重构是一个糟糕的想法。你可能会想到类似于的东西,但如果我们用猴子补丁编写测试,然后稍后重构为不同风格的 DI,那么我们必须重构这些测试,你是对的;这个例子有点做作。这就是说,现在编写测试为您提供安全网或高水平的信心,然后在以后删除它们并没有错。这可能感觉像是双重工作,但它肯定会比将回归引入人们所依赖的运行系统更不丢脸,而且调试回归的工作量也可能更少。

首先跳出的是 SQL。在测试中,我们将需要几乎完全相同的字符串。因此,为了更容易长期维护代码,我们将把它转换为一个常量,并将其移到文件的顶部。由于测试将非常类似于我们前面的示例,所以让我们首先检查一下猴子补丁。根据上一个示例,我们有以下内容:

// define a mock db
testDb, dbMock, err := sqlmock.New()
defer testDb.Close()

require.NoError(t, err)

在这些行中,我们正在创建一个*sql.DB的测试实例和一个模拟来控制它。在对*sql.DB的测试实例进行 monkey 补丁之前,我们首先需要创建原始实例的备份,以便在测试完成后恢复它。为此,我们将使用defer关键字。

对于不熟悉它的人来说,defer是在当前函数退出之前运行的函数,也就是说,在执行return语句和将控制权返回给当前函数的调用者之间。defer的另一个重要特征是参数立即被计算。这两个功能的结合使我们能够在评估defer时复制原始sql.DB,而不必担心当前函数如何或何时退出,从而避免大量复制和粘贴清理代码。此代码如下所示:

defer func(original sql.DB) {
   // restore original DB (after test)
   db = &original
}(*db)

// replace db for this test
db = testDb

完成此操作后,测试如下所示:

func TestSave_happyPath(t *testing.T) {
   // define a mock db
   testDb, dbMock, err := sqlmock.New()
   defer testDb.Close()
   require.NoError(t, err)

   // configure the mock db
   queryRegex := convertSQLToRegex(sqlInsert)
   dbMock.ExpectExec(queryRegex).WillReturnResult(sqlmock.NewResult(2, 1))

   // monkey patching starts here
   defer func(original sql.DB) {
      // restore original DB (after test)
      db = &original
   }(*db)

   // replace db for this test
   db = testDb
   // end of monkey patch

   // inputs
   in := &Person{
      FullName: "Jake Blues",
      Phone:    "01234567890",
      Currency: "AUD",
      Price:    123.45,
   }

   // call function
   resultID, err := Save(in)

   // validate result
   require.NoError(t, err)
   assert.Equal(t, 2, resultID)
   assert.NoError(t, dbMock.ExpectationsWereMet())
}

太棒了,我们已经完成了快乐路径测试。不幸的是,我们只测试了 13 行函数中的 7 行;也许更重要的是,我们不知道我们的错误处理代码是否工作正常。

测试错误处理

我们需要处理三种可能的错误:

  • SQL 插入可能会失败
  • 无法获取数据库
  • 我们可能无法检索插入记录的 ID

那么,我们如何测试 SQL 插入失败?使用 SQLMock 很容易:我们复制上一个测试,而不是返回sql.Result,而是返回一个错误,如下代码所示:

// configure the mock db
queryRegex := convertSQLToRegex(sqlInsert)
dbMock.ExpectExec(queryRegex).WillReturnError(errors.New("failed to insert"))

然后,我们可以将预期从结果更改为错误,如下代码所示:

require.Error(t, err)
assert.Equal(t, defaultPersonID, resultID)
assert.NoError(t, dbMock.ExpectationsWereMet())

继续测试获取数据库失败,这次 SQLMock 帮不了我们,但是猴子补丁可以。目前,我们的getDB()函数如下代码所示:

func getDB() (*sql.DB, error) {
   if db == nil {
      if config.App == nil {
         return nil, errors.New("config is not initialized")
      }

      var err error
      db, err = sql.Open("mysql", config.App.DSN)
      if err != nil {
         // if the DB cannot be accessed we are dead
         panic(err.Error())
      }
   }

   return db, nil
}

让我们将函数更改为变量,如下代码所示:

var getDB = func() (*sql.DB, error) {
    // code removed for brevity
}

我们没有以其他方式改变该功能的实现。我们现在可以对该变量进行 monkey 修补,结果测试如下所示:

func TestSave_getDBError(t *testing.T) {
   // monkey patching starts here
   defer func(original func() (*sql.DB, error)) {
      // restore original DB (after test)
      getDB = original
   }(getDB)

   // replace getDB() function for this test
   getDB = func() (*sql.DB, error) {
      return nil, errors.New("getDB() failed")
   }
   // end of monkey patch

   // inputs
   in := &Person{
      FullName: "Jake Blues",
      Phone:    "01234567890",
      Currency: "AUD",
      Price:    123.45,
   }

   // call function
   resultID, err := Save(in)
   require.Error(t, err)
   assert.Equal(t, defaultPersonID, resultID)
}

您可能已经注意到快乐路径测试和错误路径测试之间存在大量重复。这在 Go 测试中有些常见,可能是因为我们有意使用不同的输入或环境重复调用函数,本质上记录并强制执行我们正在测试的对象的行为契约

考虑到这些基本职责,我们应该确保我们的测试易于阅读和维护。为了实现这些目标,我们可以在 Go 中应用我最喜欢的特性之一,即表驱动测试(https://github.com/golang/go/wiki/TableDrivenTests )。

使用表驱动测试减少测试膨胀

对于表驱动测试,我们在测试开始时定义场景片段(通常是函数输入、模拟配置和我们的期望),然后定义场景运行器,它通常是测试的一部分,否则会被复制。让我们看看这个例子是什么样子的。Load()函数的快乐路径测试如下所示:

func TestLoad_happyPath(t *testing.T) {
   expectedResult := &Person{
      ID:       2,
      FullName: "Paul",
      Phone:    "0123456789",
      Currency: "CAD",
      Price:    23.45,
   }

   // define a mock db
   testDb, dbMock, err := sqlmock.New()
   require.NoError(t, err)

   // configure the mock db
   queryRegex := convertSQLToRegex(sqlLoadByID)
   dbMock.ExpectQuery(queryRegex).WillReturnRows(
      sqlmock.NewRows(strings.Split(sqlAllColumns, ", ")).
         AddRow(2, "Paul", "0123456789", "CAD", 23.45))

   // monkey patching the database
   defer func(original sql.DB) {
      // restore original DB (after test)
      db = &original
   }(*db)

   db = testDb
   // end of monkey patch

   // call function
   result, err := Load(2)

   // validate results
   assert.Equal(t, expectedResult, result)
   assert.NoError(t, err)
   assert.NoError(t, dbMock.ExpectationsWereMet())
}

这个函数大约有 11 行函数(在删除了格式之后),其中大约 9 行在我们的 SQL 加载失败测试中几乎相同。将其转换为表驱动测试可以提供以下信息:

func TestLoad_tableDrivenTest(t *testing.T) {
   scenarios := []struct {
      desc            string
      configureMockDB func(sqlmock.Sqlmock)
      expectedResult  *Person
      expectError     bool
   }{
      {
         desc: "happy path",
         configureMockDB: func(dbMock sqlmock.Sqlmock) {
            queryRegex := convertSQLToRegex(sqlLoadAll)
            dbMock.ExpectQuery(queryRegex).WillReturnRows(
               sqlmock.NewRows(strings.Split(sqlAllColumns, ", ")).
                  AddRow(2, "Paul", "0123456789", "CAD", 23.45))
         },
         expectedResult: &Person{
            ID:       2,
            FullName: "Paul",
            Phone:    "0123456789",
            Currency: "CAD",
            Price:    23.45,
         },
         expectError: false,
      },
      {
         desc: "load error",
         configureMockDB: func(dbMock sqlmock.Sqlmock) {
            queryRegex := convertSQLToRegex(sqlLoadAll)
            dbMock.ExpectQuery(queryRegex).WillReturnError(
                errors.New("something failed"))
         },
         expectedResult: nil,
         expectError:    true,
      },
   }

   for _, scenario := range scenarios {
      // define a mock db
      testDb, dbMock, err := sqlmock.New()
      require.NoError(t, err)

      // configure the mock db
      scenario.configureMockDB(dbMock)

      // monkey db for this test
      original := *db
      db = testDb

      // call function
      result, err := Load(2)

      // validate results
      assert.Equal(t, scenario.expectedResult, result, scenario.desc)
      assert.Equal(t, scenario.expectError, err != nil, scenario.desc)
      assert.NoError(t, dbMock.ExpectationsWereMet())

      // restore original DB (after test)
      db = &original
      testDb.Close()
   }
}

对不起,这里有很多事情,所以让我们把它分成几个部分:

scenarios := []struct {
   desc            string
   configureMockDB func(sqlmock.Sqlmock)
   expectedResult  *Person
   expectError     bool
}{

这些行定义了一个切片和一个匿名结构,它将成为我们的场景列表。在本例中,我们的场景包含以下内容:

  • 说明:用于添加测试错误消息。
  • 模拟配置:当我们正在测试我们的代码如何对来自数据库的不同响应作出反应时,这就是最神奇的地方。
  • 预期结果:考虑到输入和环境(即模拟配置),相当标准。这就是我们想要回来的。
  • 一个布尔值,用于指示我们是否期望出现错误:我们可以在此处使用错误值;这将更加精确。但是,我更喜欢使用自定义错误,这意味着输出不是常量。我还发现错误消息会随着时间的推移而改变,因此检查的范围会使测试变得脆弱。本质上,我是在用测试的特异性来换取耐久性。

然后我们有我们的场景,每个测试用例一个:

{
   desc: "happy path",
   configureMockDB: func(dbMock sqlmock.Sqlmock) {
      queryRegex := convertSQLToRegex(sqlLoadAll)
      dbMock.ExpectQuery(queryRegex).WillReturnRows(
         sqlmock.NewRows(strings.Split(sqlAllColumns, ", ")).
            AddRow(2, "Paul", "0123456789", "CAD", 23.45))
   },
   expectedResult: &Person{
      ID:       2,
      FullName: "Paul",
      Phone:    "0123456789",
      Currency: "CAD",
      Price:    23.45,
   },
   expectError: false,
},
{
  desc: "load error",
  configureMockDB: func(dbMock sqlmock.Sqlmock) {
    queryRegex := convertSQLToRegex(sqlLoadAll)
    dbMock.ExpectQuery(queryRegex).WillReturnError(
        errors.New("something failed"))
  },
  expectedResult: nil,
  expectError: true,
},

现在有了测试运行程序,它基本上是一个覆盖所有场景的循环:

for _, scenario := range scenarios {
   // define a mock db
   testDb, dbMock, err := sqlmock.New()
   require.NoError(t, err)

   // configure the mock db
   scenario.configureMockDB(dbMock)

   // monkey db for this test
   original := *db
   db = testDb

   // call function
   result, err := Load(2)

   // validate results
   assert.Equal(t, scenario.expectedResult, result, scenario.desc)
   assert.Equal(t, scenario.expectError, err != nil, scenario.desc)
   assert.NoError(t, dbMock.ExpectationsWereMet())

   // restore original DB (after test)
   db = &original
   testDb.Close()
}

这个循环的内容与我们最初测试的内容非常相似。首先编写快乐路径测试,然后通过添加其他场景将其转换为表驱动测试通常更容易。

也许我们的测试运行程序和原始函数之间的唯一区别是我们正在进行猴子补丁。我们不能在for循环中使用defer,因为defer只在函数退出时运行;因此,我们必须在循环结束时恢复数据库。

这里使用表驱动测试不仅减少了测试代码中的重复,而且还具有另外两个显著的优点。首先,它将测试提炼为输入等于输出,使它们非常容易理解,也非常容易添加更多场景。

其次,可能更改的代码,即函数调用本身,只存在于一个地方。如果该函数被修改为接受另一个输入或返回另一个值,我们将不得不在一个地方修复它,而不是在每个测试场景中修复一次。

软件包之间的猴子补丁

到目前为止,为了在data包中进行测试,我们已经研究了 monkey 对私有全局变量或函数进行修补。但是如果我们想测试其他包,会发生什么呢?将业务逻辑层与数据库分离不是很好吗?这肯定会阻止我们的业务逻辑层测试因无关事件而中断,例如优化 SQL 查询。

再次,我们面临着两难境地;我们可以开始大规模的重构,但正如我们前面提到的,这需要大量的工作和大量的风险,尤其是在没有测试来避免麻烦的情况下。让我们看看我们拥有的最简单的业务逻辑包,get包:

// Getter will attempt to load a person.
// It can return an error caused by the data layer or 
// when the requested person is not found
type Getter struct {
}

// Do will perform the get
func (g *Getter) Do(ID int) (*data.Person, error) {
   // load person from the data layer
   person, err := data.Load(ID)
   if err != nil {
      if err == data.ErrNotFound {
         // By converting the error we are encapsulating the 
         // implementation details from our users.
         return nil, errPersonNotFound
      }
      return nil, err
   }

   return person, err
}

正如您所看到的,除了从数据库中加载人员之外,该函数的作用非常小。因此,你可以说它不需要存在;别担心,我们以后会给它更多的责任。

那么,在没有数据库的情况下,我们如何测试它呢?首先想到的可能是像以前一样对数据库池或getDatabase()函数进行猴子补丁。

这是可行的,但它会很草率,并且会用我们不希望生产代码使用的东西污染data包的公共 API,这正是测试引起的损害的定义。它也无法将此包与data包的内部实现解耦。事实上,这会让事情变得更糟。对data包实现的任何更改都可能破坏我们对该包的测试。

另一个需要考虑的方面是,我们可以做任何改动,因为服务很小,我们拥有所有的代码。事实往往并非如此;该包可以由另一个团队拥有,它可以是外部依赖项的一部分,甚至是标准库的一部分。因此,最好养成习惯,将我们的更改保持在我们正在处理的包的本地。

考虑到这一点,我们可以采用上一节简要介绍的技巧,猴子修补的优势。让我们截取从get包到data包的调用,如下代码所示:

// Getter will attempt to load a person.
// It can return an error caused by the data layer or 
// when the requested person is not found
type Getter struct {
}

// Do will perform the get
func (g *Getter) Do(ID int) (*data.Person, error) {
   // load person from the data layer
   person, err := loader(ID)
   if err != nil {
      if err == data.ErrNotFound {
         // By converting the error we are hiding the 
         // implementation details from our users.
         return nil, errPersonNotFound
      }
      return nil, err
   }

   return person, err
}

// this function as a variable allows us to Monkey Patch during testing
var loader = data.Load

现在,我们可以通过 monkey patching 拦截调用,如下代码所示:

func TestGetter_Do_happyPath(t *testing.T) {
   // inputs
   ID := 1234

   // monkey patch calls to the data package
   defer func(original func(ID int) (*data.Person, error)) {
      // restore original
      loader = original
   }(loader)

   // replace method
   loader = func(ID int) (*data.Person, error) {
      result := &data.Person{
         ID:       1234,
         FullName: "Doug",
      }
      var resultErr error

      return result, resultErr
   }
   // end of monkey patch

   // call method
   getter := &Getter{}
   person, err := getter.Do(ID)

   // validate expectations
   require.NoError(t, err)
   assert.Equal(t, ID, person.ID)
   assert.Equal(t, "Doug", person.FullName)
}

现在,我们的测试不依赖于数据库或data包的任何内部实现细节。虽然我们没有完全解耦软件包,但我们已经显著减少了get软件包中的测试必须正确进行的次数。这可以说是通过猴子补丁进行 DI 的要点之一,通过减少对外部因素的依赖和增加测试的重点,减少了测试可能中断的方式。

当魔法消失时

在本书早些时候,我向您提出挑战,要求您以批判的眼光检查本书中介绍的每种 DI 方法。考虑到这一点,我们应该考虑猴子修补的潜在成本。

数据竞争-我们在示例中看到,猴子补丁是用一个副本替换全局变量的过程,该副本以特定测试所需的方式执行。这也许是最大的问题。将某个全局的(因此是共享的)东西交换为某个特定的东西会导致该变量上的数据竞争。

为了进一步了解这种数据竞争,我们需要了解 Go 如何运行测试。默认情况下,包内的测试按顺序执行。我们可以通过使用t.Parallel()标记测试来减少测试执行时间。在我们当前对data包的测试中,将测试标记为并行将导致出现数据竞争,从而导致不可预测的测试。

Go 测试的另一个重要特性是 Go 并行执行多个包。就像t.Parallel()一样,这对于我们的测试执行时间来说是非常棒的。使用我们当前的代码,我们可以安全地避免这种情况,因为我们只在与测试相同的包中进行猴子补丁。如果我们跨包边界修补了 monkey,那么就会出现数据竞争。

如果您的测试不可靠,并且您怀疑存在数据竞争,您可以尝试 Go 的内置竞争检测(https://golang.org/doc/articles/race_detector.html )带有:

$ go test -race ./...

如果没有发现问题,您可以尝试按以下顺序运行所有测试:

$ go test -p 1 ./...

如果测试开始一致通过,那么您需要开始挖掘数据竞争。

详细测试-正如您在我们的测试中所看到的,monkey 补丁和恢复的代码可能会变得相当长。通过一点重构,就有可能简化样板文件。例如,看看这个:

func TestSaveConfig(t *testing.T) {
   // inputs
   filename := "my-config.json"
   cfg := &Config{
      Host: "localhost",
      Port: 1234,
   }

   // monkey patch the file writer
   defer func(original func(filename string, data []byte, perm os.FileMode) error) {
      // restore the original
      writeFile = original
   }(writeFile)

   writeFile = func(filename string, data []byte, perm os.FileMode) error {
      // output error
      return nil
   }

   // call the function
   err := SaveConfig(filename, cfg)

   // validate the result
   assert.NoError(t, err)
}

我们可以将其更改为:

func TestSaveConfig_refactored(t *testing.T) {
   // inputs
   filename := "my-config.json"
   cfg := &Config{
      Host: "localhost",
      Port: 1234,
   }

   // monkey patch the file writer
   defer restoreWriteFile(writeFile)

   writeFile = mockWriteFile(nil)

   // call the function
   err := SaveConfig(filename, cfg)

   // validate the result
   assert.NoError(t, err)
}

func mockWriteFile(result error) func(filename string, data []byte, perm os.FileMode) error {
   return func(filename string, data []byte, perm os.FileMode) error {
      return result
   }
}

// remove the restore function to reduce from 3 lines to 1
func restoreWriteFile(original func(filename string, data []byte, perm os.FileMode) error) {
   // restore the original
   writeFile = original
}

在这次重构之后,我们在测试中有了更少的重复,从而减少了维护,但更重要的是,测试不再被所有与猴子补丁相关的代码所掩盖。

模糊依赖关系-这不是 monkey 补丁本身的问题,而是依赖关系管理的一般风格。在传统 DI 中,依赖项作为参数传入,使关系显式可见。

从用户的角度来看,缺少参数可以被认为是对代码 UX 的改进;毕竟,较少的输入通常会使函数更易于使用。然而,当涉及到测试时,事情很快就会变得一团糟。

在我们前面的示例中,SaveConfig()函数依赖于ioutil.WriteFile(),因此模拟该依赖关系来测试SaveConfig()似乎是合理的。然而,当我们需要测试调用SaveConfig()的函数时会发生什么?

SaveConfig()的用户如何知道他们需要模仿ioutil.WriteFile()

由于关系混乱,所需知识增加,顺便说一句,测试长度也增加了;不久之后,我们在每个测试开始时都会有半个屏幕的功能修补程序。

总结

在本章中,我们学习了如何在测试中利用 monkey 补丁来交换依赖项。通过 monkey 补丁,我们已经测试了全局,分离了包,并消除了对外部资源(如数据库和文件系统)的依赖。在改进示例服务代码的同时,我们已经研究了一些实际示例,并且坦率地讨论了使用 monkey 补丁的优点和缺点。

在下一章中,我们将研究第二种,也许是最传统的 DI 技术,依赖注入和构造器注入。有了它,我们将进一步改进我们服务的代码。

问题

  1. 猴子修补是如何工作的?
  2. 猴子修补的理想用例是什么?
  3. 如何使用 monkey patching 在不更改依赖项包的情况下解耦两个包?

进一步阅读

Packt 还有许多其他学习猴子修补的优秀资源: