11月阅读周·编写可测试的JavaScript代码:代码覆盖率之练习与部署篇

举报
叶一一 发表于 2024/11/22 11:33:04 2024/11/22
【摘要】 背景去年下半年,我在微信书架里加入了许多技术书籍,各种类别的都有,断断续续的读了一部分。没有计划的阅读,收效甚微。新年伊始,我准备尝试一下其他方式,比如阅读周。每月抽出1~2个非连续周,完整阅读一本书籍。这个“玩法”虽然常见且板正,但是有效,已经坚持阅读十个月。已读完书籍:《架构简洁之道》、《深入浅出的Node.js》、《你不知道的JavaScript(上卷)》、《你不知道的JavaScri...

背景

去年下半年,我在微信书架里加入了许多技术书籍,各种类别的都有,断断续续的读了一部分。

没有计划的阅读,收效甚微。

新年伊始,我准备尝试一下其他方式,比如阅读周。每月抽出1~2个非连续周,完整阅读一本书籍。

这个“玩法”虽然常见且板正,但是有效,已经坚持阅读十个月。

已读完书籍《架构简洁之道》、《深入浅出的Node.js》、《你不知道的JavaScript(上卷)》、《你不知道的JavaScript(中卷)》、《你不知道的JavaScript(下卷)》、《数据结构与算法JavaScript描述》、《WebKit技术内幕》、《前端架构:从入门到微前端》、《秒懂算法:用常识解读数据结构与算法》、《JavaScript权威指南》、《JavaScript异步编程设计快速响应的网络应用》

当前阅读周书籍编写可测试的JavaScript代码

练习与部署

对客户端和服务器端JavaScript文件生成coveraged文件需要使用不同的策略。HTTP服务器可以通过查询参数或其他方法动态地提供指定JavaScript文件的coveraged版本。通过在Node.js加载器上运行spy,可以很容易地动态生成服务器端版本的coveraged文件。

客户端JavaScript

对于单元测试,其HTML文件现在可以包括coveraged版本的JavaScript文件了,而不用再使用常规版本。有几种方式来做此事,在第6章我们将讨论如何自动化这个过程。最简单的方法就是在运行单元测试之前,转换所有的JavaScript代码到中间文件。单元测试运行时,HTML将引用instrumented版的代码。如果你是使用Apache的爱好者,可以使用mod_rewrite规则动态转换其代码。在这种情况下,必须在HTML文件中,对想转换的JavaScript文件进行标记。使用查询字符串实现此目的是最方便的。

mod_rewrite的匹配规则,会将请求重定向,读取原始文件并将其转换成coveraged版本并返回,而不是再返回原始的版本,规则示例如下:

RewriteEngine On
RewriteCond %[QUERY_STRING] coverage=1
RewriteRule ^(.*)$ make_coverage.pl?file=%[DOCUMENT_ROOT]/$1 [L]

上述代码,任何带有coverage=1查询参数的请求,都会返回请求文件的coveraged版本内容。

不需要转换测试代码本身;要转换的只有实际要测试的JavaScript代码。如果模块有外部依赖,也需要将其包含在测试里,为了查看代码的连通性,可能还要转换这些外部依赖。然而,我反对这样做。可能针对依赖项也有相关的单元测试,进一步说,任何外部模块的测试,都不能算做“已经覆盖了”,因为对该外部模块的测试并不是为了测试其他模块。单元测试都是孤立的,不会测试被测模块所使用的其他模块。

事实上,理想情况下,单个模块测试的时候不用加载任何外部依赖;这些外部依赖应该在测试代码中进行模拟(mock或stub)。没有什么比必须要调试另一个模块,而不是当前要测试的模块更糟糕了。隔离是关键。

对集成测试/ Selenium类型的测试部署coveraged版本的代码,其设置再简单不过了。这里,所有的代码都要被转换,并照常部署。注意,转换代码运行会比较慢,因为它的大小是原始语句的两倍,所以不要对coveraged版本的部署进行性能测试!

一旦部署完代码,就可以运行测试,但注意,重新加载时,覆盖率信息不会持久化。如果每个测试都重新加载浏览器,或者需要跳转到另一个页面,则需要在这之前提取并保存覆盖率信息。

幸运的是,Selenium使这项任务变得很容易,因为每个测试用例或测试套件都有一个tearDown函数,在该函数内部可以做到这一点。

此外,对于转换版本的构建,其手工测试也很有趣。在浏览器中加载该页面并点击;点击完成后,可以将覆盖率信息转储到控制台(通过查看_yuitest_coverage全局变量),还可以将其剪切并粘贴到文件,从而转换成HTML。现在,在随机点击过程中,就可以看到到底执行了哪些代码。

重要的是要注意,对coveraged版本的构建进行手工点击时,并没有真正“测试”任何东西。我们只是想知道在点击时执行什么代码,满足好奇心而已。

服务器端JavaScript

将coveraged版本的被测JavaScript代码动态地混入到Node.js加载器,并不是一件很可怕的事情。如果觉得很疯狂(需要覆盖一个私有方法),也不用担心,因为还有另外一个选择。

疯狂但更加透明的技术是覆盖Node.js中的Module_load方法。是的,这是一个可以随时改变的内部方法,覆盖之后该方法所有原有功能都将丢失,但在覆盖之前,该方法是非常透明的。

如下是基本代码:

var Module = require('module'),
  path = require('path'),
  originalLoader = Module._load,
  coverageBase = '/tmp',
  COVERAGE_ME = [];
Module._load = coverageLoader;

首先,我们判断哪些JavaScript文件需要进行代码覆盖率测试,然后生成这些文件的coveraged版本——所有这些文件都会被移动到/tmp目录中,而不会有任何其他路径信息。

然后,使用我们喜欢的框架执行测试。测试执行时,对require的调用将会被coverageLoader函数所过滤。如果是一个我们想对其进行代码覆盖率计算的文件,我们就返回该文件的coveraged版本;否则,就将请求委托回Node.js加载器,利用自身的功能去加载正常的原始模块。

所有测试完成后,全局的_yuitest_coverage变量将可以被持久化,可以将其转换为LCOV格式,或者进行HTML化。

在上述代码中,客户端JavaScript使用一个查询参数,告诉HTTP服务器生成覆盖率信息——但是服务器端该如何做呢?为此,我想为require调用添加一个额外的参数。像客户端JavaScript的查询字符串一样,require的额外参数是透明的。这可能是多余的,所以另一个选择是,如果所需要的文件存在于本地开发环境,可以利用正则进行匹配(与返回coveraged版本的原生、外部、第三方模块相反)。

要注意,所有这些选择都需要两个过程:第一次是用于确定哪些文件需要生成代码覆盖率,第二次是实际运行测试、动态拦截require调用或返回请求文件的coveraged版本。这是因为yuitest_coverage代码需要外部的异步过程去创建coveraged文件,但require调用却是同步的。这不是必须要做的,但却是需要注意的一件事。如果Node.js发布同步方法,或者如果有一个纯同步的JavaScript覆盖率生成器可用,这些JavaScript文件的coveraged版本文件,则可以通过覆盖_load方法生成。

因此,如何为require添加一个额外的参数,用于请求代码覆盖率工作?首先,测试代码应该像如下这样:

my moduleToTest = require('./src/testMe',true)

测试文件中的这行语句,仅在require调用时添加了第二个参数(true)就可以请求正在测试的模块了。Node.js会忽略这个非预期参数。简单的正则表达式将能捕获该参数:

/require\s*\(\s*['"]([^'"]+)[^'"]\s*,\s*true\s*\)/g;

它看起来比实际糟糕。该思想是将其加入到要测试的JavaScript源文件中,并运行该正则表达式,它将捕获所有请求时带有true参数的模块的实例。可以任意使用一个抽象语法树工具(使用JSLint或Uglify.js生成的一棵树)或JavaScript解析器,但实际上该正则表达式已经100%稳固了(如果有一个该正则解析不了的require声明,让我看看它有多糟糕!)。

一旦要进行代码覆盖率测试的模块列表都收集好了,如下代码就会生成这些模块的coveraged版本文件:

var tempFile = PATH.join(coverageBase, PATH.basename(file));
var realFile = require.resolve(file);

exec('java -jar ' + coverageJar + ' -o ' + tempFile + ' ' + realFile, function (err) {
  FILES_FOR_COVERAGE[keep] = 1;
});

上述代码会遍历正则表达式的结果,并请求Node.js询问这些文件在哪里,接着运行YUI代码覆盖率工具,并将其结果返回,以便稍后测试的时候,coverageLoader可以找到该结果。

最后一点就是,运行测试并对覆盖率结果进行持久化。_yuitest_coverage变量是一个JavaScript对象,需要将其转换成JSON并进行持久化。最后,它可以转化成LCOV格式和漂亮的HTML,然后就OK了:

var coverOutFile = 'cover.json';
fs.writeFileSync(coverOutFile, JSON.stringify(_yuitest_coverage));
exec(['java', '-jar', coverageReportJar, '--format', 'lcov', '-o', dirname, coverOutFile].join(''), function (err, stdout, stderr) {
  ...
});

总结

使用Node.js获取覆盖率信息的另外一种方式。当然,可能还有其他很多我没有想到的方法,但是我提到的这种方法是利用后缀。默认情况下Node.js加载器可以识别三种文件扩展名——.js、.json、.node——并进行相应的处理。当Node.js加载器搜索文件进行加载时,如果没有提供扩展名,加载器就会利用这些默认扩展名继续搜索。由于加载器的同步特性,很遗憾,我们不能在加载的时候动态生成覆盖率信息,所以我们仍然需要require('module', true)这样的技巧,以确定哪些文件需要代码覆盖率并将它们预先生成。

有很多可以生成服务器端JavaScript代码覆盖率信息的方法。


作者介绍
非职业「传道授业解惑」的开发者叶一一。
《趣学前端》、《CSS畅想》等系列作者。华夏美食、国漫、古风重度爱好者,刑侦、无限流小说初级玩家。
如果看完文章有所收获,欢迎点赞👍 | 收藏️ | 留言📝

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。