详解如何在Go中如何编写出可测试的代码

 更新时间:2023年08月21日 09:33:26   作者:江湖十年  
在编写测试代码之前,还有一个很重要的点,容易被忽略,就是什么样的代码是可测试的代码,所以本文就来聊一聊在 Go 中如何写出可测试的代码吧

之前写了几篇文章,介绍在 Go 中如何编写测试代码,以及如何解决被测试代码中的外部依赖问题。但其实在编写测试代码之前,还有一个很重要的点,容易被忽略,就是什么样的代码是可测试的代码?为了更方便的编写测试,我们在编码阶段就应该要考虑到,自己写出来的代码是否能够被测试。本文就来聊一聊在 Go 中如何写出可测试的代码。

本文不讲理论,只讲我在实际开发过程中的经验和思考,使用几个实际的案例,来演示怎样从根上解决测试代码难以编写的问题。

使用变量来定义函数

假设我们编写了一个 Login 函数,用来实现用户登录,示例代码如下:

func Login(u User) (string, error) {
	// ...
	token, err := GenerateToken(32)
	if err != nil {
		// ...
	}
	// ...
	return token, nil
}

Login 函数接收 User 信息,并在内部通过 GenerateToken(32) 函数生成一个 32 位长度的随机 token 作为认证信息,最终返回 token

这个函数只编写了大体框架,具体细节没有实现,但我们可以发现,Login 函数内部依赖了 GenerateToken 函数。

GenerateToken 函数定义如下:

func GenerateToken(n int) (string, error) {
	token := make([]byte, n)
	_, err := rand.Read(token)
	if err != nil {
		return "", err
	}
	return base64.URLEncoding.EncodeToString(token)[:n], nil
}

现在我们要为 Login 函数编写单元测试,可以写出如下测试代码:

func TestLogin(t *testing.T) {
	u := User{
		ID:     1,
		Name:   "test1",
		Mobile: "13800001111",
	}
	token, err := Login(u)
	assert.NoError(t, err)
	assert.Equal(t, 32, len(token))
}

可以发现,在调用 Login 函数后,我们只能断言获得的 token 长度,而无法断言 token 具体内容,因为 GenerateToken 函数每次随机生成的 token 值是不一样的。

这看起来似乎没什么问题,但通常情况下,我们应该尽量避免测试代码中出现随机性的值。并且,有可能被测试代码较为复杂,比如我们要测试的是调用 Login 函数的上层函数,那么这个函数可能还会使用 token 去做其他的事情。此时,就会出现代码无法被测试的情况。

所以,在编写测试时,我们应该让 GenerateToken 函数的返回结果固定下来,但现在定义的 GenerateToken 函数显然无法做到这一点。

要解决这个问题,我们需要重新定义下 GenerateToken 函数:

var GenerateToken = func(n int) (string, error) {
	token := make([]byte, n)
	_, err := rand.Read(token)
	if err != nil {
		return "", err
	}
	return base64.URLEncoding.EncodeToString(token)[:n], nil
}

GenerateToken 函数内部逻辑没变,不过换了一种定义方式。GenerateToken 不再是函数名,而是一个变量名,这个变量指向了一个匿名函数。

现在我们就有机会在测试 Login 的时候,将 GenerateToken 变量进行替换,实现一个只会返回固定输出的 GenerateToken 函数。

新版单元测试代码实现如下:

func TestLogin(t *testing.T) {
	u := User{
		ID:     1,
		Name:   "test1",
		Mobile: "13800001111",
	}
	token, err := Login(u)
	assert.NoError(t, err)
	assert.Equal(t, 32, len(token))
	assert.Equal(t, "jCnuqKnsN5UAM9-LgEGS_COvJWp15RDv", token)
}
func init() {
	GenerateToken = func(n int) (string, error) {
		return "jCnuqKnsN5UAM9-LgEGS_COvJWp15RDv", nil
	}
}

我们利用 init 函数,在测试文件执行一开始就替换了 GenerateToken 变量的指向,新的匿名函数返回固定的 token。这样一来,在测试时 Login 函数内部调用的就是 GenerateToken 变量所指向的函数了,其返回值已经被固定,因此,我们可以对其进行断言操作。

使用依赖注入来解决外部依赖

现在我们有一个 GenerateJWT 函数,用来生成 JSON Web Token,其实现如下:

func GenerateJWT(issuer string, userId string, expire time.Duration, privateKey *rsa.PrivateKey) (string, error) {
	nowSec := time.Now().Unix()
	token := jwt.NewWithClaims(jwt.SigningMethodRS512, jwt.MapClaims{
		"expiresAt": nowSec + int64(expire.Seconds()),
		"issuedAt":  nowSec,
		"issuer":    issuer,
		"subject":   userId,
	})
	return token.SignedString(privateKey)
}

