知乎大 V 李松蔚讲了个和女儿互动的故事,很有意思:
我关上灯,对女儿说:「闭上眼睛,别乱动了。」
女儿立刻大声抗议:「可是我睡不着!」
我只好又强调了一遍:「我只是请你闭上眼睛,别乱动。」
-- 李松蔚 《忍受简单的能力》
他并没有要求女儿「尽快睡着」,而是做了个更简单的要求;但是聪明的女儿立刻联想到了「即使闭上眼睛现在也睡不着」并做出抗议。
在这篇文章里,他说:「对于聪明人来说,最难以忍受的情况不是一件事有多难,而是纯粹的简单」,「没有难度挑战的任务,会让他们感到无所着力」,「重复的练习是他们的死穴」。
单元测试似乎就是一种「简单而重复」的过程,不论是看起来还是写起来,都是由一大堆 GIVEN - WHEN - THEN 组成。
但是这「简单」的表象之下,隐藏着两个「简单」却很重要的问题:
按照套路,接下来应该先说「为什么要写单测」,但是太套路就有点无聊,所以咱们先聊聊「如何写好单测」。
所谓面条式代码( 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 (例如大小月、闰年等)做校验。
小结一下:
当然,以上只是一个简单的例子,并不是完整的单测方法论。实践中还有很多其他环节需要考虑:
感兴趣的同学可以参考腾讯技术工程的《聊聊单元测试那些事儿》。
通过上面的一番骚操作,我们已经看到了单测的好处:
而比重构现有代码更重要的是:
当然,刚开始实践单测的同学可能会感受到,这降低了编码的速度;
但是经过一段时间的重复练习,这种思维会被内化,自然地就能写出高质量的代码。
在实践中,单测实际上也大幅提高了测试的效率。
构造一个完整的测试往往是很耗时的,编译 1 分钟、启动 1 分钟,发个测试请求 1 秒钟,「性价比」很低(这可能是很多同学不喜欢测试的原因)。
而单测只需要编译运行少部分代码,因此可以快速验证代码逻辑。
由于大量代码 bug 在单测时就已经被发现并修复了,可以大幅减少后续 “修改 - 编译 - 启动 - 测试” 环节的数量,这也极大提高了整体的测试效率。
在《聊聊单元测试那些事儿》里还有一份微软的数据:
不同测试阶段发现 BUG 的平均耗时:
- 单元测试阶段,平均耗时 3.25 小时
- 集成测试阶段,平均耗时 6.25 小时 (+92%)
- 系统测试阶段,平均耗时 11.5 小时 (+254%)
最近遇到的一个 case 也是很好的例子:手头项目多版本并行,我在 A 版本开发的功能,需要 merge 到 B 版本,merge 以后,跑了一轮 test case,就可以比较放心地说,merge 后的代码没有问题 ——
同样地,当我们需要给一段代码添加新功能时,如果有存量的 unit test,我就可以比较放心地去修改它了。
在《忍受简单的能力》里,李松蔚说:
所以我认识的学生里面,除了少部分天赋异禀的奇才之外,真正最影响一个人的成就的因素,可能不是智商,也不是努力,而在于他有多「踏实」。
写高质量的代码,从踏实地写单测开始。
btw,李松蔚这篇文章实在太经典,我忍不住要再引用一段:
一口一口地吃饭太慢了。恨不得一口吃下一百口,谁叫锅里还有那么多? 所以重要的事情才要说三遍。可是上一段让你看了三遍的话是什么,你还记得吗?
如果不记得的话,可以试试下面这句:
加入神策数据,帮助客户实现数据驱动。
加入神策数据,帮助客户实现数据驱动。
加入神策数据,帮助客户实现数据驱动。
神策数据是一家致力于“帮助三千万企业重构数据根基,实现数字化经营”的大数据公司。公司正在飞速发展,在北京、上海、武汉、成都、西安、合肥等地都有研发中心,后端、前端、客户端、QA 等岗位均虚位以待,对大数据感兴趣的同学千万不要错过 ——
▄▄▄▄▄▄▄ ▄ ▄▄▄▄ ▄▄▄▄▄▄▄
█ ▄▄▄ █ ▄▀ ▄ ▀██▄ ▀█▄ █ ▄▄▄ █
█ ███ █ █ █ █▀▀▀█▀ █ ███ █
█▄▄▄▄▄█ ▄ █▀█ █▀█ ▄▀█ █▄▄▄▄▄█
▄▄▄ ▄▄▄▄█ ▀▄█▀▀▀█ ▄█▄▄ ▄
▄█▄▄▄▄▄▀▄▀▄██ ▀ ▄ █▀▄▄▀▄▄█
█ █▀▄▀▄▄▀▀█▄▀█▄▀█████▀█▀▀█ █▄
▀▀ █▄██▄█▀ █ ▀█▀ ▀█▀ ▄▀▀▄█
█▀ ▀ ▄▄▄▄▄▄▀▄██ █ ▄████▀▀ █▄
▄▀▄▄▄ ▄ ▀▀▄████▀█▀ ▀ █▄▄▄▀▄█
▄▀▀██▄▄ █▀▄▀█▀▀ █▀ ▄▄▄██▀ ▀
▄▄▄▄▄▄▄ █ █▀ ▀▀ ▄██ ▄ █▄▀██
█ ▄▄▄ █ █▄ ▀▄▀ ▀██ █▄▄▄█▄ ▀
█ ███ █ ▄ ███▀▀▀█▄ █▀▄ ██▄ ▀█
█▄▄▄▄▄█ ██ ▄█▀█ █ ▀██▄▄▄ █▄
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.