就像没有人愿意吃烂苹果一样,不会有人喜欢写烂代码。
开发人员都希望自己写出的代码又高效又高质,实现所有需求和设计的目标,永远没有生产问题。幸运的开发人员能遇到新的项目从零开始写代码,大部分项目还是在以前的代码基础上进行功能迭代。前期代码已经写成了,让后来的开发者如何接盘?开发人员素质参差不齐,需求旺盛的情况下,产品不可避免的写入了很多为了满足需求的低效低质代码。这些低质代码又变成了后来者的独木桥,周而复始地恶性循环下去。即便是全新的项目,中间一两个版本没控制好代码质量,又会留下一堆烂代码。那些开发质量控制不好的软件寿命都不长,因为随着时间的推移,支撑不了业务发展就只能得了个推翻重做的下场。
代码质量和项目计划是一对冤家,总难和谐。如何在进度紧张,人力资源有限的情况下保证代码质量?
经过这些年的不断努力和探索,我们已经实现了基于持续集成技术的自动化代码质量监测工具链和报告管理体系。这个体系管控的范围可大可小,小可以小到某一个产品的安全扫描和单元测试,大可以大到几个工程的全面管控。自动化代码质量管理,核心在于减少人员投入和重复的自动验证,用工具为开发人员服务。主要目标是让数据报告指引工作方向,培养开发人员自我规范意识,从而提升软件产品质量,降低缺陷修复成本。在技术人员眼里它是一套自动化的质量工具箱,在项目管理人员眼里它是一种代码质量管理的实践方法。
本文中阐述的所有项目都以 Java 代码为基础,其他语言的情况也是大同小异,这里不做赘述。我们仅仅把眼光放在代码开发阶段的管理问题进行剖析,暂不去关心其他阶段的问题,着重在管理理念和技术落地方法两个方面展开探讨。
软件质量 (softwarequality)是与软件产品满足明确或隐含需求的能力有关的特征和特性的总和。高质量的软件通常具备了这样一些特性:
管理代码质量,首先要取得完整的目标代码和准确的版本,通过版本管理工具可以解决这个问题。早期集中式的管理软件,比如,IBM 的 ClearCase,微软的 Visual SourceSafe,开源的 Subversion,时下比较流行的分布式管理软件,比如 Git、Github、GitLab 都是大家比较常用的版本管理软件。对于开发者而言,版本管理工具的作用是获取和提交代码,偶尔用来回退一下版本,找找代码以前的样子。
从代码质量管理角度来看,知道谁在什么时间做过什么非常关键,当问题发生时我们可以通过版本控制信息去追溯缺陷的始作俑者。大规模项目开发中,版本管理是让开发人员清楚自己在做什么,自己在项目中处于什么位置。就像足球场上的运动员,谁负责守门、谁踢后卫、谁踢前锋,大家各司其职,位置感尤为重要,如果大家不清楚谁踢什么位置,像没头苍蝇一样乱跑乱踢,踢踢野球还行,碰上了强大的对手肯定是输球。
程序员写代码的风格不尽相同,提交代码习惯也不一样,项目启动时就必须要提前做好约束工作。规范各种提交件中的说明必须是有意义的,每次提交不要间隔太久或者一次提交太多东西。有了版本管理工具,大家每天更新代码时就能看到其他人在做什么,完成了什么功能,是不是和我的功能有关?版本管理员也可以通过开发人员提交的信息掌握代码开发是否覆盖了版本启动会的需求范围,有没有超范围实施和遗漏功能的情况发生。版本的回退和合并如果有准确的代码提交信息作保证,也能顺利很多。我们后面的质量管理工作,就有了一个比较扎实的关联信息作为跟踪问题的依据了。
如果你的质量管理还在依赖 FTP 或者什么共享目录保存代码,后续的工作对你来说简直就是噩梦了,更谈不上什么自动化管理。
软件配置管理 (即 Software Configuration Management,简称 SCM) 是通过技术或行政手段对软件产品及其开发过程和生命周期进行控制、规范的一系列措施。配置管理的目标是记录软件产品的演化过程,确保软件开发者在软件生命周期中各个阶段都能得到精确的产品配置。
良好的配置管理能使软件开发过程有更好的可预测性,使软件系统具有可重复性。配置管理在整个软件研发过程中起着至关重要的作用,版本控制只是配置管理最基本的层次和功能。当然只有进行了版本控制,其他的功能才可能会逐渐提升,但就一个基本的版本控制功能而言,在部分软件公司中也并不是一个非常正规和完善的过程。
注:也有种说法,SCM= 源代码控制管理
有了代码,我们还要把代码编译成可执行的产品,通常我们使用的工具是 Maven。
Maven 是一个项目管理工具,它包含了一个项目对象模型 (Project Object Model,pom.xml),一组标准集合,一个项目生命周期 (Project Lifecycle),一个依赖管理系统 (Dependency Management System),和用来运行定义在生命周期阶段 (phase) 中插件 (plugin) 目标 (goal) 的逻辑。当你使用 Maven 的时候,你用一个明确定义的 pom 来描述你的项目,然后 Maven 可以应用横切的逻辑,这些逻辑来自一组共享的(或者自定义的)插件。
Maven 有一个生命周期,当你运行 mvn install 命令的时候被调用。这条命令告诉 Maven 执行一系列的有序的步骤,直到到达你指定的生命周期。遍历生命周期旅途中,Maven 运行了许多默认的插件目标,这些目标完成了像编译和创建一个 jar 文件这样的工作。此外,Maven 能够很方便的帮你管理项目报告,生成站点,管理 jar 文件等等。
与 Maven 相似的工具还有 Ant,与 Ant 相比 Maven 使用更广泛、更简单,支持的插件更多,适合多版本、多任务、多环境的开发模式代码编译打包。
像现金管理这样的项目有十多个子产品,起初我们使用 Shell 脚本来执行 Maven 命令,通过在质检服务器上给不同的产品配置统一的调度任务,定时编译打包代码、执行单元测试。
像 JTest、Fortify 这样的质量扫描任务,也是通过运行不同的 Shell 脚本来进行,累积了大量的任务脚本。不同的 Shell 脚本生成不同的质量报告,再将这些报告集中存放,供开发人员下载查看。
那时,代码还是通过 ClearCase 胖客户端,定时打包推送到质量检查服务器上,然后等待定时任务对代码包进行代码扫描。这么做了一段时间后,遇到最大的困扰就是时间问题,运行质量检查任务的时间太长了,因为质量扫描没有和代码提交挂钩,不知道代码是不是有变化,每次检查只能是全部执行,需要的时间就会很长。提交 -> 打包 -> 上传 -> 取版本 -> 执行扫描 -> 生成报告 -> 查找问题 -> 发现新问题,一个过程下来快的话也 1 个小时了。后来,我们发现了 Jenkins 这个工具,Jenkins 最早帮我们解决的就是代码扫描的时机问题。
如果说 Maven 是一个项目的单兵作战,或者说是用来操控某一个作战单元的工具,那么 Jenkins 就像是一个司令部,可以指挥士兵,协调、调度不同方面的数据或者任务,形成集团作战优势。Jenkins 是一套自带管理界面的持续集成工具,用于监控重复执行的工作,它的优点是:
图 1 配置好的 Jenkins(v2.64)首页
我们将 ClearCase 胖客户端安装到 Jenkins 服务器上,使用 Jenkins 版本管理插件将质量任务配置为拉取方式(Poll SCM)。拉取方式能分析变化情况后决定是否触发构建任务,而不是全量镜像代码,这是不同于以往的代码获取方式。它有效的节省了服务器时间和空间资源,避免产生大量重复的报告 。
图 2 ClearCasePollSCM 配置页面
图 3 通过 ClearCaseSCM 得到的变更详情
图 4 通过 ClearCaseSCM 得到的变更详情
将版本管理工具和 Jenkins 结合以后,可以看到在准确获取源代码变更情况的同时,各种源头信息都一清二楚的收集在我们的手中,可供后续查询。这里面的信息也与派工单信息一致,方便项目经理确定任务是否落实。
Jenkins 本身不带有数据库,它都是通过文件记录的方式进行存档,如果你需要质量报告的全部历史信息,可以通过安装相应的数据库存储插件解决这一问题。当然,因为 Jenkins 服务器上也已经安装了版本管理客户端,你也可以通过直接访问版本管理服务器来获取代码的提交信息,这点后文中也会进行讨论。在实际应用中,为避免一些特殊的情况(比如 rebase 或者版本切换等),我们建议用每周一次定期检查保证质量服务器版本和配置管理库的一致性。
Jenkins 支持群集配置和多配置模式。质量检查工具,例如 JTest、Fortify 和 Sonar 的执行都很消耗服务器资源,群集的使用可以为任务提供更多的资源,有效的降低任务资源依赖,分配任务到不同的服务器上执行,也能避免不同任务之间的相互影响。多配置是配合某些测试场景的需求,比如我们需要针对不同的操作系统、Jdk 版本、浏览器版本进行产品有效性验证,就需要为同一个任务配置不同的执行环境来进行验证。
当我们将 Jenkins 结合版本管理工具进行合理的配置,它便能够变得“智能”,自主判断是否需要工作,我们的代码就具备了“自主”的质量检查能力,即随着代码的不断变化,Jenkins 分析这个变动情况,决定哪些代码需要质量扫描,甚至判断出要做哪种力度的扫描,并对变更代码第一时间发起检查,给出新的质量报告,提供给质量管理者准确的行动指导,也就实现了智能的持续集成。
获取代码和自动对代码进行质量扫描的问题我们已经解决了,那么我们要做哪些质量检查?哪些工具能帮我们找出代码里的缺陷?
根据发现代码缺陷的手段,质量工具可以分为两大类:静态测试工具和动态测试工具。
静态测试 :不实际运行程序,而是通过代码扫描的手段来发现错误并评估代码质量的软件测试技术。这类测试主要依托各种代码扫描工具,虽然有些工具说明中出现了“动态检查”等描述,但实际上这种动态并不是通过数据输入后运行代码得到某种输出结果来测试的,大多是基于某种规则引擎匹配代码模式的,有时我们也称呼这种测试为文档级检查。
FindBugs、JTest、Fortify、CodeStyle 等等都是这种静态测试工具,通过它们可以找到代码中的一些编写规范问题。
表 1 常见的静态扫描插件和它们的特点
扫描工具 | 功能 | 特点 | 规则数量 | 必要修复性 |
---|---|---|---|---|
JTest | 静态检查,命名规范,简单的动态分析,覆盖率 | 使代码规范化,清理一些初级 Bug | 1146 | 严重的需修复 |
Fortify | 结构性问题,运行时问题,经验类问题 | 修复一些不易被发现的问题 | 598 | 中等以上都 应修复 |
FindBugs | 无效代码、经验库、通过 Bytecode 检查 class | 不注重样式或者格式,排除隐含的缺陷 | 358 | 都应修复 |
CodeStyle | 注释、命名、量度、缩进、重复等检查 | 格式规范 | 修复 | ? |
PMD | 静态检查、潜在问题分析、重复代码分析 | 重复代码 | 分析 | 16 套规则集 |
图 6 JTest 执行规则
图 7 Fortify 执行规则
图 8 FindBugs 的一些规则
动态测试 :实际运行程序,将不同参数数据传入程序,通过观察程序运行的实际结果来发现缺陷的软件测试技术。
最基本的动态测试就是编译代码了,即通过 JDK 编译器将代码进行编译打包。拿到最新代码,通常我们先做个编译检查,通过 Maven build 来确认代码的基本有效性,编译不能通过后面的检查也都做了,很多扫描工具也无法支持对编译错误的代码进行测试。单元测试、组件测试、用户测试测试任务都可以算作动态测试。
在业内,单元测试作为验证代码质量的技术手段已经是常态,单元测试案例的编写和执行也都要纳入开发工作量之中,而且比功能开发投入资源更多。
开发人员将案例编写为单元测试代码,通过执行案例代码,模拟业务调用,看功能代码能否得到预期的结果,在用断言(Assert)的方式自动化的验证案例的这些预期结果。每一个单元测试案例就像是一段功能代码的“守护者”,守护者越多,被看护的代码也就越多,一旦代码出现问题,相应的守护者就会站出来报告。借助持续集成工具的能力不断的执行单元测试案例,让这些守护者不断的巡逻,检查单元测试案例的执行结果,就可以让我们及时发现新增代码或是环境变化引发的代码问题。
在执行单元测试案例检查的同时,获取案例所执行代码行数与全部代码行数数据,这两项数据的比值就是单元测试覆盖率了(或称覆盖度),也即“代码行覆盖率”。除了行覆盖率,单元测试检查比较常用的还有分支覆盖率和方法覆盖率。
单元测试覆盖率检查是一种计划实施跟踪手段,目的是告诉开发人员哪一部分代码还没有守护者,赶紧去写单元测试案例。
覆盖率越高代码质量是不是就越好?单元测试覆盖率的目标肯定是达到 100% 的覆盖,但单纯的追逐覆盖率有时会把程序员逼疯,他们就会从“神秘的工具箱”中拿出一两个“法宝”来,“高效”地达成指标,单元测试案例不写断言,或乱写断言,这样的操作让单元测试偏离了它的最初目标,守护者只有眼睛没有了嘴巴,即使发现了问题也发不出声音。
所以,我们不提倡为了覆盖率而覆盖,要使用覆盖率来指导编写单元测试案例。单元测试案例的质量有时还需要技术扫描和人工走查的方式来保证。
还有人说,我们不写单元测试,我们有代码 review 工具和人工代码评审,不也一样能保证代码质量?如果说一篇一百万字的文章,最近一个版本有十几个章节和几万字的变化,编审人员想通读全文来确保本次改编的效果和正确性,不着急还行的通,但如果这种变化是每天都在发生呢?我们是无法通过一遍遍的迅速阅读来确保文章质量的,花费一周时间通读一遍,等你读完了,已经又产生了 5 个新版本了……即使能通过增加审查人员来审计代码变化,且不说评审人的专业度和专注度,这么大量的代码评审要花费大量的时间和人力成本,显然不具备可操作性。对于一个快速开发,不断变化需求的软件工程来说,如果有单元测试案例以及覆盖度检查的保证,再加上关键代码的人工评审,这样的代码质量才能更让大家放心。
再来回答刚才的问题,对于体量相当的两个产品,覆盖率为 90% 的产品一定比只覆盖率只有 50% 的产品要可靠,因为前者被测试过的代码更多,未知的风险更少。
BrianMarick(敏捷宣言最早的 17 个签署人之一)也说过,“测试覆盖是一种学习手段”。学习什么呢?学习为什么有些代码没有被覆盖到,为什么有些代码明明改变了,测试案例结果却没有变?理解“为什么”背后的原因,程序员就可以做相应的改善和提高,这比凭空想象单元测试的有效性和代码的好坏来讲更加有效。我们就曾经因为发现覆盖率下降了而没有失败案例,分析出代码设计的缺陷,从而去重新编写代码,避免了一次生产事故。单元测试覆盖率是一种工具,而不是神器,需要使用它的人理解它的精髓,而不是被它驱使。
单元测试开发是 TDD(测试驱动开发)么?我们在这里使用单元测试和覆盖率检查,并不表明我们在做测试驱动开发。我们的目的不是通过测试来推动开发,而是把单元测试作为开发人员自我保证代码质量的技术手段,提升开发人员对自己代码负责的态度,养成为其代码提供守护者的习惯。单元测试覆盖率的提升可以为项目积累大量的测试案例,方便在其他场合使用,比如自动化功能测试和接口测试,还有代码重构。
那么又如何将单元测试自动化呢?自动化单元测试要注意哪些关键点?我总结有以下几个:
实现了自动化单元测试,我们便有了一个好的开端,接下来更深层次的管理需求就好搭车了,比如:
测试驱动开发(Test-DrivenDevelopment)起源于极限编程(XP)开发过程中所提倡的测试先行实践。
测试先行实践重视单元测试(UnitTesting),强调程序员除了编写代码之外,还应该编写单元测试代码。在开发的顺序上,它改变了以往先编写代码,再编写测试的过程,而采用先编写测试,再编写代码来满足测试的方法。这种方法在实际中能够起到非常好的效果,使得测试工作不仅仅是单纯的测试,而成为设计的一部分。
1. 语句覆盖 (StatementCoverage) : 又称行覆盖 (LineCoverage),段覆盖 (SegmentCoverage),基本块覆盖 (BasicBlockCoverage),这是最常用也是最常见的一种覆盖方式,就是度量被测代码中每个可执行语句是否被执行到了。这里说的是“可执行语句”,因此就不会包括像 C++的头文件声明,代码注释,空行,等等。非常好理解,只统计能够执行的代码被执行了多少行。需要注意的是,单独一行的花括号{}也常常被统计进去。语句覆盖常常被人指责为“最弱的覆盖”,它只管覆盖代码中的执行语句,却不考虑各种分支的组合等等。
2. 判定覆盖 (DecisionCoverage) : 又称分支覆盖 (BranchCoverage),所有边界覆盖 (All-EdgesCoverage),基本路径覆盖 (BasicPathCoverage),判定路径覆盖 (Decision-Decision-Path)。它度量程序中每一个判定的分支是否都被测试到了。这句话是需要进一步理解的,应该非常容易和下面说到的条件覆盖混淆。因此我们直接介绍第三种覆盖方式,然后和判定覆盖一起来对比,就明白两者是怎么回事了。
3. 条件覆盖 (ConditionCoverage) : 它度量判定中的每个子表达式结果 true 和 false 是否被测试到了。为了说明判定覆盖和条件覆盖的区别,我们来举一个例子,假如我们的被测代码如下:
int foo(int a, intb)
{
if(a <10|| b <10)// 判定
{
return0;// 分支一
}else{
return1;// 分支
}
}
设计判定覆盖案例时,我们只需要考虑判定结果为 true 和 false 两种情况,因此,我们设计如下的案例就能达到判定覆盖率 100%:
TestCaes1: a = 5, b = 任意数字 覆盖了分支一
TestCaes2: a = 15, b =15 覆盖了分支二
设计条件覆盖案例时,我们需要考虑判定中的每个条件表达式结果,为了覆盖率达到 100%,我们设计了如下的案例:
TestCase1: a = 5, b =5 true,true
TestCase4: a = 15, b =15 false,false
通过上面的例子,我们应该很清楚了判定覆盖和条件覆盖的区别。需要特别注意的是:条件覆盖不是将判定中的每个条件表达式的结果进行排列组合,而是只要每个条件表达式的结果 true 和 false 测试到了就 OK 了。因此,我们可以这样推论:完全的条件覆盖并不能保证完全的判定覆盖。
比如上面的例子,假如我设计的案例为:
TestCase1: a = 5, b =15 true, false 分支一
TestCase1: a = 15, b =5 false,true 分支一
我们看到,虽然我们完整的做到了条件覆盖,但是我们却没有做到完整的判定覆盖,我们只覆盖了分支一。
4. 路径覆盖 (PathCoverage): 又称断言覆盖 (PredicateCoverage)。它度量了是否函数的每一个分支都被执行了。这句话也非常好理解,就是所有可能的分支都执行一遍,有多个分支嵌套时,需要对多个分支进行排列组合,可想而知,测试路径随着分支的数量指数级别增加。
本文转载自公众号金科优源汇(ID:jkyyh2020)。
原文链接:
领取专属 10元无门槛券
私享最新 技术干货