Skip to content
Scroll to top↑

工作经验谈

测试与TDD

是否要写单元测试

如果没经历过代码未充分测试上线之后提心吊胆的日子,和一个小BUG浪费半天生命去寻找的感觉的话是很难意识到其必要性的。养成写单元测试的习惯至少有如下好处:

  1. 变相减少了我们花费在调试上的时间。因为比起写一大段代码然后找半天的问题点,将代码划分成小的部分并确保每个部分的行为都符合预期,再来排查集成导致的问题要快得多;

  2. 形成良好的项目资产,便于重构。测试案例也是一种项目文档,优秀的测试案例可以让新入门的开发者快速熟悉某个模块的行为和功能,在重构时也给我们打上了一针“强心剂”,减缓“Shit Mountain”的形成;

  3. 促使我们养成良好的编程习惯。为了减轻单个测试案例的负担也为了思路的连贯性,我们会逐渐习惯于将代码模块化、组件化,养成写小的、纯的、易于测试的函数的意识,做到“低耦合、高内聚”。多说无益,用代码表达这个思想:

    • Before:
    js
    function doSth() {
      // hundreds of code that doA() 
      // hundreds of code that doB()
      // ...
    }
    
    test("test doSth", () => {
      // 因为doSth的繁重,为了做判定覆盖条件覆盖可能使测试案例也变得臃肿、难以维护
    })
    • After:
    js
    function doSth() {
      doA();
      doB();
      // ...
    }
    
    function doA() {} // 析取出来的功能可以复用
    function doB() {}
    
    test("test doA", () => {});
    test("test doB", () => {});
    test("test doSth", () => {
      // 在doA()和doB()都得到充分测试的情况下,对doSth()的测试要轻松很多
    });

存在的限制:

  1. 工期限制,很多时候不是不想写测试案例,而是没时间去写。这个属于上层建筑的问题,没什么好说的。作为一线开发者管好自己适当提高效率就是,勤写测试案例是对自己负责,也是基本的职业素养;
  2. 容易形成无效案例,例如前端的表示层,业务需求迭代频繁,如果案例是针对DOM结构的,往往会频繁的修改,造成资源浪费。我的建议是尽可能针对业务状态进行测试,视图层保持轻量,业务逻辑用各种状态管理库进行隔离。

TDD?

我对“测试驱动开发”的意见是不必苛求,还是那句话,每个团队每个项目的情况都不一样,也不是每个人都支持这个理念。与其爆发不必要的争论甚至最后沦为形式主义,不如从TDD的思想中得到启发,用测试案例或者伪码用例指导设计、捋清思路,对于经常感觉大脑一团糨糊的我来说屡试不爽。

防御性编程

对防御性编程的思想我并不感冒,因为觉得它比较依赖开发者的经验和自觉,而开发者的经验和自觉就是项目中最不可靠的东西,要不然也不会有eslintclang-formatstylua这些东西的出现了。如果不承认这一点,我推荐去看《空中浩劫》全集,这个纪录片给我留下深刻印象的就是关于灾难发生的“瑞士奶酪模型”,灾难从来不是突然袭来的,它一直就在我们身边,伺机待发,在某一刻因为种种小的疏忽和意外得以穿过层层防护造成损失。站在前端工程的角度,我们能做的就是不断吸取教训,用统一的代码分析工具、提交规范、审核标准等增加更多的防护层,尽可能减少事故发生的概率,“防患于未然”。

设计模式与编程范式的应用

各种设计原则、设计模式,面向对象函数式面向逻辑命令式各种范式,我个人的经验依然是不必强求,在实践中总结经验就好。这其实来自于一段反思,刚毕业参加工作的时候,我自认为对各种设计模式“门清儿”,代码量也远超很多同学,然而一到项目里写出的东西依旧是“惨不忍睹”,自己都没眼看。后来反思的时候就发现,我们在拿到需求的时候其实有一种冲动,用过程化的代码去尽快完成它,因此不想写设计文档不想写测试案例,只想赶工期,而且常人的思维是非常线性的,很容易写出大段大段的“死逻辑”。要克服这一点只能通过大量的训练和总结积累,比如有过一段重构“Shit Mountain”的痛苦经历,浪费大把时间做了很多无效工作,从而意识到设计的缺陷之处并在以后的类似场景加以改进。

后来的某个阶段,我又有走向另一个极端的趋势,即总是想要设计出一劳永逸的框架代码,瞻前顾后犹豫不决,最后甚至都不好落笔了。其实仔细想一想软件项目是不断迭代进步的,不存在一劳永逸的设计,跟随需求不断迭代优化必要的时候推倒重来才是常态。因此初始设计要做到什么程度,最后是一个非常个性化的问题,还是要具体情况具体分析。

软件依赖管理

