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

单元测试 ——「简单」的乐趣

  •  
  •   felix021 ·
    felix021 · 46 天前 · 2622 次点击
    这是一个创建于 46 天前的主题,其中的信息可能已经有所发展或是发生改变。

    忍受简单的能力

    知乎大 V 李松蔚讲了个和女儿互动的故事,很有意思:

    我关上灯,对女儿说:「闭上眼睛,别乱动了。」

    女儿立刻大声抗议:「可是我睡不着!」

    我只好又强调了一遍:「我只是请你闭上眼睛,别乱动。」

    -- 李松蔚 《忍受简单的能力

    他并没有要求女儿「尽快睡着」,而是做了个更简单的要求;但是聪明的女儿立刻联想到了「即使闭上眼睛现在也睡不着」并做出抗议。

    在这篇文章里,他说:「对于聪明人来说,最难以忍受的情况不是一件事有多难,而是纯粹的简单」,「没有难度挑战的任务,会让他们感到无所着力」,「重复的练习是他们的死穴」。


    单元测试

    单元测试似乎就是一种「简单而重复」的过程,不论是看起来还是写起来,都是由一大堆 GIVEN - WHEN - THEN 组成。

    但是这「简单」的表象之下,隐藏着两个「简单」却很重要的问题:

    1. 为什么要写单测?
    2. 如何写好单测?

    按照套路,接下来应该先说「为什么要写单测」,但是太套路就有点无聊,所以咱们先聊聊「如何写好单测」。

    面条式代码

    所谓面条式代码( spaghetti code ),是说某段代码和意大利面(不是通心粉)一样。

    反正不是什么好话。

    最近看到这么一段代码,功能是创建某个月的值班记录:

    def onduty(names):
      date = datetime.strptime("2021-07-01", "%Y-%m-%d")
      idx = 0
      while date < datetime.strptime("2021-07-31", "%Y-%m-%d"):
        post_data = {
          "date": date.strftime("%Y-%m-%d"),
          "name": names[idx],
          "backup": names[(idx+1)%len(names)],
        }
        requests.post(API_URL, json=post_data)
        idx = (idx + 1) % len(names)
        date += timedelta(days=1)
    

    注:原代码有 60 行,这里略作简化。

    这是一段典型的「逻辑很齐全,但是 un 单测 able 」的代码:

    • 需要请求外部系统(核心原因)
    • 硬编码了时间段(次要问题)

    那么应该如何为它写单测呢?


    重构

    如果一段代码不好写单测,说明它的代码结构有问题。

    -- 鲁迅《我没说过这句话》

    对于结构有问题的代码,首先要做的显然是重构。

    我们首先关注这段代码的主要问题:调用「 requests.post 」请求了外部系统,这导致它和外部系统耦合在一起。

    一个很容易想到的思路是,通过依赖注入的方式来解耦:

    def onduty(names, saver)
      ...
      saver(post_data)
      ...
    

    这样简单的改造以后,它就变成了一段「单测 able 」的代码了:通过 mock 一个 saver,我们可以采集并校验它的输出,例如

    class Saver(object):
      def __init__(self):
        self.output = []
      def mocker(self):
        def f(post_data):
          self.output.append(post_data)
        return f
        
    def test():
      f = Saver()
      onduty(['a', 'b', 'c'], f.mocker())
      check(f.output)
    

    但是这样写出来的代码非常晦涩。更合理的方法是,将这段逻辑拆分成「生成值班列表」和「上报到值班系统」:

    def onduty(names):
      arrangement = arrange(names)
      register(arrangement)
    

    然后我们就可以将「生成值班列表」实现成一个纯函数:

    def arrange(names):
      arrangement = []
      date = datetime.strptime("2021-07-01", "%Y-%m-%d")
      idx = 0
      while date < datetime.strptime("2021-07-31", "%Y-%m-%d"):
        arrangement.append({
          "date": date.strftime("%Y-%m-%d"),
          "name": names[idx],
          "backup": names[(idx+1)%len(names)],
        })
        idx = (idx + 1) % len(names)
        date += timedelta(days=1)
      return arrangement
    

    就像数学课上的 y = f(x),不产生任何副作用,于是我们可以非常容易地给 arrange 方法写单测:

    def TestArrage():
        // Given
        names = ['a', 'b', 'c']
        // When
        arrangement = arrange(names)
        // Then
        check(arrangment)
    

    「上报到值班系统」的实现就像这样:

    def register(arrangement):
      for item in arrangement:
        requests.post(API_URL, item)
    

    因为涉及到外部系统,确实不太适合写单测,更适合用功能测试来保障其正确性。

    另外,因为在 arrange 里硬编码了两个日期,单测的校验逻辑会非常繁琐,我们可以再对其进行重构,把日期作为参数输入:

    def arrange(names, from_date, to_date):
      ...
    

    这样使得代码的职责更明确,不但可以提高这段代码的复用性,还可以对更特别的 case (例如大小月、闰年等)做校验。

    小结一下:

    • 通过重构来提高代码的「单测 ability 」
    • 通过依赖注入来解决对外部的依赖 ——「面向接口编程」
    • 通过拆分不同环节的业务逻辑,进一步提高代码的内聚性
    • 通过将硬编码的值参数化,提高代码的可复用性

    当然,以上只是一个简单的例子,并不是完整的单测方法论。实践中还有很多其他环节需要考虑:

    • 选择合适的单测框架(例如 JUnit )
    • 如何使用 mock 工具 /库来提高覆盖率
    • 如何在语句覆盖、分支覆盖、条件覆盖之间做权衡
    • 如何结合 CI 工具、使用单测覆盖率来评估代码质量
    • ……

    感兴趣的同学可以参考腾讯技术工程的《聊聊单元测试那些事儿》。


    单测的好处

    通过上面的一番骚操作,我们已经看到了单测的好处:

    • 为了写单测,结构不好的代码必须被重构,从而提高了代码的质量

    而比重构现有代码更重要的是:

    • 为了写单测,新增的代码也必须保证合理的结构,从而提高了思维的质量

    当然,刚开始实践单测的同学可能会感受到,这降低了编码的速度;

    但是经过一段时间的重复练习,这种思维会被内化,自然地就能写出高质量的代码。

    在实践中,单测实际上也大幅提高了测试的效率

    构造一个完整的测试往往是很耗时的,编译 1 分钟、启动 1 分钟,发个测试请求 1 秒钟,「性价比」很低(这可能是很多同学不喜欢测试的原因)。

    而单测只需要编译运行少部分代码,因此可以快速验证代码逻辑。

    由于大量代码 bug 在单测时就已经被发现并修复了,可以大幅减少后续 “修改 - 编译 - 启动 - 测试” 环节的数量,这也极大提高了整体的测试效率。

    在《聊聊单元测试那些事儿》里还有一份微软的数据:

    不同测试阶段发现 BUG 的平均耗时:

    • 单元测试阶段,平均耗时 3.25 小时
    • 集成测试阶段,平均耗时 6.25 小时 (+92%)
    • 系统测试阶段,平均耗时 11.5 小时 (+254%)

    最近遇到的一个 case 也是很好的例子:手头项目多版本并行,我在 A 版本开发的功能,需要 merge 到 B 版本,merge 以后,跑了一轮 test case,就可以比较放心地说,merge 后的代码没有问题 ——

    unit-test

    同样地,当我们需要给一段代码添加新功能时,如果有存量的 unit test,我就可以比较放心地去修改它了。


    结语

    在《忍受简单的能力》里,李松蔚说:

    所以我认识的学生里面,除了少部分天赋异禀的奇才之外,真正最影响一个人的成就的因素,可能不是智商,也不是努力,而在于他有多「踏实」。

    写高质量的代码,从踏实地写单测开始。

    btw,李松蔚这篇文章实在太经典,我忍不住要再引用一段:

    一口一口地吃饭太慢了。恨不得一口吃下一百口,谁叫锅里还有那么多? 所以重要的事情才要说三遍。可是上一段让你看了三遍的话是什么,你还记得吗?

    如果不记得的话,可以试试下面这句:

    加入神策数据,帮助客户实现数据驱动。

    加入神策数据,帮助客户实现数据驱动。

    加入神策数据,帮助客户实现数据驱动。

    神策数据是一家致力于“帮助三千万企业重构数据根基,实现数字化经营”的大数据公司。公司正在飞速发展,在北京、上海、武汉、成都、西安、合肥等地都有研发中心,后端、前端、客户端、QA 等岗位均虚位以待,对大数据感兴趣的同学千万不要错过 ——

    点此查看神策数据的所有职位


    欢迎关注我的公众号

       ▄▄▄▄▄▄▄   ▄      ▄▄▄▄ ▄▄▄▄▄▄▄  
       █ ▄▄▄ █ ▄▀ ▄ ▀██▄ ▀█▄ █ ▄▄▄ █  
       █ ███ █  █  █  █▀▀▀█▀ █ ███ █  
       █▄▄▄▄▄█ ▄ █▀█ █▀█ ▄▀█ █▄▄▄▄▄█  
       ▄▄▄ ▄▄▄▄█  ▀▄█▀▀▀█ ▄█▄▄   ▄    
       ▄█▄▄▄▄▄▀▄▀▄██   ▀ ▄  █▀▄▄▀▄▄█  
       █ █▀▄▀▄▄▀▀█▄▀█▄▀█████▀█▀▀█ █▄  
        ▀▀  █▄██▄█▀  █ ▀█▀ ▀█▀ ▄▀▀▄█  
       █▀ ▀ ▄▄▄▄▄▄▀▄██  █ ▄████▀▀ █▄  
       ▄▀▄▄▄ ▄ ▀▀▄████▀█▀  ▀ █▄▄▄▀▄█  
       ▄▀▀██▄▄  █▀▄▀█▀▀ █▀ ▄▄▄██▀ ▀   
       ▄▄▄▄▄▄▄ █ █▀ ▀▀   ▄██ ▄ █▄▀██  
       █ ▄▄▄ █ █▄ ▀▄▀ ▀██  █▄▄▄█▄  ▀  
       █ ███ █ ▄ ███▀▀▀█▄ █▀▄ ██▄ ▀█  
       █▄▄▄▄▄█ ██ ▄█▀█  █ ▀██▄▄▄  █▄  
    

    参考链接

    1. 忍受简单的能力
    2. 聊聊单元测试那些事儿
    26 条回复    2021-09-23 20:46:58 +08:00
    peacelove
        1
    peacelove   46 天前 via iPhone
    这么长的文章,我一般先拉到最后…
    yolee599
        2
    yolee599   46 天前
    直接拉到最后,果然是一个二维码
    cloudfox
        3
    cloudfox   46 天前
    字符二维码有点骚,不过扫不出来
    nockyQ
        4
    nockyQ   46 天前   ❤️ 1
    深以为然。足够多的单元测试不仅能对项目的 QC 做出贡献,还能像说明文档一样帮助其他成员理解具体的代码功能。是非常有意义的工作。
    felix021
        5
    felix021   46 天前
    @cloudfox 诶?我昨天试了几次都可以。。失策失策
    Wincer
        6
    Wincer   46 天前
    楼主之前发的帖子内推都是头条,这次跳槽了?
    nickr
        7
    nickr   46 天前
    ascii 二维码很酷. 可以扫出来.
    可能和字体有关. mac+safari 上没问题.
    2435043xia
        8
    2435043xia   46 天前
    有一说一,虽然后面有广告,但文章的内容还是很不错的
    via
        9
    via   46 天前 via iPhone
    我用 py,理所当然所有人都应当用 py 。
    jie170601
        10
    jie170601   45 天前 via Android
    感谢,看完有收获的,关于单测,最纠结的还是带 IO 的代码要不要测,似乎还没个定论
    kidlj
        11
    kidlj   45 天前   ❤️ 2
    @jie170601 带 IO 的属于 e2e 测试,相辅相成。
    bytesfold
        12
    bytesfold   45 天前
    带 IO 的怎么测试,例如串口
    shyrock
        13
    shyrock   45 天前
    知道单测理论并认同单测有价值的人占程序员总数 85%;真正在工作中执行单测的人占比有多少?
    rioshikelong121
        14
    rioshikelong121   45 天前
    看时间给的够不够了
    42is42is42
        15
    42is42is42   45 天前
    Unit testing won’t help you write good code.
    zzlit
        16
    zzlit   45 天前
    正好借楼问一句 mocha 不借助 karma 如何才能有浏览器里面 window 的这个参数?就像这个[问答]( https://stackoverflow.com/questions/41194264/mocha-react-navigator-is-not-defined)我加了 jsdom 也还是不行
    felix021
        17
    felix021   45 天前
    @Wincer 是的,现在神策数据,欢迎勾搭~
    felix021
        18
    felix021   45 天前
    @bytesfold 参考 11L,文中也说了( register 方法),副作用本身没法用单测来做,单测能做的事情是,在把副作用剥离出来(依赖注入、mock )的前提下,验证其他逻辑的正确性。
    otakustay
        19
    otakustay   45 天前
    @bytesfold mock 之,直接和 IO 完全耦合的部分放弃掉单测,走 E2E 测试
    otakustay
        20
    otakustay   45 天前   ❤️ 3
    @42is42is42 但是 UT 在把 ugly code 往 good code 重构的过程中作用非常非常的大,只要有对 good code 的追求,UT 就是最好的武器
    bytesfold
        21
    bytesfold   45 天前
    @otakustay 谢谢大佬指导。待我细细品味。。
    qwe520liao
        22
    qwe520liao   44 天前   ❤️ 2
    对我而言,单元测试提高了团队成员的自信,放下了悬在心中的那块石头,不用担心每一次修改是否会破坏某些东西而诚惶诚恐。
    lei2j
        23
    lei2j   44 天前
    尼玛,拉出来一个二维码
    chaleaoch
        24
    chaleaoch   34 天前
    谢谢, 学到很多.
    chaleaoch
        25
    chaleaoch   34 天前
    另外, 那个依赖注入的方式真的好吗?
    看起来确实影响了原业务的编写. 为了单元测试这个目的, 整个项目会增加很多的回调啊.

    但是在"聊聊单元测试那些事儿" 这篇文章中, 却看到 这种方式实际上还有一个概念叫"打桩". 那么一定有很多项目是通过这种方式实现的单元测试了?

    大佬能够针对这一点进行一些指点.
    谢谢.
    felix021
        26
    felix021   31 天前
    @chaleaoch 依赖注入是比较常见的解耦方式,也就是我们常说的「面向接口编程」,在 Java 生态里常见的做法是通过 Bean 的属性注入,写起来会容易一点。
    关于   ·   帮助文档   ·   FAQ   ·   API   ·   我们的愿景   ·   广告投放   ·   感谢   ·   实用小工具   ·   2982 人在线   最高记录 5497   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 35ms · UTC 04:48 · PVG 12:48 · LAX 21:48 · JFK 00:48
    ♥ Do have faith in what you're doing.