开源 | Canyon: 提升JavaScript代码质量的全面覆盖率分析工具

作者简介

wr_zhang25,携程资深前端开发工程师,关注前端代码覆盖率、JavaScript开源方向。

Liang, 携程资深研发经理,质量专家,专注质量工程领域。

一、背景

istanbuljs 是一款优秀的JavaScript代码覆盖率工具,主要用于单元测试的代码覆盖率检测和生成本地覆盖率报告。然而,随着现代前端技术和UI自动化测试的发展,对端到端测试的代码覆盖率检测需求逐渐增加,istanbuljs提供的功能显得捉襟见肘。

在携程内部JavaScript代码覆盖率使用的是gitlab内置的coverage上报,也是只支持单元测试的覆盖率收集和概览数据展示。随着携程的前端技术日益精进,我们有了自己的前端流量录制平台,并且部署了相当大规模的模拟器集群进行UI自动化(flybirds)回放。这种场景下,需要对端到端测试的代码覆盖率进行收集和展示,以便开发同学更好的了解到自己的代码质量。

传统的istanbuljs提供的功能已经无法满足我们的需求。我们需要处理UI自动化过程中来自前端高并发的覆盖率上报,实时的覆盖率聚合,以及覆盖率数据的聚合展示。因此,我们在Istanbuljs的基础上开发了Canyon,解决端到端测试覆盖率难收集的问题。

目前,携程的多个部门已经开始使用Canyon,并在持续集成流水线构建阶段插入探针代码,在UI自动化测试阶段收集和上报覆盖率数据。服务端实时生成详尽的覆盖率报告,为UI自动化测试用例提供全面的覆盖率数据指标。

二、介绍

Canyon 通过简单的 Babel 插件配置即可实现代码插装、覆盖率上报和实时报告生成。其技术栈完全基于 JavaScript,只需 Node.js 环境即可运行,部署方便,适用于云原生环境的部署(如 Docker、Kubernetes)。

应用的架构设计适用于处理高频、大规模的覆盖率数据上报,能够应对 UI 自动化测试中的各种场景。同时,Canyon 与现有的 CI/CD 工具(如 GitLab CI、Jenkins)无缝集成,使用户能够轻松地在持续集成流水线中使用。

架构图如下:

bf7acc7a46b96eeffa7553e1b2e744c1.png

下面会根据以下几个部分来介绍 Canyon 的主要功能:

  • 代码覆盖率

  • 代码插桩

  • 测试与上报

  • 覆盖率聚合

  • 覆盖率报告

  • 变更代码覆盖率

  • react native 覆盖率收集方案

  • 覆盖率提升优先级列表

三、代码覆盖率

随着编写更多的end-to-end测试case,你会发现有一些疑问,我需要写更多的测试用例吗?究竟还有哪些代码没测到?用例会不会重复了?这个时候代码覆盖率就派上用场了,它的原理是在代码执行前将代码探针插入到源代码中(其实就是上下文加计数器),这样每当case执行的时候就可以触发其中的计数器。

在代码中插入代码探针的步骤称为代码插桩(instrument)。插桩前的代码:

// add.js
function add(a, b) {
  return a + b
}
module.exports = { add }

插桩过程是对代码解析以查找所有函数、语句和分支,然后将计数器插入代码中。对于上面的代码,插桩完成后:

// 这个对象用于计算每个函数和每个语句被执行的次数
const c = (window.__coverage__ = {
  // "f" 记录每个函数被调用的次数
  f: [0],
  // "s" 记录每个语句被调用的次数
  // 我们有3个语句,它们都从0开始
  s: [0, 0, 0],
})


// 第一个语句定义了函数
c.s[0]++
function add(a, b) {
  // 函数被调用后是第二个语句
  c.f[0]++
  c.s[1]++


  return a + b
}
// 第三个语句即将被调用
c.s[2]++
module.exports = { add }

我们希望确保文件中的每个语句和函数add.js都已被我们的测试至少执行一次。因此我们编写一个测试:

// add.cy.js
const { add } = require('./add')


it('adds numbers', () => {
  expect(add(2, 3)).to.equal(5)
})

