JavaScript-专家级编程-四-

80 阅读1小时+

JavaScript 专家级编程(四)

原文:Expert JavaScript

协议:CC BY-NC-SA 4.0

九、代码质量

Abstract

品质不是一种行为,而是一种习惯。

—亚里士多德

品质不是一种行为,而是一种习惯。—亚里士多德

写出高质量的 JavaScript 意味着什么?质量可以被测量吗,或者它是一种主观的观点,类似于柏拉图式的关于美和艺术的理想?程序员倾向于在对质量的主观和客观理解之间摇摆不定。他们提升了软件工艺等概念,软件工艺是编写软件的手工方法。软件工匠被描述为拥有高超的技能,并且已经将他们的工作浓缩到基本的组成部分。匠人的电气化体现就是所谓的摇滚明星程序员。一个人的定义是基于一个概念,即一个人可以是如此独特的天才艺术家,工作产品在某种程度上大于他们的部分之和。然而,许多编程都围绕着通过过程化和可重复的过程来度量、重构和改进代码。这意味着质量可以被提取为一系列独立的和可测量的步骤。

如果质量是可以衡量的,那么 JavaScript 开发人员有什么机制可以确保他们产生优秀的代码?本章深入探讨了编写高质量 JavaScript 的概念,首先定义了与编程相关的质量,然后提供了一个评估和改进代码的框架。

定义代码质量

像许多吸引来自不同背景的个人的复杂学科一样,程序质量的定义经常跨越艺术和科学之间的界限。编程行为通常是创造性解决问题和运用工程师的严谨来提炼解决方案的融合。编程是通过编码的可重复步骤进行的客观观察和来自个人经验和洞察力的主观评估之间的一种张力。事实上,质量这个词支持这两种观点。芭芭拉·w·塔奇曼是这样解释品质的两面性的:“品质”这个词当然有两个意思:第一,某物的本质或本质特征,如“他的声音具有命令的品质”;第二,优秀的条件意味着好的质量,区别于差的质量(塔奇曼,1980)。

塔奇曼继续将质量描述为“自我滋养”,这是一个非常令人回味的形象。质量也被描述为一种追求,这表明它不是一个目的地,而是一个旅程。这可能是因为定义不固定;它属于时代精神。要证明这一点,你只要看看艺术史就知道了,艺术史一直在排斥或拥抱不同的艺术表现形式。在一生的时间里,法国印象派画家从被艺术机构嗤之以鼻到数年后达到了艺术界的顶峰。他们的画没有改变——只是品质的定义变了。

在这一章中,我认为评价 JavaScript 源代码需要主观和客观两种立场。事实上,我相信你甚至不能把一个和另一个完全分开。然而,在我提出这个问题之前,我需要正确地展示这两种形式。

主观质量

主观质量通常描述受启发的或必要的代码,或者 Truchman 所说的“天生的优秀”在他关于产品质量的文章中,大卫·加文定义了一种质量形式,他称之为超越。他将卓越品质定义为

.... absolute and universally accepted, uncompromising standards and signs of high achievement. However, supporters of this view claim that quality cannot be defined accurately; On the contrary, it is a simple and unanalyzable attribute, and we can only know it through experience. This definition borrows a lot from Plato's discourse on beauty. In the seminar, he thought that beauty was one of "Platonic forms", so it was an undefined term. Like other terms considered by philosophers as "logical primitive", beauty (and perhaps quality) can only be understood after a person comes into contact with a series of objects showing its characteristics. (Gavin, 1984)

这个定义清楚地阐明了这样一个观点,即主观质量依赖于个人经验或有技能的个人的指导,以认可和促进他们领域内的优秀。它断言,主观质量在其本质层面上是普遍真实的,与其说是创造的,不如说是发现的。

客观质量

客观质量断言,如果天才是可以衡量的,它是可以量化和重复的。蛋糕的质量并不取决于面包师天生的优秀,而是精确选择和测量配料以及精确遵循食谱的结果。客观质量在一个反馈循环中产生、应用和提炼关于主题的经验近似值。这种形式的质量有助于算法、测试套件和软件工具。在本章的剩余部分,我将介绍一种通过客观质量来改进代码的方法。

质量是如何衡量的?

您正在为质量寻找一个可用的定义,但是首先您需要考虑它与编程相关的各个方面。这些方面通常被表示为软件度量:

Software metrics are measurements of some attributes of a piece of software or its specifications. Because quantitative measurement is essential in all sciences, computer science practitioners and theorists have been trying to introduce similar methods into software development. The goal is to obtain objective, repeatable and quantifiable metrics, which may have many valuable applications in schedule and budget planning, cost estimation, quality assurance testing, software debugging, software performance optimization and optimal personnel task assignment.

为了通过度量来框定代码质量,我包含了六个度量标准:

  • 美学:这个标准衡量代码的视觉内聚性,但是也包括格式、命名约定和文档结构的一致性和思考性。美学被测量来回答这些问题:
  • 代码的可读性如何?
  • 页面上的各个部分是如何组织的?
  • 它在编程风格方面使用了最佳实践吗?
  • 完整性:完整性度量代码是否“适合目的” 1 一个程序要被认为是完整的,必须满足或超过指定问题的要求。完整性还可以衡量特定实现符合行业标准或理想的程度。这项措施试图回答的关于完整性的问题是:
  • 代码解决了它想要解决的问题吗?
  • 给定一个期望的输入,代码会产生期望的输出吗?
  • 它满足所有定义的用例吗?
  • 安全吗?
  • 它能很好地处理边缘情况吗?
  • 是否经过充分测试?
  • 性能:性能根据已知的基准来测量实现,以确定它有多成功。这些指标可能会考虑程序大小、系统资源效率、加载时间或每行代码的错误等属性。使用绩效评估,您可以回答以下问题:
  • 这种方法的效率如何?
  • 它能承受多大的负荷?
  • 这种代码的容量限制是什么?
  • 努力:这个度量测量产生和支持代码的开发成本。努力可以根据所用的时间、金钱或资源来分类。衡量努力有助于回答这些问题:
  • 代码可维护吗?
  • 它容易部署吗?
  • 有记录吗?
  • 写作花了多少钱?
  • 持久性:为了具有持久性,程序在产品中的生命周期被测量。耐久性也可以被认为是对可靠性的一种衡量,这是衡量寿命的另一种方式。耐久性可以通过测量来回答这些问题:
  • 它的性能可靠吗?
  • 在必须重启、升级和/或更换之前,它可以运行多长时间?
  • 是否可扩展?
  • 接受度:接受度衡量其他程序员如何评估和评价代码。跟踪接收允许您回答这些问题:
  • 代码有多难懂?
  • 设计决策有多周密?
  • 该方法是否利用了已有的最佳实践?
  • 用起来过瘾吗?

为什么要度量代码质量?

“我不能对代码质量收费。”当我问一个朋友对这个问题的看法时,他直接引用了我的一个朋友的话。他的意思是代码质量主要对程序员有利,对客户来说是无形的税收。我能理解他的观点;我有不止一次的经历,当我抱怨测试方法的时候,一个潜在的客户的眼睛会相应地转回来。我朋友接着说:“客户花钱买的是一个结果,不是一个过程。当我购买西南航空的机票时,我支付的是到达目的地的费用,而不是乘坐飞机。”这种说法听起来有些天真,但是我将在这一节中论证测量代码质量不会让你失去竞争优势;这是你的竞争优势。

管理顾问汤姆·彼得斯曾经说过,“能衡量的就能完成。”在这种情况下,衡量意味着向前看,以便预测变化。通常,测试和质量度量只是在出现问题后进行的事后分析。当在开发过程中持续应用时,度量代码质量可以让您理解项目的健康状况。它也可以暗示未来负面事件的可能性。考虑以下方式,代码质量不仅可以提高您的代码,还可以提高项目的底线:

  • 技术债务是一个隐喻,它描述了随着时间的推移,坏代码从您的项目中窃取的时间、金钱和资源等不断增加的成本。有许多质量度量,包括代码复杂性分析,可以识别软件中代码不足的区域。
  • 有几个度量标准(比如 Halstead metrics,我将在后面介绍)可以表明维护您的代码库所需的未来工作量。了解这一点可以帮助你准确地为这些改进做预算。
  • 许多代码质量度量试图理解代码中的路径。然后,这些措施可以识别缺陷存在的可能性,以及它们可能隐藏的位置。这些工具在评估另一个团队的代码时特别有价值,因为它们可以像地雷探测器一样,通过算法扫描未知的函数领域。
  • 虽然发现新错误的能力很重要,但是知道何时停止编写测试是安全的技能也很重要。许多著名的开发人员已经证明测试不是免费的,所以知道什么时候停止可以省钱。许多代码质量工具可以使用简单的试探法告诉您何时达到了适当的测试覆盖率水平。
  • 接受代码质量度量是预防性维护的一种形式。我听过有人不屑一顾地谈论代码质量,说这就像刷牙一样。在某种程度上,他们是对的,质量的本质是后期添加要困难得多,就像刷牙不会去除现有的蛀牙一样。

既然有了代码质量的基线,您就有了一个有效的定义,并且您不仅理解了如何度量质量,还理解了为什么您应该这样做。在下一节中,我将解释您在追求质量的过程中可以使用的各种工具和技术。

在 JavaScript 中测量代码质量

客观质量分析在程序上搅动代码,以便计算精华能够上升到顶端。这项任务是通过使用编程工具来完成的,这些工具在各种环境中评估代码,使用度量标准来得出最终的质量分数。本节解释静态代码分析,这是一种非常适合评估 JavaScript 质量的方法。

静态代码分析

静态代码分析是在不运行代码的情况下分析代码的过程。静态分析很像文本编辑器中的拼写检查器。拼写检查器扫描文档以查找文本正文中的错误和歧义,而无需理解书写的含义。类似地,代码的静态分析分析源代码的功能正确性,而不必知道它做什么。尽管 JavaScript 是一种非常动态的语言,但它非常适合静态分析,因为它没有被编译成另一种形式。本节将评估 JavaScript 中的两种静态分析方法,包括语法验证器和复杂性分析工具。

语法验证

在 JavaScript 中,语法验证有两种方法。第一种是使用诸如 JSLint 3 或 JSHint, 4 之类的 linter,它不仅检查你的代码的功能正确性,而且当你的程序没有遵循它们的最佳实践时,偶尔还会提供一点严厉的爱。考虑以下质量可疑的代码:

// foo.js

onmessage = function(event) {

"use strict"

event = event

if(event){

return {"success" : postMessage('pong'), "success" : "ok"}

}

};

我使用的是 JSHint,您可以通过这种方式将它作为 npm 模块安装(根据您的用户帐户权限,您的系统可能需要使用sudo):

npm install jshint -g

安装完成后,您可以从终端的命令行对源文件运行 linter:

jshint foo.js

JSHint 将报告这些警告:

foo.js: line 7, col 3, Missing semicolon.

foo.js: line 8, col 16, Missing semicolon.

