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

1f5af105acfe0369487a7e64abab059e.jpeg

- 忍受简单的能力 -

知乎大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, post_func)
  ...
  post_func(post_data)
  ...

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

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

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

def generate_arrangement(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


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


def onduty(names):
  register(generate_arrangement(names))

于是我们可以非常直观地给「generate_arrangement」写单测。

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

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

def generate_arrangement(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后的代码没有问题 ——

93164ac6e6681a28af1b732eab662ea6.jpeg

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


- 结语 -

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

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

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

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

一口一口地吃饭太慢了。恨不得一口吃下一百口,谁叫锅里还有那么多?

所以重要的事情才要说三遍。可是上一段让你看了三遍的话是什么,你还记得吗?

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

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

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

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

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

c76263f976dd8c07fa6a612b10a03f78.jpeg

字节的同学就请不要点↑了,竞业在身不能挖人d42770de9f930b17790ce17149cef2bc.png


e8959fb3dadb7bbd9735d2f79dcd3411.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值