待测单元

在第 1 章《掌握测试驱动开发》中,我们讨论了使用 安排-执行-断言(Arrange-Act-Assert, AAA)模式的测试结构。我们还简要提到,Arrange 步骤用于设置被测试单元(UUT)及其依赖项。接下来,测试会执行并验证 UUT 的功能。

在 Go 中,源代码是按包(packages)和模块(modules)组织的。我们将首先探索这些概念及其工作原理,然后看看测试文件如何融入这个结构。深入理解包的功能将为我们开始思考如何编写测试以及测试什么内容奠定基础。

模块和包

如果你已经使用 Go 有一段时间,你可能已经熟悉 Go 的模块系统,该系统在 Go 1.13 中作为默认的依赖管理解决方案引入。本文写作时的最新 Go 版本是 1.19,因此模块系统已经成为标准解决方案一段时间了。

模块

模块是一些软件包(packages)的集合,它们一起分发和发布。模块的名称应该能够代表其功能,并指明如何找到它。Go 工具链支持流行的代码托管平台,如 GitHub 和 Bitbucket,并能够发出正确的请求,从它们的版本控制系统中下载依赖项。

模块通过在项目目录的根目录放置一个 go.mod 文件来声明。可以通过运行 go mod init 命令并提供模块路径作为参数来初始化一个新的模块。我们可以通过在终端运行以下命令来初始化我们代码示例的模块:

$ go mod init "github.com/PacktPublishing/Test-DrivenDevelopment-in-Go"

我们提供的模块路径与 GitHub 存储库路径相同。生成的文件只包含两行内容——一行是模块路径,一行是所需的 Go 版本:

module github.com/PacktPublishing/Test-Driven-Development-in-Go
go 1.19

一般来说,go.mod 文件包含以下属性:

  • 模块路径

  • 项目所需的 Go 版本

  • 在构建项目时需要导入的任何外部依赖项

由于我们当前的项目为空,因此我们的模块文件未指定任何外部依赖项。标准库的包不需要声明为依赖项。一旦它们在模块的源代码中被使用,go.mod 文件将自动更新以包含这些依赖项。

生成的go.mod文件

go.mod 文件是生成的,但并非只读文件。不过,作为经验法则,你应该避免手动编辑 go.mod 文件。一般情况下,开发人员只会在需要更改版本号时编辑它,而不会手动更改其中的条目。你还可以随时通过 go mod init 命令重新创建它。

虽然模块是打包和发布项目的绝佳方式,但如果没有内部组织或层次结构,大多数生产系统几乎不可能进行维护或理解。正是在这里,Go 的包帮助我们提供了这种急需的结构。

Go 源代码是按包组织的。每个源文件的第一行必须是包声明,可以使用 package 关键字来完成。然后,源文件中定义的所有名称都将被添加到声明的包中。

回顾第 1 章《掌握测试驱动开发》中的简单终端计算器示例,我们可以为它指定一个包和源代码结构,如图 2.1 所示:

image 2025 01 02 10 57 07 054
Figure 1. Figure 2.1 – The module, packages, and source files of the simple Terminal calculator

该模块包含三个包,每个包包含专门的功能:

  • input 包包含输入解析和验证功能。它依赖于 calculatorformat 包。

  • calculator 包包含所有计算引擎的逻辑,提供计算器提供的所有操作功能。它依赖于 format 包。

  • format 包包含结果和错误的格式化逻辑。它没有依赖于其他现有的包。

包命名很重要

包名称应该能够代表它们提供的功能,以便其他代码可以引用它们。包名称应该简短且富有描述性。它们在与类型和函数的名称一起使用时也应该具有意义。

format 包位于包层次结构的底部,我们可以从定义它并立即开始编写其结果格式化功能开始。查看其 result.go 源文件的内容,可以看到其简单的定义:

package format

func Result(expression string, result float64) string {
    // 实现代码
    return ""
}