做前端工作,没有遇到过几个npm依赖导致的问题都不好意思说自己是搞前端的。我现在还有点印象的有两次,一次发生在自己头上,是pdf.js的某个Patch版本更新出现疑似破坏性变更。当时是项目首次引入pdf.js,流水线上也未推广npm ci的使用,本地因为安装的时间早有缓存是旧版本,测试环境流水线上实时安装的是新版本,使得一个功能一直测试不通过。最重要的是那时候我非常菜,没有意识到问题出在项目依赖上,更没想到清npm缓存,花费了很多时间排查却没有结果,最后还是组件团队的老师出手,大晚上的义务帮我们找出了问题,非常感动。另一次事故发生在隔壁React Native团队,具体内情不清楚,听说是合并冲突导致某依赖组件的版本存在问题,测试环境缺乏特定数据没测出来,线上业务一配置就崩了,直接导致App首页白屏超过将近两小时,可以说是非常严重的线上事故了。

我们的前端团队一开始规模不大,类似问题经常出现之后也总结了一些应对方法:

  1. 组织进行Semver规范和npm ci的技术培训,尤其要求各个业务团队的前端负责人(在我们这通常也是负责组内代码合并的同学)要搞懂这些知识。线上流水线统一使用npm ci,一线开发者如果涉及项目直接依赖变更,需通知前端负责人,前端负责人不能确定必要性的话继续上报到骨干团队,在代码审核时也要额外关注。这样的缺点是降低了开发效率产生更多的沟通,不过我觉得为了线上安全性这个代价还是必要的,事实上很多依赖都收拢到组件团队做进一步封装了,业务开发团队要变更依赖的情况非常少;
  2. 组件、脚手架团队还需要进行dependenciesdevDependenciespeerDependencies等知识的培训,对于很多常用的三方依赖例如lodashday.jsswiper.js等等等等,通常会召集各组前端负责人一起开会商讨一个大版本,以内部文档形式开放,大家都尽可能用这个版本,一者方便管理二来避免App缓存的冗余。为了防止遗漏,还会利用代码托管网站的接口周期性扫描各项目的package.json进行依赖统计和分析。原则上不允许业务团队私自引入三方依赖,存在新依赖或新版本的需要都走这个流程,繁琐但有用。对于自研组件,通常在“需求淡季”统一安排升级,避免要维护的版本太多造成混乱和负担。

如何进行代码审核

“有幸”以提审人和审核方两种身份参与过一线业务开发的代码审核,加上我自己也有点代码洁癖,喜欢一遍遍地回看自己的代码不断修改,因此对代码审核这个问题还是有些思考的。为了节省审核双方的时间,我的经验也可以说是来自于现实中的工程,飞机的机组在起飞前有一个长长的Checklist清单对飞机的状态进行检查,我们也沿用这个思想,整理一套代码审核的Checklist,以内部文档的形式开放给各团队,业务开发者在提交合并请求前根据这个列表先自审一遍,审核人员审核时也按照这个列表进行审核,只有业务需求相关的话题才需要共同讨论,整理出的经验或者清单的问题定期更新。

这里的注意点是Checklist的每一项不能含糊,必须是有明确指示的操作。例如不能写“代码风格良好”这种抽象话,而要写“代码符合自研eslint规则集,使用eslint --fix-dry-run不会有警告和出错”等。常见的检查项集中在代码规范、提交规范、依赖规范等方面,资源允许的话还是尽可能把这些规范实现成软件分析工具,并集成到流水线上,毕竟人力有穷尽。

遗憾的是审核的大头其实还是软件分析手段覆盖不到的地方,尤其是业务需求的具体实现,有很多难点:一线开发人员能力不齐(我见过的例子,给函数设计了6个参数,其他参数不用就传null,几个同质需求复制粘贴了N份,代码看得我脑溢血,关键你说人家错也没错,需求也完成了,给人家解释一些编程理念人家未必理你,还觉得你吹毛求疵……)、审核人员对业务团队的情况或者相关业务概念不熟悉(有时连待审核需求的说明书都看不懂,全是专业性比较高的业务名词,非相关团队真不了解)、工期紧迫审出了不合理之处也难以跟进修改最后不了了之……这些顽疾说实话我没找到太好的解决方式,目前我们的审核模式具体业务方面依然非常依赖开发者团队的自审,前端骨干的审查主要集中在项目共性问题和生产事故多发问题。

组件团队的工作模式

