技术解析

你还在用单元测试, TDD?这玩意太不靠谱了!
0
2021-06-02 18:32:24
idczone

原文出自 山尽写东西的 cache

互联网言必谈代码质量,单元测试,TDD ( Test Driven Development )很久了,仿佛 TDD 就是灵丹妙药,用上了就是质量高没 bug 的代码,但真的是这样子吗?

单元测试、TDD 的缺陷

问题不对,路线错误,测试越多越没用

TDD 很简单,假如你实现整数除法这功能,那么对除法这函数 divide 人工做一些测试用例:

  • 除 1 divide(12/1) == 1
  • 除 0 divide(12/0) == panic
  • 除某个负数 divide(12/-2) == -6
  • 除某个正数 divide(12/4) == 3

上述例子都通过,那么代码实现就对了。

这当然可以,但人脑就可以穷尽这些特殊例子吗?会有遗漏吗?上面是不是漏了 0/某个数呢?

是的,上面这些就是特例验证的缺陷 ,你无法证明你就是对的。

Property-based Testing

那么,我们可以怎么做呢?

本质验证。

整数除法是不是有一些定律特质吗?比如:

  • 0 / a = 0
  • a / 0 = fatal
  • a / b / c = a / c / b
  • a / b * b = a
  • a / b国外服务器 = 10*a / 10*b

这些就是定律,我们实现的除法在满足这些定律的情况下,就是对的行为。

那么,对 a 与 b 这两个变量,随机生成大量的如一百万个随机数,分别验证上述行为,跑几次没问题后,是不是就证明是对的,且自动化、正确率远远大于 TDD 呢?

没错,这就是Property-based Testing(基于特性测试)

golang 的 PBT test

以我在工作的例子做示范,我们要实现一个函数,reformat IP 地址的,就是所有的 CIDR IP (如: 192.168.1.0/24:7777")或者正常 IP 地址要转换为 IP 地址,去掉没用的/24,得到192.168.1.0:7777, 旧代码是这样子做的:

// 10.233.100.175/26:6379 to 10.233.100.175:6379
func ReformatAddress(addr string) string {
	slashIndex := strings.IndexByte(addr, '/')

	portString := ":6379"
	portIndex := strings.IndexByte(addr, ':')
	if portIndex >= 0 {
		portString = addr[portIndex:]

		if slashIndex == -1 {
			slashIndex = portIndex
		}
		return addr[:slashIndex] + portString //只要 / 前的 IP + 端口
	}else // 没有端口号
		slashIndex = len(addr)
	}

	return addr[:slashIndex] + portString
}

你可以想想这代码有没有问题。

TDD 的单元测试是这么写的:

func TestReformatAddress(t *testing.T) {
    if addr := ReformatAddress("10.233.100.175/1:6379"); addr != "10.233.100.175:6379" { //nolint
        t.Errorf("1: %s", addr)
    }
    if addr := ReformatAddress("10.233.100.175:6379"); addr != "10.233.100.175:6379" { //nolint
        t.Errorf("2: %s", addr)
    }
    if addr := ReformatAddress("10.233.100.175"); addr != "10.233.100.175:6379" { //nolint
        t.Errorf("3: %s", addr)
    }
}

我用 PBT 重写后就是这样子的:

核心就是ReformatAddress(a)后的内容,必须是一个正则表达式上个符合 ip 格式的内容

  • 根据正则表达式生成合理的 CIDR 地址或者 IP 地址
  • 大量生成上述两者,都调用ReformatAddress,然后用正则表达式校验结果
func TestPBTReformatAddress(t *testing.T) {
    const ipv4re = `(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])` +
        `\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])` +
        `\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])` +
        `\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])` +
        `(/[1-3]?[1-9])?` + // \
        `(:^()([1-9]|[1-5]?[0-9]{2,4}|6[1-4][0-9]{3}|65[1-4][0-9]{2}|655[1-2][0-9]|6553[1-5])$)?` // :port range

    const validIP4re = `(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])` +
        `\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])` +
        `\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])` +
        `\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])` +
        `(:([1-9]|[1-5]?[0-9]{2,4}|6[1-4][0-9]{3}|65[1-4][0-9]{2}|655[1-2][0-9]|6553[1-5])$)` // :port range

    rapid.Check(t, func(t *rapid.T) {
        addr := rapid.StringMatching(ipv4re).Draw(t, "addr").(string)
        fmtAddr := ReformatAddress(addr)
        net.ParseIP(strings.Split(fmtAddr, ":")[0])
        var re = regexp.MustCompile(validIP4re)

        fmt.Printf("origin is %s addr, fmtAddr is %s, match is %v\n", addr, fmtAddr, re.MatchString(fmtAddr))
        match := re.MatchString(fmtAddr)
        if !match {
            t.Fatalf("%s is not correct", fmtAddr)
        }
    })

}

结果我真就发现了代码有问题,当时修复的截图:

现在代码是这样子的:

// 10.233.100.175/26:6379 to 10.233.100.175:6379
func ReformatAddress(addr string) string {
	slashIndex := strings.IndexByte(addr, '/')

	portString := ":6379"
	portIndex := strings.IndexByte(addr, ':')
	if portIndex >= 0 {
		portString = addr[portIndex:]

		if slashIndex == -1 {
			slashIndex = portIndex
		}
		return addr[:slashIndex] + portString
	}

	if slashIndex == -1 {
		slashIndex = len(addr)
	}

	return addr[:slashIndex] + portString
}

原因是,不一定所有的入参都一定是对的 CIDR 地址啊,就是不一定 addr 都有/的。

那这时候slashIndex-1就有 bug 了,所以要特殊处理。

PBT test

我工作中还写了很多 PBT test,帮助了好多:

  • 某服务主从切换
    • 多次随机启动停止事件,都要满足有一个主,其他都是它的 slave 的特质
  • 某服务 HA 高可用
    • 多次 n 个服务随机停止启动事件,至少保证有一个在接收处理请求

等等等等。

通过这,我几乎 pbt test 对了,几乎就没问题了,堪称完美。

如果这文章对你有启发,请多多点赞转发,感谢


看来我是降维了..

这个理论上也属于单元测试吧,我只知道.net 的 xunit 测试里面这个叫 Theory

对,其实用在单元测试上跟集成测试上都可以。这个实际上可以叫 generative test

数据地带为您的网站提供全球顶级IDC资源
在线咨询
专属客服