作者 | Jorge Fernández Rodriguez
译者 | 明知山
策划 | 丁晓昀
关键要点
当前的问题
软件工程与其他职业相比具体它的特殊性,我想你会同意这样的说法。技术的变化剧烈而迅速,仅仅是跟上时代发展的步伐就需要耗费大量的脑力。
也许正因为如此,我们会停留在一些公认的常规实践或想法(即使它们会给我们带来麻烦或不适用于某些场景)上。这些实践试图涵盖大多数情况,但实际上又无法涵盖所有情况。不管怎样,这些实践给了我们安慰。我们需要一些不会改变的东西让我们感到安全,让我们的大脑从思考变化的负担中解脱出来,我们进入了自动驾驶模式。
这里的问题在于,我们希望软件开发像装配线一样:一旦装配线建立起来,就永远不再需要去碰它。我们开始变得一成不变。或许这在一段时间内适用于我们的 CI/CD 管道,但遗憾的是,它并不总是适用于我们的代码。
糟糕的是,有时候信息经过多轮的辗转远离了它的本质。到了某个时候,我们甚至把这些实践作为我们自身的一部分,我们捍卫它们,拒绝接受不同的观点。大部分时候,我们只是想融入其中,不想提出新的想法。
在编写代码时,我们需要与之斗争,检验这些实践是否适合当前的场景。假设我们将“最佳实践”看作“最佳通用实践”。
我们以敏捷可能被误解的方式为例——在某些情况下,人们丢掉了敏捷的精髓。
在那篇文章中,我表达了人们经常会忘记敏捷的本质,因为很多时候敏捷的实现关注的是错误的东西。根据定义,敏捷的东西可以很容易改变方向并对变化快速做出响应。
我们尝试用不同性质的实践来实现这种响应性:技术上的,比如 CI/CD(持续集成 / 持续部署),战略上的,比如迭代开发。然而,在处理软件开发的核心部分——编码时,我们经常会忘记了敏捷。想象一下你如何在没有主要食材的情况下准备你最喜欢的餐点。我们在不考虑代码的情况下追求敏捷性跟这个如出一辙。
这很可能会发生,因为改进代码质量看起来很可怕,很复杂,或者很容易掉入兔子洞陷阱。也许只是因为我们不容易看到一些解决方案对我们的可操作性产生的负面影响,把未来的开发变成了一场噩梦:敏捷的反面。我们没有把注意力集中在代码上,而是放在了让我们的流程(如 Scrum 等方法论)变得完美上,但这些流程可能并不太重要,我们还试图在不解决主要本质问题的情况下去解决其他问题。
最后,我建议提升代码对未来开发的影响(以及业务的未来)的可见性。或许 AI 能够帮助我们量化这些东西,它不仅可以告诉我们开发质量,还可以根据我们的潜在选择预测开发速度会有多慢。我认为这样可以帮助公司意识到他们需要在可持续的开发上进行更多的投入。关于何时解决技术债务的讨论将成为历史。
在本文中,我将重点关注一种编码实践,它在敏捷开发中起着至关重要的作用,但很少受到质疑:传统的测试方法。
我还将介绍“变化免疫力”,这是我最近发现的一种策略,它可以帮助我们实现目标和改变一些习惯,不仅是编程习惯,还有生活中的其他习惯。此外,我还将解释我在上面的那篇文章中提到的一个观点:我们希望通过改变一些实践或习惯来实现可操作性,但却在不知不觉中掉入陷阱,因为我们忽视了编码在实现这一目标中所扮演的角色。
测试——是安全网还是束缚衣
我们都希望好的自动化测试会给我们带来巨大的好处:我们可以修改代码并快速验证现有的功能不会出现中断。测试成了我们的安全网。
然而,它们并不是免费的,它们是有成本的,而且会在一段时间后显现出来,我们需要对其做出补偿。测试运行的次数越多,我们从中获得的价值就越大。但如果我们修改了测试,测试的“收益计数器”将重置为零。
除了补偿成本之外,我们还需要考虑另一件事:在创建测试时,我们尽最大努力保证测试是正确的,但我们又不能百分之百确定。如果我们能确定我们所写的代码是正确的,那就不需要测试了,对吧?
测试只是给了我们信心,每次运行测试都会给我们增加一点信心。如果运行 1000 次,我们就获得 1000 点信心。
如果在某个时间点测试中发现了一个 bug,我们就会失去这 1000 点信心。我们认为这是在保护我们,但事实并非如此。
类似的,如果我们对逻辑进行重构,并且测试发生了中断,我们就不能确定是业务逻辑不对还是测试不对。我们丢掉了之前积累的“信心”,需要重新构建一个新的安全网。
这是对未来的投资。人们很少考虑长期投资,但值得庆幸的是,自动化测试的好处已经被广泛接受(尽管我不时看到有人仍然不愿意拥抱自动化测试)。
遗憾的是,其他长期投资,比如通过重构来保持代码的灵活性,并没有被广泛接受。希望本文中提到的这些“完全非创新”的想法能够帮助你消除这个障碍。
每次在添加新功能时,我都会感谢已经编写好的测试,特别是在包含了众多功能的大型服务中。我无法想象手动去验证每一个功能点意味着什么,那可能是个无稽之谈。
然而,如果我们不遵循恰当的策略,它们也会对我们不利。不恰当的测试策略会减慢交付速度,并影响开发者体验。
我们可以问自己下面这些问题。
你所有的单元测试都是单独测试类或方法吗?
如果答案是肯定的,并且你正在大量模拟依赖项,那么你很可能会对一个接一个地模拟依赖项感到厌倦。在某些情况下,你会发现模拟不够真实,并且代码的逻辑实际上并没有按照应有的方式执行。但愿你能在发布之前发现这个问题,并且不需要作太多的修改。
你是否有时候觉得测试限制了你修改代码的方式?
我猜你的答案也是肯定的。有时候,已有的代码不再适合新的功能,或者变得过于复杂。你决定重构它。重构可能需要 10 或 15 分钟,因为这是一个小的修改。然后,许多测试突然间无法编译,或者执行失败。调整和执行测试可能需要大量的时间,甚至比修改代码要长得多。为了 10 到 15 分钟的代码修改,你最终会花上几天的时间来调整测试。为什么会这样?代码的行为并没有发生变化啊!
如果功能没有发生变化,那么理想情况下测试也应该不会发生中断。如果你调整了测试,能再次进入安全网吗?请记住,如果你修改了测试,之前获得的“收益计数器”和“信心计数器”将被重置。
稍后我们将看到,在许多情况下,我们可以避免这种情况和使用 hack 代码。因为 hack 给了我们一种错觉,让我们觉得交付速度变快,至少一开始是这样。但这是一种短期的投资,很快我们就会遭到回击:代码变得僵化,几个月后,我们需要花更多的时间来理解和修改它们。即使是几天后我们也可能不记得代码做了什么。这种情况会变得越来越糟,对于那些不清楚背景的新人来说就更是摸不着头脑了。如果你的代码库中充斥着 hack,你可能不需要一直操心它们,因为它们可能不会存在太久。
你的测试套件中是否包含了大量的集成和端到端测试?
集成测试比单元测试更容易经受住变化,但是它们要慢得多。
如果你写了很多端到端测试,那么可能有过这些痛苦的经历:
如果端到端测试涉及到通过网络连接的许多组件(但请不要通过构建大泥球来避免这种情况),则会更加痛苦。
因此,糟糕的测试策略本身可能会影响你的交付速度。单元测试会“阻止”我们写出更好的代码,你的“敏捷”是否实现正确并不重要。你会感觉被穿上了一件束缚衣(或者不止一件,如果我们偏执地试图通过增加越来越多的测试来达到 200% 的安全)。与此同时,那些能够更快地适应市场变化的竞争对手正在向我们逼近,而束缚衣正把你裹得无法移动,对抵挡那些竞争对手一点用都没有。
所以,我想让你再问自己一些关键的问题:
一个例子
传统的方式
我们来看一个常见的场景的简化版本。一个使用 Spring 实现的后端应用程序,我通常会看到生产类和测试类之间的一对一映射,如图所示:
“包含主要逻辑的类”通常被叫作“服务”。有时候服务是特定于每个领域实体,有时候代表某个更抽象的概念。
有时候,“主要逻辑”也会渗透到框架组件(如控制器或监听器)中。不仅框架和业务逻辑之间的界限变得模糊,区分哪些可以在单元测试或集成测试中测试的界限也变得模糊。因此,有时候在单元测试和集成测试中会看到相似的场景,这是一种浪费。
如果类包含了复杂的功能,那么每个类对应一个单元测试是有意义的。对于复杂的功能来说,识别出错误是很困难的,使用小段代码有助于更快地定位问题,这实际上也是支持使用单元测试的原因之一。请记住,我说的是“复杂的功能”而不是“复杂的逻辑”,因为人们很可能通过复杂的方式实现简单的功能。
问题来了
现在想象一下,由于需求发生了变化,你需要添加一个新特性或修改已有的逻辑。已有的实现不适合新的想法或环境,现在有两种选择:使用 hack 手段或重构。当然,我们总是选择重构:
假设逻辑拆分得很干净(只是将一些方法移到新的类中),旧类保留对新类的引用。那么我们有必要为了保持一对一的映射而对测试类进行拆分吗?如果这样做我们会得到什么好处?当然,我们也可以保持测试类不变(只需要稍做修改)。
对于更复杂的重构,我们可能在 20 分钟内就调整好了逻辑(实现细节)。但正如我们已经提到的,测试不会修改得这么快。
对于测试,我们看到了两点:
我们看到每种测试类型都有一些好处。因此,或许我们可以将集成测试和传统的单元测试的优点结合起来。传统的单元测试对应一个类或一个方法,至少很久以前是这样的,似乎不对类进行单独的测试就是不对的。但也许对“单元”的概念进行一番“重构”会更有意义。
有很长一段时间,我一直在想,为什么我没有看到人们谈论这个问题。后来,我找到了一些讨论这个话题的文章和视频。在我阅读 Vladimir Khorikov 的《单元测试原则、实践和模式》一书时,我对这个问题和其他概念有了更清晰的了解。
如果单元不是对应类,那应该是什么?一个跨了几个方法或类的功能?你可能会说:“等等,那不就是集成测试?”好吧,不完全是。我借用书中的一些定义:
单元测试: 验证一小段代码; 快速完成; 用独立的方式进行; 集成测试至少不满足上述标准中的一个。 一个好的单元测试: 防止回归; 不抗拒重构; 快速提供反馈; 易于维护。
现在,“单元”的概念看起来更加抽象和灵活了。另外,对于集成测试,我们可以考虑很多种测试,比如系统测试、端到端测试等等。
一种更灵活的方式
现在我们进入更大的粒度级别,将初始的逻辑放到一个模块中,并做一些修改:
我们先是将领域逻辑放到一个相对较小的模块中。我们可以把模块想象成微服务中的微服务。那么模块究竟有多小?对此并没有简单的规则可以遵循。请记住,这个示例是经过简化的:可能涉及几个实体,也可能没有实体,并且可能有更多的相关类。
我们需要注意这里的权衡:
优点:
代码更加灵活。频繁重新调整模块内的逻辑不需要修改测试。
有内聚力的小块内容比混乱的大块内容更容易理解。这与微服务对大泥球之间的对比是一样的。
单元测试已经测试了更多东西,因此需要的集成测试更少了。这给了我们:
更快的反馈和更短的构建时间。
单元测试比集成测试更容易调试,也更稳定。
清除了集成测试的障碍。业务逻辑只在单元测试中进行测试。
更少的处理过程,所以更加节省能源。
由于不需要模拟内部类,因此减少了工作量。
我们不需要模拟很多类,而是模拟模块和框架组件,因此在测试时不需要启动应用程序。
缺点:
需要注意的是,对非常复杂的功能进行单独的测试可能是必要的。
我们做的第二件事是将领域与框架组件进行分离。
框架(如 Spring)的功能之一是将应用程序的不同元素粘合在一起。我们所要做的类似于六边形架构,也就是端口和适配器:控制器、监听器、过滤器、DAO 或其他框架构建块是将领域逻辑(应用程序核心)连接到外部世界的端口。理想情况下,这些组件不包含领域知识。例如,控制器、监听器和过滤器只包含对领域逻辑的调用。
优点:
分离领域和框架逻辑的一种扩展做法是将每个功能的领域逻辑放在一起,以实现内聚性。与每个功能相关的所有逻辑都在同一个模块中实现,而不是分散在多个模块中。
我已经经历了一场噩梦,从一个单体代码库中找出所有需要修改的地方。我们密集调试了几个月,而主要的改动在两天内就完成了。
应用程序级别的集成测试
我们只需要对单元测试没有覆盖到的东西进行集成测试,比如其他框架功能:端点配置、序列化、数据和错误的反序列化、数据访问、远程调用、验证。对正向路径进行烟雾测试可能也会很有趣。
端到端测试
最后我想说的是端到端测试。端到端测试的成本比集成测试要高得多,所以要小心进行端到端测试。
对于非关键场景,可以考虑使用契约驱动测试来代替端到端测试。
改变习惯的策略
我记得这种方法在几个案例中为我带来了帮助,其中最重要的一个是我们为投标系统开发算法的案例。
算法的初始实现看起来还不错,但在发布后我们每周都会发现一些棘手的情况。我们单独对它进行修复,很快,算法变得复杂起来,并最终发生了一个事故。
在那之后,我们又检查了一遍,找到了一个不一样的方法。我们最终减少了 75% 的代码,实现也更加简单。
遗憾的是,单元测试单独对这些方法进行了测试,而我们有一堆这样的单元测试。如果我们将逻辑作为一个功能单元进行测试,可以节省 12 天(总共 15 天)的工作量,并避免因再次检查所有测试用例而导致的挫折感。
所以,除了上述所有优点之外,这种测试方法还可以增加开发人员 / 团队的幸福感。其他旨在更快交付的策略需要说服周边的人,这可能非常困难。这一切都掌握在你的手中。
总的来说:
请记住:
我并不是说这种测试方式在任何时候对所有人都有好处,你需要评估这是否对你有益,以及在哪些情况下对你有益。正如之前所说的,它在敏捷环境中特别有用,因为在敏捷环境中会有很多实验性的特性,领域是在迭代中不断被创建和演化的,并且有规律地加入新的特性。
如果我们有意识地采用 TDD(测试驱动开发),也同样可以获得其中的一些好处。问题在于,我们总是倾向于过于关注工具或技术,而不了解其本质,忘记了初心,所以到最后我们一无所获,并最终选择放弃。
变化免疫力
要采用这样的实践,我们需要改变习惯,而要改变习惯非常困难。我们认为我们需要的是意志力,但这远远不够。我想分享我最近发现的一种方法。哈佛大学教育研究生院成员 Lisa Lahey 博士和 Robert Kegan 博士在其合著的《对变化的免疫:如何克服它并释放你自己和组织的潜力》一书中对其进行了描述。
我们常常试图通过意志力来实现改变。他们的理论是,这可能不起作用,因为我们有一种类似于“免疫系统”的东西,它会抗拒变化,破坏我们所有的尝试。因此,要接受改变,我们首先需要发现我们的“免疫系统”,并从根本上解决问题。
这种“免疫系统”是在过去逐步建立起来的。我们总是不断地尝试实现目标,而要实现这些目标,我们需要遵循一系列步骤:
这些概念构成了他们的方法论的基础。我将这些步骤简单地描述为:
如果我们发现这些假设不再有效,就继续研究它们,朝着让我们可以更容易接受变化的方向发展。
在播客“Dare to Lead”中,Lisa Lahey 和 Brené Brown 例举了 Brown 在自己的生活中实践“变化免疫力”的例子。播客分为两个部分(第一部分和第二部分)。我经常会在做其他事情的同时收听播客,但这个博客值得你认真听。
播客以 Brown 提出的一个问题开始:“为什么我们都想要发生转变,但没有人想要做出改变?”她们说,我们倾向于将改变的失败与渴望不足或虚假的意图联系起来。她们提到,意图并不是万能的,因为那些生命处于危险之中、想要活下去的人,有时仍然无法做出改变。
后来,她们提出了一个我们可能很熟悉的例子:Brown 说她希望团队能够更加自律地参加定期会议。
Lisa 用一些问题引导 Brown 填写表格。对于 Brown 来说,这是让她的生活变得更加轻松和取得成功的关键。这些原本是她自己可以改变的事情,但她却无法实现改变。
当 Brown 发现这些没有说出口的承诺、假设和担忧正在破坏她并导致目前的窘况时,她感到很惊讶。她说,她从过去的经历中学到,纪律和创造力是相互排斥的。因此,她认为定期开会会减少她做自己喜欢的(具有创造性的)事情的时间。所以她跳过了那些会议,结果是她开了很多一次性会议。她说,因为她想要表现出自己是一位平易近人的领导者,这帮她实现了很多目标,但现在她花了太多时间在这上面,她意识到定期开会并不会让事情变得更糟,反而可以节省很多时间。
最后,Lisa 建议她想办法接受这个新理念,即纪律和创造力是相容的。她需要建立新的神经通路,并覆盖已经存在多年的旧神经通路。
我认为这个策略可以为我们带来两个方面的好处:这种来自我们“免疫系统”的破坏说明了我们在尝试变得敏捷时做了什么。我相信这可以应用到我们生活的许多方面。
例如,如果我们以切换到新测试策略为例,我们需要找出我们想要通过改变来实现什么目标,以及为什么它比其他事情更重要。对于我目前的情况来说,灵活性是至关重要的。
关于我们可能在做的与目标相悖的事情、我们为什么要做它们以及我们做出的触发这些活动的假设,我们可以认为,如果按照通常的方式去做可能会更容易,因为我们只需要惯性地继续下去。我们也会找到不这么做的理由:“这是我们一直以来的测试方式,我们就是被这么教育出来的。所有人都在这么做,所以它一定是对的。”
因此,为了对抗这种情况,我们需要承认负面影响,重写那些信念,然后找到一种方法来证明这些假设是错误的,最后实现改变。
如果你想了解更多细节,可以查看上面提到的“Dare to Lead”播客(第一部分和第二部分)、他们的网站或著作。
原文链接:
https://www.infoq.com/articles/delivering-fast-testing-strategies/
声明:本文由 InfoQ 翻译,未经许可禁止转载。