当测试调用时add(2, 3),执行“add”函数内的计数器递增,覆盖范围对象变为:

{
  f: [1],
  s: [1, 1, 1]
}

这个测试用例覆盖率达到了100%,每个函数和每个语句都至少执行了一次。但是在实际应用中,要达到100%的代码覆盖率需要多次测试。

这是覆盖率的基本介绍,有了这个前置知识,方便大家理解下面的内容。

四、代码插桩(instrumenting-code)

代码覆盖率最重要的一环就是代码插桩

istanbuljs 是久经沙场的js代码插桩黄金标准。Canyon主要为端到端测试提供解决方案,经过大量的实验验证,现代化前端工程的覆盖率插桩必须要编译时插桩。具体原因是istanbuljs提供的nyc插桩工具只能对原生js进行插桩,然而前端模版语法层出不穷,例如ts、tsx、vue,虽然nyc也可以插桩,但是结构实践证明直接插桩的覆盖率效果不尽人意,无法精确到该插桩到的函数、语句、分支。

幸运的是经过调研,我们发现了babel-plugin-istanbul、vite-plugin-istanbul(experimental)、swc-plugin-coverage-instrument(experimental)。等类型工程的插桩解决方案。这些方案无一例外都是在前端工程编译阶段在将代码分析成ast抽象语法树的时候在适当时机进行插桩方法调用,更精确的插桩到的函数、语句、分支。

适用的工程类型:

工程类型方案
vanilla javascriptnyc
babelbabel-plugin-istanbul
vitevite-plugin-istanbul (experimental)
swcswc-plugin-coverage-instrument (experimental)

用户可以根据自己的工程类型选择合适的插桩方案,只需要在工程中安装对应的插件,然后就会在编译时自动插桩。

以babel.config.js为例:

module.exports = {
  plugins: [
    [
      'babel-plugin-istanbul',
      {
        exclude: ['**/*.spec.js', '**/*.spec.ts', '**/*.spec.tsx', '**/*.spec.jsx'],
      },
    ],
  ],
};

插桩完成后,代码中会插入一些代码探针,这些代码探针会在运行时收集覆盖率数据,然后上报到Canyon服务端。

检查是否插桩成功,可以在编译后的产物中搜索__coverage__,如果有则说明插桩成功。

691fc760cd4a2042c7bb374d50592666.png

为了紧密关联插桩代码的源代码,我们适配了各种provider,将环境变量发送到Canyon服务端,兑换到reportID,方便覆盖率数据聚合计算完成后的覆盖率源文件的关联展示。

我们还提供了babel-plugin-canyon的babel插件,可以在各种流水线内(aws,gitlab ci)读取环境变量(branch、sha),以供后续覆盖率数据与对应的gitlab源代码关联。

babel.config.js

module.exports = {
  plugins: [
    [
        'babel-plugin-canyon',
        {
          provider: 'gitlab',
          branch: process.env.CI_COMMIT_REF_NAME,
          sha: process.env.CI_COMMIT_SHA,
        },
    ],
  ],
};

支持的提供商:

需要特别注意的是,代码探针的插桩会在构建产物上下文加上代码探针,会是代码整体产物增大30%,建议不要上生产环境。

五、测试与上报

当插桩完成发布到测试环境后,我们就可以进行测试了。拿playwright举例,对于插桩成功的前端应用站点,window对象上面都会挂载__coverage__和__canyon__对象,我们需要在playwright测试过程中收集并上报这些数据到canyon的服务端。

playwright示例:

const {chromium} = require('playwright');
const main = async () => {
  const browser = await chromium.launch()
  const page = await browser.newPage();
  // 进入被测页面
  await page.goto('http://test.com')
  // 执行测试用例
  // 用例1
  await page.click('button')
  // 用例2
  await page.fill('input', 'test')
  // 用例3
  await page.click('text=submit')
  const coverage = await page.evaluate(`window.__coverage__`)
  // 收集上报覆盖率
  upload(coverage)
  browser.close()
}


main()