此包定义了一个 Result 函数,它输出给定表达式和结果的格式化字符串。为了确保代码能够编译,我们返回了一个空字符串,直到我们准备好使用 TDD 开始实现。error.go 文件的定义类似,为简洁起见这里省略了。

进一步查看计算器引擎,engine.go 源文件的内容可以这样开始:

package calculator

type Engine struct {}

func (e *Engine) Add(x, y float64) float64 {
    // 实现代码
    return 0
}

// ... 方法声明

我们从 calculator 包的定义开始,添加源文件和所有定义到该包中。然后,我们创建了一个 Engine 类型,它将包含计算器的所有依赖项。接下来的几行代码,我们可以开始定义引擎需要提供的所有操作的方法。Engine 类型的 Add 方法是定义加法操作的一个示例。

有眼尖的读者会注意到,类型、方法和函数的定义都使用了大写字母。这使得它们成为导出名称。

包外的可见性

只有包的导出名称才能在包外使用。与其他编程语言不同,Go 没有可见性修饰符。在 formatcalculator 包的代码示例中,我们希望它们的函数在各自的包外部可用,因此它们已经被导出,并且使用了大写字母来定义。

一个包可以通过使用 import 关键字声明对另一个包的依赖。然后,我们可以通过包名和点操作符(.)来引用被导入包的变量、类型和函数。

有一些例外情况,通常每个目录只能有一个包名。声明新的 input 包时,也需要创建一个新的目录。Parser 的声明,它依赖于 Engine 类型,如下所示:

package input

import "github.com/PacktPublishing/Test-Driven-Development-in-Go/chapter02/calculator"

type Parser struct {
    engine    *calculator.Engine
    validator *Validator
}

func (p *Parser) ProcessExpression(expr string) (*string, error) {
    // 实现代码
}

// ... 方法声明

Parser 作为 input 包的一部分进行声明。Parser 类型需要 Calculator 类型的功能,因此它导入了 calculator 包。如上所述,引用 calculator.Engine 时,需要使用它的包名和点操作符来限定。这让编译器知道引用的类型来自导入的包,而不是当前包。

高亮的导入路径由三部分组成:

  • 包所属的模块:github.com/PacktPublishing/Test-Driven-Development-in-Go

  • 从模块根目录开始的子目录路径:chapter02

  • 被导入包的名称:calculator

Go 包的强大功能

包作为 Go 的核心概念

包是 Go 中一个强大且核心的概念。它们允许开发人员实现以下功能:

  • 分组组件:当命名得当时,包提供了一种简单的方式来理解、独特地分组和文档化多个具有相同功能的组件。

  • 封装代码:由于只有导出的方法对外部代码可见,包是 Go 中最重要的封装机制。它们为开发人员提供了精细的控制,能够精确控制对外可用的内容。

  • 代码重用:包为我们的程序提供了模块化,允许我们在多个地方重用代码,通过提供导入包的方式。能够利用当前模块之外的代码,让开发人员可以共享相同的解决方案,而无需重新发明轮子。

  • 轻松管理依赖:Go 的模块系统遵循语义版本控制(SemVer),它使用三个主要的版本号来管理导入的依赖:主版本号、次版本号和修补版本号。这让开发人员能够将依赖固定到某个特定版本,并轻松了解何时需要升级到更新的版本。

现在我们已经理解了模块和包的基本概念,接下来让我们关注测试如何融入到代码库和其包中。注意,EngineParser 自定义类型的方法还没有实现任何代码:这正是因为 TDD(测试驱动开发)的核心就是先写测试!

包作为 API

由于包的封装和模块化特性,包使得开发人员能够使用类似于设计外部 API 的技术来构建和组织他们的代码,选择他们希望对外提供的函数签名和功能。

测试文件命名和放置

与其他编程语言不同,Go 中的测试文件与源代码文件共存。所有测试文件必须以 _test.go 后缀结尾。Go 的测试运行器会扫描代码库中的这些测试文件并按需运行它们。测试运行器是 Go 工具链的一部分,可以通过 go test 命令调用。

图 2.2 展示了我们迄今为止讨论的简单终端计算器的目录结构:

image 2025 01 02 10 59 30 328
Figure 2. Figure 2.2 – The directory structure of the simple Terminal calculator

在本章讨论的所有代码可以在与本书相关的专用代码库的 chapter02 目录中找到。在这个目录下,还有三个子目录:formatcalculatorinput,每个子目录包含它们的源代码文件和相应的测试文件。

命名测试文件

虽然测试文件需要以 _test.go 后缀结尾,但没有强制要求测试文件的其余部分与对应的源代码文件名称完全匹配。然而,强烈建议你使用源代码文件名并附加测试后缀。这样做可以确保这两个文件在按字典顺序排序时彼此相邻。

源代码文件和测试文件直接放在同一个目录中,这使得开发人员在进行 TDD 时可以更加方便地切换编写实现代码和测试代码。一些编辑工具甚至可以通过快捷键实现这一点!

附加测试包

尽管测试文件与其对应的源代码文件同名并且位于同一目录中,但包结构会有所不同。我们之前提到过,除某些例外外,每个目录只能声明一个包。测试文件就是这些例外之一。

测试文件允许声明一个额外的测试包,该包与源文件的包名相同,只是在包名后加上 _test。从可见性角度来看,这个测试包与任何其他包一样,并且需要导入它希望访问的包。它仅能访问所导入包的已导出名称。

测试包的推荐做法

虽然在 Go 中不强制要求使用专门的 _test 包,但这是一个推荐的做法。尽可能地,你应该在专门的测试包中声明你的测试。

图 2.3 展示了简单终端计算器中测试包的独立定义:

image 2025 01 02 11 00 46 499
Figure 3. Figure 2.3 – The package and directory structure of the simple calculator

专门的测试包在与源代码包相同的目录中定义,从而实现了源代码和测试代码的完全分离。使用专门的测试包带来了以下优势:

  • 防止脆弱的测试:限制对仅导出功能的访问,使得测试代码无法访问包内部的实现细节(如状态变量),从而避免了可能导致不一致结果的情况。

  • 分离测试和核心包的依赖:测试包允许测试导入所需的任何依赖,而不会将这些依赖添加到核心包中。在实践中,测试代码通常有自己的专用验证器和功能,这些通常不希望在生产代码中可见。测试包是实现这种分离的无缝方式。

  • 允许开发人员与自己的包集成:我们之前提到过,包允许开发人员将内部代码构建为小型 API。从专门的测试包中编写测试,让开发人员能够轻松地看到如何与他们设计的外部接口集成,确保代码的可维护性。

图 2.4 展示了更新后的模块、包、源文件和测试文件结构,其中简单的终端计算器现在使用了 _test 包:

image 2025 01 02 11 01 08 767
Figure 4. Figure 2.4 – The module, packages, source, and test files of the simple Terminal calculator

让我们从第 1 章《理解测试驱动开发》中学到的 AAA 模式的角度描述包之间的依赖关系。ArrangeAssert 步骤是针对 UUT(待测试单元)执行的,并且这些步骤分别在对应命名的包中定义:

  • format 包 没有其他包的依赖。因此,format_test 包在格式包上执行 ArrangeActAssert 步骤。

  • calculator 包 依赖于 format 包。因此,calculator_test 包安排来自格式包的依赖,然后在计算器包上执行 ActAssert 步骤。

  • 最后,input 包 依赖于 calculator 和 input 包。因此,input_test 包安排输入包的依赖,这些依赖由计算器和输入包提供。

这一节为你介绍了 Go 模块系统,并讨论了如何在整个代码库中放置和命名测试。接下来,我们将看看如何在 Go 中实现测试。