4

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

 3 years ago
source link: https://www.v2ex.com/t/800759
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

忍受简单的能力

知乎大 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 等岗位均虚位以待,对大数据感兴趣的同学千万不要错过 ——

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


欢迎关注我的公众号

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK