bigo-frontend / blog

👨🏻‍💻👩🏻‍💻 bigo前端技术博客
https://juejin.cn/user/4450420286057022/posts
MIT License
129 stars 9 forks source link

【译】测试驱动开发彻底改变了我的生活.md #39

Open orocsy opened 3 years ago

orocsy commented 3 years ago

【译】测试驱动开发彻底改变了我的生活

Banner 现在是早上七点十五,可怜的客服陷入了泥沼中无法自拔。我们上了《早安美国》的头条,我们一直在因为各种bug而不断流失一大群新客户。 现在到了紧急状态。我们要跟时间赛跑,赶在损失更多客户之前发布一个紧急修复!其中一位开发做了修复,他认为这应该可以修复问题。我们在这个修复上线前向公司内部聊天软件里粘贴了staging链接,然后让每个人都去测试。啊!终于!BUG修复了!测试通过!

我们的超级英雄火速执行了部署脚本,然后过了几分钟后,修复上线了。然鹅,我们发现客服的电话数量反而翻倍了!我们的紧急修复造成了其他的bug, 这时开发们开始一顿发git blame,刚才的超级英雄只能回滚了刚才的修复。

为什么要测试驱动?

距离上次处理这种情况已经有好久了。这并不是因为开发人员不会犯错了,而是因为几年来我代领的或者工作过的每个团队都会遵循TDD的开发规范。Bug 还是会有,但即使我们面对软件改动升级维护的负担在指数增长的情况下,那种能阻碍上线的 bug 基本上已经降为 0。 每当有人问我为什么要如此费力的去搞TDD, 我就会被刚刚那个小故事(以及更多类似的事故)所警示。我开始使用TDD的最主要的一个原因是为了更高的测试覆盖率,这可以减少生产中 40% 到 80% 的bug。这是我最喜欢看到的TDD带来的好处。 TDD 根除了改变的恐惧

在我的项目中,我们的自动测试套件以及功能测试几乎在每一天都能预防灾难性的改变发生。例如说:我从上周开始在跟进10个自动化测试库的升级,这要是换做之前我会非常犹豫要不要合并发布更新,因为我会担心万一它不好用了怎么办? 所有这些升级都是自动整合的, 而且它们都已经上线了。我都没有亲自去看这些升级的改变是什么,但我并不担心这些升级会有问题,说这个例子的时候我都不用去深挖(译者注:就是对他们这个测试特别有信心以至于在举例子的时候随口就能说出来一个)。 我迅速打开 Github,查看了下最近的合并,它们就已经在那里了。这些曾经都需要人为干预(或者更糟的情况是都没人仔细去看)现在都是自动完成了。你当然可以继续在没有好的测试覆盖的情况下认为干预,但我可不推荐你这么做。

什么是测试驱动?

TDD是 Test Driven Development 的简写。流程很简单:红色(失败),绿色(通过),重构 What is TDD

在你写功能代码之前,先写一些代码能验证这个实现到底是不是可行。观察到测试失败之后再去进行下一步(这是我们判断一个测试是否符合预期结果的一步——测试我们的测试代码是正确的)。 写功能代码然后跑通测试用例 如果有问题就重构。而且你现在重构的话会更有信心因为你有个测试告诉你改变是否成功。

测试开发如何节省你的开发时间

表面上看,写这么多测试是很多额外的工作,写这么多额外代码会花更多时间。开始,这确实是这样,因为我也在纠结如何才能写出可测试的代码,而且也在纠结如何给已经存在的代码加测试。

TDD其实有一个学习曲线,当你在攀爬这个曲线的上坡时,它会增加 15%-35% 的时间。但是在大约两年左右,神奇的事情发生了: 我现在在写单元测试的情况下比我之前不写的时候开发还要快。

几年前我在做一个视频剪辑的UI功能。想法就是你可以设置视频的起始点和终点,然后当用户分享视频链接的时候,它会将剪辑过的视频片段分享出去而不是整个视频。

然而做完后发现并不好使。播放器进度总是马上变到剪辑的结束点然后如此循环下去,而且我找不到原因。 我总是在想是不是事件没有正确绑定。我的代码长这样:

