Go语言工程实践之测试 | 青训营

98 阅读6分钟

背景

说到测试,大家最先想到的是什么?考试?体测?但凡是测试,都是为了一个目的——检验。一个人的本事是高是低,一件产品的质量是优是劣,都需要通过测试检验出来,软件自然也不例外。如果一个软件上线之前没有经过测试,那么就很有可能发生事故,轻则出现功能障碍,带来不好的用户体验;重则出现致命的系统性错误,使公司资金大量流失。因此,在开发后进行完备的测试是十分有必要的,因为这样一来能够有效避免事故发生的可能性。

测试的分类

根据测试的代码覆盖率以及成本,可以将测试分为以下三类:

  • 单元测试

    单元测试是对软件系统中最小的可测试单元(通常是函数、方法或模块)进行测试的过程。它的目的是验证这些单元能否按照期望工作,确保其功能正确性。单元测试通常在开发过程中的早期阶段进行,以便尽早发现和修复缺陷。单元测试具有快速执行、易于调试和隔离依赖的特点。

    从代码覆盖率上看,单元测试几乎是面面俱到的,因为它把所有的函数、方法之类的最小可测单元全部都测过去了;另外,因为单元测试关注于较小的代码和片段,一般而言编写和执行是比较容易的,因而单元测试的成本也是偏低的。

  • 集成测试

    集成测试是在单元测试之后,对多个组件(如模块、子系统)进行联合测试的过程。它的目标是验证这些组件之间的交互是否正常,确保各个组件协同工作的正确性。集成测试可以检测出模块之间的接口问题、数据流问题等。集成测试一般在开发过程的中后期进行,需要确保不同组件协调工作,而不仅仅是单个组件的功能。

    相比于单元测试,集成测试更加关注于模块之间的交互,这是从系统功能维度上所做的测试,因此相对而言没法测试得像单元测试那么细致,代码的覆盖率会有所降低;但在集成测试中,需要考虑组件之间的接口、数据流以及各种依赖关系。这使得编写和执行集成测试需要更多的时间和资源,因此成本相对较高。

  • 回归测试

    回归测试是在软件经历更改后,重新运行之前通过的测试用例来确保新的更改没有引入新的错误或导致现有功能受损。它的目的是验证软件在经过修改后仍然保持正常运行,并且旧功能没有受到负面影响。回归测试可以防止之前已经修复过的缺陷再次出现,同时也能够发现由于修改引入的新问题。

    其测试流程是针对于整个主流程而言,当然万一主流程里有选择性语句,那回归测试就不可能执行其中所有的代码,所以有些错误是没法发现的,故代码覆盖率也是相对而言比较低的;但是由于回归测试需要重新运行大量的测试用例,以确保整个代码库的功能完整性,其成本可能比单元测试和集成测试更高。

从单元测试到集成测试再到回归测试,代码覆盖率逐渐降低,但是成本逐渐变高,那既然这样只做单元测试不就好了吗?当然不是。即使单一的功能模块测试运行正常也不代表各模块间的交互以及程序的总体执行是正常的,因此每一轮测试都有其对应的意义,都是不可或缺的。

Go 语言中的单元测试

由于单元测试相对而言比较简单,而 Go 语言中的 testing 包又有极其丰富的测试功能和工具,所以接下来就结合 Go 语言来感受一下单测的流程。

单元测试一般包括输入、测试单元(接口、函数、模块等)、输出以及校对。开发者通过输入用例查看输出并且与期望输出进行校对来检查测试单元是否正常,并以此保证测试单元的质量;随着开发的进行,只要覆盖率足够,单测不仅能保证新功能的正常运行,又能保证现有功能不被破坏,如果哪里出现了错误,也可以通过单测在短期内修复 bug,长远上提高了开发效率。

规则

在 golang 中,单元测试一般遵循以下规则:

  1. 测试文件通常以 _test.go 为后缀,并使用 testing 包来编写测试函数和测试逻辑;
  2. 测试函数的签名必须是 func TestXxx(t *testing.T),其中 Xxx 可以是任意字符串(但是首字母要大写);
  3. 测试函数的返回值类型必须是 void

golang 的测试文件里可以使用 TestMain 函数来定制测试的启动逻辑,比如可以在其中进行一些初始化或者资源清理的设置,并调用 m.Run() 来运行所有的测试函数,TestMain 函数必须调用 m.Run() 来运行测试,否则测试将不会执行,另外最好还要在测试失败时设置以某状态码退出,不然就一直是返回 0(以成功状态退出),其函数签名为 func TestMain(m *testing.M),注意这里比较特殊的是传参类型为 *testing.M

测试文件里还是可以有非测试函数的存在,比如可以在测试文件里编写初始化和清理资源的函数,到 TestMain 函数中使用,不过这些函数与 TestMain 函数一样都不是必须的,用 go test 命令一样可以执行测试文件里所有的测试函数。

例子

就我的个人理解,测试文件的主要逻辑就是比源代码文件中的每一个函数或方法在名字前都多加了一个 Test,然后把其中的内容换成测试的内容,当然在测试文件中除了测试函数之外其他都不是必要的(包括但不限于测试主函数),接下来用一个简单的例子来看看 golang 中的单测:

// 需要测试的函数
// func Add(a, b int) int { return a + b }

// 测试函数
// 测试函数可以和被测函数不同包,如果那样的话就要多 import 一个包
import (
    "testing"
)

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5

    if result != expected {
        t.Errorf("Add(2, 3) = %d; expected %d", result, expected)
    }
}

上述代码一个针对两整数相加的测试函数,在该测试函数里,输入(用例)为 2 和 3,期望值为 5,通过将输出值与期望值进行比对来判断测试是否通过,以下是通过与不通过的终端输出:

通过:

image.png

不通过:

image.png

使用第三方断言库 github.com/stretchr/testify/assert,可以更好地完善测试代码,比如上述代码就可以把 if 语句改成:

assert.Equal(t, expected, result, "Wrong result!")

这次不通过则输出了更为详细的结果:

image.png

同时代码也更加简洁了。

代码覆盖率

之前一直提到的