foo.js: line 10, col 56, Duplicate key 'success'.

foo.js: line 10, col 63, Missing semicolon.

请注意,linter 只通知您缺少分号和重复的键。就个人而言,我认为event = event的无意义赋值也值得一提,但从技术上讲,这段代码没有任何问题。这种模糊性说明了 linter 的观点驱动方法,即它不仅验证语法,还验证您的方法。

对于那些对识别代码中所谓的不良气味不太感兴趣的人,可以使用一个简单的独立 ECMAScript 解析器,比如 Esprima, 5 ,它只会剔除无效代码。可以从 npm 模块安装 Esprima,如下所示:

npm install -g esprima

与 JSHint 类似,它可以从终端的命令行验证代码:

esvalidate foo.js

完成后,Esprima 应该会在终端窗口中输出如下内容:

foo.js:6: Duplicate data property in object literal not allowed in strict mode

Linters 和 parsers 是建立代码质量基线的优秀工具。这些工具中的许多可以并且应该集成到更大的开发工作流中。它们是提高我前面提到的美学、努力和接收质量的关键因素。然而,在大多数情况下,简单的语法卫生不足以确保代码质量。下一节将探索有助于减少代码库中复杂性蔓延的工具。

复杂性

Antoine de Saint-Exupery 可能是在谈论代码质量,他说,“完美是可以实现的,不是当没有什么可以添加的时候,而是当没有什么可以删除的时候。”高质量的代码不仅在形式上是正确的,而且在概念上是清晰的,并且在向读者说明所需的问题是如何解决的能力上是富有表现力的。不幸的是,有许多原因可以解释为什么简洁的代码会退化成操作数和操作符的混乱。团队可能会改变,功能可能会增加或减少,涉众的目标可能会改变;所有这些事件都发生在程序员们顶着压力继续发货的时候。

任何从事编程工作过一段时间的人都知道“代码是我们的敌人” 6 一个简单的发展事实是,代码越多,质量越下降。代码是句法脂肪团;添加比移除容易。所谓的代码膨胀会导致一个复杂的程序,因为程序员需要阅读、理解和维护更多的源代码。聪明的程序员通过利用设计模式和应用框架来对抗复杂性。他们采用的开发方法试图通过一致的编程方法来降低复杂性。

除了这些方法之外,复杂性也可以通过编程使用质量度量标准来测量,这些质量度量标准被调整以发现困难的代码。本节探讨了识别、隔离并最终从程序中删除复杂 JavaScript 的方法。

通过代码度量测量复杂性

对于运行时引擎来说,JavaScript 并不复杂。它可能漏洞百出,效率低下,或者不完整,但是与编程相关的复杂性是一个纯粹的人类难题。因此,代码复杂度是程序员为了完全理解一个代码单元而必须忍受的脑力劳动的度量。

多年来,程序员已经开发了度量复杂性的方法。这些度量确定了源代码中的缺点,这些缺点通常会导致复杂的代码。其中一些指标是经验观察的结果,其他的是关于程序员如何思考代码的算法解释。其他程序员已经将这些度量利用到工具中,这些工具可以定期扫描程序,以帮助开发人员了解他们的代码哪里需要重构或额外的测试。

本节展示了这些复杂性度量的精选,它们非常适合 JavaScript,以及一组可以自动化质量控制的工具。

过多的评论

复杂代码的一个明显结果是,对于读者来说,源代码不再是自文档化的。注释通常被用来使未来的程序员能够翻译以前的开发人员的方法。出于这个原因,注释可能是一个引人注目的复杂性度量,因为它们表明还有工作要做或者可以进行改进。

代码行

就像过度注释度量一样,计算代码行数具有直观的意义。随着功能的扩展,开发人员误解实现细节的可能性也在增加。代码行数可以用多种方法来度量,包括代码行数(LOC),源代码行数(SLOC),或者无注释的源代码行数(NCSL)。

评估 LOC 指标时,请确保您在正确的详细程度上进行分析。例如,将一个函数重构为三个函数可能会增加 LOC 度量,但实际上会降低源代码的整体复杂性。出于这个原因,开发人员有时称 LOC 为一个天真的度量。在评估 JavaScript 时,我发现 LOC 度量在函数级最有效,因为长函数通常是不必要的复杂性的标志。

耦合

如果一个对象需要另一个对象的实现的显式知识才能工作,那么依赖对象就被认为是与另一个对象紧密耦合的。应尽可能避免这种耦合,因为它会使整个源变得脆弱。此外,这意味着信息隐藏正在失败,实现逻辑正在泄漏到更大的代码库中。

当静态分析 JavaScript 的紧密耦合时,可以计算用于访问对象链中属性的点数。在可能的情况下,你应该将通话链控制在三点或更少。这里有一个例子:

// too tighly coupled

var word = library.shelves[0].books[0].pages[0].words[10];

// loosely coupled

var shelf = library.getShelfAt(0);

var book = shelf.getBookAt(0);

var page = book.getPageAt(0);

var word = page.getWordAt(10);

每个函数的变量

带有太多局部变量的 JavaScript 函数可能表明该函数可以改进,要么通过关注点分离,要么通过将变量分组到一个公共对象中。考虑以下示例:

var race = function () {

var totalLaps = 10;

var currentLap = 0;

var driver1 = "Bob";

var driver2 = "Bill";

var car1 = {

driver: driver1

fuel: 100

maxMph: 100

miles: 0

tires: 4

};

var car2 = {

driver: driver2

fuel: 100

maxMph: 100

miles: 0

tires: 4

};

var cars = [car1, car2];

while (currentLap < totalLaps) {

currentLap++;

cars.forEach(function (car) {

car.miles += Math.floor(Math.random() * car.maxMph) + 1;

});

}

if (car1.miles > car2.miles) {

console.log(car1.driver + " wins!");

} else {

console.log(car2.driver + " wins!");

}

}

// => (Bob or Bill) wins!

race();

race函数处理的不仅仅是模拟比赛,所以函数体充满了局部变量。通过改进关注点的分离,您可以将变量的数量从七个减少到两个,如下所示:

var addCar = function (driver) {

return {

driver: driver

fuel: 100

maxMph: 100

miles: 0

tires: 4

};

};

var race = function (cars) {

var totalLaps = 10;

var currentLap = 0;

while (currentLap < totalLaps) {

currentLap++;

cars.forEach(function (car) {

car.miles += Math.floor(Math.random() * car.maxMph) + 1;

});

}

cars.sort(function (a, b) {

return a.miles > b.miles ? -1 : 1;

});

console.log(cars[0].driver + " wins!");

};

// => (Bob or Bill) wins!

race([addCar('Bob'), addCar('Bill')]);

每个函数的参数

对于使函数过于复杂的参数数量,并没有硬性规定。然而,向函数中传递一系列参数可能表明函数的目的是混乱的。在某些情况下,你可以通过逻辑地组织相关的论点来减少读者必须记住的论点的数量。这可以通过将它们分组到一个对象中来实现,您可以将该对象提供给函数:

var detectCollision = function (x1, x2, y1, y2, xx1, xx2, yy1, yy2) {

// more code

}

// Restructure the function to accept logically organized objects.

// rect1 == { x1:0, x2:0, y1:0, y2:0 }

var detectCollision = function (rect1, rect2) {

// more code

}

嵌套深度

深度嵌套的代码比浅层代码更复杂,也更难测试。函数中的嵌套深度有多种测量方法。例如,这些函数中的每一个都有四个嵌套深度:

// Nesting depth of three

var isRGBA = function (color) {

if (color != 'red') {

if (color != 'blue') {

if (color != 'green') {

if(color != 'alpha'){

return false;

}

}

}

}

return true;

};

// Nesting depth of three

var isRGBA = function (color) {

if (color != 'red' && color != 'blue' && color != 'green' && color != 'alpha') {

return false;

}

return true;

};

isRGBA的第二个实现与第一个版本具有相同的嵌套深度,这似乎是不正确的;毕竟只有一个 if 语句。然而,逻辑操作符(&&)的使用是用来嵌套条件逻辑的,所以读者必须在心里解开它们。应该重新考虑总嵌套深度为 4 或更多的函数。

圈复杂度

圈复杂度有一个听起来非常复杂的名字。每次大声说出来都觉得自己更聪明。自己试试;你会明白我的意思。幸运的是,这项措施背后的概念比其名称更容易理解。圈复杂度是由 Thomas McCabe (McCabe,1976)发明的,作为发现函数内部复杂度的一种方法。他断言,函数的复杂性与函数体内发生的控制流决策的数量成正比。

这种方法以两种方式之一得出复杂性分数:

  • 它可以计算一个函数中的所有决策点,然后加 1。
  • 它可以把函数看成一个控制流图 7 (G)从顶点总数(n)和连通平方分量总数(p)中减去边数(e);例如:

v(G) = e - n + 2p

基本示例

为了更好地理解这一措施,让我们看看它的行动。在下面的例子中,我编写了一个假想的页面路由器,它可以从一些重构中受益。为了更加清晰,我在函数的每个决策点都增加了复杂度分数。此外,分数从 1 开始,而不是在末尾加 1。

var route;

// score = 1

route = function() {

// score = 2

if (request && request.controller) {

switch (true) {

// score = 3

case request.controller === "home":

// score = 4

if (request.action) {

// score = 5

if (request.action === "search") {

return goTo("/#home/search");

// score = 6

} else if (request.action === "tour") {

return goTo("/#home/tour");

} else {

return goTo("/#home/index");

}

}

break;

// score = 7

case request.controller === "users":

// score = 8

if (request.action && request.action === "show") {

return goTo("/#users/show" + request.id);

} else {

return goTo("/#users/index");

}

}

} else {

return goTo("/#error/404");

}

};

这个函数的复杂度为 8,McCabe 认为这是非常复杂的。理想情况下,McCabe 认为这个函数的得分应该在 4 分以下。8 分说明这个功能做的太多了。圈分数能告诉你的不仅仅是一个函数需要修剪的事实;McCabe 建议为每个圈点编写一个测试。这样做将确保覆盖所有可能的决策路径。因为分数越低越好,所以任何分数为 10 或更高的函数都会增加函数中出现 bug 的可能性。

限制

圈度量的一个盲点是,它只关注控制流,把它作为函数中复杂性的唯一来源。任何花了很少时间阅读代码的程序员都知道复杂性不仅仅来自控制流。例如,这两个表达式将获得相同的圈分数,尽管其中一个显然更难理解:

// Cyclomatic score: 2

if(true){

console.log('true');

}

// Cyclomatic score: 2

if([+[]]+[] == +false){

console.log('surprise also true!');

}

此外,通过使用 McCabe 的推理,一个单一的整体程序,不管多长,总会被认为没有一个只有一个 if 语句的程序复杂。对于开发者来说,这与现实不符。这并不是说这个指标没有价值;它像代码矿坑中的金丝雀一样工作得很好,对函数中可能潜伏的潜在问题起到了早期警告的作用。值得考虑另一个度量,它不仅仅使用控制流来度量复杂性。为此,您需要发现 NPATH。

n 路径复杂性

Brian Nejmeh 创建了 NPATH 复杂度度量来分析函数或单元级别的代码质量。Nejmeh 认为软件质量的最大收益是在单元级别上实现的,因为它们可以从源代码的其余部分中分离出来,因此提供了一种客观度量复杂性的有效方法。根据 Nejmeh:

NPATH, the non-circular execution path of the function, is an objective measure of software complexity related to the ease with which the software can be comprehensively tested. (Nejmeh,1988)

计算非循环执行路径是一种隐晦的说法,这种方法是对一个函数可以执行的所有不同方式的总结。NPATH 使用这个路径计数来导出函数的最终复杂度分数。这类似于 McCabe 的圈复杂度测量的工作方式。两者的区别在于圈复杂度计算控制流决策,而 NPATH 计算所有可能的路径。奈梅赫认为 NPATH 的非循环计数是对麦凯布方法的改进。具体来说,Nejmeh 认为 McCabe 的度量标准未能衡量一个函数的全部复杂性,原因如下:

  • 与指数函数相比,圈复杂度不能正确地解释通过线性函数的不同数量的非循环路径。
  • 它以同样的方式对待所有的控制流机制。然而,Nejmeh 认为有些结构天生就难以理解和正确使用。
  • McCabe 的方法没有考虑函数中嵌套的级别。例如,三个连续的 if 语句与三个嵌套的 if 语句得分相同。然而,Nejmeh 认为程序员理解后者会更困难,因此应该被认为更复杂。
基本示例

为了更好地理解 NPATH 度量如何对 JavaScript 函数进行评分,考虑下面的例子。如前所述,NPATH 对各种控制流机制进行不同的评分。为了帮助读者,我在每个控制流语句上方添加了评分说明作为注释。

var equalize;

equalize = function(a, b) {

// NP[(if)] = NP[(if-range)] + NP[(else-range)] + NP[(expr)]

// 1 + 1 + 0

// NPATH Score = 2

if (a < b) {

// NP[while] = NP[(while-range)] + NP[(expr)] + 1

// 1 + 0 s+ 1

// NPATH Score = 2

while (a <= b) {

a++;

console.log("a: " + a + " b: " + b);

}

} else {

// NP[while] = NP[(while-range)] + NP[(expr)] + 1

// 1 + 0 + 1

// NPATH Score = 2

while (b <= a) {

b++;

console.log("a: " + a + " b: " + b);

}

}

console.log("now everyone is equal");

};

// Total NPATH Score: 2 * 2 * 2 = 8

equalize(10, 9);

Note

所有的 NPATH 表达式计算 NP[(expr)]都得到 0 分。NPATH 通过计算逻辑运算符(&&||))的数量来确定表达式得分。这是因为这些操作符对可能的控制流路径的数量有复杂的分支影响。

限制

正如我前面所讨论的,量化复杂性有益于程序员,而不是运行时引擎。因此,这些度量标准是基于创造者个人对复杂性的定义的基础水平。就 NPATH 而言,Nejmeh 认为一些控制流语句天生就比其他语句更容易理解。例如,在一对连续的 if 语句上使用带有两个case标签的 switch 语句,您将得到较低的 NPATH 分数。尽管这对 if 语句可能需要更多的代码行,但我不认为它们在本质上更难理解。这就是为什么不要盲目地应用复杂性度量,而是花时间去理解他们的世界观是至关重要的。对于复杂性的另一个固执的观点,让我们考虑霍尔斯特德度量。

霍尔斯特德度量

在 70 年代后期,计算机程序被写成单个文件,随着时间的推移,由于它们的整体结构,变得难以维护和增强。为了提高这些程序的质量,Maurice Halstead 开发了一系列定量的方法来确定程序来源的复杂性(Halstead,1977)。众所周知,霍尔斯特德度量是“最早的软件度量之一,[而且]它们是复杂性的一个强有力的指示器。” 8

Halstead 回避了这样一个普遍的观点,即衡量质量和复杂性只能由熟悉程序目标和语言的领域专家来完成。相反,霍尔斯特德的论点是“软件应该反映算法在不同语言中的实现或表达,但独立于它们在特定平台上的执行。因此,这些指标是从代码中静态计算出来的。” 9

自从引入 Halstead 以来,近 40 年来,开发人员已经将 Halstead 的度量标准应用到许多不同的语言中,包括 JavaScript。尽管这些衡量标准及其关于人类认知的潜在假设并非没有批评者,但单独考虑每个衡量标准以及它们如何得出 JavaScript 代码的分数仍然是有益的。通过理解这些度量标准是如何工作的,您可以扩展自己评估代码的思维框架,并且至少可以更好地理解如何以及为什么使用这些度量标准对一个 JavaScript 单元进行评分。

输入

霍尔斯特德的度量标准将函数对运算符和操作数的使用作为其各种度量的输入。然而,在收集这些输入之前,您必须考虑 Halstead 在 JavaScript 中的操作数和运算符是什么意思。

JavaScript 中的操作数是语句的一部分,包含要执行的对象或表达式。相比之下,JavaScript 有许多形式的操作符 10 对操作数执行操作。下面是一个基本的例子:

var x = 5 + 4;

为了清楚地看到操作符和操作数的细节,可以使用 Esprima JavaScript 解析器 11 将语句提取到语法树中:

// Syntax tree of: var x = 5 + 4;

{

"type": "Program"

"body": [

{

"type": "VariableDeclaration"

"declarations": [

{

"type": "VariableDeclarator"

"id": {

"type": "Identifier"

"name": "x"

}

"init": {

"type": "BinaryExpression"

"operator": "+"

"left": {

"type": "Literal"

"value": 5

"raw": "5"

}

"right": {

"type": "Literal"

"value": 4

"raw": "4"

}

}

}

]

"kind": "var"

}

]

}

使用这个语法树,您可以计算唯一操作数(3)和运算符(2)。出于本章的目的,我使用这个简单的陈述作为霍尔斯特德指标中使用的计算的基础。现在有了 JavaScript 中操作数和操作符的工作定义,您可以通过以下方式获得 Halstead 指标的输入:

  • n1 =唯一运算符的数量
  • n2 =唯一操作数的数量
  • N1 =总运营商数量
  • N2 =总操作数的数量

使用语法树中的运算符和操作数计数,可以得到以下值:

n1 = 2

n2 = 3

N1 = 2

N2 = 3

有了这些输入的值,您现在可以将它们输入到各种指标中来计算分数。使 Halstead 度量如此灵活的一个事实是,它们的定量性质意味着它们可以很好地应用于整个源文件或单个函数。事实上,在同一个程序上以不同的分辨率运行霍尔斯特德度量标准会给你带来有趣的结果。不过,出于本节的目的,我将解释这些指标,就好像您将在函数级别应用它们一样。

程序长度(N)

程序长度通过将操作数和运算符的总数加在一起(N1 + N2)来计算。较大的数字表示将函数分解成较小的组件可能会有好处。你可以用这种方式表达节目长度:

var N = N1 + N2;

词汇量(n)

词汇表的大小是通过将唯一的操作符和操作数相加得到的(n1 + n2)。正如程序长度度量一样,较高的数字表示该函数可能做得太多。你可以用下面的表达式来表示词汇量:

var n = n1 + n2;

程序体积(V)

如果你的大脑是一个玻璃罐,一个程序的体积描述了它占据了容器的多少。它描述了为了完全理解函数,读者必须在心里分析的代码量。程序量考虑在一个函数中对操作数执行的操作的总数。因此,无论是否缩小,函数都将得到相同的分数。这不能说是其他复杂性度量标准,包括源代码行(SLOC)作为他们计算的一部分。节目量是通过将节目长度(N)乘以词汇量(N)的以 2 为底的对数来计算的。你可以用 JavaScript 这样写:

// => 11.60964047443681

var V = N * (Math.log(n) / Math.log(2));

体积是这一衡量标准的一个令人回味的名称,因为它可以有多种含义。之前,我谈到体积是一种取代其他精神资源的质量,但你也可以把它看作是一种信噪比度量。就像在现实世界中一样,当音量设置在一定范围内时,信息传输效果最佳。想象你正在听收音机;当音量旋钮调得太低时,你必须使劲才能听到。但是,将旋钮转到 11 会使输出声音过大,影响理解。

程序级别(L)

程序级别定义了一种方法的相对复杂性。它使用潜在量(V1)除以实际量(V)来得出感知的能力水平。一个函数的潜在体积是这样定义的,就好像它是以最理想的实现形式写的一样。程序级别可以表示如下:

var L = (V1 * V);

因此,实现越接近 1,该方法就越可取。

Note

每种语言的潜在音量不同。高级语言比低级语言得分高得多,因为高级语言从程序源中抽象出复杂性。

难度级别(D)

难度衡量读者误解源代码的可能性。难度级别的计算方法是将唯一运算符的一半乘以操作数的总数,再除以唯一操作数的数量。在 JavaScript 中,应该这样写:

var D = (n1 / 2) * (N2 / n2);

如果你考虑到当一个程序的容量增加时,理解它的难度也增加,这就可以直观地理解了。当操作数和操作符被重用时,它们增加了跨许多控制流路径引入错误的可能性。

规划工作(E)

这种方法估计了一个有能力的程序员在理解、使用和改进一个基于容量和难度分数的函数时可能付出的努力。因此,编程工作可以表示如下:

var E = V * D;

毫不奇怪,与卷和难度一样,需要较低的努力分数。

实施时间(T)

这种方法估计了一个合格的程序员实现一个给定功能所需要的时间。霍尔斯特德通过将努力(E)除以一个斯特劳德数得出这个度量。 12 斯特劳德数是一个人每秒可以做出的基本(二元)决策的数量。因为斯特劳德数不是从程序源中导出的,所以可以通过比较预期结果和实际结果来随时间进行校准。实施的时间可以这样表示:

var T = E / 18;

Note

有效的斯特劳德数范围从 5 到 25,其中 25 是一个人在每单位测量中可以做出的简单决策的最大数量。霍尔斯特德认为数字 18 可以很好地替代一个合格程序员的表现。

错误数量(B)

这个度量估计了给定程序中已经存在的软件缺陷的数量。正如您所料,错误的数量与其复杂性(数量)密切相关,但可以通过程序员自己的技能水平(E1)来减轻。霍尔斯特德在他自己的研究中发现,在 3000 到 3200 的范围内可以找到足够的 E1 值。可以使用下面的表达式来估计错误:

var B = V/E1;

限制

虽然霍尔斯特德指标可以提供信息,但一些人质疑它们的可靠性和表面有用性。一些人,如卢·马尔科,批评了评分系统的模糊性和如何应用的不确定性。Marco 指出,霍尔斯特德并没有在这个问题上提供明确的方向:

Halsted pointed out that the lower the program level, the more complicated the program is. Unfortunately, he didn't go further. Is the program of level 100 complicated? How about class 05? All you can do is compare the versions of the same program and compare their program levels. Recall that the McCabe metric gives the upper limit of complexity of 10. The calculation of halsted metric of bubbling sort shows that bubbling sort is very complicated in implementation. The problem is that the calculation of potential volume requires the number of input and output parameters. For bubble sort, only the array to sort is needed. The low number of potential capacity distorts the program and language level. Most programmers will agree that this algorithm is not complicated. (Marco, 1997)

工具作业

客观质量分析的一个主要目标是创建一系列程序化的度量,这些度量可以使用一致的和可重复的过程按需对复杂性进行评分。这些度量的过程性质意味着它们是包含在编程工具中的主要候选对象。毫不奇怪,有几个项目就是专门为此而设计的。本节比较和对比了两个 JavaScript 复杂性分析程序。

复杂性报告

菲尔·布斯的复杂性报告 13 是一个简单的命令行工具,它分析任何 JavaScript 文件,然后从中生成复杂性报告。复杂性由以下指标决定:

  • 代码行
  • 每个函数的参数
  • 圈复杂度
  • 霍尔斯特德度量
  • 保养率指数

因为 complexity-report 是一个命令行工具,部署工程师可以毫不费力地将其添加到他们持续集成工作流中。它可以配置为当源文件低于任意质量阈值时阻止代码部署。

基本示例

要查看此库如何工作,您必须首先将其作为 npm 模块安装:

npm install -g complexity-report

为了测试复杂性报告的输出,您将对它自己的一个源文件运行该工具,这被亲切地称为吃自己的狗粮。从命令行中,键入以下代码:

cr ./node_modules/complexity-report/src/cli.js

Note

您可能需要将目录更改为复杂性报告节点模块的本地目录。

一旦完成,库应该将结果打印到终端窗口。该报告首先对整个文件的复杂性进行评分,然后分别评估每个函数。以下是整份报告的摘录:

Maintainability index: 125.84886810899188

Aggregate cyclomatic complexity: 32

Mean parameter count: 0.9615384615384616

Function: parseCommandLine

Line No.: 27

Physical SLOC: 103

Logical SLOC: 19

Parameter count: 0

Cyclomatic complexity: 7

Halstead difficulty: 11.428571428571427

Halstead volume: 1289.3654689326472

Halstead effort: 14735.605359230252

Function: expectFiles

Line No.: 131

Physical SLOC: 5

Logical SLOC: 2

Parameter count: 2

Cyclomatic complexity: 2

Halstead difficulty: 3

Halstead volume: 30

Halstead effort: 90

// report continues

复杂性报告非常有用,因为它不仅自动化了对源代码评分的手工工作,而且还在文件和函数级别分析了源代码。这为开发者提供了一种机制来评估一个尺度上的变化如何影响另一个尺度上的分数。尽管该图书馆的报告信息丰富,但它们并没有为技术含量较低的利益相关者提供一个获得整体复杂性快照的途径。幸运的是,还有其他专门为此目的设计的工具。

柏拉图

Jarrod Overson 的 Plato 14 是一个代码质量分析仪表板,它创建了一组视觉上令人愉悦且信息丰富的报告。Plato 利用 JSHint 和 complexity-report 进行实际的分析,然后将它们的原始报告整理成一组信息丰富的图表。像任何好的可视化套件一样,Plato 理解当在不同的上下文中查看数据时,可以有不同的理解。出于这个原因,柏拉图将原始分数转换成各种信息空间,我将在接下来讨论。在本节中,我将使用关于 Grunt 15 项目的柏拉图报告的截图。

项目质量时间表

Plato 的第一个报告界面是项目质量时间表(见图 9-1 )。它提供了项目整体质量变化的一英里高的视图。

A978-1-4302-6098-1_9_Fig1_HTML.jpg

图 9-1。

Plato’s project quality timeline charts

不像其他的质量报告,在任何给定的时间仅仅给你一个快照,Plato 的总结视图,绘制了项目质量随时间的变化。这是非常重要的,因为它允许开发人员或经理了解质量的趋势。

项目度量视图

A978-1-4302-6098-1_9_Fig2_HTML.jpg

图 9-2。

Plato’s project maintainability chart

在程序摘要下面,Plato 显示了一组条形图,如图 9-2 所示。这些图表显示了常见测试的各种度量分数:“可维护性”(如图所示)、“代码行数”、“估计错误”和“lint 错误”。使用该视图,用户可以在选择一个文件进行详细检查之前,从整体上对文件进行视觉评估。

文件质量概述

A978-1-4302-6098-1_9_Fig3_HTML.jpg

图 9-3。

Plato’s file quality overview charts

最后一个概览图组织了每个文件的各种指标得分,如图 9-3 所示。度量视图允许您在心里将文件的性能相对于其对等文件进行排名;文件质量视图让您全面了解哪些文件在所有指标中问题最大。

Plato 总结视图的要点是快速识别代码库中的全局关注区域。然后,您可以深入检查任意文件。文件视图使用由数据源提供的相同的原始数据,但是将它们限定为在文件级别有意义,我将在下面解释这一点。

文件质量时间线

A978-1-4302-6098-1_9_Fig4_HTML.jpg

图 9-4。

Plato’s file quality timeline charts

文件质量时间线绘制了给定文件的质量随时间的变化,如图 9-4 所示。它们与项目时间表非常相似。Overson 已经有意识地决定只绘制可维护性和 LOC 度量作为时间表。他将难度和估计误差度量表示为单个值。然而,如果这些也是时间序列,将会提供更多的信息。

功能质量

A978-1-4302-6098-1_9_Fig5_HTML.jpg

图 9-5。

Plato’s function quality charts

一旦 Plato 建立了全局文件质量,文件级报告的剩余部分将致力于功能级分析。Plato 将文件的所有功能表示为一对环形图(见图 9-5 )。切片和颜色代表每个函数的不同分数。选择环形图是明智的,因为一个文件在函数总数上可以有很大的变化。然而,就信息密度而言,这些图表是最不成功的。

当用户选择一个圆环切片时,剩余圆环中的相应切片也被选择将是有益的。允许多重选择将使得复杂性和 LOC 之间的关系变得清晰。然而,两个图表甚至不需要。这两个指标可以很容易地在一个圆环图中表示出来,其中 LOC 控制切片的大小,complexity 控制颜色。更成问题的是选择使图表单色化。例如,除非你知道大的复杂性分数是不可取的,否则你很难仅凭柏拉图的颜色选择得出这个结论。更好的方法是重新引入概览图表中使用的红色、橙色和蓝色编码。这些颜色清楚地描述了哪个分数是理想的,哪个不是。更重要的是,柏拉图已经训练它的用户理解这些颜色语义,所以不再次利用它们是一种浪费。

源代码视图

柏拉图的最终视图根本不是图,而是程序源代码的注释视图(见图 9-6 )。查看者可以手动滚动到该部分,也可以单击功能质量图表中的任意环形切片。单击一个切片会立即将它们直接带到源代码中该函数出现的位置。通过点击函数的名称,查看者可以看到它收到的各种分数。在视觉上将分数定位到源中为观看者提供了在更大的源主体的上下文中考虑分数的机会。

A978-1-4302-6098-1_9_Fig6_HTML.jpg

图 9-6。

Plato’s source view screen

Plato 是探索特定代码库的质量度量的一个极好的工具。它做了所有好的可视化所做的事情,让浏览者对数据有更深的理解。柏拉图通过允许观众在不同的尺度和不同的语境中思考相同的分数来达到这个目的。对于非技术人员来说,当涉及到代码库的质量时,它尤其有用。对于这些观众来说,它提供了一种与开发人员就质量展开有见识的对话的方式,而不需要首先理解程序的实现。

摘要

本章考虑了 JavaScript 中代码质量的需求和推理。一个程序的质量经常影响程序员维护、增强甚至完全理解其源代码的能力。质量差的代码通常被描述为一种技术债务,它剥夺了项目的时间和资源,而这些时间和资源本可以更好地用在其他地方。然而,编程是一门学科,经常跨越艺术和科学之间的界限,使得质量的定义更加复杂。此外,质量同时是主观和客观的衡量标准。

可以说,素质受一个人的当代文化和个人经历的影响。这种形式将质量描述为具有一种“内在的卓越”,这种卓越必须由一个有这方面经验的人来识别。这可以解释为什么随着质量观念的改变,美术等领域的某些运动会经历潮起潮落。主观质量经常出现在手工编程的描述中(例如,软件工匠)。

相反,客观质量分析认为质量可以被提炼为一系列可重复的步骤。这些步骤可以通过质量度量来监控,这为程序员提供了如何改进代码的洞察力。这些度量很大程度上围绕着代码的静态分析,这是在不首先运行代码的情况下研究代码的能力。本章研究了静态分析的三种用途:

  • 检查语法正确性
  • 识别程序员偏离既定最佳实践的领域
  • 找到其他人难以理解的代码

这一章的大部分内容都是关于其他人为复杂代码评分而创建的算法度量。在编程中,复杂性是对开发人员为了完全理解一个代码单元而必须忍受的脑力劳动的度量。然而,这些措施中的许多,尽管信息丰富,但也不是没有它们自己的盲点。有些衡量标准,如霍尔斯特德的度量标准,利用了关于人类认知生理学的可疑假设。其他人,如 NPATH,认为额外的复杂性是基于某些流结构本来就比其他流结构更难理解。为了适应这些缺陷,最好是使用彼此一致的复杂性度量,并且只有当它们符合你自己对复杂性的世界观时。

本章的剩余部分致力于各种现成的工具,为您完成质量分析的重任。将这些工具作为持续集成工作流的一部分来使用,可以确保当您将代码发布到野外时,它将有最好的机会在将来被理解和维护。

Footnotes 1

http://en.wikipedia.org/wiki/Quality_assurance

  2

http://37signals.com/svn/posts/3159-testing-like-the-tsa

  3

http://www.jslint.com/

  4

http://www.jshint.com/

  5

https://github.com/ariya/esprima

  6

http://www.skrenta.com/2007/05/code_is_our_enemy.html

  7

http://en.wikipedia.org/wiki/Control_flow_graph

  8

http://www.verifysoft.com/en_halstead_metrics.html

  9

http://en.wikipedia.org/wiki/Halstead_complexity_measures

  10

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators

  11

http://esprima.org/demo/parse.html

  12

http://www.eetimes.com/author.asp?section_id=36&doc_id=1265859

  13

https://github.com/philbooth/complexityReport.js

  14

https://github.com/es-analysis/plato

  15

http://gruntjs.com/

十、提高可测试性

Abstract

对于每一个复杂的问题,都有一个简单、简洁而错误的解决方案。

门肯

每一个复杂的问题都有一个简单、简洁而错误的解决方案。—H. L .门肯

在一本书里完全涵盖 JavaScript 测试可能是不可能的,更不用说一章了。如果做得正确,测试应该是一个全神贯注的挑战,提供了一个令人信服的创造性和技术障碍的组合来克服。计算机科学中许多最聪明的头脑已经致力于工具、方法和模式的创造,这些工具、方法和模式使测试人员能够提高他们所负责的程序的质量和可靠性。因此,将测试排除在本书之外至少是对读者的一种伤害,并且可能会从整体上降低测试 JavaScript 的重要性。

鉴于测试是一本书大小的主题,我将本章的范围浓缩为提高 JavaScript 的可测试性。通过我的研究和作为开发人员的个人经验,我已经确定了几个通常阻止开发人员成功测试代码的因素。通常,失败是由于代码编写和评估中的各种偏见,当错误的测试目标被应用时,这些偏见就更加严重了。本章将指出开发人员在测试代码时不知不觉中陷入的各种偏见和盲点。本章的剩余部分将集中在通过重新聚焦测试来帮助减轻这些问题并提高代码质量的工具和过程上。

为什么测试失败了

当测试套件通过时,JavaScript 测试失败。测试的目标不是写测试,而是通过让程序失败来发现 bug。稍后我会更详细地探讨这个断言,但现在我想把这个想法放入你的头脑中。知道如何编写测试不仅仅包括这样做的技术能力。为了正确地测试一个程序,你必须有正确的心理定势,以及一个在技术资料中很少讨论的测试目标的清晰定义。尽管如此,正如 Glenford Myers 在他的书《软件测试的艺术》中所指出的,“对测试采用适当的心态似乎比纯粹的技术考虑更有助于测试的成功”(Myers,1979)。

偶尔,我会遇到一些开发人员,他们把测试推迟到他们的程序完成之后。他们没有把最好的留到最后,就好像写测试是一顿饭最后的甜点。相反,他们正在拖延他们认为是一项不幸的苦差事,只需要找到他们代码中偶尔的迟钝错误。如果你是一个有好奇心和创造力的程序员,并且编写测试不是饭后甜点,那么你很可能做错了。然而,为了完全公开,我对 JavaScript 测试的感觉已经发展到这种观点。在过去的十年中,我花了很多时间尝试热爱测试 JavaScript。

2005 年,我加入了我的第一个敏捷开发团队。我们练习了测试驱动开发(TDD)和结对编程。当时,我们主要编写 Ruby web 应用,它们有一个开发良好的测试生态系统。TDD 就像任何新工具一样;一旦我知道如何使用它,我就想在所有东西上试一试。然而,当我从服务器级 Ruby 代码转移到控制视图的 JavaScript 时,我的测试变得几乎不存在了。在公开场合,当谈到 TDD 和 JavaScript 测试的重要性时,我继续热情地挥舞着拳头,但是私下里,我实际上没有编写任何测试。

我记得我被其他高级开发人员中的一个人骂了一顿。在我的辩护中,我声称 JavaScript 测试是一种棘手的问题,由语言特有的问题交织在一起。我认为,这些隐含的困难会棘手地阻碍任何开发人员准确测试程序的尝试。这些复杂性使得测试 JavaScript 成为一个耗时的过程,几乎没有回报的机会。为了说明我的观点,我列举了可测试性结中的线程。

  • JavaScript 社区不像其他语言那样拥有测试代码所需的同等质量的工具。没有质量工具,编写测试是不切实际的,甚至是不可能的。
  • JavaScript 通常与用户界面有着千丝万缕的联系,用于产生必须由人类体验和评估的视觉效果。对手工评估的需求使得 JavaScript 无法以编程方式进行测试。
  • 您无法像使用其他语言中的命令行调试器那样快速通过正在运行的测试。如果没有这种能力,调试 JavaScript 就是一场噩梦。
  • 不断变化的主机环境(浏览器)使测试变得不切实际,因为开发人员必须为每个主机环境重复测试相同的程序逻辑。

在 2005 年,我并不是唯一一个对测试 JavaScript 的现实进行失望评估的人。然而,今天,JavaScript 已经有了一个强大而充满活力的测试社区,有了越来越多的工具和框架来寻找程序缺陷。然而,即使是经验丰富的开发人员仍然抱怨 JavaScript 很难测试。在最近的一篇博客文章中,Rebecca Murphy 要求举例说明为什么开发人员不测试他们的 JavaScript。在她发布的回复列表中,你会发现近十年前我给我同事的每一个借口都有不同的版本。但是有一个例外:不是测试工具太少,而是抱怨工具太多了!事实上,JavaScript 语言和测试工具是程序员误用和误解测试的方便替罪羊。这种误用很大程度上可以归结为使用了错误的测试定义,这反过来设定了错误的目标,并最终产生了错误的结果。

测试谬误

本节列举并纠正关于测试的常见误解。这些误解经常导致开发人员采用错误的测试目标,这决定了他们如何以及何时编写测试。为了充分理解这些决策最终如何影响最终产品的质量,我将解开这些谬误,并解释它们对测试实践的影响。

测试证明程序中没有错误

程序测试可以用来显示缺陷的存在,但永远不能显示缺陷的不存在!艾兹格·迪科斯彻

在继续之前,简要考虑一下 Dijkstra 关于测试的引用。它打破了测试确保程序没有错误的普遍误解。这是一个谬误,因为正如 Dijkstra 指出的,它是无法证明的。更重要的是,当一个目标基于一个无法量化的指标时,这个目标就变得无法实现。从理性的角度来看,在无法达到的目标下进行测试意味着测试过程注定要失败,因为任务没有逻辑结论。因此,经理、程序员和利益相关者对测试过程充满矛盾,因为它永远不会结束。这使得测试成为事实上的资源税,随着时间的推移,各方都会对此不满。

成功的测试是那些没有错误地完成的测试

Glenford Myers 详细描述了通过测试给开发团队带来的虚假的安全感。他断言,许多开发人员和管理人员以完全错误的方式衡量测试的成功,指出没有发现 bug 的测试套件是他们程序健康的标志。迈尔斯用了一个绝妙的类比来贬低这种相关性:

Consider the analogy of a person going to see a doctor because of general malaise. If doctors have conducted some laboratory tests without positioning problems, we don't call these laboratory tests "successful"; They are unsuccessful experiments. .. the patient is still ill, and the patient now questions the doctor's ability as a diagnostician. (Miles, 1979)

很明显,为什么这个特殊的测试神话在我们的行业中如此根深蒂固。编写软件通常是高度个人化和详尽的努力。当发现错误时,很容易理解程序员会觉得错误是他们自身能力的反映。所以程序员必须抵制这种把 bug 个人化的冲动。这种对代码的个人依恋感是他们经常编写肤浅的构象测试的原因,这种测试更多的是保护脆弱的自我,而不是真正地询问程序。正如 Boris Beizer 所写:

Programmer! Throw away your guilt! Spend half the time on happy testing and debugging! Track bugs with care, methods and reasons. Build traps for them. More cunning than those cunning bugs, enjoy the fun of innocent programming! (Baesel, Software Testing Technology, 1990)

测试确保程序是高质量的

当管理人员使用测试作为对低质量软件的防御封锁时,他们可能会确保相反的结果。当测试成为程序员需要克服的障碍时,它们就变成了障碍,而不是素材。虽然识别 bug 的测试确实提供了提高程序质量的机会,但是当测试成为一种正式的质量度量时就有危险了。测试作为质量的衡量标准是有问题的,原因如下:

  • 作为质量度量的测试挫伤了程序员的士气,因为他们推断他们的源代码从一开始就是低质量的。
  • 它在测试和开发过程之间构建了一个无用的二分法。这也可能会加剧团队中的紧张气氛,将测试人员和开发人员分开。
  • 这表明测试本身可以增加代码库的质量。
  • 如果测试是质量的仲裁者,他们推断测试人员和开发人员之间的权力是不对称的,就好像开发人员代表他们的程序向测试人员恳求一样。

你不能仅仅通过测试来提高程序的质量。Beizer 再次指出了测试作为质量度量的误用,他写道:

Programmers are responsible for software quality-the quality of their own work, including the quality of the products they work on, and the quality of interfaces between components. Quality has never been and will never be. This is not only a moral responsibility, but also a professional responsibility. (Baesel 1990)

测试防止未来的错误

测试用例是程序执行环境的开发工件,它们是用来测试的。随着计划目标的发展,在早期测试中做出的假设可能不再有效。在这些情况下,测试中断是受欢迎的,因为它说明了测试套件在哪里已经与代码库分离。

在其他情况下,对程序的新修改可能会破坏仍然有效的旧测试。在这些情况下,通过之前的测试覆盖,新的 bug 会立即被发现。经理们经常使用这样的事件作为测试防止未来错误的证据。这种观点将测试视为一种程序化的免疫系统,保护应用免受未来未知错误的影响。

这种观点的谬误在于,它假设测试应该具有某种预测性。这导致开发人员编写多余的假设测试,这是对测试目的的打击。

测试证明该程序按设计运行

许多开发方法,如 TDD,使用测试作为一种验证程序是否按设计运行的手段。从业者首先编写解释方法预期功能的测试。在 TDD 中,测试套件总是在运行,所以新的测试最初会失败,因为它验证的方法还不存在。TDD 的支柱之一是编写足够的代码来通过测试。这样做可以确保额外的和不需要的功能不会作为所谓的自由功能添加到程序中。此外,如果开发人员只是试图推断特性需要什么,那么将测试作为规范与源代码紧密耦合会限制引入的代码量。

TDD 的盲点是它会导致一些开发人员狭隘地考虑他们的测试,并且仅仅作为一种事实上的文档形式来证明函数做了它应该做的事情。“按规格测试”方法的意想不到的结果是,将编写测试来确保该方法是有效的。毕竟,规范应该是表达性的和简洁的,所以测试可能会肯定一种方法,而不是发现其局限性。以证明功能为目的的测试忽略了一个事实,即一个功能可以工作,但仍然包含 bug。没有发现任何错误的肤浅的肯定测试最终是浪费时间和精力。

对于像 TDD 这样的方法来说,要正确地工作,他们依赖于一个程序员,这个程序员可以一边写代码,一边试图破解它。也就是说,设计测试的行为与编写通过测试的代码一样重要。再次引用 Boris Biezer 的话:

Design behavior is one of the best known error prevention measures. Thinking necessary to create a useful test can find and eliminate errors before they are coded-in fact, test design thinking can find and eliminate errors in every stage of software creation, from concept to specification, to design, coding and so on. (Baesel 1990)

单独一个人并不总是能够充分设计测试。确认偏差——我将在下一节解释——经常阻止开发人员深入思考测试。许多方法通过让开发人员一前一后地编写代码来减轻这种偏见。当一个程序员试图证明一种方法时,另一个程序员会试图寻找例外。只有当参与者在技能和等级上相对平等时,这种结对程序员才起作用;否则,两者中的较低者将倾向于服从领导。

确认偏差

确认偏差描述了一个人倾向于支持其世界观的信息而忽视相反证据的可能性。政治家和宗教狂热者是两个以生活在泡沫中而闻名的群体,这实际上是他们确认偏见的表现。毫不奇怪,花大量时间思考的程序员也会遭受确认偏差,尽管它的表达可能更微妙。

本节解释了软件开发过程中导致编程中确认偏差的各种因素。这些原因包括开发人员固有的认知失调,测试框架中经常未被认识到的偏见,以及测试人员在错误的地方寻找 bug 的倾向。

选择性观看

当我完成这本书时,我花了几个小时写和重写段落,尽一切努力解除我早先埋下的语法和拼写地雷。在把每一章提交给我的编辑之前,我会先默读一遍,然后再大声朗读。然而,毫无疑问,当这一章被归还时,它充满了更正。我敢打赌,你们中的许多人都有过类似的经历,因为这种有选择地看东西的倾向在人类生活中相当普遍。

写软件和写书一样,应该是作者手艺的个人表现。因此,程序员和其他工匠一样,倾向于在他们的工作产品中看到自己的影子。程序员倾向于对自己的能力持乐观态度,进而对他们编写的软件持乐观态度。程序员和他们的工作之间的这种亲密关系会阻止他们诚实地评估它。他们倾向于有选择地从他们打算运行的参考框架中看到他们的功能,而不是他们实际实现的。

知识的诅咒

知识的诅咒有时被描述为不能敲钟。这个比喻提供了一个令人愉快的视觉效果:声波永远向外传播到太空中。显而易见,声波一旦在空气中荡漾,就无法被吸回钟口。在编程中,知识的诅咒是程序员不能从一个不太了解的用户的角度考虑他们的软件。

你可能认为对一个程序的深入理解会给开发人员提供围绕它编写健壮测试的能力。然而,在现实中,这些测试不太可能发现函数中隐藏的边缘情况,因为程序员无法从他们的方法中获得足够的临界距离。知识的诅咒增加了缺陷密度,缺陷密度是 bug 挤在一起的可能性。这些错误被一个关于应用如何工作的错误假设从程序员的角度屏蔽了。

如果一开始你成功了

假设您是一名开发人员,任务是对一个具有大量测试覆盖的应用中的现有功能进行微小的更改。在做出改变并运行测试之后,你会因为它们都通过了而感到欣慰。你觉得代码仍然是健康的,这是可以理解的,尤其是当一个大规模的测试套件重申了这一信念。然而,这被称为“无错误谬误”,意思是仅仅因为你的测试没有发现任何错误并不意味着没有错误可找。为了确保您的更改实际上是安全的,您必须将您的更改与测试的意图进行交叉引用。所写的测试是为了覆盖你的变化,还是因为不相关的原因而继续通过?对抗这种偏见时,我遵循的座右铭是:如果你第一次成功,尝试,再尝试。

农药悖论

Boris Beizer 的软件测试第一定律陈述如下:

"Every method you use to prevent or find mistakes will leave more subtle mistakes that are ineffective." (Baesel 1990)

杀虫剂悖论解释了过去的测试会发现未来错误的谬论。事实上,这些测试想要捕捉的错误已经被捕捉到了。这并不是说这些测试没有回归测试的价值,回归测试可以确保这些已知的错误不会再次出现。然而,你不应该期望通过在旧的地方寻找测试来发现新的错误。

缺陷簇

在现实世界中,bug 并不是均匀分布在整个场景中的。相反,他们挤在角落里,冰箱下面,以及其他难以触及的地方。冒险进入太空的虫子很容易被鞋子碰到并被压扁。在软件中,这被称为帕累托原则,该原则指出,几乎 80%的结果来自 20%的原因。 3 简单来说,bug 就是其他 bug 在的地方。

Bug 集群经常是由于开发人员成为他们自己的知识诅咒的牺牲品而出现的。然而,一旦程序员识别出这种偏差,群集就可以指导程序员在哪里应用未来的努力。例如,如果相同数量的测试在一个模块中发现了六个错误,而在另一个模块中只发现了一个,那么您很可能会在前者中发现更多的错误。这是因为 bug 集群可以向程序员揭示关于他们程序的关键误解。

框架偏差

测试自动化框架是为了捕捉软件缺陷而有计划地运行测试用例的软件工具。这些框架通过使测试运行更容易来降低维护成本。通常,这些框架成为开发工作流程链中的一个环节,它允许测试在最大化其有效性的环境中运行。测试框架是现代开发生活的重要组成部分,应该尽可能地被接受。

然而,测试框架不是在真空中创建的;他们受到创造者认为正确的关于测试的一套假设和哲学的影响。就像任何通过隐喻联系起来的过程一样,测试框架有可能使一项任务变得更简单,而使另一项任务变得更困难。这是因为隐喻允许你把对一个主题的理解移植到对陌生事物的理解中。然而,隐喻所提供的启示并不总是与它要解释的主题完全重叠。

例如,浏览器供应商最初用一本书的比喻来解释互连服务器的网络,这对于非技术人员来说很难概念化。被告知浏览器是阅读互联书籍的工具,新用户可以利用他们现有的关于书籍如何工作的知识。阅读网页和在浏览器中放置书签变得很自然。不幸的是,书的比喻掩盖了访问网络的许多更有趣的潜力,例如不要将数据视为一系列不同的页面,而是视为一个持续流动的信息流,用户将其捕获到池中使用,然后发布。

当程序员将他们的测试范围限制在框架的能力之内时,就会出现框架偏差。例如,许多应用依赖远程 API 提供的数据。然而,在测试过程中对 API 进行实时调用是不可接受的,因为这会降低测试运行程序的速度,并对实时 API 进行不必要的调用。相反,许多框架提供了模拟或剔除 API 集成的机制,从而允许测试接受与被测试的上下文相关的固定答案。如果您的测试框架没有为测试编写人员提供这些功能,他们必须通过重载函数来缩短生产代码以使测试工作,或者(更糟糕的是)避免一起测试这些特性。

减轻确认偏差

上一节详细介绍了无意偏差渗入测试实践的各种方式。这些偏见中的大多数会导致开发人员忽视或忽略他们可能会捕捉到的潜在错误。本节描述了纠正措施,这些措施有助于减轻测试中的确认偏差。

测试失败

许多偏见的根源在于这样一个事实,即程序员将失败的测试视为他们自己无知的标志,而不是他们追踪缺陷的坚韧的证明。测试的目标是让程序失败。运行时没有引发异常的测试套件应该被视为资源浪费,就像不能诊断问题的机械师的旅行被视为浪费金钱一样。

获得临界距离

当开发人员失去了对他们测试的代码进行批判的能力时,就会出现确认偏差。这在测试自己代码的开发人员中尤其明显。让一个独立的团体编写测试,或者开发一系列概念性的提示或程序性的步骤来帮助构建测试环境,对于保持临界距离是有帮助的。

找到边缘

测试通常是探索未知,验证假设,然后使用这些发现来重新调整你对程序如何工作的心理模型。为了找到边缘案例,开发人员需要抵制有选择地查看他们认为代码在做什么的倾向。找到一个函数的边界情况的一个方法是列举你对这个方法的假设,然后分别测试它们。这种系统化的方法可以迫使开发人员在更全面的环境中考虑这些功能。这种方法有助于减轻掩盖功能细节的倾向,因为这些细节已经变得太熟悉而不能进行批判性的考虑。第二种选择是实现特定类型的测试,这些测试旨在以开发人员可能不希望的方式使用该函数。模糊测试——您将在后面详细讨论——是一种找到程序边缘的方法。

寻找基线

测试套件在覆盖所有重要部分的能力方面被认为是床罩。当通过应用的所有路径都被至少一个测试覆盖时,程序被认为是测试良好的。具有足够测试覆盖率的程序被认为不太可能包含错误,因此经常被用作程序质量的衡量标准。开发人员维护足够的测试覆盖率的能力是累积测试实践中的一个重要因素。

然而,测试写作中的几个倾向会导致覆盖率降低。当测试没有与应用代码并行编写时,它们可能会失去同步。随着大量开发下的程序的发展,测试可能会落后。此外,很难一眼看出测试是否真正覆盖了程序中的所有不同路径。对一个快速发展的应用的整体测试覆盖率进行精确的度量有点像在某人跑步的时候测量他的身高。

幸运的是,测试覆盖率可以通过使用代码覆盖工具来自动计算。这些工具库可以与测试运行程序进程一起运行。当测试运行时,覆盖率工具会跟踪程序源代码的哪些部分在测试执行时被调用。

在测试套件完成之后,覆盖工具可以生成一个报告供程序员查看。许多最强大的覆盖报告是交互式的和高度可视化的。它们允许读者衡量整个应用的整体覆盖范围,或者深入到特定的源文件。在文件级别,报告通常会进行注释和颜色编码,以反映代码覆盖率的各个方面。图 10-1 显示了伊斯坦布尔覆盖工具的截图,我将在本章的后面演示。这个视图代表一个单一文件的覆盖率报告。较黑的线条代表文件中没有被测试触发的区域。

A978-1-4302-6098-1_10_Fig1_HTML.jpg

图 10-1。

The Istanbul coverage tool in action

本节将讨论这些工具用来测量测试覆盖率的几种常见的覆盖率算法。一旦您有了理解代码覆盖率计算如何工作的基线,我将演示如何在您自己的 JavaScript 应用中使用它。最后,为了与本章的主题保持一致,我还将讨论这些工具可能引入的意想不到的偏见。

报表覆盖范围

语句覆盖是代码覆盖最直接的形式。它只是记录语句执行的时间。考虑下面的例子,其中测试在userundefined的上下文中运行。在本例中,加粗的语句表示使用语句覆盖率计算的行数。

var friendNames;

function findFriends(user) {

var friends = [];

if (user) {

friends = user.getFriends().map(function (friend) {

return friend.firstName + " " + friend.lastName;

});

} else {

friends = ["You are unpopular!"];

}

return friends;

}

friendNames = findFriends();

功能覆盖

代码覆盖的另一个基本形式是函数覆盖,它决定任何测试是否至少调用一次函数。函数覆盖并不跟踪测试如何摆弄函数的内部结构;它只是关心函数是否被调用。这个度量标准的一个派生叫做function call coverage,它计算测试调用的函数的总百分比。要使用函数调用覆盖率达到 100%的分数,每个函数必须至少调用一次。继续前面的例子,您可以看到代码的哪些部分将被函数覆盖:

var friendNames;

function findFriends(user) {

var friends = [];

if (user) {

friends = user.getFriends().map(function (friend) {

return friend.firstName + " " + friend.lastName;

});

} else {

friends = ["You are unpopular!"];

}

return friends;

}

friendNames = findFriends();

分支覆盖

为了有足够的分支覆盖率,测试中必须覆盖通过一个函数的每一条路径。在我的代码示例中,只有else分支会被覆盖。这意味着至少应该添加一个其他的测试来获得这个功能的完整的分支覆盖。分支覆盖对于突出可能未被测试的边缘案例非常有用。下面的代码演示了分支覆盖率指标将计算的内容:

var friendNames;

function findFriends(user) {

var friends = [];

if (user) {

friends = user.getFriends().map(function (friend) {

return friend.firstName + " " + friend.lastName;

});

} else {

friends = ["You are unpopular!"];

}

return friends;

}

friendNames = findFriends();

伊斯坦布尔

使用上一节中的覆盖率度量,很明显可以将它们结合起来为一个程序创建一个总的覆盖率分数。在测试运行过程中,这些指标的编组是代码覆盖工具的工作。在这一节中,我将介绍伊斯坦布尔, 4 这是克里希南·阿南瑟瓦兰创造的一个覆盖工具。伊斯坦布尔是用 JavaScript 为 JavaScript 写的。因此,与许多其他覆盖工具不同,伊斯坦布尔被设计为在 JavaScript 可以运行的任何地方运行,因此支持基于浏览器的执行和命令行工具。根据文档,伊斯坦布尔的设计考虑了以下使用案例:

  • 为了提供nodejs单元测试的透明覆盖
  • 为了适应npm测试脚本的使用,以允许有条件的覆盖
  • 允许批量检测测试,这对浏览器测试很有用
  • 支持与自定义 Node.js 中间件的集成,该中间件支持服务器端的代码覆盖
安装伊斯坦布尔

伊斯坦布尔需要 Node.js 才能运行,可以像这样作为npm包安装:

npm install -g istanbul

在下一节中,我将演示伊斯坦布尔的cover命令,这只是伊斯坦布尔提供的几个有用工具中的一个。

涉及

命令为任意文件生成一个覆盖对象和报告。coverage 对象不仅是程序的单个元素(即函数和语句)的 JSON 表示,也是描述执行路径的分支图。伊斯坦布尔使用 coverage 对象作为它创建的可视报告的输入。例如,您可以像这样通过cover命令运行findFriend程序:

istanbul cover find-friend.js

此时,伊斯坦布尔不仅会生成coverage对象,还会将一些有用的概述统计数据打印到终端窗口:

=============================================================================

Writing coverage object [/Users/heavysixer/Desktop/js/coverage/coverage.json]

Writing coverage reports at [/Users/heavysixer/Desktop/js/coverage]

=============================================================================

=============================== Coverage summary ===============================

Statements : 77.78% ( 7/9 )

Branches : 50% ( 1/2 )

Functions : 50% ( 1/2 )

Lines : 77.78% ( 7/9 )

================================================================================

请注意,这个覆盖率总结与您之前可以确认的内容相一致——通过手动逐步执行各种覆盖率度量标准。因为用户变量是undefined,所以从来没有执行过findFriends函数。这种未开发的路径解释了为什么您在分支和功能上都只有 50%的覆盖率,以及为什么超过 20%的代码没有运行。

为了更清楚地可视化测试覆盖率,打开伊斯坦布尔创建的覆盖率报告。它应该位于相对于测试文件的 coverage 文件夹中。首先,在浏览器中打开索引文件;根据您的系统,您可能能够像这样打开和查看文件:

open coverage/lcov-report/index.html