video.addEventListener('timeupdate', () => {
    if (video.currentTime >= clip.stopTime) {
        video.pause();
    }
});

修改,编译,重启,点击,等待,重复如此。。。 每次我改一点都要大约等个一分钟去测试,我还尝试了很多荒唐的修改(每种尝试都大概有2-3次尝试)。

我在怀疑的东西是:我难道拼错了 timeupdate ?我是不是把API调错了?这个 video.pause() 真的有用吗?每次修改我都去加一个 console.log(),回到浏览器,刷新,点击视频剪辑结束前的某个时间段,并且耐心的等待它播放到最后。在 if 里面打 log 并没有执行到。好的,这是一个线索。从文档里面 复制粘贴“timeupdate”这个单词确保它是正确的。刷新,点击,还是不行!

最后,我将一个 console.log() 放到 if 语句外面。 啊不,这样做根本没用,我想到。毕竟那个 if 语句的逻辑太简单了,我不可能这块也出错。然后 log 打出来了,我撒了咖啡,惊讶到想骂人。什么?!

==Debug的墨菲定律:== 你认为错误绝对不可能发生的地方是你绝对不会去测试的地方,然后这个地方通常就是你在搞到头破血流以后发现出错的地方。而且你唯一去改变这里的原因是你改变了所有的地方都不好使然后才重新注意到这个地方的。

然后我打了个断点来一探究竟。我看到 clip.stopTime.undefined 这样的值????我重新看回我的代码。当用户点击选择终止时间的时候,它只是放置了一个小的停止光标而已,但是从来没有设置 clip.stopTime。我顿时觉得“天啊,我自己就是天底下最大的白痴以后任何人都绝对绝对不能让我有生之年再去接近一台电脑”。

多年以后我还是记得当时的那种感觉。你知道我在说什么,我们都曾经经历过,我们都是反面典型。 Different types of programmer

如果换成是现在的我写当时的那段UI代码,我会这么写:

describe('clipReducer/setClipStopTime', async assert => {
  const stopTime = 5;
  const clipState = {
    startTime: 2,
    stopTime: Infinity
  };
  assert({
    given: 'clip stop time',
    should: 'set clip stop time in state',
    actual: clipReducer(clipState, setClipStopTime(stopTime)),
    expected: { ...clipState, stopTime }
  });
});

确实,表面上,这看起来比clip.stopTie = video.currentTime 要多很多代码。但是这就是目的。这段代码其实传递出一种规范。文档,和这段证明它好使的代码写在一起。 而且正因为它的存在,如果我改变了终止时间光标在 UI 中的定位的方式,我就不会担心我是否会在我改代码的时候出 bug 。 注意:你想像这样写单元测试吗?看一下这篇文章 “Rethinking Unit Test Assertions”。

==所以说重点是不写这段代码需要多少时间,而是出错后需要多长时间去 debug 。如果这段代码不好使了, 这个测试将会给我一个非常好的测试报告。== 我会马上知道问题不是出在了 event handler。我会马上知道问题是出在 setClipStopTime() 或者 出现在实现状态改变的 clipReducer() 里面。我就会知道应该去做什么,预期什么结果,和实际什么结果——而且更重要的是——我的同事也会马上知道错在哪里,她/他也许半年后要在我的代码基础上加新需求。

在每个项目中我首先做的众多事情之一就是设置好观察脚本,它会在我每次做改变的时候自动跑单元测试。我经常用两个显示器肩并肩的写代码并且将 console 打开并跑着我的观察脚本在,在其中一个显示器上专门显示脚本实时运行结果,在另外一台显示器显示我正在写的代码。当我修改代码3秒钟之后我就会知道正确与否。

对我来说,TDD 的安全可靠性真的是太高太高了。它同时有着非常稳定,快速,实时的反馈。在我代码写对时我会得到很好的正反馈,在我出错的时候能马上得到一份 bug 报告。

TDD 教会了我如何写出更好的代码