携程内部有自己的UI自动化平台 flybirds,我们在flybirds内部集成了Canyon覆盖率数据的收集和上报。真实的浏览器UI自动化测试的覆盖率收集场景较为复杂,主要体现在多页面(MPA)的覆盖率收集时机不确定性。

单页面(SPA)与多页面(MPA)

当测试用例执行完成后,对于单页面应用(SPA)或者多页面应用而言,上报步骤是将页面window对象上的__coverage__对象上报到Canyon服务端,对于单页面应用来说,相对来说比较简单,在所有测试内容都在单页面应用内,覆盖率数据会常驻在window对象中,对于多页面应用而言,路由的跳转会导致window对象的重制,丢失coverage对象。所以这个时机是至关重要的,经过大量实践验证,我们找到了浏览器的onvisiblechange方法。

  • visibilitychange

在浏览器可见性改变的时候上报覆盖率数据,值得一提的是,对于visibilitychange这种可能会导致重复数据上报,但是对于覆盖率统计来说,未执行到的代码多次合并来说不会影响覆盖率的具体指标数据统计。

  • fetchLater

Chrome 浏览器正在积极引入一个革命性的 JavaScript API——fetchLater()。这个全新的 API 旨在彻底简化关闭页面时的数据发送过程,确保即使在页面关闭后或用户离开的情况下,请求也能在未来某个时刻被安全、可靠地发出。

这个API的推出时令人振奋的,可以很好的解决多页面(MPA)收集难的问题,只需要在浏览器关闭时收集。

注:fetchLater() 已在 Chrome 中提供,用于在版本 121(2024 年 1 月发布)开始的原始试验中供真实用户测试,该试验将持续到 Chrome 126(2024 年 7 月)。

六、聚合

覆盖率数据的来源是同一版本的代码,覆盖率数据是可以聚合的,Canyon内部使用reportID来关联测试用例和细分聚合维度。这样做可以让海量的覆盖率数据聚合成有限个,即Case的数量。

/**
 * 合并两个相同文件的文件覆盖对象实例,确保执行计数正确。
 *
 * @method mergeFileCoverage
 * @static
 * @param {Object} first 给定文件的第一个文件覆盖对象
 * @param {Object} second 相同文件的第二个文件覆盖对象
 * @return {Object} 合并后的结果对象。请注意,输入对象不会被修改。
 */
function mergeFileCoverage(first, second) {
  const ret = JSON.parse(JSON.stringify(first));


  delete ret.l; // 移除派生信息


  Object.keys(second.s).forEach(function (k) {
    ret.s[k] += second.s[k];
  });


  Object.keys(second.f).forEach(function (k) {
    ret.f[k] += second.f[k];
  });


  Object.keys(second.b).forEach(function (k) {
    const retArray = ret.b[k];
    const secondArray = second.b[k];
    for (let i = 0; i < retArray.length; i += 1) {
      retArray[i] += secondArray[i];
    }
  });


  return ret;
}

端到端测试的覆盖率数据特点之一是单体数据体积大,在项目整体插桩的情况下相当于整体源代码体积的30%。携程Trip.com flight站点的预定页UI自动化case上报次数每次可达2000次,每次10M数据,这样的数据量对于Canyon服务端来说是一个巨大的挑战。

对于单条数据大且高频次的数据上报场景,很难做到实时数据聚合计算。Canyon采用消息队列的形式来消费数据,并且设计成无状态服务,适用于云原生时代的容器化部署,可通过HPA弹性伸缩容来应用不同场景下的测试覆盖率上报。

七、报告

对于覆盖率报告展示,我们沿用了istanbul-report的界面风格,但是由于istanbul-report只提供了静态html文件的生成,不适合现代化前端水合数据生成html的模式,为此我们参考了它的源码,使用了monaco-editor标记源代码覆盖率。

const decorations = useMemo(() => {
    if (data) {
        const annotateFunctionsList = annotateFunctions(data.coverage, data.sourcecode);
        const annotateStatementsList = annotateStatements(data.coverage);
        return [...annotateStatementsList, ...annotateFunctionsList].map((i) => {
            return {
                inlineClassName: 'content-class-found',
                startLine: i.startLine,
                startCol: i.startCol,
                endLine: i.endLine,
                endCol: i.endCol,
            };
        });
    } else {
        return [];
    }
}, [data]);

