V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
ChristopherWu
V2EX  ›  程序员

你还在用单元测试, TDD?这玩意太不靠谱了!

  •  1
     
  •   ChristopherWu · 2021-05-11 16:30:54 +08:00 · 1895 次点击
    这是一个创建于 1300 天前的主题,其中的信息可能已经有所发展或是发生改变。

    原文出自 山尽写东西的 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 对了,几乎就没问题了,堪称完美。

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

    4 条回复    2024-05-08 15:12:36 +08:00
    ChristopherWu
        1
    ChristopherWu  
    OP
       2021-05-11 16:50:50 +08:00
    看来我是降维了..
    he1a2s0
        2
    he1a2s0  
       2021-05-17 14:46:08 +08:00
    这个理论上也属于单元测试吧,我只知道.net 的 xunit 测试里面这个叫 Theory
    ChristopherWu
        3
    ChristopherWu  
    OP
       2021-05-17 18:54:33 +08:00
    @he1a2s0 对,其实用在单元测试上跟集成测试上都可以。这个实际上可以叫 generative test
    Zzhiter
        4
    Zzhiter  
       207 天前
    老哥牛啊,最近我也在看这个,感觉可以抽象出来一些基本操作的对应的可以验证的属性。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2599 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 18ms · UTC 10:32 · PVG 18:32 · LAX 02:32 · JFK 05:32
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.