这个函数使用当前时间戳作为 payload,并且使用了 RS512,来生成 JWT。

此时,我们要为这个函数编写一个单元测试,代码如下:

func TestGenerateJWT(t *testing.T) {
	key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKey))
	assert.NoError(t, err)

	token, err := GenerateJWT("jianghushinian", "1234", 2*time.Hour, key)
	assert.NoError(t, err)
	assert.Equal(t, 499, len(token))
}

因为 GenerateJWT 函数生成 token 所使用的 payload 是依赖当前时间的(time.Now().Unix()),故每次生成的 token 都会不同。所以同之前的 GenerateToken 函数一样,我们也无法断言 GenerateJWT 返回的 token 内容,只能断言其长度。

但这是不合理的,断言 token 长度仅能表示这个 token 生成出来了,但是不保证正确。因为 JWT 有很多算法,假如在编写 GenerateJWT 函数时选错了算法,比如选成了 RS256,那么 TestGenerateJWT 函数就无法测试出来这个 BUG。

为了提高 GenerateJWT 函数的测试覆盖率,我们需要解决 time.Now().Unix() 依赖问题。

这次我们不再采用变量 + init 函数的方式,而是采用依赖注入的思想,将外部依赖当做函数的参数传递进来:

func GenerateJWT(issuer string, userId string, nowFunc func() time.Time, expire time.Duration, privateKey *rsa.PrivateKey) (string, error) {
	nowSec := nowFunc().Unix()
	token := jwt.NewWithClaims(jwt.SigningMethodRS512, jwt.MapClaims{
		"expiresAt": nowSec + int64(expire.Seconds()),
		"issuedAt":  nowSec,
		"issuer":    issuer,
		"subject":   userId,
	})
	return token.SignedString(privateKey)
}

可以发现,所谓的依赖注入,就是当 GenerateJWT 函数依赖当前时间时,我们不再通过 GenerateJWT 函数内部直接调用 time.Now() 来获取,而是使用参数(nowFunc)的方式,将 time.Now 函数传递进来,当函数内部需要获取当前时间时,就调用传递进来的函数参数。

这样,我们便实现了将依赖移动到函数外部,在调用函数时,将依赖从外部注入到函数内部来使用。

现在实现的单元测试代码就可以断言生成的 token 是否正确了:

func TestGenerateJWT(t *testing.T) {
	key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKey))
	assert.NoError(t, err)
	nowFunc := func() time.Time {
		return time.Unix(1689815972, 0)
	}
	actual, err := GenerateJWT("jianghushinian", "1234", nowFunc, 2*time.Hour, key)
	assert.NoError(t, err)
	expected := "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJleHBpcmVzQXQiOjE2ODk4MjMxNzIsImlzc3VlZEF0IjoxNjg5ODE1OTcyLCJpc3N1ZXIiOiJqaWFuZ2h1c2hpbmlhbiIsInN1YmplY3QiOiIxMjM0In0.NmCDxFaBfAPPgWQ0zVMl8ON1UQMeIVNgFCn1vtbppsunb-VrOMCdnJlguvPnNc6fMD9EkzMYM3Ux8zFnTiICDMRX23UlhAo2Zb3DorThdrBcNWHMUd26DBNI9n_oUY5B6NPqtrutvqCex9lQH0vUYOt2O5dOyZ-H9cVNY1r3fJHNkYuNWxmoZRfka5o1oSWvUw8hBJfgjANOzZ5ACIi0q5hnou5hQ8VljjFsP4zj2a2lU6w5Db8_rOA04BxilkfurdExcPeaAVCtA-Km0zNwL3gGwJB21gwyb4MRHsEf-ra-4-V7O5_JGiSOQgfkNB63RoASljRXpD6q-gakm0e0fA"
	assert.Equal(t, expected, actual)
}

在单元测试中,调用 GenerateJWT 函数时,我们可以使用一个返回固定值的 nowFunc 函数来作为 time.Now 的替代品。这样当前时间就被固定下来,因而 GenerateJWT 函数的返回结果也就被固定下来,就可以断言 GenerateJWT 函数生成的 token 是否正确了。

提示:expected 的值可以在这个网站 生成,测试所用到的 private.pempublic.pem 文件我都放在了这里

对于 GenerateJWT 函数,我还编写了一个 JWT.GenerateToken 方法版本,代码如下:

type JWT struct {
	privateKey *rsa.PrivateKey
	issuer     string
	// nowFunc is used to mock time in tests
	nowFunc func() time.Time
}
func NewJWT(issuer string, privateKey *rsa.PrivateKey) *JWT {
	return &JWT{
		privateKey: privateKey,
		issuer:     issuer,
		nowFunc:    time.Now,
	}
}
func (j *JWT) GenerateToken(userId string, expire time.Duration) (string, error) {
	nowSec := j.nowFunc().Unix()
	token := jwt.NewWithClaims(jwt.SigningMethodRS512, jwt.MapClaims{
		// map 会对其进行重新排序,排序结果影响签名结果,签名结果验证网址:https://jwt.io/
		"issuer":    j.issuer,
		"issuedAt":  nowSec,
		"expiresAt": nowSec + int64(expire.Seconds()),
		"subject":   userId,
	})
	return token.SignedString(j.privateKey)
}

对于 TestJWT_GenerateToken 单元测试函数的实现,就交给你自己来完成了。

使用接口来解耦代码

我们有一个 GetChangeLog 函数可以返回项目的 ChangeLog,实现如下:

var version = "dev"
type ChangeLogSpec struct {
	Version   string
	ChangeLog string
}
func GetChangeLog(f *os.File) (ChangeLogSpec, error) {
	data, err := io.ReadAll(f)
	if err != nil {
		return ChangeLogSpec{}, err
	}
	return ChangeLogSpec{
		Version:   version,
		ChangeLog: string(data),
	}, nil
}

GetChangeLog 函数接收一个文件对象 *os.File,使用 io.ReadAll(f) 从文件对象中读取全部的 ChangeLog 内容并返回。

如果要测试这个函数,我们需要在单元测试中创建一个临时文件,测试完成后还要对临时文件进行清理,实现代码如下:

func TestGetChangeLog(t *testing.T) {
	expected := ChangeLogSpec{
		Version: "v0.1.1",
		ChangeLog: `
# Changelog
All notable changes to this project will be documented in this file.
`,
	}
	f, err := os.CreateTemp("", "TEST_CHANGELOG")
	assert.NoError(t, err)
	defer func() {
		_ = f.Close()
		_ = os.RemoveAll(f.Name())
	}()
	data := `
# Changelog
All notable changes to this project will be documented in this file.
`
	_, err = f.WriteString(data)
	assert.NoError(t, err)
	_, _ = f.Seek(0, 0)
	actual, err := GetChangeLog(f)
	assert.NoError(t, err)
	assert.Equal(t, expected, actual)
}

在测试时,为了构造一个 *os.File 对象,我们不得不创建一个真正的文件。好在 Go 提供了 os.CreateTemp 方法能够在操作系统的临时目录创建文件,方便清理工作。

其实,我们还有更好的方式来实现这个 GetChangeLog 函数:

func GetChangeLog(reader io.Reader) (ChangeLogSpec, error) {
	data, err := io.ReadAll(reader)
	if err != nil {
		return ChangeLogSpec{}, err
	}
	return ChangeLogSpec{
		Version:   version,
		ChangeLog: string(data),
	}, nil
}

我对 GetChangeLog 函数进行了小改造,函数参数不再是一个具体的文件对象,而是一个 io.Reader 接口类型。

GetChangeLog 函数内部代码无需改变,函数和它的外部依赖,就已经通过接口完成了解耦。

现在,测试过程中我们可以使用 Fake obejct 或者 Mock object 来替换真实的 *os.File 对象。

使用 Fake obejct 实现测试代码如下:

type fakeReader struct {
	data   string
	offset int
}
func NewFakeReader(input string) io.Reader {
	return &fakeReader{
		data:   input,
		offset: 0,
	}
}
func (r *fakeReader) Read(p []byte) (int, error) {
	if r.offset >= len(r.data) {
		return 0, io.EOF // 表示数据已读取完毕
	}
	n := copy(p, r.data[r.offset:]) // 将数据从字符串复制到 p 中
	r.offset += n
	return n, nil
}
func TestGetChangeLogByIOReader(t *testing.T) {
	expected := ChangeLogSpec{
		Version: "v0.1.1",
		ChangeLog: `
# Changelog
All notable changes to this project will be documented in this file.
`,
	}
	data := `
# Changelog
All notable changes to this project will be documented in this file.
`
	reader := NewFakeReader(data)
	actual, err := GetChangeLogByIOReader(reader)
	assert.NoError(t, err)
	assert.Equal(t, expected, actual)
}

这一次,我们没有直接创建一个真实的文件对象,而是提供一个实现了 io.Reader 接口的 fakeReader 对象。

在测试时,可以使用这个 fakeReader 来替代文件对象,而不必在操作系统中创建文件。

此外,因为使用了接口来解耦,我们还可以使用 Mock 技术来编写测试代码。

不过 io.Reader 是一个 Go 语言内置接口,gomock 无法直接为其生成 Mock 代码。

解决办法是,我们可以为其起一个别名:

type IReader io.Reader

然后再为 IReader 接口实现 Mock 代码。

还可以对 io.Reader 进行一层包装:

type ReaderWrapper interface {
	io.Reader
}

然后再为 ReaderWrapper 接口实现 Mock 代码。

两种方式都可行,你可以根据自己的喜好进行选择。

Mock 测试代码就交给你自己来完成了。

总结

如何编写测试代码,不仅仅是在业务代码实现以后,写单元测试时才要考虑的问题。而是在编写业务代码的过程中,时刻都要思考的问题。好的代码,能够大大降低编写测试的难度和周期。

在编写测试时,我们应该尽量固定所依赖对象的返回值,这就要求依赖对象的代码能够方便替换。如果依赖对象是一个函数,我们可以将其定义为一个变量,测试时将变量替换成返回固定值的临时对象。

我们也可以采用依赖注入的思想,将被测试代码内部的依赖,移动到函数参数中来,这样在测试时,可以将依赖对象进行替换。

在 Go 语言中,使用接口来对代码进行解耦,是惯用方法,同时也是解决测试依赖的突破口,使用接口,我们才有机会使用 Fake 和 Mock 测试。

此外,在我们自己编写业务代码时,如果代码实现方能够提供 Fake object,那么也能为编写测试代码的人提供便利。这一点可以参考 K8s client-go 项目,K8s 团队在实现 client-go 时提供了对应的 Fake object,如果我们的代码依赖了 client-go,那么就可以直接使用 K8s 提供的 Fake object 了,而不必自己来创建 Fake object,非常方便,值得借鉴。

以上就是详解如何在Go中如何编写出可测试的代码的详细内容,更多关于Go编写可测试代码的资料请关注脚本之家其它相关文章!

相关文章

  • Go语言中defer语句的用法

    Go语言中defer语句的用法

    这篇文章介绍了Go语言中defer语句的用法,文中通过示例代码介绍的非常详细。对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2022-07-07
  • Go设计模式之备忘录模式讲解和代码示例

    Go设计模式之备忘录模式讲解和代码示例

    备忘录是一种行为设计模式, 允许生成对象状态的快照并在以后将其还原,本文就通过代码示例给大家讲讲Go备忘录模式,感兴趣的小伙伴跟着小编一起来看看吧
    2023-08-08
  • Go实现MD5加密的三种方法小结

    Go实现MD5加密的三种方法小结

    本文主要介绍了Go实现MD5加密的三种方法小结,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-03-03
  • prometheus client_go为应用程序自定义监控指标

    prometheus client_go为应用程序自定义监控指标

    这篇文章主要为大家介绍了prometheus client_go为应用程序自定义监控指标详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-02-02
  • Go语言实现Sm2加解密的示例代码

    Go语言实现Sm2加解密的示例代码

    本文主要介绍了Go语言实现Sm2加解密的示例代码,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-03-03
  • Go语言映射内部实现及基础功能实战

    Go语言映射内部实现及基础功能实战

    这篇文章主要为大家介绍了Go语言映射的内部实现和基础功能实战,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪<BR>
    2022-03-03
  • 深入浅出go依赖注入工具Wire的使用

    深入浅出go依赖注入工具Wire的使用

    但随着项目规模的增长,组件之间的依赖关系变得复杂,手动管理可能会很繁琐,所以本文将深入探讨一个备受欢迎的 Go 语言依赖注入工具—— Wire,感兴趣的可以了解下
    2023-09-09
  • 详解如何使用Golang实现自定义规则引擎

    详解如何使用Golang实现自定义规则引擎

    规则引擎的功能可以简化为当满足一些条件时触发一些操作,通常使用 DSL 自定义语法来表述,本文给大家介绍了如何使用Golang实现自定义规则引擎,文中有相关的代码示例供大家参考,需要的朋友可以参考下
    2024-05-05
  • Go语言如何轻松编写高效可靠的并发程序

    Go语言如何轻松编写高效可靠的并发程序

    这篇文章主要为大家介绍了Go语言轻松编写高效可靠的并发程序实现示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-05-05
  • 详解minio分布式文件存储

    详解minio分布式文件存储

    MinIO 是一款基于 Go 语言的高性能、可扩展、云原生支持、操作简单、开源的分布式对象存储产品,这篇文章主要介绍了minio分布式文件存储,需要的朋友可以参考下
    2023-10-10

最新评论