经过着色后的效果:

deb896ecbe4c3794a77f86d55fa3d758.png

八、变更代码覆盖率

对于变更代码覆盖率,我们统计的公式是覆盖到的新增代码行/所有新增代码行。

通过配置compareTarget来指定对比目标,再联合gitlab的git diff接口获取变更代码行结合覆盖率数据计算。

/**
 * returns computed line coverage from statement coverage.
 * This is a map of hits keyed by line number in the source.
 */
function getLineCoverage(statementMap:{ [key: string]: Range },s:{ [key: string]: number }) {
  const statements = s;
  const lineMap = Object.create(null);


  Object.entries(statements).forEach(([st, count]) => {
    if (!statementMap[st]) {
      return;
    }
    const { line } = statementMap[st].start;
    const prevVal = lineMap[line];
    if (prevVal === undefined || prevVal < count) {
      lineMap[line] = count;
    }
  });
  return lineMap;
}

九、react native 覆盖率收集方案

携程的移动端技术栈主要是react native,好消息是对于我们的插桩方案一样适用,因为都是基于babel编译。并且得力于得力于公司内部的react native项目结构统一,我们将编译时插桩做到了流水线中,在流水线中分别打包“正常包”和”插桩包“,这样搭配UI自动化可以形成一套完整的录制回放覆盖率指标收集的测试体系。

利用websocket暴露模拟器内覆盖率数据:

// 创建WebSocket连接
const socket = new WebSocket('ws://localhost:8080');


// 当WebSocket连接打开时触发
socket.onopen = () => {
    console.log('Connected to coverage WebSocket server');
};


// 当收到WebSocket消息时触发
socket.onmessage = event => {
    try {
      if (JSON.parse(event.data).type === 'getcoverage') {
        // 发送覆盖率数据
        socket.send(JSON.stringify(payload));
      }
    } catch (e) {
      console.log(e);
    }
};


// 当WebSocket连接关闭时触发
socket.onclose = () => {
    console.log('Disconnected from coverage WebSocket server');
};

目前携程机票部门的APP模块均已接入Canyon,经过实践istanbuljs可以很好的对其进行插桩及覆盖率数据收集,测试团队在每次生产发布前会以Canyon的覆盖率数据指标来衡量此次发布的质量情况。

十、覆盖率提升优先级列表

在用户最初接入Canyon系统时,会面临一个挑战:如果没有大量的UI自动化测试用例,大型应用的代码覆盖率会显得尤为低下。一开始,仅仅提供一个Istanbul代码覆盖率报告,并不能有效指导团队如何提高覆盖率,这让大家感到困惑和无所适从。

为了解决这个问题,我们进行了深入的调研,并发现公司已经有了一个成熟的生产环境代码覆盖率收集系统。基于这一发现,我们决定将这个系统的数据与我们自己的覆盖率数据相结合,创建了一个“覆盖率提升优先级列表”。这个列表的目的是为开发团队提供明确的指引,帮助他们了解在哪些方面可以优先提升代码覆盖率。

为了使这个指引更加科学和实用,我们制定了一个覆盖率权重公式:

生产环境覆盖率×100×0.3 + (1 - 测试覆盖率)×100×0.3 + 函数数量×0.2

通过这个公式,我们能够优先识别出那些生产环境使用率高、行数多,测试覆盖率低的代码文件,从而为开发团队提供针对性的提升建议。这样的方法不仅提高了代码质量,也增强了我们对整体覆盖率的掌控。

十一、社区推广

从这篇文章发表时起,我们将正式开源Canyon。JavaScript是时下最流行的编程语言,但是端到端测试覆盖率收集领域一直空白,我们的代码开发基于了istanbuljs,monaco editor等优秀开源项目,我们有信心推出Canyon开源可以赢得社区的反响,并且可以有大量JavaScript开发者参与进来。