打开后,进入js文件夹,查看相关文件的覆盖报告。从这个屏幕上(见图 10-2 ,应该很清楚哪些行没有被执行。

A978-1-4302-6098-1_10_Fig2_HTML.jpg

图 10-2。

Istanbul coverage report for a single file Note

图 10-2 中被黑框包围的“I”是伊斯坦布尔用来描述缺乏保险原因的术语的一部分。在这种情况下,I 代表未被采用的分支。

覆盖偏差

像伊斯坦布尔这样的代码覆盖工具对于快速和可视化地探索一个程序的覆盖是非常棒的。他们使观众不费吹灰之力就能发现测试中的不足。然而,覆盖工具有它们自己的偏见,你应该知道。执行一行代码与测试一行代码有很大的不同。代码覆盖率度量会给人一种 100%覆盖率等同于 100%测试的虚假安全感。为了不陷入这种偏见,开发人员应该使用覆盖工具来发现测试覆盖的缺失,而不是作为证明它的一种方式。

偏差破坏测试

程序的可测试性很大程度上取决于开发人员克服各种偏见的能力,否则这些偏见可能会阻止应用得到充分的测试。正如我所讨论的,这些偏见可能包括心理障碍,如知识诅咒或选择性观看,这阻止了测试人员客观地思考测试。或者,开发人员可能在对抗另一个工具中隐含的偏见;例如,一个测试框架支持一种类型的测试,同时阻碍另一种类型的测试。本节提供了三个偏差消除测试,您可以使用它们来增强您的测试方法。

模糊测试

正如我之前详述的,当开发人员不能客观地思考他们自己的代码时,测试偏差经常发生。在这些情况下,程序员可能不再编写测试来使代码失败,而是编写测试来证明功能按设计运行。为了减少这种偏见,开发人员从不测试他们自己的代码。然而,这并不总是可能的,甚至不是所期望的。另一种方法是使用测试实践,强制程序以意想不到的方式使用。

模糊测试(Fuzz testing), 5 或 fuzzing,是一种软件分析技术,它试图通过向应用提供意外的、无效的或随机的输入,然后评估其行为,来自动发现缺陷。巴顿·米勒创造了“模糊”一词。当被问及这个名字的由来时,他说这个 ?? 6T7:

The original work was inspired by logging into a modem in a storm with a lot of line noise. The line noise produced garbage characters, which seemed to cause the program to crash. Noise is reminiscent of the word "fuzzy".

模糊化对于黑盒测试特别有用,黑盒测试的目标是发现程序的功能边界。模糊化也被黑帽和白帽安全专家广泛用作挖掘系统漏洞的工具。

模糊测试的两种最常见的形式是基于突变的和基于生成的。基于突变的模糊器对其数据的格式或结构知之甚少。他们盲目地修改他们的数据,仅仅在异常发生时记录下来。由于这个原因,这些模糊器通常被称为(哑模糊器。)基于生成的模糊器理解它们的数据格式的语义,因此在那些约束内创建它们的输入。在预定义的规则空间内操作通常意味着它们的结果会更精确。然而,正如你所料,制作一个基于世代的模糊器比基于突变的模糊器需要更多的时间。

JavaScript fuzz tester 的目标是强制主机环境在运行时执行或至少编译模糊的输入。为了实现这一目标,模糊器必须遵守语言的句法规则。否则,解释器将无法执行代码,这与发现错误是不同的。jsfunfuzzer(由杰西·鲁德曼 7 编写)是最著名的 JavaScript fuzzers 之一。Jsfunfuzz 被证明在寻找漏洞方面有点太有效了,并且不再公开提供。在针对火狐浏览器的最初模糊测试活动中,jsfunfuzz 在火狐 JavaScript 引擎中发现了 280 个错误 8 。从那以后,它已经找到了 1000 多个漏洞。想一想。这个测试工具在 JavaScript 解释器中发现了数百个错误,解释器的开发是由语言的创造者管理的!鲁德曼推测为什么 jsfunfuzz 能找到这么多 bug:

  • 它知道 JavaScript 语言的规则,这使得它能够很好地覆盖语言特性的组合。
  • 它打破了规则,允许它在语法错误处理中找到错误,如 bug 350415,并且更普遍地帮助 fuzzer 避免出现“盲点”。
  • 它不怕以相当复杂的方式嵌套 JavaScript 结构,比如当它发现 bug 353079 时。
  • 它允许通过在循环中创建和运行函数来累积状态。(请参见 bug 361346,它是一个很难找到的 bug 的例子。)
  • 它测试正确性,而不仅仅是崩溃和断言。

自从 jsfunfuzz 发布以来,出现了其他著名的模糊工具,如 LangFuzz 和 Crossfuzz。 9 Crossfuzz 甚至可以直接在浏览器中运行,这大大方便了使用。许多模糊器不仅触发异常,还能够将导致失败的步骤转录到生成的测试中。例如,以下测试 10 由 LangFuzz 生成,以在 Google V8 JavaScript 引擎中产生断言失败:

var loop_count = 5;

function innerArrayLiteral(n) {

var a = new Array(n);

for (var i = 0; i < n; i++) {

a[i] = void ! delete 'object' % ∼ delete 4;

}

}

function testConstructOfSizeSize(n) {

var str = innerArrayLiteral(n);

}

for (var i = 0; i < loop_count; i++) {

for (var j = 1000; j < 12000; j += 1000) {

testConstructOfSizeSize(j);

}

}

然而,尽管这些模糊器在寻找程序中的安全漏洞和盲点方面非常强大和有效,但它们有几个缺点:

  • 基于突变的模糊器可以永远运行。因此,很难选择一个持续时间,给你一个有意义的机会在不消耗太多时间的情况下找到 bug。
  • 大多数 JavaScript fuzzers 主要针对主机环境,如浏览器和 JavaScript 引擎;将模糊引向独立 JavaScript 程序的选择有限。
  • 模糊器可以通过一个共同的故障点找到数百个相关的 bug,这意味着得到数百个有不必要重叠的测试。因此,在将生成的测试添加到永久测试套件之前,每个缺陷都必须被单独考虑,并放在整个 bug 集合的上下文中。
  • 模糊器不仅能发现程序中的错误,还能发现底层语言中的错误。因此,很难区分程序中的错误和 JavaScript 解释器中的错误。

尽管模糊化 JavaScript 应用确实会让程序员对他们根除 bug 的能力大吃一惊,但很难让他们直接关注您的应用。然而,有一些“fuzzeresque”工具可以以意想不到的方式扭曲应用,并且只关注程序,在下一节介绍 JSCheck 时,您将会看到这一点。

JSCheck

JSCheck 11 是由道格拉斯·克洛克福特编写的测试工具,灵感来自 QuickCheck。在模糊化部分,我解释了基于生成的模糊化器使用约束和规则空间来限制它们产生的随机数据的类型。JSCheck 以类似的方式工作,但是调用这些约束规范。它使用规范来验证关于程序的声明。由于这个原因,JSCheck 被认为是一个规范驱动的测试工具。JSCheck 通过处理关于程序的声明来生成测试,试图根除边缘情况和异常。就像模糊测试一样,这种方法的优势在于打破了程序员对他们程序的偏见。为了更好地理解 JSCheck 是如何工作的,我将带您看一个基本的例子。

安装 JSCheck

不幸的是,JSCheck 不能通过任何您喜欢的包或依赖项管理器获得,所以您必须从 git repo:

https://github.com/douglascrockford/JSCheck/archive/master.zip

基本示例

我将使用 Node.js 来运行测试,所以如果您想继续的话,请确保您已经安装了它。首先,我需要一个函数来测试。将以下代码另存为flip-test.js:

function flipSign(val) {

return ∼(val - 1);

}

这个函数的目的是取任意一个数字并相应地翻转符号。我现在的目标是使用 Node.js 用 JSCheck 对此进行严格的测试。为了将 JSCheck 包含到测试中,我需要求助于一些eval诡计,因为它不能作为 Node.js 模块导出。(当然,除了在示例中瞎搞之外,不建议做任何其他事情。)如果您想将 JSCheck 作为集成测试工作流的一部分,您必须首先为它创建一个合适的导出工具。flip-test.js文件现在应该看起来像这样:

eval(require('fs').readFileSync('./jscheck.js', 'utf8'));

function flipSign(val) {

return ∼(val - 1);

}

Note

请确保 JSCheck 的路径是正确的。如果您直接下载了 zip 文件,jscheck.js可能会嵌套在另一个文件夹中。

测试文件中包含了 JSCheck,并且编写了flipSign函数,我已经准备好对该功能做一些声明,JSCheck 将会验证这些声明。

提出索赔

声明由三个必需属性和第四个可选属性组成。

名字

JSCheck 总是期望第一个参数是name。JSCheck 报告功能使用它来简要解释索赔的上下文。

述语

所有的声明都需要一个predicate参数,它指定了一个能够返回布尔值的函数,这取决于声明是否能够被证实。在这种情况下,您需要确保符号被翻转,这样函数看起来就像这样:

function predicate(verdict, value) {

return verdict(value === flipSign(flipSign(value)));

}

predicate()方法至少有两个参数。第一个参数总是verdict函数,JSCheck 使用它来报告比较的结果。verdict功能非常健壮,旨在支持需要网络事务或异步请求的功能。其余的参数是 JSCheck 根据您在签名数组中配置的说明符为您生成的值。

签名

签名是描述参数范围的说明符数组,可以提供给predicate函数。JSCheck 提供了一系列说明符模板,可用于约束随机值。例如,JSC.integer(0,10)会生成一个 0 到 10 之间的整数。其他说明符可能相当复杂。object说明符接受一个对象作为模板,它反过来利用其他说明符:

JSC.object({

left: JSC.integer(640)

top: JSC.integer(480)

color: JSC.one_of(['black', 'white', 'red', 'blue', 'green', 'gray'])

})

在您的程序中,您只需要一个数字,因此签名数组可能是这样的:

[JSC.integer(-10, 10)]

分类者

classifier是唯一可选的参数,根据 JSCheck 文档,它有两个主要用途:

  • 它可以检查参数并返回一个对案例进行分类的字符串。该字符串是描述性的。该报告可以包括显示属于每个分类的案例数量的摘要。它可以用来识别琐碎的或有问题的类,或者帮助分析结果。
  • 因为案例是随机生成的,所以有些案例可能没有意义或没有用。分类器可以通过返回false拒绝案例。JSCheck 将尝试生成另一个案例来替换它。建议分类器拒绝少于 90%的情况。如果你接受的潜在案例少于 10 %,你也许应该重新阐述你的主张。

因为你的函数很琐碎,所以 JSCheck 提供的几乎任何值都是可用的;因此,测试不需要分类器。

核实索赔

现在我有了一个predicate和一个合适的签名数组,我准备像这样连接 JSCheck 测试:

eval(require('fs').readFileSync('./jscheck.js', 'utf8'));

function flipSign(val) {

return ∼ (val - 1);

}

function predicate(verdict, value) {

return verdict(value === flipSign(flipSign(value)));

}

JSC.on_report(function(str) {

console.log(str);

});

JSC.test("flips integers", predicate, [JSC.integer(-10, 10)]);

注意,我为JSC变量提供了一点配置。现在,每当 JSCheck bon_report事件进行广播时,结果都会被写入控制台。配置完成后,脚本调用test函数,并为其提供各种所需的属性。将更改保存到文件,然后从命令行以如下方式运行它:

node flip-test.js

该命令完成后,JSCheck 会将套件的结果输出到终端窗口:

flips integers: 100 cases tested, 100 pass

Total pass 100

优秀;不出差错,正如我所料!不过,为了确保安全,我将添加另一个测试,除了整数之外,它还使用了数字说明符。这将允许我测试浮点数。测试文件现在应该如下所示:

eval(require('fs').readFileSync('./jscheck.js', 'utf8'));

function flipSign(val) {

return ∼ (val - 1);

}

JSC.on_report(function(str) {

console.log(str);

});

function predicate(verdict, value) {

return verdict(value === flipSign(flipSign(value)));

}

JSC.test("flips integers", predicate, [JSC.integer(-10, 10)]);

JSC.test("flips numbers", predicate, [JSC.number(-10,10)]);

我将再次从命令行对这个文件运行 JSCheck。然而,这次我遇到了一些意想不到的失败:

node flip-test.js

flips integers: 100 cases tested, 100 pass

Total pass 100

flips numbers: 100 cases tested, 0 pass, 100 fail

FAIL [1] (-4.945855345577002)

FAIL [2] (0.6835379591211677)

...

FAIL [100] (0.6536271329969168)

更仔细地看一下flipSign函数,它在浮点数上失败的原因就清楚了。虽然按位NOT运算符像我希望的那样反转操作数的位,但它也将结果转换为整数。这种副作用正是 JSCheck 擅长发现的错误类型,因为它发生在我对该函数的心理模型之外。现在我已经确定了函数中的盲点,我可以这样重写它:

function flipSign(val) {

return val * -1;

}

再次运行测试,我可以看到修改达到了预期的效果,我的函数现在可以很好地处理浮点数和整数了:

node flip-test.js

flips integers: 100 cases tested, 100 pass

Total pass 100

flips numbers: 100 cases tested, 100 pass

Total pass 100

自动化测试

许多工业制造商使用机器人作为产品测试的一个组成部分。如果你走进任何一家宜家商场,你都有可能看到他们的机器人在展示。在一个巨大的玻璃盒子里,机器人将重复地、有条不紊地把它的模拟屁股坐在一把椅子上。宜家希望其消费者将机器人的严格重新安置与宜家自己彻底测试其产品的尝试联系起来。

在自动化软件测试领域,大多数用户交互都是使用 PhantomJS 之类的无头浏览器或 Selenium 之类的浏览器插件模拟的。这些工具被编写成像人一样与应用交互。然而,他们假设用户使用鼠标和键盘通过计算机进行交互,并且交互由点击和按键组成。在当今世界,这是一个过时的假设,一个节目可以在数百种不同的设备上观看,这些设备的屏幕大小、输入能力和响应能力各不相同。此外,许多设备现在支持复杂的手势;例如在屏幕上滑动手指。

我毫不怀疑,像苹果电脑这样的主要硬件和软件制造商拥有私人机器人测试人员大军,他们可以通过以脚本化和自动化的方式物理地使用他们的设备来不断地回归测试他们的产品。不幸的是,对于一般的开发团队来说,在目标设备上对其产品进行物理回归测试的唯一方法是以一种痛苦且耗时的方式进行。由于没有办法在设备上合理地测试应用,许多团队选择让最终用户为他们测试,当他们报告任何错误时,让他们自己去调查。显然,这不是一个理想的方法。幸运的是,即使是小团队也可以成为机器人测试革命的一部分,只要他们懂一点 JavaScript。

酒吧的酒保

Jason Huggins 接受自动化测试的程度可能让许多开发人员感到敬畏。他是 Sauce Labs 的首席技术官,该公司提供各种不同的产品,允许客户将设备测试外包给他们。Huggins 的部分工作是找到从他们的测试流程中剔除手动过程的方法,同时确保测试尽可能高保真。因此,必须在使用软件模拟用户选择的速度和通过物理控制设备手动重建用户交互之间取得平衡。他可能已经找到了 Tapster 项目的最佳切入点。 13 Tapster 是图 10-3 中看到的机器人,它以可脚本化和自动化的方式模拟用户的物理交互。

A978-1-4302-6098-1_10_Fig3_HTML.jpg

图 10-3。

Tapster robot

在《连线》杂志最近的一篇文章中, 14 哈金斯解释了为什么他认为 Tapster 这样的项目是必不可少的:

Future tests will be more and more difficult to replicate in laboratory or software simulator. My favorite example is Zipcar's iPhone application, which can open your car for you. To really test it, you need an iPhone and a car-not something easy to virtualize in the cloud. You can also use your mobile phone to buy coffee or control TV at Starbucks. I believe that in the future, the mobile phone will be the remote control of everything. However, with the application of the world, many new problems have emerged. Digital systems are more complex and fragile than analog systems. With complexity comes a higher risk of mistakes. The way to avoid this risk is to conduct more tests-lots of tests-faster and more "real". This is where robots appear.

驱动 Tapster 的技术都是开源的。事实上,大部分技术,包括 johnny-five、grunt 和 node-serialport,已经在前面的章节中详细介绍过了。甚至 Tapster 的实体零件都可以用你最喜欢的 3D 打印机打印出来。

Tapster 提供了一个创造性的尝试来解决测试中最普遍的问题之一,即框架偏差。正如我前面提到的,开发人员倾向于不测试程序中因测试过程而变得困难的方面。Tapster 代表了一个坚实的尝试,我认为这是一个社区开发和开源物理测试设备的新领域。当我们寻找新的方式将技术嵌入到我们的日常生活中时,我们也必须同时确保这些新的形式是经过精心设计和充分测试的。

摘要

知道如何编写测试不仅仅包括这样做的技术能力。要正确地测试一个程序,你必须有正确的心理定势和清晰的测试目标定义。这些目标经常被各种测试谬误所混淆,程序员可能会错误地推广这些谬误。在其他情况下,程序员自己的确认偏差可能会影响他们为自己的工作准确编写测试的能力。

幸运的是,程序员可以通过使用帮助他们对代码进行分析性思考的工具来克服这些偏见。例如,通过使用代码覆盖工具,开发人员可以快速可视化他们代码覆盖中的缺陷。此外,开发人员可以使用模糊测试或 JSCheck 以他们从未想到的方式以编程方式扭曲他们的代码。最后,程序员可以混合和匹配测试框架,以抵消一个框架可能对测试产生的任何偏见。当所有这些技术以一种深思熟虑的令人信服的方式一起使用时,你会发现你的代码的可测试性必然会提高。

Footnotes 1

http://bob.ippoli.to/archives/2005/06/02/javascript-sucks-volume-1/

  2

http://storify.com/rmurphey/what-s-making-it-hard-to-get-started-with-js-testi

  3

http://en.wikipedia.org/wiki/Pareto_principle#In_software

  4

http://gotwarlost.github.io/istanbul/

  5

http://en.wikipedia.org/wiki/Fuzz_testing

  6

http://resources.infosecinstitute.com/fuzzing-mutation-vs-generation/

  7

http://www.squarefree.com/2007/08/02/introducing-jsfunfuzz/

  8

https://bugzilla.mozilla.org/show_bug.cgi?id=jsfunfuzz

  9

http://lcamtuf.blogspot.com/2011/01/announcing-crossfuzz-potential-0-day-in.html

  10

https://www.usenix.org/system/files/conference/usenixsecurity12/sec12-final73.pdf

  11

http://www.jscheck.org/

  12

http://en.wikipedia.org/wiki/QuickCheck

  13

https://github.com/hugs/tapsterbot

  14

http://www.wired.com/insights/2012/12/robots-at-the-intersection-of-cool-and-useful/