Go的单元测试和插桩测试 | 青训营

333 阅读8分钟

前言

软件测试是软件工程中的重要环节,有助于提早发现软件问题,减少生产环境事故。本次青训营的课程带我探索了Go语言工程实践中的测试方式,对之后的团队开发很有帮助。本文将会记录课程中提到的知识要点,主要讲述如何进行单元测试和插桩测试。

测试的分类

软件测试主要分为回归测试、集成测试、单元测试三类。它们置顶向下的测试覆盖率逐渐增大,但是成本却逐渐减小。三者间的测试对象和方式有显著的差异,了解他们的区别将会便于我们在项目开发中灵活使用:

回归测试

回归测试的对象通常是整个应用程序或系统,此时代码可能已经通过了单元测试和集成测试,但是仍然需要确保新的代码在组合运行时不会引入新的错误或是导致原有功能失效。

回归测试的方法有多种,最直观的是测试人员模拟用户使用软件系统,如打开抖音测试各项功能是否正常;此外,还可以让新系统运行上一版本的测试用例,来确保前后功能行为一致性。

回归测试是可以自动化的,通常与持续集成和交互(CICD)工作流一起使用,以便在每次版本迭代时都能及时测试。

集成测试

集成测试的对象是系列单元和组件的结合,通常是为了确保一个较为独立的模块、类、服务的正常运行。

集成测试一般直接关注接口的调用、数据流传输、消息传递等方面,来确认内部组件的协作和集成正确无误。当然,集成测试的层次是可以自由划分的,其下面涉及的模块数量也会有所不同。

单元测试

单元测试的对象是代码的最小可测试单元,通常是一个函数、方法、功能类。测试验证这些代码单元在隔离环境下工作正常,可以在较短的周期内发现错误,减少损失的扩散和传递,提高项目的开发效率。

单元测试需要编写测试用例,验证过程中需要关注函数的输入、输出、代码覆盖率等,以确保单个功能稳定可靠。对于复杂的参数输入,单元测试有时候需要模拟前序流程的输出才能正常进行。

编写单元测试

准备业务代码

在文章中我使用如下例子:编写一个注册信息的校验函数,需要校验用户名和密码是否合法,以及用户名是否已经被占用。我在密码检查的正则表达式中埋了一个bug,之后会用单元测试发现。

package main

import (
	"errors"
	"fmt"
	"log"
	"regexp"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

type User struct {
	ID       uint
	Username string
	Password string
}

var DB *gorm.DB

func isUsernameValid(username string) bool {
	// 检查用户名是否合法
	usernamePattern := "^[a-zA-Z0-9_]{3,20}$"
	return regexp.MustCompile(usernamePattern).MatchString(username)
}

func isPasswordValid(password string) bool {
	// 密码必须是 8-24 位的可见字符
	passwordPattern := "[\\x21-\\x7e]{8,24}$"
	return regexp.MustCompile(passwordPattern).MatchString(password)
}

func isUsernameTaken(username string) bool {
	// return false
	// 从数据库中检查用户名是否已经被占用
	var count int64
	DB.Model(&User{}).Where("username = ?", username).Count(&count)
	return count > 0
}

func validateUser(username string, password string) (bool, error) {
	// 检查用户名是否合法
	if !isUsernameValid(username) {
		return false, errors.New("用户名不合法")
	}

	// 检查密码是否合法
	if !isPasswordValid(password) {
		return false, errors.New("密码不合法")
	}

	// 检查用户名是否已经被占用
	if isUsernameTaken(username) {
		return false, errors.New("用户名已经被占用")
	}

	// 如果通过了所有验证,返回 true 和 nil
	return true, nil
}

func main() {
	// 初始化数据库连接,这里假设你已经完成了数据库初始化
	db, err := gorm.Open(mysql.Open("user:password@tcp(host:port)/database"), &gorm.Config{})
	if err != nil {
		log.Fatalf("连接数据库失败: %v", err)
	}
	DB = db

	// 示例使用
	username := "user123"
	password := "weakpass"
	fmt.Printf("用户名:%s 密码:%s\n", username, password)

	valid, err := validateUser(username, password)
	if valid {
		fmt.Println("用户验证通过")
	} else {
		fmt.Println("用户验证失败:", err.Error())
	}
}

创建测试函数

可以看到这个段代码里做了三步检测,其中用户名和密码使用正则表达式进行匹配。实际匹配效果是否和我们的注释一致呢?我们可以编写单元测试来详细检测每个要求。这里我以包含bug的isPasswordValid函数为例编写一个测试。

首先,单元测试文件应该遵循的一定的命名规范:

  • 所有测试文件以_test.go结尾
  • 函数命名为func TestXxx(t *testing.T)
  • 初始化的逻辑放在func TestMain(m *testing.M)

我的代码存放在main.go中,因此我创建main_test.go文件用于测试:

image.png

要测试的是isPasswordValid函数,因此给单元测试函数起名为TestIsPasswordValid:

package main

import "testing"

func TestIsPasswordValid(t *testing.T) {

}

func TestMain(m *testing.M) {
    // 测试前:数据装载、配置初始化等前置工作
    code := m.Run()
    // 测试后:释放资源等收尾工作
    os.Exit(code)
}

因为本次测试不需要准备工作,因此TestMain只需要负责启动测试然后退出即可。如果需要连接数据库,应该在这里进行操作。

准备测试用例

接着就可以开始编写测试逻辑,这里对每一个密码要点都要单独设置一项测试。

密码期望描述
"password"true普通密码
"12abCD#$"true包含特殊字符
"1234 5678"false包含空格等不可见字符
"1234567890abcdefgABCDEFG!@#$%^&*"false密码太长
"1111"false密码太短

为了可以自动化测试这些用例,我编写一个结构体并储存这几个例子

testCases := []struct {
        password    string
        expect      bool
        description string
    }{
        {"password", true, "普通密码"},
        {"12abCD#$", true, "包含特殊字符"},
        {"1234 5678", false, "包含空格等不可见字符"},
        {"1234567890abcdefgABCDEFG!@#$%^&*", false, "密码太长"},
        {"1111", false, "密码太短"},
    }

编写测试逻辑

接着,就使用for range循环来依次使用这些用例。测试逻辑很简单,就是调用isPasswordValid函数,之后检测返回结果是否和expect一致。这里要用t.Run包裹一层,并传入测试函数。这样才能智能检测出哪项测试失败。完整代码如下:

func TestIsPasswordValid(t *testing.T) {
    // 密码必须是 8-24 位的可见字符
    testCases := []struct {
        password    string
        expect      bool
        description string
    }{
        {"password", true, "普通密码"},
        {"12abCD#$", true, "包含特殊字符"},
        {"1234 5678", false, "包含空格等不可见字符"},
        {"1234567890abcdefgABCDEFG!@#$%^&*", false, "密码太长"},
        {"1111", false, "密码太短"},
    }

    for _, tc := range testCases {
        t.Run(tc.description, func(t *testing.T) {
            valid := isPasswordValid(tc.password)
            if valid != tc.expect {
                t.Errorf("期望密码%s为%v, 但得到%v", tc.password, tc.expect, valid)
            }
        })
    }
}

查看测试结果

此时IDE通常已经会显示出测试按钮,点击即可测试

image.png

——很可惜,测试失败了,IDE弹出了定位到了具体失败的行数,同时命令行窗口也给出了详细的测试过程

image.png

image.png

往好处想,这就是单元测试发挥作用的时候了!可以清晰得看到过长密码的这项测试失败了,这说明我们正则表达式错误放行了这个情况,这个时候就可以检查源代码修bug了。

修正代码

实际上,这个正则表达式的错误出现在开头。

image.png

密码检测的本意是从头到尾匹配整条密码是否合规,所以要加上^形成^[\\x21-\\x7e]{8,24}$。如果缺失,就会导致正则表达式只关注字符串末端是否匹配所需要的规则,这样过长密码就被误判为通过。

再次测试

修正错误后再次运行测试,就能得到通过的结果:

image.png

image.png

可喜可贺,这样就完成了一次单元测试发现和修正问题的实践。

编写插桩测试

实际业务中,应该在一个请求处理函数中调用validateUser方法,还应该连接数据库,检查用户名是否被占用,然后根据返回信息决定是否放行注册。但是如果编码时暂时不能访问数据库,或是单独引入数据库很复杂,我们只能修改isUsernameTaken函数,使其提前返回false,才能跑通这个函数流程,得到以下的结果:

image.png

但是实际编码中,我们不能总是通过修改代码来跑通测试,特别是修改后忘记恢复,会对业务产生不利影响。这个时候就可以借助插桩测试来实现绕过某个函数的需求。

编写测试函数

首先准备好测试函数

func TestValidateUser(t *testing.T) {
    testCases := []struct {
        username    string
        password    string
        expect      bool
        description string
    }{
        {"username", "password", true, "普通用户名和密码"},
        {"taken", "password", false, "用户名被占用"},
    }

    for _, tc := range testCases {
        t.Run(tc.description, func(t *testing.T) {
            valid, err := validateUser(tc.username, tc.password)
            if err != nil {
                t.Logf("用户名:%s 密码:%s 不通过:%v", tc.username, tc.password, err.Error())
            } else {
                t.Logf("用户名:%s 密码:%s 通过", tc.username, tc.password)
            }

            if valid != tc.expect {
                t.Errorf("期望用户名%s和密码%s为%v, 但得到%v", tc.username, tc.password, tc.expect, valid)
            }

    })
    }
}

现在运行这个测试一定会失败,因为没有连接数据库,DB指针为nil,程序会panic。

Mock函数

这里使用monkey这个库方便地进行快速插桩,首先获得这个库

go get bou.ke/monkey

然后导入这个包

import (
    //...
    "bou.ke/monkey"
)

之后在测试函数开头添加插桩:

```go
monkey.Patch(isUsernameTaken, func(username string) bool {
    return username == "taken"
})

defer monkey.Unpatch(isUsernameTaken)

这里传入的函数替换了原本的函数,简单检查username是否为taken,然后同样返回一个bool值。

不要忘记用defer自动解除插桩,养成良好的习惯。

运行测试

现在运行测试,可以看到插桩后的函数按照预期绕过了数据库,成功完成测试。

image.png

image.png

后记

到这里,对常见的单元测试和插桩测试的介绍就完成了。实际工程中还会经常使用代码覆盖率测试和基准测试评估代码代码质量和性能问题,在这里不多讲述。后端开发中还可能遇到想要调试框架内函数的情况,根据实际情况需要引入额外的库,要结合具体案例分析。