在此期间,Airflow坏掉了,所以无法等到发布Flask-OAuthlib。作为一个临时的修复方案,Airflow复制了Flask-OAuthlib的依赖声明。这一变化伴随着这样的注释:“ 一旦包括这些变更内容的Flask-OAuthlib新版本发布,我们就可以取消这些。”但18个月后,那些复制的依赖声明仍然没有被恢复。这就是人们在修复依赖相关的问题时不得不采用的“诡计”。
5.3.4 依赖范围最小化
前面讨论过的依赖范围,定义了在构建生命周期中何时使用某个依赖。依赖范围有一个层次:编译时的依赖项在运行时使用,但运行时的依赖项不会用于编译代码,只会用于运行代码。测试依赖项只会在测试执行时被拉取,对于正常使用的已发布的代码来说是没有必要的。
对每个依赖项使用尽可能精确的依赖范围。用编译时的依赖范围来声明所有的依赖项也可以,但这不是个好习惯。精确的依赖范围将有助于避免冲突并减小运行时的二进制包或者文件的大小。
5.3.5 保护自己免受循环依赖的影响
不要引入循环依赖。循环依赖会导致构建系统的奇怪行为和部署顺序问题。构建系统会出现构建先正常进行,然后突然失败,应用程序会出现难以捉摸的零星bug。
使用构建工具保护自己。许多构建系统都有内置的循环依赖检测的特性,当检测到循环依赖时就会提醒你。如果你的构建系统不能防止循环依赖,通常有插件可以提供帮助。
5.4 行为准则
5.5 升级加油站
依赖相互冲突和不兼容的变化无处不在,一般的说法是相依性地狱(许多生态系统都有自己的叫法,比如DLL地狱、JAR地狱、“任何时候我都要用一下pip”)。尽管依赖管理很复杂,但关于这个问题的图书并不多。针对技术生态系统的讨论和解释在网上非常多见。从了解其历史的角度讲,你可以自行搜索一些关于相依性地狱的文章和参考资料看看。
关于语义化版本管理的紧凑而可读的规范,可以参见SemVer的官方主页。Python中也有一个类似的方案,可以参见Python官方主页上的说明。这两种版本管理方案都在被广泛地使用着,所以值得学习。还有很多其他的方案,在同一个项目中遇到使用不同版本规范的构件也很常见。遵循帕累托法则,我们不建议你在刚开始的时候就对语义化版本进行过深的研究,除非这是你工作范畴中明确的一环,或者你需要更多的信息来解决某个具体的问题。本章的内容对大多数日常活动来说应该是足够的。
本章中的许多版本控制的概念都适用于类库和服务端API。我们会在第11章中更多地讨论API的版本问题。
第6章 测试
编写、运行和修复测试用例会让人感觉很忙碌。但事实上,测试本身才更容易成为繁忙的工作。糟糕的测试会增加开发人员的开销而不提供价值,并且还会增加测试套件的不稳定性。本章将教你如何有效地进行测试。我们将讨论测试目的、不同的测试类型、不同的测试工具、如何进行负责任的测试,以及如何处理测试中的不确定性。
6.1 测试的多种用途
大多数开发者都知道测试的基本用途:测试可以检查代码是否正常工作。但测试也有其他作用,比如保护代码不会被将来那些无意中的修改所影响、鼓励清爽的代码、强迫开发者试用他们自己的API、记录组件之间如何交互,以及将其作为一个实验的“游乐场”。
最重要的是,测试本身就可以验证软件的行为是否符合预期。预料之外的软件行为会给用户、开发人员和运维人员带来很多困扰。最初,测试这道工序可以证明代码已经按规定生效了;紧接着,测试可以保护现有的行为不受新变化的影响。当某项旧的测试失败时,必须做出判断:开发人员是打算改变软件的现有行为,还是他们引入了一个bug?
编写测试也迫使开发人员思考他们程序的接口和实现过程。开发人员通常首先会在测试代码中与他们的业务代码联动。新的代码会有粗糙的边缘,测试可以尽早地暴露出笨拙的接口设计,以便于它们被纠正。测试也可以暴露出混乱的实现过程,“意面式”的代码,或有太多依赖项的代码,都很难进行测试。编写测试也可以迫使开发人员分别通过改进关注点分离和降低紧耦合的方式来确保他们的代码拥有良好的构造。
配套资源验证码:220304
测试中的代码整洁程度的副作用是如此强烈,以至于测试驱动的开发(test driven development,TDD)变得很普遍。TDD是指在写 代码之前先编写测试的实践,如果测试写好之后运行测试失败了,那么就去编写代码使其通过。TDD迫使开发人员在写出一堆代码之前思考软件的行为、接口设计和集成。
测试其实是另一种形式的文档,它说明了代码是如何被交互的。它是一名有经验的程序员开始阅读并了解一个新的代码库的首选入口,测试套件是一个伟大的游乐场。开发人员通过调试器来运行测试,并进行逐行调试。当发现bug或出现关于软件行为的问题时,可以通过添加新的测试来了解它们。
6.2 测试类型
测试领域一共有几十种不同的测试类型和测试方法。我们的目标并不是涵盖这个主题的全部内容,而是去讨论比较常见的几种类型——单元测试、集成测试、系统测试、性能测试和验收测试,这样做可以给你奠定一个坚实的基础。
单元测试是验证代码的“单元”,这通常指某个单一的方法或行为。单元测试应该快速、简短且集中。运行速度很重要,因为这些测试经常运行,通常是在开发人员的笔记本计算机上。专注于单个代码单元的小型测试,在测试失败时更容易理解是什么地方出了问题。
集成测试验证多个组件集成在一起之后是否还能正常工作。如果你发现自己在测试中实例化了多个相互作用的对象,那么你正在写的可能就是集成测试。集成测试通常执行得比较慢,需要比单元测试更复杂的设置。开发人员运行集成测试的频率较低,所以反馈的周期更长。这些测试可以找出那些通过各自独立的单元测试难以发现的问题。
回头看才知道
几年前,德米特里在选购新的洗碗机。他看了网上的评论,然后去了一家商店,详尽地比对了所有的规格,仔细权衡了优缺点,最后确定了他最喜欢的型号。引领着德米特里跑前跑后的销售人员检查了库存,准备下订单,就在他的手在Enter键上徘徊时,他停住了。“这台洗碗机是要放在你厨房的一个角落里吗,有这种可能吗?”“为什么这么问,是的,有这种可能。”“是否有一个抽屉从橱柜里伸出来,与这台洗碗机要放的地方呈90度角,这样它就可以滑到洗碗机门前的地方?”“为什么这么问呢,是的,是有这样一个抽屉。”
“啊,”销售人员说着,把他的手从键盘上移开了,“你可能会想要一个不同的洗碗机。”德米特里选择的型号的洗碗机有一个在门上突出的把手,这将完全阻挡抽屉的抽出路线。
功能完美的洗碗机和功能完美的橱柜完全不兼容。很明显,销售人员以前就见过这种特殊的整合方案会失败!(解决办法是购买一个带有内嵌式门把手的类似的洗碗机。)
系统测试是验证整个系统的整体运行情况。端到端(end-to-end,e2e)的工作流程是为了模拟在预生产环境中系统与真实用户的互动。系统测试自动化的方法各不相同。一些组织要求软件在发布前通过系统测试,这意味着所有的组件都应被测试并同步发布。有些组织提供的系统过于庞大,以至于同步发布是不现实的。这些组织通常会进行广泛的集成测试,并以连续的合成监控进行生产环境测试作为补充。合成监控脚本在生产环境中运行,可以模拟用户注册、浏览和购买商品等。合成监控可进行计费、财会以及其他系统行为的一整套动作来区分这些生产环境测试和真实的活动。
性能测试(如负载测试和压力测试)监控不同配置下的系统性能。负载测试可以监控不同负载水平下的性能:例如,系统的性能在10个、100个或1000个用户同时访问时究竟如何。压力测试将系统负载推高到崩溃的程度。压力测试可暴露系统的负载能力究竟有多大,以及在过度负载下会发生什么状况。这些测试对于容量规划和服务等级目标定义非常有用。
验收测试是指由客户或其代理人进行的,以验证交付的软件是否符合验收标准的测试。这些测试在企业软件中相当普遍,正式的验收测试及验收标准是作为昂贵的合同中的一部分来规定的。国际标准化组织(International Standards Organization,ISO)要求验收测试可以验证明确的业务需求,作为其安全标准的一部分;ISO认证审核委员会要求提供需求和相应的测试文件证据。在监管较少的组织中发现的不太正式的验收测试,是下面主题的一个变体:“我刚刚改变了一件事,你能让我知道一切是否看起来还不错吗?”
在现实世界中测试
在编写本章时,我们研究了许多成功的开源项目的测试设置。许多项目缺少某些类型的测试,而有些项目在架构分离上不一致——将“单元测试”和“集成测试”混为一谈。了解这些分类的含义和它们 之间的权衡很重要。不过,不要执意于把它做得完美无缺。成功的项目会在现实世界中做出务实的测试决定,你也应该如此。如果你有机会去改进测试和测试套件,请务必去做。不要拘泥于测试代码中的命名和分类,如果不是设置完全不正确的话,就不要妄下结论。软件的熵是一种强大的力量(参见第3章)。
6.3 测试工具
测试工具分为几类:测试用例的编写工具、测试框架,以及代码质量工具。测试用例的编写工具,如模拟库,可以帮助你编写干净和高效的测试。测试框架通过模拟测试的生命周期,从setup 到teardown,帮助测试的运行;测试框架还可以保存测试结果,与构建系统集成,并提供其他的辅助功能。代码质量工具被用来分析代码覆盖率和代码复杂性,通过静态分析来寻找bug,并检查代码风格错误;代码质量工具通常被设置为构建或编译步骤的一部分来运行。
每一个添加到你的环境中的工具都有各自的成本。每个人都必须理解相应工具,以及它所有的特异性。该工具可能依赖于许多其他类库,这将进一步增加系统的复杂性。有些工具会减慢测试速度。因此,在你能证明引入的复杂性带来的利弊之前,请避免使用外部工具,即使新引入的工具利大于弊,也要确保你的团队可以接受它。
6.3.1 模拟库
模拟库通常用于单元测试,特别是在面向对象的代码中。代码经常依赖于外部系统、类库或对象。模拟库用模仿真实系统提供的接口来代替外部依赖性。模拟库通过对输入的响应来实现测试所需的特性,这些响应一般都是带有硬编码的响应。
消除外部依赖性可以使单元测试快速而集中。模拟远程系统允许测试绕过网络调用,可简化设置,并且避免缓慢的运行过程。模拟方法和对象允许开发人员编写集中的单元测试,这些单元测试可以只完成一个特定的行为动作。
模拟库还可以防止应用程序的代码中充斥着测试专用的方法、参数或变量。只针对测试的变更难以维护,也使代码难以阅读,并导致 混乱的bug(不要在你的方法中添加布尔型的参数isTest!)。模拟库可以帮助开发人员访问受保护的方法和变量,而无须修改常规代码。
虽然模拟库很实用,但也不要过度地使用它。具有复杂的内部逻辑的模拟方法会使你的测试变得脆弱和难以理解。从单元测试中的基本内联模拟开始,一直到你开始在测试用例之间重复地使用模拟逻辑之前,并不需要写一个共享的模拟类。
对模拟库的过度依赖是一种代码异味,它表明代码紧紧地耦合在了一起。无论何时,只要在使用模拟库,就要考虑是否可以重构代码以消除对模拟系统的依赖。将计算和数据转换逻辑从I/O代码中分离出来,这有助于简化测试,使程序不那么脆弱。
6.3.2 测试框架
测试框架可以帮助你编写和执行测试。你会发现一些框架可以帮助你统筹执行单元测试、集成测试、性能测试,甚至是UI测试。下面是框架的作用。
$\bullet$ 管理测试的setup和teardown。
$\bullet$ 管理测试执行和编排。
● 生成测试结果报告。
● 提供工具,如扩展的断言方法。
$\bullet$ 与代码覆盖率工具集成。
setup和teardown方法允许开发人员指定测试步骤,如数据结构的构建或文件清理,需要在单个测试或一组测试之前或之后执行。许多测试框架为setup和teardown的执行提供了多种选择,比如在每项测试之前、在某个文件中的所有测试之前,或在一个构建任务中的所有测试之前。在使用setup和teardown方法之前,请阅读文档,以确保你可以正确地使用它们。不要期望teardown方法在所有情况下都能运行。例如,如果某项测试灾难性地失败了,这会导致整个测试过程强制退出,teardown方法就不会被执行了。
测试框架可以通过编排测试流程来帮助控制测试的速度和隔离度。测试可以串行或并行地执行。串行测试是一个接一个地执行,一次执行一个测试会更安全,因为测试之间相互影响的机会比较少;并行执行更快捷,但由于共享的状态、资源或其他污染,因而更容易出错。
测试框架可被配置于为每项测试启动一个新的进程。这进一步提高了测试的隔离度,因为每项测试都会重新开始。请注意,为每项测试启动新的进程是一种开销极高的操作。参见6.5节以了解更多关于测试隔离的信息。
测试报告帮助开发人员调试那些失败的构建任务。报告提供了一个详细的数值,说明哪些测试通过了,哪些测试失败了,或者被跳过了。当一项测试失败时,报告会显示究竟哪个断言失败了。报告还会把每项测试的日志和堆栈信息组织起来,以方便开发人员快速地调试失败的用例。注意:测试结果的存储位置并不总是那么明显,比如报告的摘要被输出到控制台,而完整的报告则被写入磁盘。如果你在查找报告时遇到困难,请在测试和构建任务的目录中翻翻看。
6.3.3 代码质量工具
利用工具可以帮助你写出高质量代码。强制执行代码质量规则的工具被称为linter,linter可以运行静态分析并执行代码风格检查。代码质量检测工具会报告复杂度和测试覆盖率等指标。
静态代码分析工具可以寻找常见的错误,比如残留的文件句柄或使用未赋值的变量。静态代码分析工具对于像Python和JavaScript这样的动态语言特别重要,因为这些语言没有编译器来捕捉语法错误。分析工具可以寻找已知的代码异味,并高亮出有问题的代码,但不能避免误报,所以你应该认真思考静态代码分析工具报告出来的问题,并用代码上的注解覆盖误报,告诉分析工具可以忽略特定的违规行为。
代码风格检查工具可以确保所有源代码的格式相同:每行最大字符数、驼峰命名法与蛇形命名法、适当的缩进,诸如此类。一致的风 格有助于多名程序员在一个共享代码库上进行协作。我们强烈建议你设置你的IDE,以便可以自动应用所有的风格规则。
代码复杂度工具可以通过计算圈复杂度——换句话说,大致上通过代码的路径数量来防范过于复杂的逻辑。你的代码复杂度越高,就越难测试,也越有可能包含更多的bug。圈复杂度通常随着代码库的大小而增加,所以总体得分高并不一定是坏事。然而,复杂度的突然跃升可能会引起担忧,高复杂度的个别方法也是如此。
代码覆盖率工具衡量的是有多少行代码被测试套件执行过。如果你修改的代码降低了代码覆盖率,你应该编写更多的测试用例。确保 测试用例可以对你所做的任何新的修改进行验证,以合理的覆盖率为目标(经验值是65%到 $85%$ )。请记住,仅仅依靠覆盖率并不能够衡量测试质量的优劣:它可能会有很大的误导性,无论是在高覆盖率还是低覆盖率的时候。检查自动生成的代码,如脚手架或序列化类,它们会创建具有误导性的低覆盖率。相反,为了达到 $100%$ 的覆盖率而痴迷于创建单元测试并不能保证你的代码能够安全地集成。
工程师倾向于对代码质量指标进行严格的检查。仅仅因为某个工具发现了一个质量问题,并不意味着它确实是一个问题,也不意味着它值得立即被修复。对那些未能通过质量检查的代码库要有务实精神。
不要让代码变得更糟,但也要避免以破坏性地停止一切的方式来清理工程。可以以3.2节作为指导来确定何时修复代码质量问题。
6.4 自己动手编写测试
你有责任确保你的团队的代码按预期运行。编写你自己的测试,不要指望别人为你清理战场。许多公司都有正式的质量保证(QA)团队,尽管其职责各不相同,但都包括以下内容。
$\bullet$ 编写黑盒或白盒测试。
$\bullet$ 编写性能测试。
$\bullet$ 进行集成测试、用户验收测试或系统测试。
● 提供和维护测试工具。
$\bullet$ 维护测试环境和基础设施。
$\bullet$ 定义正式的测试认证和发布流程。
QA团队可以帮助你验证你的代码是否稳定,但千万不要把代码直接丢给他们,然后让他们做所有的测试。QA团队早就不写单元测试了,那些日子早就过去了。如果你所在的公司有正式的QA团队,请了解他们负责什么,以及如何与他们接触。如果他们被嵌入你的团队中,他们很可能会参加Scrum和冲刺计划会议(关于敏捷开发的更多信息,请参见第12章)。如果他们是一个集中的组织,想要得到他们的帮助可能需要填写任务票或提交一些正式的请求。
6.4.1 编写干净的测试
编写测试代码的时候要像编写其他代码一样谨慎。测试代码会引入新的依赖关系,这需要维护,并需要随着时间的推移而进行重构。笨拙的测试有很高的维护成本,这将减缓未来的发展。笨拙的测试也不太稳定,不太可能提供可靠的结果。
在测试中要采用良好的编程实践。记录测试如何生效,如何运行,以及为什么写这些测试。避免硬编码的值,不要重复代码。使用设计的最佳实践来保持关注点的分离,并保持测试的内聚性和解耦性。
专注于测试基本功能而不是实现细节,这有助于代码库的重构,因为测试代码在重构后仍然可以运行。如果你的测试代码与实现的细节结合得太紧密,对代码主体的改变就会破坏测试代码。这些破坏并 不意味着有什么东西坏掉了,而只是表示代码改变了。这并不提供价值。
将测试的依赖项与常规代码的依赖项分开。如果一项测试需要某个类库来运行,不要强迫整个代码库都依赖这个类库。大多数的构建和打包系统将允许你专门为测试定义依赖关系,可以善加利用这一特点。
6.4.2 避免过度测试
不要淹没在编写测试的这项工作中,这样很容易跟丢那些值得投入精力去编写测试的地方。要编写那些在测试失败的时候有意义的测试,不要为了提高代码覆盖率而去提高代码覆盖率。测试数据库包装器、第三方类库或基本的变量赋值,即使它们能提高覆盖率指标,也是毫无价值的。要专注于那些对代码风险有最大影响的测试。
某项测试的失败结果应该提示开发人员,程序的行为发生了一些重要的变化。当代码发生了琐碎的变化,或者当一个有效的实现方法被另一个取代时,测试就会失败,这就造成了繁忙的工作并使程序员变得见怪不怪。当代码没有被破坏时,我们应该不需要去修复测试。
将代码覆盖率作为一个指南,而不是硬性的规则。高的代码覆盖率并不能保证正确性。在测试中执行过的代码的比率可以算作覆盖率,但这并不意味着它被有效地测试了。在测试覆盖率为 $100%$ 的代码库中,完全有可能存在严重的bug。追求特定的代码覆盖率是一种短视行为。
不要为自动生成的代码手动编写测试,如Web框架、脚手架或OpenAPI客户端。如果你的覆盖率工具没有被设置为忽略自动生成的代码,工具会将这些代码报告为未测试。在这种情况下,请修复覆盖工具的配置。代码生成器是经过彻底测试的,所以测试自动生成的代码是在浪费时间(除非你手动引入对生成文件的修改,在这种情况下,你应该测试它们)。如果由于某种原因,你发现确实需要测试生成的代码,那就想办法在生成器中加入测试。
把精力集中在最高价值的测试上。测试需要时间来编写和维护。专注于高价值的测试,可以产生最大的收益。可以使用风险矩阵来寻找需要关注的领域,风险矩阵将风险定义为失败的可能性和影响。
图6-1所示的是一个风险矩阵的样例。纵向为失败的可能性,横向为失败的影响,事件失败的可能性与影响的交叉点定义了它的风险。
图6-1 风险矩阵
测试可以将代码风险向左下方转移,因为测试越多,失败发生的可能性就越低。首先应该关注代码中的高风险的区域;而那些低风险或被废弃的代码,诚如其概念所言,并不值得测试。
6.5 测试中的确定性
确定性的代码对于相同的输入总是给予相同的输出。相比之下,非确定性的代码对于相同的输入可以返回不同的结果。一个需要在网络套接字上调用远程网络服务的单元测试具有不确定性,如果网络出现问题,那么测试也会失败。非确定性测试是一个困扰许多项目的问题。重要的是需要了解为什么非确定性测试是糟糕的、如何去修复它们,以及如何避免编写它们。
测试中的不确定性降低了测试的价值。间歇性的测试失败(被称为拍打测试)是很难重现和调试的,因为它们不会在每次运行时都复现,甚至每10次运行都不容易复现。你不知道问题是出在测试本身还是你的代码上,因为拍打测试的结果不能提供有意义的信息,开发人员可能会忽略它们,并因此提交错误的代码。
间歇性失败的测试应该被禁用或立即修复。可以在一个循环中反复运行某项失败的测试来进行再现。IDE有重复运行测试的特性,但在shell的循环中也可以。有时这些非确定性是由测试之间的相互作用或特定的计算机配置造成的,面对这些情况你必须去做一些实验,一旦你重现了失败的测试,你就可以通过消除不确定性或修复bug来解决它。
非确定性通常是由对休眠、超时和生成随机数的不恰当处理引入的,测试中遗留下来的副作用或与远程系统交互也会导致非确定性。通过下面几种手段可以避免出现非确定性,比如使用具有确定性的时间类型和随机数并在测试后进行清理,以及避免网络调用。
6.5.1 种子随机数生成器
随机数生成器(random number generators,RNG)必须使用一个值作为种子,这个值决定了你从它那里获取的随机数。在默认情况下,随机数生成器将使用系统时钟作为种子。但是系统时钟会随时间而变化,所以用随机数生成器进行两次测试时就会产生不同的结果,即非确定性。
可用一个常数作为随机数生成器的种子,迫使它每次运行时都能确定地生成相同的序列。使用常数种子的随机数生成器的测试将总是通过或总是失败。
6.5.2 不要在单元测试中调用远程系统
远程系统的调用需要网络跳转,这是不稳定的。网络调用可能会超时,这就给单元测试引入了非确定性。某项测试可能会先通过数百次,然后由于网络超时而失败一次。远程系统也是不可靠的,它们可以被关闭、重新启动或冻结。如果一个远程系统出现了回退,你的测试就会失败。
避免远程调用(一般这也很慢)也能保持单元测试的快捷和可移植性。快捷的运行速度和可移植性对于单元测试至关重要,因为开发人员经常在本地的开发计算机上运行它们。依赖于远程系统的单元测试无法移植,因为运行测试的主机必须能够访问远程系统,而远程系统往往是在内部的集成测试环境中,网络通常不可达。
你可以通过使用模拟库或重构代码来剔除单元测试中的远程系统调用,从而使远程系统仅在集成测试中被需要。
6.5.3 采用注入式时间戳
如果处理不当,依赖于特定时间间隔的代码会导致非确定性。网络延迟和CPU速度等外部因素会影响操作所需的时间,而系统时钟的进程是独立的。为了某件事情的发生而等待500毫秒的代码很脆弱:如果代码在499毫秒内运行测试就会通过,但在501毫秒内运行则会失败。使用静态的系统时钟方法,如now或sleep,则表明你的代码依赖于时间。可以使用注入式时间戳而不是静态时间方法,这样你就可以控制你的代码在测试中获取的时间。
代码清单6-1所示的名为SimpleThrottler的Ruby类说明了这个问题。当操作数超过阈值时,SimpleThrottler会调用一个节流方法,但该例时钟是不可注入的。
代码清单6-1
class SimpleThrottler
def initialize(max_per_ $\scriptstyle S\in C=10,0,0$ )
@max_per_sec $=$ max_per_sec
@last_sec $=$ Time.now.to_i
@count_this_sec $\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\$
end
def do_work
@count_this_sec $+=~\,\,\perp$
# ...
end
def maybe_throttle
if Time.now.to_i $==$ @last_sec and @count_this_sec
$>$ @max_per_sec
throttle()
@count_this_sec = 0
end
@last_sec $=$ Time.now.to_i
end
def throttle
# ...
end
end
在代码清单6-1的例子中,我们不能保证在测试中触发maybe_throttle条件。如果测试计算机的性能下降或操作系统决定不公平地安排测试进程,两个连续的操作就可能需要无限制的时间来运行。没有对时钟的控制,就不可能正确地测试节流的逻辑。
相反,可采用注入式时间戳。注入式时间戳将让你使用模拟来精确控制测试中的时间流逝,如代码清单6-2所示。
代码清单6-2
class SimpleThrottler
def initialize(max_per_sec=1000, clock $\it{.}=$ Time)
@max_per_sec $=$ max_per_sec
@clock $=$ clock
@last_sec $=$ clock.now.to_i
@count_this_sec $\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\$
end
def do_work
@count_this_sec $+=~\,\,\perp$
# ...
end
def maybe_throttle
if @clock.now.to_i $==$ @last_sec and @count_this_
sec > @max_per_sec
throttle()
@count_this_sec $\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\qquad\$
end
@last_sec $=$ @clock.now.to_i
end
def throttle
# ...
end
end
这种方法被称为依赖注入,允许测试通过向时钟参数注入一个模拟值来覆盖时钟行为。模拟器可以返回整数型的数值来触发maybe_throttle。常规代码可以默认为常规的系统时间戳。
6.5.4 避免使用休眠和超时
当某项测试需要在一个单独的线程、进程或计算机中完成前置工作,才能验证其结果时,开发人员经常使用sleep函数或超时。这种使用方法的问题是,它假设了另一个执行线程会在特定的时间内结束,但这并非你可以强行依赖的条件。如果编程语言的虚拟机或解释器正在回收垃圾,或者操作系统决定“饿死”执行测试的进程,你的测试将(时而)失败。
在测试中休眠或设置长的超时,也会减慢你的测试执行过程,从而延缓你的开发和调试过程。如果你有一项需要休眠30分钟的测试,你的测试最快也要30分钟才能执行完。如果你设置了一个很长的超时或压根儿没设置超时,你的测试就会被卡住。
如果你发现自己想在测试中设置休眠或超时,看看你是否能重组测试步骤,进而确认一切能否以确定的方式来执行。如果不能,那也没关系,但要做出真诚的努力。在测试并发或异步的代码时,并不总能提供确定性。
6.5.5 记得关闭网络套接字和文件句柄
许多测试都会泄露操作系统的资源,因为开发人员认为测试是短暂的,当测试终止时,操作系统自己会清理一切。然而,测试执行框架经常对多例测试使用同一进程,这意味着如果网络套接字或文件句柄不被立即释放的话,就会造成系统资源的泄露。
泄露的资源会导致不确定性。操作系统对打开的套接字和文件句柄的数量有一个上限,当有太多的资源被泄露时,操作系统就会开始拒绝新的请求。一旦某项测试无法打开新的网络套接字或文件句柄,那么它将会失败。已经泄露的网络套接字也会破坏使用相同端口的测试。即使测试是被连续执行的,第二个测试也会因为无法绑定到端口而失败,因为网络套接字之前已经被打开了,却没有关闭。
对于在局部使用的资源可以利用标准的资源管理技巧,如try-with-resource 或block 。在测试中共享的资源应使用setup 和teardown方法进行关闭。
6.5.6 绑定到0端口
测试不应该绑定到某个特定的网络端口。绑定静态端口会导致不确定性:在一台计算机上运行良好的测试在另一台计算机上会失败,只是因为端口被占用了。将所有测试都绑定到同一端口是一种常见的做法。这些测试在串行时会运转良好,但是在并行时就会失败。测试失败将是不确定的,因为测试并不总以相同的顺序执行。
相反,将网络套接字都绑定到0端口,这将使操作系统需要自动去选择一个开放的端口。测试可以检索被选中的端口,并在该项测试的剩余部分使用这个端口。
6.5.7 生成唯一的文件路径和数据库位置
测试不应该写入某一个已经被静态定义好了的位置。数据的持久性与网络端口绑定有同样的困境。恒定的文件路径和数据库位置会导致测试之间相互干扰。
应该动态地生成唯一的文件名、目录路径以及数据库或表名。动态ID可以让测试并行执行,因为它们都会读写到各自的位置。许多语言都会提供工具类库来安全地生成临时目录(如Python 中的tempfile)。将UUID附加到文件路径或数据库位置也是可行的。
6.5.8 隔离并清理剩余的测试状态
不清理测试状态会导致不确定性。状态存在于数据存续周期内的任何地方,通常在内存或磁盘上。全局变量如计数器是常见的内存状态,而数据库和文件是常见的磁盘状态。某项需要向数据库追加记录并明确肯定该行存在的测试,如果另一项测试也写到了同一张表里,该项测试就会失败。在一个干净的数据库上单独运行时,同样的测试就会通过。这些没被清理掉的剩余的状态也会慢慢填满磁盘空间,从而破坏测试环境的稳定性。
集成测试环境的设置很复杂,所以它们经常会被共享。许多测试都是并行执行的,同时读取和写入相同的数据存储单元。在这种环境中要小心,因为共享资源会导致意外的测试行为。这些测试可以影响彼此的性能和稳定性。共享数据存储还会导致互相干扰测试数据。务必遵循我们在6.5.7小节中的指导,以避免发生测试冲突。
无论你的测试是否通过,你都必须重置状态,不要让失败的测试“留下残渣”。使用setup和teardown方法来删除测试文件,清理数据库,并在每次执行之前重置内存中的测试状态。在测试套件运行的间隙重建环境,这样可以消除测试机的遗留状态。像容器或虚拟化这样的工具可以很容易地废弃整个环境并开启一个新的环境。然而,废弃和开启新的虚拟机要比运行setup和teardown方法慢,所以这样的工具最好用于大型的测试分组。
6.5.9 不要依赖测试顺序
测试不应该依赖于特定的执行顺序。顺序依赖通常发生在某项测试会先行写入数据,而随后的测试会假设数据已经写入的场景。这种模式很糟糕,原因有很多。
$\bullet$ 如果第一个测试失败了,第二个也会失败。
$\bullet$ 这使得并行测试更加困难,因为在第一个测试完成之前,你不能执行第二个测试。
$\bullet$ 对第一个测试的修改可能会意外地破坏第二个测试。 $\bullet$ 对测试运行器的修改可能会导致你的测试以不同的顺序运行。
使用 setup 和 teardown 方法,在测试之间共享逻辑。在setup方法中为每项测试提供数据,并在teardown中清理数据。在每次运行之间重置状态,将防止测试在状态发生突变时相互破坏。
6.6 行为准则
6.7 升级加油站
关于软件测试的图书已经有许多,甚至篇幅很长。我们建议可以针对具体的测试技术去阅读,而不是阅读详尽的测试教科书。
如果你想了解更多测试的最佳实践,弗拉基米尔·霍里科夫的《单元测试:原则、实践与模式》(Unit Testing: Principles, Practices,and Patterns,由Manning出版社于2020年出版)是本可看的书。它涵盖了单元测试的理论、常见的模式和反模式。尽管这本书的名字叫作“单元测试”,但它也涉及了集成测试。
肯特·贝克的《测试驱动开发:实战与模式解析》(Test-DrivenDevelopment: By Example,已由机械工业出版社于2013年引进出版)详细地介绍了TDD。TDD是一项伟大的技能。如果你发现自己处在某个贯彻了TDD思想的组织中,那么这本书是必读的。
看一看安德鲁·亨特和戴维·托马斯合著的《程序员修炼之道——从小工到专家》(The Pragmatic Programmer: From Journeymanto Master,已由电子工业出版社于2011年引进出版)中关于基于属性的测试的部分。我们调查了基于属性的测试,但没有将相关内容放入本书中,但是如果你想扩展你的能力,基于属性的测试是一项值得学习的技术。
伊丽莎白·亨德里克森的《探索吧!深入理解探索式软件测试》( Explore It!: Reduce Risk and Increase Confidence with Exploratory Testing,已由机械工业出版社于2014年引进出版)讨论了通过探索性测试来学习代码的方法。如果你正在处理复杂的代码,这本书非常值得一读。
第7章 代码评审
大多数团队会在合并代码的修改之前进行代码评审。高质量的代码评审文化有助于所有具有不同经验水平的工程师的成长,并促进他们对代码库的共同理解。糟糕的代码评审文化会抑制创新,减慢开发速度,并且导致滋生怨恨情绪。
你的团队会希望你参与到代码评审中来——无论是作为评审者还是被评审者。代码评审会带来冒充者综合征和邓宁-克鲁格效应——我们在第2章中讨论过这两个现象。对于评审产生焦虑和过度自信都是一种自然反应,但如果有正确的环境和技巧,你就可以克服它们。
本章将解释为什么代码评审是有用的,以及如何成为一名优秀的被评审者和评审者。我们将告诉你如何让你的代码得到他人的评审,以及当你得到反馈时如何回应。然后,我们将翻转角色,告诉你如何成为一名好的评审者。
7.1 为什么需要评审代码?
执行良好的代码评审极具价值。首先它有明显的、肉眼可见的好处——评审可以捕捉bug并保持代码整洁,但代码评审的价值不仅仅是让人来代替自动测试和代码质量检查工具。优秀的代码评审可以作为一个教学工具,传播认识,记录实现的决策,并提供代码的更改记录以确保安全性与合规性。
代码评审对你的团队来说是一种教学和学习工具,你可以从别人评审你的代码给予的反馈中学习,评审者会指出那些你可能不知道的有用的类库和编码实践。你也可以阅读更资深的队友的代码评审请求,以了解代码库,并学习如何编写生产级别的代码(关于编写生产级别代码的更多内容,请参见第4章)。代码评审也是了解你的团队的编码风格的一种简单方法。
评审整个代码库的变更可以确保不止一个人熟悉生产环境中代码的每一行,对代码库的共同理解有助于团队更有凝聚力地扩展代码。让别人知道你在改什么,意味着一旦出现了问题,你不是团队中唯一可以仰仗的人。On-Call工程师会追加什么时候哪些代码被修改了的背景信息,这种共享的知识意味着你可以在休假时不必担心还要必须对你的代码做支持。
被记录下来的评审意见也是一种文档,它们解释了为什么事情会这样做,因为需要以某种特定方式编写代码的原因并不总是显而易见的。代码评审可以作为实现决策的档案,有旧的代码评审作为参考,可以为开发人员提供一份书面的历史记录。
为了安全性与合规性,甚至也可能需要代码评审。安全性和合规性政策通常规定了代码评审作为一项防范措施来防止任何一名开发人员恶意修改代码库。
只有当所有的参与者能够在一个“高度信任”的环境中工作时,代码评审的这些好处才会适用,在这个环境中,评审者有意提供有用的反馈,被评审者也愿意接受意见。
执行不力的代码评审会成为一种有害的阻碍。轻率的反馈不提供任何价值,还会拖慢开发人员的速度。缓慢的周转时间会使代码的变化停滞不前。如果没有正确的评审文化,开发人员可能会陷入反复拉锯扯皮的分歧中,这可能会毁掉一个团队。评审不是一个证明你有多聪明的机会,也不是一个橡皮图章式的官僚主义障碍。
7.2 当你的代码被评审时
代码修改由准备、提交、评审、最后批准和合并这几个环节组成。开发人员从准备提交他们的代码这个环节开始。一旦代码准备好了,他们就会提交这些改动,并创建一个“评审请求”,然后通知评审者。如果有反馈,就会进行来来回回的讨论,并进行相应的修改。然后,这些修改被批准并最终合并到代码库中。
7.2.1 准备工作
一个精心准备的评审请求可以使开发人员很容易理解你在做什么并提供有建设性的反馈。遵循我们在第3章中给出的VCS指导:保持单个代码的小幅改动,将特性和重构工作分到不同的评审中,并写出描述性的提交信息,务必将注释和测试包括在内。不要执着于那些你提交评审的代码,要期待它在评审过程中发生变化,有时甚至是重大的变化。
记得附加一个标题和描述,添加评审者,并链接到你的评审请求所要解决的问题。标题和描述与提交信息不完全一样,评审请求的标题和描述应该包括相应修改需要如何被测试的附加背景,与其他资源的链接,以及关于未解决的问题或实现细节的标注。下面有一个样例,如代码清单7-1所示。
代码清单7-1
评审者 : agupta, csmith, jshu, UI/UX 团队。 标题 : [UI-1343] 修复了在目录 Header 上缺失链接的问题。 描述:
概述 主页目录Header上缺失“关于我们”的链接。 现状是单击目录按钮没有反应,通过追加一个正确的链接来修正这个问题。
追加了一项Selenium测试来验证本次修改。
# 检查列表 本次拉取请求:
[x] 添加新的测试代码;
[ ] 修改面向公众的API;
[ ] 把设计文档涵盖在内。
这个评审请求的样例遵循了几项最佳实践。个人和整个UI/UX团队都被加到了评审者列表中;标题引用了正在修复的问题(UI-1343);使用一个已经约定好的标准格式来引用问题,这样可以使集成环境自动连接问题的跟踪器和代码评审,这在未来参考旧的问题时会很有帮 助。
代码评审中的描述内容也附加了一个该代码库的评审模板。有些代码库会有一个填写描述的模板,这种模板会给评审者提供关于本次修改的重要背景。例如,一个关于面向公众的API的改动可能需要增加评审。
7.2.2 用评审草案降低风险
许多开发人员一般通过写代码来思考问题。代码修改的草案是一种思考和提出相应修改的很棒的方式,这种方式不需要投入那么多时间来编写测试、打磨代码和添加文档。你可以通过提交评审草案来检查你正在做的事情:一项非正式的评审请求,旨在从队友那里获得快速和低成本的反馈,这可大大降低你在错误道路上走得太远的风险。
为了避免混淆,要清楚代码评审的时候是草案还是正在进行的工作(work-in-progress,WIP)。许多团队都有关于草案的惯例,通常会在代码评审的标题前添加“DRAFT”或“WIP”作为区分。一些代码评审平台对此有内置支持,例如,GitHub有“草案拉动请求”。一旦你的草案看起来像在正确的轨道上,你就可以通过完成代码实现、测试和文档,并增加润色,将其从“草案”的状态中迁移出来。同样,要清楚你的代码何时可以进行正式的评审,然后按照7.2.1小节所述去准备评审请求。
7.2.3 提交评审请勿触发测试
大型项目往往带有复杂的测试工具。作为一名新的开发者,要彻底弄清楚如何运行所有相关的测试可能会很困难。一些开发者通过提交代码评审的方式来触发持续集成(continuous integration,CI)系统来绕过这个问题,这是一种糟糕的做法。
通过提交代码评审来触发执行测试的方式是一种浪费。你的评审将填满测试队列,这将阻碍那些真正需要在合并前运行测试的评审。你的队友可能会误以为你的评审请求是他们应该看的东西。CI系统将运行全部的测试套件,而你可能只是需要运行那些与你的修改有关的测试。
需要投入时间来学习如何在本地运行你的测试。在本地调试某项失败的测试比在CI环境中更容易一些,你不能在远程计算机上附加调试器或轻松地获取调试信息。建立你的本地测试环境,学习如何只执行那些你关注的测试。使你的编码和测试周期缩短,这样你就能立即知道你的改动是否破坏了什么。这是一项需要前期投入的成本,但从长远来看,它将节省你的时间(而且对你的队友更友好)。
7.2.4 预排大体量的代码修改
在做大体量的修改时,要进行代码层面上的预排会议(walk-through)。预排会议是一种面对面的会议,开发人员在会上共享他们的屏幕,并引导队友了解正在进行的修改内容。预排会议是启发想法和让你的团队适应代码修改的好方法。
提前分发相关的设计文档和代码,并要求你的团队成员在预排会议之前简单地浏览。给他们足够的时间,不要把预排会议安排在一个小时之后。
在预排会议开始的时候,要介绍有关修改的背景,可能需要快速回顾一下设计文档。然后,分享你的屏幕,并在你的IDE中浏览代码。最好的预排方法是通过浏览代码的运行步骤来完成,从最开始的页面加载、API调用或应用程序启动,一直到执行结束。解释任何新模型或抽象背后的主要概念、它们是如何被使用的,以及它们是如何与整个应用程序进行整合的。
不要试图让你的队友在预排会议中实际地进行代码评审,参加者应该把他们的评论留到未来真正的代码评审环节。预排会议的目的是帮助你的团队理解为什么要提出修改,并给他们一个良好的心理模型,以便他们可以自行去进行详细的代码评审。
7.2.5 不要太在意
从你的代码上得到的那些批评性的评论可能让你很难接受。切记应该保持一些情感上的距离——这些评审意见是针对代码的,而不是针对你个人的,而且这甚至都不算是你的代码,将来整个团队会拥有这些代码。得到很多建议并不意味着你没有通过考验,这意味着评审者正在参与到你的代码中并且在思考如何去改进它。得到很多评论是一种完全正常的现象,尤其当你是团队中经验不足的开发者之一时。
评审者可能会要求你做一些看起来并不那么重要或可以稍后解决的修改,他们可能有不同的优先级和进度安排。你要尽力地保持开放的心态,去理解他们的想法。要乐于接受意见,并期望根据反馈意见来修改你的代码。
7.2.6 保持同理心,但不要容忍粗鲁
每个人的沟通方式各有不同,但不应该容忍粗鲁。请记住,一个人的“简短且命中要害”可能对于其他人意味着“粗暴无礼”。应该允许评审者怀疑,但如果他们的评论似乎偏离了中心或粗鲁无礼,请明确地告知他们。如果讨论总拖拖拉拉或让人感觉“哪里不太对劲”,那么试着去面对面地交流,这样可以扫清沟通中的障碍并找到解决办法。如果你觉得不舒服,可以和你的管理者谈谈。
如果你不同意某项建议,试着解决分歧。首先审视你自己的反应,你本能地保护你的代码只是因为你编写了它们,还是因为你的方式事实上更好?清楚地解释你的观点,如果你们还是不能达成一致,咨询一下你的管理者下一步该怎么做。团队处理代码评审冲突的方式各不相同,有的服从提交者,有的服从技术负责人,还有的服从小组的法定人数。应该遵循团队惯例。
7.2.7 保持主动
不要羞于要求别人评审你的代码。评审者经常被代码评审和任务票通知淹没,所以在高速推进的项目中可能会漏掉某些评审请求。如果你没有得到任何反馈,请向团队报告(但不要催促)。当你收到评论时,要有所回应。你不希望你的代码评审要拖上几个星期。每个人的记忆都会“消失”,你回应得越快,你得到他人回应的速度就越快。
在你收到批准后请及时合并你的修改。让代码评审一直悬而未决是不体谅他人的做法。其他人可能正在等待你的修改,或者想在你合并后再修改代码。如果你等待的时间太长的话,你的代码将需要被变基(rebase)和修复。在极端的情况下,变基操作可能会破坏你的代码逻辑,这将导致需要再一次进行代码评审。
7.3 评审别人的代码时
好的评审者将评审请求分成几个阶段。首先分流评审请求,以确定其紧急度和复杂度,并预留出时间来评审代码的修改。开始评审时,阅读代码并提出问题,以了解变化的背景。然后,给出反馈意见,在评审工作中推动决断。将这一流程与一些最佳实践相结合,将大大改善你在评审他人代码时的表现。
7.3.1 分流评审请求
当你收到评审请求的通知时,你作为评审者的工作就开始了。首先要对评审请求进行分流。有些代码修改很关键,需要立即评审。然而,大多数的修改是不那么紧急的。如果紧急度不明确,请询问提交者。修改的规模和复杂度也需要考虑在内。如果一项修改是小且简单明了的,快速的评审将有助于你的队友扫清前进的路障。大型修改的评审则需要更多的时间。
高速推进的团队会产生大量的代码评审需求。你不需要评审每一项代码修改,要专注于那些你可以从中学习的修改和你熟悉的代码。
7.3.2 给评审预留时间
代码评审类似于运维工作(将在第9章中讨论),其规模和频率在某种程度上无法预知。不要每次有评审需求时就中止你正在做的一切。如果不加以控制,评审带来的中断会破坏你的生产力。
在你的日历上划出代码评审时间。预定的评审时间会使你很容易继续你的其他任务,因为你知道你以后会有集中的时间段进行代码评审。这也会使你的评审保持高质量——当你有专门的时间时,你就不会对需要切换回其他任务而感到有那么大的压力。
大型的代码评审可能需要进行额外的计划。如果你收到的评审请求可能需要花费一两个小时以上的时间,请创建一张任务票来跟踪代码评审本身。与你的管理者合作,在冲刺计划中分配专门的时间(参见第12章中关于敏捷开发的部分)。
7.3.3 理解修改的意图
不要一上来就以提交评论的方式开始你的评审工作,首先要阅读并提出问题。如果评审者真的花时间去理解拟议的代码修改,那么代码评审是较有价值的事情。争取理解为什么要进行这项修改,代码过去的表现是什么样的,以及改变后的代码表现是怎么样的。考虑API设计、数据结构和其他关键决策的长期影响。
了解修改的动机将解释具体实现的决策,你可能会发现某些修改甚至是不需要的。比较修改前后的代码也会帮助你检查正确性,并启发其他的实现想法。
7.3.4 提供全面的反馈
你需要对代码修改的正确性、可实施性、可维护性、可读性和安全性提供反馈,指出那些违反代码风格手册、难以阅读或令人困惑的代码,阅读测试用例并寻找bug以验证代码的正确性。
问问你自己,你将如何实现这些改动,以引发关于替代方案的想法,并权衡各个方案的利弊。如果公共的API被改变了,想想这可能会影响到兼容性和计划中的展开(参见第8章,关于这个主题可以了解更多)。考虑未来的程序员可能会误用或误解这段代码的使用方式,以及如何修改代码以防止这种情况发生。
思考有哪些类库和服务可以帮助这项修改。建议采用第11章中讨论的模式来保持代码的可维护性。寻找OWASP十大违规行为,如SQL注入攻击、敏感数据泄露和跨站脚本攻击的漏洞。
写评论时不要过于简短——请按照你们坐在一起评审代码时的说话方式来写评论。评论应该是有礼貌的,并且包括“什么”和“为什么”。
校验端口是否大于或等于0 ,如果不是,需要触发InvalidArgumentException异常。端口不可能是负值。
7.3.5 要承认优点
在评审代码时,会很自然地把注意力集中在发现问题上,但代码评审不一定全都是负面的评论。对好的东西也要进行赞扬。如果你从阅读代码中学到了一些新的东西,请明确地传达给作者。如果一次重构清理了代码中的问题区域,或者新的测试看起来会降低未来修改的风险,那么请用积极的、鼓励性的评论来嘉许这些内容。即使是一项令你讨厌的修改,你也可以对它说些好话——如果没有别的原因,就承认它的意图和努力。
这是一项有趣的改动。我完全理解把队列代码迁移到第三方库的想法,但我很不喜欢添加新的依赖项。现有的代码很简单,而且做了它需要做的事情。如果我误解了你的动机,请你一定要告诉我,我很乐意和你进一步讨论。
7.3.6 区分问题、建议和挑剔
并非所有的评审意见都有相同的重要性。重大问题需要比中性的建议和肤浅的挑剔投入更多的关注。
不要回避文体方面的反馈,但要清楚地表明你是在吹毛求疵。在评论前添加一个“Nit”作为前缀是惯例。
Nit:双空格。
Nit:对于这里和其他所有的内容,针对方法名请使用蛇形命名法,针对类名请使用驼峰命名法。
Nit:方法名让我感觉很奇怪。“maybeRetry(int threshold)” 怎么样?
如果同样的代码风格类的问题反复出现,不要一直喋喋不休。指出第一个例子,并指出这是需要全面展开的问题。没有人喜欢被反复告知同样的事情,而且也没有必要这样做。
如果你发现自己经常对代码风格挑挑拣拣,请询问该项目是否设置了足够的代码检查工具。理想情况下,应该是工具为你做这项工作。如果你发现你的评审意见中大多是挑剔的内容,很少有实质性的评论,那就放慢速度,做更深入的阅读。指出有用的代码美观的问题是评审的一部分,但它不是主要目标。参见第3章,了解更多关于代码检查和代码整洁工具的信息。
把那些对你来说更好但并不需要批准的建议指出来,在反馈前加上“可选”(optional)、“接受或不接受”(take it or leave it)或“非必须”(nonblocking)的字样。将你提出的建议与你真正希望看到的修改区分开来,否则,提交者就不一定清楚了。
7.3.7 不要只做橡皮图章
你可能会迫于压力,在没有真正看清楚的情况下就批准了某项评审。一项紧急的修改、来自同行的压力、一项看似微不足道的变动,或者一项过于大型的改动都会迫使你签字。同情心可能会促使你迅速扭转评审的局面——你知道不得不等待评审是一种什么样的感觉。
要抵制那种用草率批准的方式快速给评审盖上橡皮图章的诱惑,橡皮图章式的评审是有害的。团队成员会认为你已经知道了这项修改是什么、为什么要这么改,你可能会在以后被追究责任。提交者会认为你已经浏览并批准了他们的工作。如果你不能充分地确定评审的优先次序,那就根本不要评审相应代码修改。
给某项请求盖上橡皮图章的诱惑可能是一个信号,表明代码的变 化对一个单独的请求来说太大了。不要害怕要求你的团队成员将大型的代码评审分割成较小的部分分批进行。对开发者来说,很容易就会产生一项数千行的改动。期望一次性就能充分评审一项巨大的代码改动是不合理的。如果你觉得代码预排会议可能更有效率,你也可以要求这样做。
7.3.8 不要只局限于使用网页版的评审工具
代码评审通常在一个专门的UI中处理,比如GitHub中的拉取请求界面。不要忘记,代码评审本身也只是代码而已。你仍然可以迁出或下载那些拟议的修改,并在本地处理它们。
在本地迁出代码可以让你在你的IDE中检查、建议那些拟议的修改。大型的改动在网页界面中很难浏览,集成开发环境和桌面的评审工具可以让你更容易地浏览这些变更。
本地代码也是可以运行的,你可以创建你自己的测试来验证事情是否如预期般进行。调试器可以被附加到正在运行的代码上,这样你就可以更好地了解事情是如何表现的。你甚至可以触发失败的场景,以更好地说明你的评审意见。
7.3.9 不要忘记评审测试代码
评审者经常会忽略测试代码,特别是当变更比较大的时候。测试代码应该像代码的其他部分一样被评审。通过阅读测试代码来开始评审工作通常是有用的,它们说明了代码是如何被使用的,以及预期会发生什么。
一定要检查测试代码的可维护性和清洁度。寻找糟糕的测试模式:依赖执行顺序、缺乏隔离和远程系统调用。请参见第6章,了解测试的最佳实践和需要注意的违规行为。
7.3.10 推动决断
不要成为促成“夭折”的原因,要帮助提交者评审以迅速批准他们的代码。不要坚持要求完美,不要扩大修改的范围,要清楚地描述哪些评审意见是关键的,不要让分歧发酵。
坚持质量,但不要成为不可逾越的障碍。谷歌的《工程实践文档》(“Engineering Practices Documentation”,可以在其官方代码的说明页上找到全文)讨论了在评审变更列表(changelist,CL,谷歌对拟议的代码修改的内部术语)时的这种张力。
一般来说,评审者应该倾向于批准CL,只要它处于肯定能改善正在运行的系统的整体代码运行的状况,即使CL并不完美。
尊重正在进行的修改的范围。在你阅读的过程中,你会发现改进相邻代码的方法,并产生一些关于新特性的想法,不要坚持将这些修改作为现有评审的一部分来进行。另开一张任务票来改进代码,把工作留到以后。确定严格的范围将提高速度并保持增量更改。
你可以通过将本项评审标记为“尚需修改”(request changes)或“批准”(approved)来做出决断。如果你留下了很多评审意见,撰写评审摘要会很有帮助。如果你要求修改,请明确说明需要哪些修改才能使你批准。这里有一个例子。
本项修改看起来很不错。几乎无处可以吹毛求疵,但是我的主要诉求是希望修改端口的处理方式。代码看起来比较脆弱。更多的内容可以参见评审意见。
如果对代码修改有重大分歧,而你和作者又不能解决分歧的话,请主动提出把这个问题移交给其他专家,他们可以帮助解决相关分歧。
7.4 行为准则
7.5 升级加油站
谷歌公司的《开发者代码评审指南》(“Code ReviewDeveloper Guide”)是公司代码评审文化的一个优秀的例子。请记住,该指南是专门为谷歌公司而编写的。你的公司对风险的容忍度,对自动化质量检查的投入,以及对速度或一致性的偏好,可能会导致不同的理念。
归根结底,代码评审是一种给予和接受反馈的专门的形式。道格拉斯·斯通与希拉·汉合著的《高难度谈话Ⅱ:感恩反馈》(Thanks forthe Feedback: The Science and Art of Receiving Feedback Well,已由光明日报出版社于2017年引进出版)是一个很棒的资源,可以帮助你成为更好的评审者和被评审者。
第8章 软件交付
你应该了解你的代码最终是如何出现在用户面前的。了解交付的过程将帮助你解决问题,并控制修改的内容何时生效。你可能不会直接参与这个过程——它可能是自动化的或由发布工程师来真正操作的,但是那些在Git提交和实时流量之间的步骤不应该是一个谜。
当软件在生产环境中稳定运行,并且被客户真实使用时,它就被交付了。交付包含诸如发布、部署和展开等环节。本章将介绍向客户交付软件所涉及的不同阶段、源代码控制的分支策略(这会影响软件的发布方式)以及当前的最佳实践。
8.1 软件交付流程
不幸的是,交付阶段并没有行业标准的定义。取决于你与谁交谈,像发布和部署这样的措辞可能指代的是交付管道中完全不同的部分。你的团队可能把整个流程——从打包到展开,统称为发布(release)。他们可能把打包一个构件称为发布,而把构件交付下载的过程称为发行(publishing)。直到一个特性在生产环境中被打开时才能称其为被“发布”了,而在这之前的一切行动都是部署(deploy)。
在本章中,我们将提到软件交付的4个阶段,即构建(build)、发布、部署和展开(rollout),如图8-1所示。软件首先必须被构建成软件包。软件包应该是不可变的,并且被标记了版本。然后,软件包必须被发布。发行说明和变更日志都会被更新,同时软件包会被发行到一个集中的存储库。已发行的发布级的构件必须被部署到预生产和生产环境中。部署的软件还不能被用户访问——它只是被安装了而已。一旦部署,软件就会通过将用户转移到新的软件上而进行展开。一旦展开完成,就意味着完成了交付。
图8-1 软件交付流程
交付过程是更大的产品开发周期中的一部分。展开阶段之后,收集反馈意见,发现bug并收集新的产品需求。特性开发重新开始,并最终启动下一轮的构建流程。交付阶段中的每一环都有一套最佳实践,这些实践将帮助你快速、安全地交付软件。但在我们深入了解每个交付环节之前,我们需要介绍源代码控制的分支策略。分支策略决定了代码变更的提交位置以及发布代码的维护方式。正确的分支策略将使软件交付变得简单和可预测,而错误的策略将使交付变成与流程本身的缠斗。
8.2 分支策略
发布的软件包是使用VCS中的代码进行构建的。主分支——有时也称为主干或主线,包含整个代码库的主版本,并有修改的历史记录。分支是从主分支上“切”下来的,以进行代码修改。多个分支允许开发人员并行工作,并在准备好时将他们修改过的内容合并回主分支上。不同的分支策略定义了分支应该持续多长时间、它们与软件的已发布版本之间的关系,以及代码的变化如何传递到多个分支上。分支策略的两个主要系列是基于主分支的开发和基于特性分支的开发。
在基于主分支的开发中,所有开发人员都在主分支上工作。分支被用于单个小型特性、修复bug或更新。
图8-2所示的是一个基于主分支的开发模式。创建了一个叫作特性-1(feature-1)的特性分支,并将其合并回主分支(trunk)上。故 障-1(bug-1)分支的创建是为了修复一个bug。一个发布分支也被切了下来,开发人员决定把这个bug的修正内容转移提交(cherry-pick)到发布版-1.0(release-1.0)版本中。
图8-2 基于主分支的开发模式
只有当各分支可以快速合并到主分支时,基于主分支的开发模式的效果才是最好的,如果不是在几小时内,也应该在几天内合并到主分支,并且不在开发人员之间共享。频繁地合并被称为持续集成(CI)。CI可降低风险,因为代码上的变化会迅速传递给所有的开发人员,使他们彼此之间不太可能有很大的分歧。让开发人员的代码库保持同步,可以防止潜在的最后一分钟的集成障碍,并尽早暴露出错误和不兼容的情况。作为一种代价,主分支中的bug会拖累所有的开发者。为了防止代码破损,在一个分支被合并到主分支上之前,要运行快速的自动化测试来验证其是否可以通过。团队通常有明确的流程来应对破损的主分支,一般的期望是主分支应该总是可以发布的,而且发布往往相当频繁。
在基于特性分支的开发模式中,许多开发人员同时在长期存续的特性分支上工作,每个特性分支与产品中的一个特性相关联。由于特性分支存续时间都较长,开发人员需要重新调整——从主分支中拉入变化——以使特性分支不至于偏离太远。通过控制变基操作来保持分支的稳定性。在准备发布时,特性分支会被拉入发布分支。发布分支被测试,而特性分支可能会继续发展。软件包是建立在稳定的发布分支上的。
当主分支太不稳定以至于无法发布给用户时,或者开发人员希望避免进入特性冻结期,即在主分支线稳定后禁止提交特性时,基于特性分支的开发就很常见。基于特性分支的开发模式在收缩型软件中更为常见,因为不同的用户使用着不同的版本。而面向服务的系统通常使用基于主分支的开发模式。
最流行的特性分支方法,由文森特·德里森在2010年归纳提出,被称为Gitflow。Gitflow使用开发分支、热修复分支和发布分支。开发分支被用作主分支,特性分支与之合并和变基。在准备发布时,发布分支会从开发分支中被切分出来。在版本已稳定的期间内,开发工作在特性分支上继续进行。发行版稳定后会合并到主分支。主分支总被认为是可以随时部署到生产环境的,因为它只包含稳定的版本。如果主分支是不稳定的,因为它包含了严重的bug,则会立即采用热修复的方式来解决这些bug,而不是等待正常的发布周期。热修复被应用于热修复分支,然后会被合并到主分支和开发分支。
图8-3所示的Gitflow示例有两个特性分支:特性-1(feature-1)和特性-2(feature-2)。特性分支是长期存续的,它与开发分支之间有提 交和合并。发布分支上有两个版本,都被拉入了主分支。
热修复分支用于修复在主分支上发现的错误。热修复分支会被拉取到开发分支之中,因此特性分支也可以将其拉取进来。
图8-3 Gitflow基于特性分支的开发模式
理解并遵循团队的分支策略。分支策略定义了代码的变动何时被 推出,设置了测试预期,定义了你的错误修复选项,并确定了你的代码变动必须被移植到的版本数量。许多公司开发了内部工具来帮助管理他们的VCS工作流,这些脚本会自动为你进行分支、合并和标记。
除非你真的需要那种长期存续的特性分支,否则请坚持使用基于主分支的分支策略。管理特性分支会变得很复杂。事实上,德里森已经修改了他最初关于Gitflow的博文,不再鼓励将Gitflow用于可持续集成和交付的软件。
8.3 构建环节
软件包在交付之前必须进行构建。构建软件包需要很多步骤:解决和连接依赖项、运行linter、编译、测试,最后是打包软件。大多数构建步骤在开发过程中也会用到,这些内容在第3章到第6章中已经介绍过了。在本节中,我们将集中讨论构建的输出结果:软件包。
软件包是为每个发布版本而构建的,所以软件不必在每台运行它的计算机上再次构建。与每台计算机使用自己的环境和特异的工具集来编译和运行代码相比,预先构建好的软件包更加一致。
如果软件的目标是在一个以上的平台或环境中运行的话,构建环节就会产生多个软件包。
构建通常会为不同的操作系统、CPU架构或语言运行环境产生软件包。你可能遇到过类似这样的Linux软件包名称。
● mysql-server-8.0_8.0.21-1_amd64.deb ● mysql-server-8.0_8.0.21-1_arm64.deb ● mysql-server-8.0_8.0.21-1_i386.deb
这些MySQL包都是为相同的MySQL版本而构建的,但每个包都为不同的架构而编译:AMD、ARM和Intel 386。
软件包的内容和结构各不相同。软件包可以包含二进制包或源代码、依赖关系、配置、发行说明、文档、媒体文件、许可证、校验和,甚至是虚拟机镜像。类库会被打包成特定语言的格式,如JAR、wheel和crate,其中大多数只是组合成符合规范的压缩目录。应用程序包通常以ZIP压缩包、TAR压缩包(.tar文件)或安装包(.dmg或setup.exe文件)的形式构建。容器和机器包允许开发者不仅可以构建他们的软件,而且还可以构建软件运行的环境。
打包决定了什么软件会被发布。糟糕的打包会使软件难以部署和调试。为了避免出现令人头痛的问题,应该总是对软件包进行版本管理,并按资源类型分割软件包。
8.3.1 打包需要带版本号
软件包也应该被纳入版本管理,并且被分配唯一的标识符。唯一的标识符帮助运维人员和开发人员将运行中的应用程序与特定的源代码、特性集合以及文档联系起来。如果没有版本号,你就不知道这个包会有什么样的表现。如果你不确定要使用哪种版本策略,语义化版本是一个安全的选择。大多数软件包都遵循某种形式的语义版本管理(参见第5章)。
8.3.2 将不同的资源单独打包
软件不仅仅是代码,配置、schema、图像和语言包(各种语言的翻译)都是软件的一部分。不同的资源有不同的发布节奏、不同的构建时间,以及不同的测试和验证需求。
不同的资源应该被分开单独地打包,这样它们就可以被修改而不需要重新构建整个软件包。分开打包让每种类型资源都有自己的发布周期,可以独立向前和向后滚动。
如果你正在向客户发送一版完整的应用程序,那么最终的包就是一个元包:一个包含所有包的包。如果你正在发布一项网络服务或一个自我升级的应用程序,你可以单独发布包,从而允许配置、翻译与代码分开升级。
Python打包
Python的打包管理有一段漫长而曲折的历史,这使它成了一个很好的案例。Python打包授权组织(Python Packaging Authority,PyPA) 已经发表了《Python 打包指南》(“An Overview ofPackaging for Python”),它试图将Python的打包选项合理化。图8-4和图8-5所示的是Python的打包选项,由马哈茂德·哈希米制作并包含在PyPA的概述中。
图8-4 对Python工具和库可用的打包选项 $\cdot$
图8-5 Python应用程序可用的打包选项
Python库洋葱图的核心是一个简单的.py源文件。下一层级sdist是一组.py文件——也被称为模块——被压缩成.tar.gz压缩包。尽管sdist包会包括一个模块的所有Python代码,但它们并不包括该包可能需要的编译代码,那在下一个层级。除了原始的Python代码外,wheel还包括用C、 $\mathsf{C++}$ 、Fortran、R或任何其他Python包可能依赖的语言编写并编译的本地库。
图8-5所示的是应用程序的打包选项,由于它包括语言的运行环境、机器虚拟化和硬件,所以更有层次感。PEX包会包括Python代码和它所有的库的依赖关系。Anaconda提供了一个生态系统来管理所有已经安装的库,而不仅仅是你的应用程序所依赖的那些。Freezers不仅捆绑了库,还捆绑了Python的运行环境。镜像、容器和虚拟机打包操作系统和磁盘镜像。在某些情况下,甚至硬件也是一种打包方式,在发布嵌入式系统的硬件时,应用程序包、系统类库和操作系统本身都已经安装好了。
虽然这些打包选项中有些是Python独有的,但许多语言都有类似的模式。像虚拟机和容器这样的外层与语言无关,它们可以一一对应到你所使用的打包栈中的每一层。了解你的打包系统的假设和约定将防止部署环节出问题。