我在这里要承认一些尴尬的事情: 其实我在学会 TDD 之前真的不清楚如何构建 app 。我觉得我当时是不符合我当时那份工作的要求的,但是当我在面试了数以百计的开发人员后我可以很自信的告诉你:很多开发者都是这种状态。TDD 几乎教会了我一切我知道的关于高效解耦和软件组件构建(模块化,函数,对象,UI 组件等等)

原因是单元测试会强迫你分开去测试每个组件,以及如何跟 I/O 分开测试。给出一些输入,测试单元应该产生期待结果。 如果没有,那么测试失败。如果符合预期,那么通过。重点是整个应用的每个模块都应该是这样。如果你在测试状态逻辑, 你不应该把他们输出到屏幕或者存入数据库。如果你在测试 UI 渲染,你应该可以不需要在浏览器中加载页面就能测试它当然不需要网络访问。

在众多事情中, ==TDD 教会了我一件很重要的事情就是要保持 UI 组件越小越好。 把业务逻辑和副作用从 UI 中分开==。实际点来说,如果你在用基于组件的框架像 React 或者 Angular , 创建和分离显示组件和容器组件就会是很好的模式。对于显示组件来说,传入同样一些参数总应该渲染出同样的状态。简单的单元测试过这些组件后就可以确保不会出问题了,任何条件渲染也都不会出问题了(例如, 一个组件在列表为空时不应该显示,而应该显示一个添加到列表的组件)。

我早已在学习 TDD 之前知道 Separation of Concerns 关注点分离,但我并不知道如何去做。 单元测试还教会了我如何使用 mocks 去测试,然后它教会了我 mocking 是对代码缺陷的提升,这让我醍醐灌顶一般并且完全改变了我接触软件构建的想法。

所有的软件开发都是构建:把大问题分成好多小的易于解决的模块块去搭建 然后将小的解决方案逐步构建起来去形成一个大的应用。为了单元测试而去做 mock 代表着你的原子性单元并不真正具有原子性。当学会将 mock 移除后且不降低测试覆盖率以后,我掌握了如何解耦的秘密。

这些使我成为了一个更好的开发,并且教会了我如何写出更简单且易于维护和拓展的代码, 既具有复杂性又可以扩展到例如云架构的分布式系统中去。

TDD 是如何节约团队时间的

我之前提到过 测试优先会提高测试覆盖率。原因是我们必须先写测试代码,才能确保后续的实施代码是符合我们预期的。==首先,写测试。然后,看着它跑失败。最后,开始写实现代码。失败,通过,重构,重复如此。==

这个过程编织了一个安全网,它可以让很少的 bug 漏出网外。并且这个安全网对整个团队都有一个魔法般的影响,那就是它消除了团队对 merge 按钮的恐惧。

这个让人信服的测试覆盖率给了团队很大的信心去每一次都小心翼翼的对待代码合并,并且会变相鼓励开发人员去经常做出好的代码提交。

移除对改变的恐惧就像给机器上机油。如果你不做,那么机器就会摩擦下去最后会停下来直到你重新清洁它然后重启它才能接着运作。

没有了恐惧,开发会变得十分顺畅。团队成员也会更积极的提交代码,发版也会更多更迅速。你的 CI/CD 会跑你的测试——直到测试失败了才会停。然后它会报警,指出哪里出错。这会让你的团队与众不同。

想学习更多关于 TDD 的知识吗?

TDD 日是一个全天的线上会议。EricElliottJS.com会员可以直接观看回放。你将会学到:

注册开始学习

Eric Elliott 是 "Composing Software""Programming JavaScript Applications" 的作者 。 也是 EricElliottJS.com 和 DevAnywhere.io的共同创立者,他会教授开发人员必备的软件开发技巧。

他参与构建并且给crypto项目开发人员提供技术咨询,并且他对 Adobe 系统和 Zumba Fitness,The Wall Street Journal, ESPN, BBC, 以及一些顶级明星 包括 Usher ,Frank Ocean, Metallica等使用的录制软件做出了不少的贡献。

他喜欢与他心目中的女神一起远程工作的模式。