V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
• 请不要在回答技术问题时复制粘贴 AI 生成的内容
felix021
V2EX  ›  程序员

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

  •  
  •   felix021 ·
    felix021 · 2021-09-09 10:17:47 +08:00 · 3796 次点击
    这是一个创建于 1206 天前的主题,其中的信息可能已经有所发展或是发生改变。

    忍受简单的能力

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

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

    大佬能够针对这一点进行一些指点.
    谢谢.
    felix021
        26
    felix021  
    OP
       2021-09-23 20:46:58 +08:00
    @chaleaoch 依赖注入是比较常见的解耦方式,也就是我们常说的「面向接口编程」,在 Java 生态里常见的做法是通过 Bean 的属性注入,写起来会容易一点。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2718 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 29ms · UTC 12:40 · PVG 20:40 · LAX 04:40 · JFK 07:40
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.