工作生涯的早期我是一线业务开发,那时觉得组件团队颐指气使很厉害,而且不用天天和业务及测试老师拉扯,后来卷到了组件团队里,然后就发现他喵的上当了。一线开发被业务老师乱捶,我们还要被一线开发乱捶,弟中弟了属于是。从前文的描述中你或许看出我们的组织模式对一线业务开发的要求是比较低的,因此很多工作都落在了“上游”组件团队上面。我总结了组件开发者面临的三个主要问题:

  1. 容易被甩锅。这是最讨厌的问题,一部分不负责任的业务开发者在遇到问题时只要用了某某组件,就把问题甩到组件开发者那里,结果最后组件开发者花费大量时间和精力去排查却发现和他们根本没有关系。特别是,往往这些业务开发提出的是X/Y问题,而组件开发者又缺乏特定业务场景调试排查的环境准备,造成双方时间的浪费,也影响了各自工作的进度;
  2. KPI/OKR不好表述不被认可。一线业务开发有一个好处是他们通常是需求驱动的,因此工作量工时都很明确,统计起来也方便。而组件团队在规模较小缺乏经验的时候,往往会“虚度光阴”,例如花费大把时间排查了一个和自己无关的问题,自己的需求延期了,过段时间写周报还说不清楚这几天到底做了些啥。另一方面组件团队作为骨干成员也经常会负责内部生产力工具、软件分析工具的开发,其缺点一是不直接由业务驱动不好得到领导的认可写进长期工作目标里,另者是经常因为重复造轮子被同质工具取代(亲眼见证过开发运维工具的某团队成果被另一个分中心的同质化工作淘汰,原团队直接解散);
  3. 某些技术路线比较狭隘容易限制住自己。一种普遍的认知是前端业务开发容易成为“大头兵”、“切图仔”,上游的组件开发好像高大上一点儿。其实真不是,组件开发多了也会模式固化,形成路线依赖,尤其是因为组件处在上游,标准和规范会更加严格,有时并没有太多发挥的空间,这也是为什么我润去做前端脚手架了,工程化各方面都能涉及,知识面丰富多彩,个人进步非常快。而业务开发也并没有想象中那么无趣,在我们的前端团队规模大起来之后,逐渐出现了几个优秀的业务开发团队,通过对手头需求的分析总结提出了很多新东西,搞业务优化、项目重构不亦乐乎,真正做到了技术驱动业务。某些比较大的业务需求,作为牵头人员也可以充分发挥一下自己的创造力,能把业务需求做好真心不容易。

吐嘈归吐嘈,问题终究是要解决的。我回忆了一下以前在组件团队的工作经历,有如下总结:

  1. 组件团队的地位和社区开源项目其实差不多,因此可以仿照开源项目的工作模式,通过Issue对外沟通。开发者提出Issue交由对应团队解决,杜绝一对一沟通。这种模式存在一些问题,在开源社区均不能避免:

    1. 多数开发者并不会善用搜索功能,经常提出重复问题。这种情况只能由组件团队定期整理高频问题,形成Q&A文档。对于文档已解决的重复Issue直接关闭,某些时候甚至可以找业务团队负责人反向投诉,只是这样也会降低大家提问题的意愿,最终除了问题得不到解决外没啥好处;
    2. 为了工作量统计也为了分析高频问题,经常要对Issue贴标签,然而贴标签又是个需要人们自觉的行为,有时问题排查之前谁也说不准该贴什么标签。我们的解决方案是,不同的团队设立专门的项目和专门的值班人员负责分派Issue,解决后再整理Issue的标签、日期等信息。虽然没有从根本上解决问题,但已经大大提高了效率。就是对分派Issue和贴标签的同学要求比较高,还容易得罪人。
  2. 组件团队的版本发布严格控制,发布日志公开透明。组件因为牵涉面广,发布太频繁不仅给业务团队造成混乱,组件团队自身要维护多个版本也很痛苦,因此要严格控制版本迭代,定期废弃过旧的版本。大的、破坏性的更新要提前通知业务团队做好准备,设立“封版期”。同时多组件团队采用Monorepo的模式管理,避免相互依赖造成开发调试的混乱。统一发布,将发布信息合并在一起交付给各业务团队前端负责人,再由前端负责人转发到一线开发那里。发布日志要体现在邮件和文档中;

  3. 重视文档。在我心目中文档才是组件团队的唯一对外窗口,理想情况下,组件的所有信息都能够在文档上找到,组件使用者也不需要问这问那。遗憾的是现实中组件文档要么没写要么没写好,组件使用方又没有读文档的好习惯,甚至有时要靠“口口相传”传递信息,浪费大量人力物力。这种情况下只能通过各种硬性规定、内部培训和问责机制,迫使大家养成ATFG、RTFM的习惯,在人员变动频繁的时期尤其如此。

  4. 关于工作成果的申报,这个只能说是对症下药各显神通了,锻炼的是表达能力。一者是尽量往业务方向上靠,和业务开发团队搞好关系,发挥“人脉”,在新轮子试点的时候也方便;另一点是尽量用具体数据支撑,例如你说你优化了前端项目开发调试体验没有内容显得很虚,但如果你说通过对脚手架工具的升级换代,从Webpack4迁移到Vite,使得项目冷启动时间从40s提高到5s,再给个图表,那就很不一样了。我在做前端性能优化的时候有类似经历,当时牵头的老师很有经验,没有立刻着手具体的优化,而是联系各方先是搭建起了一套完整的采集——上报——分析——反馈体系,再之后优化的成果就是可视的了。