Canyon在未来还有很大发展空间,例如生产环境插桩收集还未有待验证尝试,与playwright、puppeteer、cypress等自动化测试的工具还没有深度链接,这些都已经规划到了未来的开发计划中。希望在未来Canyon可以在携程及社区里有更多人参与建设。

参考链接

开源项目 Canyon:

https://github.com/canyon-project/canyon

JavaScript覆盖率工具:

https://github.com/istanbuljs/istanbuljs

基于浏览器的代码编辑器:

https://github.com/microsoft/monaco-editor

JavaScript文本差异:

https://github.com/kpdecker/jsdiff

"An O(ND) Difference Algorithm and its Variations" (Myers, 1986).

http://www.xmailserver.org/diff2.pdf

【团队开放岗位】

【推荐阅读】

fcc496889d86352e0bceb608564536da.jpeg

 “携程技术”公众号

  分享,交流,成长

  • 5
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
制作一个完整的介绍风景的网站需要编写大量的代码和涉及到多个技术和工具,以下是一个简单的示例代码: HTML代码: ``` <!DOCTYPE html> <html> <head> <title>介绍风景的网站</title> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="style.css"> </head> <body> <header> <h1>介绍风景的网站</h1> <nav> <ul> <li><a href="#">首页</a></li> <li><a href="#">国家</a></li> <li><a href="#">主题</a></li> <li><a href="#">关于我们</a></li> </ul> </nav> </header> <main> <section> <h2>最新文章</h2> <article> <h3>美国大峡谷</h3> <img src="https://www.example.com/images/grand-canyon.jpg" alt="美国大峡谷"> <p>美国大峡谷是世界上最壮观的峡谷之一,位于科罗拉多高原上,长约446公里,宽约29公里,深度可达1600米。</p> <a href="#">阅读更多</a> </article> <article> <h3>中国黄山</h3> <img src="https://www.example.com/images/huangshan.jpg" alt="中国黄山"> <p>黄山位于中国安徽省南部,是中国著名的山岳风景区之一,以奇松、怪石、云海、温泉、冬雪著称。</p> <a href="#">阅读更多</a> </article> </section> <section> <h2>推荐景点</h2> <ul> <li> <img src="https://www.example.com/images/machu-picchu.jpg" alt="秘鲁马丘比丘"> <h3>秘鲁马丘比丘</h3> <p>马丘比丘是印加帝国时期的一座古城遗址,位于秘鲁安第斯山脉上。</p> </li> <li> <img src="https://www.example.com/images/uluru.jpg" alt="澳大利亚乌鲁鲁"> <h3>澳大利亚乌鲁鲁</h3> <p>乌鲁鲁是澳大利亚的一座巨石,高度达348米,被认为是澳大利亚的象征之一。</p> </li> </ul> </section> </main> <footer> <p>© 2021 介绍风景的网站</p> </footer> </body> </html> ``` CSS代码: ``` body { font-family: Arial, sans-serif; margin: 0; padding: 0; } header { background-color: #333; color: #fff; padding: 20px; } header h1 { margin: 0; font-size: 36px; } nav ul { list-style: none; margin: 0; padding: 0; } nav li { display: inline-block; margin-right: 20px; } nav li:last-child { margin-right: 0; } nav a { color: #fff; text-decoration: none; } main { max-width: 960px; margin: 20px auto; padding: 0 20px; } section { margin-bottom: 40px; } section h2 { margin-bottom: 20px; font-size: 24px; } article { margin-bottom: 40px; border: 1px solid #ddd; padding: 20px; } article h3 { margin-top: 0; } article img { max-width: 100%; height: auto; margin-bottom: 20px; } article p { margin: 0; } article a { display: inline-block; background-color: #333; color: #fff; padding: 10px 20px; text-decoration: none; margin-top: 20px; } article a:hover { background-color: #666; } ul { list-style: none; margin: 0; padding: 0; } li { margin-bottom: 20px; border: 1px solid #ddd; padding: 20px; } li img { max-width: 100%; height: auto; margin-bottom: 20px; } li h3 { margin-top: 0; font-size: 20px; } li p { margin: 0; } ``` 以上代码仅供参考,实际项目中需要根据具体需求进行修改和完善。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值