lmk123 / blog

个人技术博客,博文写在 Issues 里。
https://github.com/lmk123/blog/issues
623 stars 35 forks source link

自动化测试 JavaScript #106

Open lmk123 opened 2 years ago

lmk123 commented 2 years ago

鉴于划词翻译的代码越来越庞大,我决定引入自动化测试。开发划词翻译 v6.x 版本时,我就是用 Karam 和 Jasmine 写测试的,但现如今我接触到的概念和工具还是看得我眼花缭乱:

我决定在这篇文章里厘清这些工具和概念之间的关系。

假设我们写了一个函数,用于将一个字符串转为数字:

const myIsNumber = (str) => Number(str)

即使不用任何工具,我们也可以通过代码来测试这个函数是否是符合预期的:

console.log(myIsNumber('0') === 0)
console.log(myIsNumber('1.1') === 1.1)
console.log(isNaN(myIsNumber('not a number')))

你可以把这段代码粘贴到浏览器的控制台运行,也可以把它保存成一个文件,用 Node.js 运行。运行之后,如果出现了 false 就说明有不符合我们预期的情况出现。

但是如果需要测试的函数变多了,那么靠人工手动运行测试代码显然是不现实的。于是这个时候,测试运行器出现了。

测试运行器

你可以把所有测试代码用同一个规则命名、或者放在同一个文件夹里,然后通过命令行执行测试运行器,测试运行器就会自动执行这些测试代码。

除此之外,测试运行期还有一些额外的功能,例如它运行完之后会输出一个报告,告诉你哪些测试失败了。一个使用了测试运行器的测试代码长下面这样:

// myIsNumber.test.js
import { myIsNumber } from './myIsNumber'

describe('测试 myIsNumber 传入正常的数字字符串是否符合预期', () => {
  test('传字符串 0 会转换成数字 0', () => {
    if (myIsNumber('0') !== 0) throw new Error('不正确')
  })

  test('传字符串 1.1 会转换成数字 1.1', () => {
    if (myIsNumber('1.1') !== 1.1) throw new Error('不正确')
  })
})

describe('测试 myIsNumber 不传入字符串是否符合预期', () => {
  test('传字符串 not a number 会转换成 NaN', () => {
    if (!isNaN(myIsNumber('not a number'))) throw new Error('不正确')
  })
})

如果有测试抛错了,那么测试运行器就会在终端里指出是哪些测试抛了错、错误信息是什么,帮助你定位问题。

Jasmine 就是一个测试运行器,但同时它自带了断言库;与它相比,Mocha 就是一个纯粹的测试运行器——它只负责运行测试,其它工具(例如断言库)都需要你自己引入。

断言库

在上面的例子中,我们是通过 throw new Error() 的方式来抛错的,但是这样不是很直观,这时,断言库就出现了。

一个用了断言(assert)的测试长这样:

test('传字符串 0 会转换成数字 0', () => {
  expect(myIsNumber('0')).toBe(0)
})

除了 .toBe(),断言库一般还带有其它判断方式,比如两个对象是否相等、一个函数是否抛了错、一个字符串是否满足正则表达式等,这样我们就可以专注于写测试,不需要我们自己来写判断逻辑了。

测试覆盖率

在目前的 myIsNumber() 函数中,如果传了空字符串,它会返回 0。假设在之后的版本中,我们希望传空字符串时能返回 NaN,那我们就需要加个 if

const myIsNumber = (str) => {
  if (str === '') return NaN
  return Number(str)
}

由于我们之前的测试代码没有测试过传空字符串的情况,所以之前的测试代码仍然会全部通过,但如果这时我们忘了给新加的代码补上测试,这就会造成一个隐患——新加的代码没有通过测试来保证它是能正常运行的。

为了确保所有代码都用测试覆盖到了,测试覆盖率工具就应运而生了。

测试覆盖率工具的原理其实就是给 JavaScript 代码注入计数函数。举个例子,myIsNumber 可能会被测试覆盖率工具改成这样:

const myIsNumber = (str) => {
  _line111()
  if (str === '') {
    _line222()
    return NaN
  }
  _line333()
  return Number(str)
}

这样一来,当我们重新运行测试之后,测试覆盖率工具就会检测到 _line222() 从来没有被执行过,测试运行器就会告诉你你的覆盖率从 100% 变成了 66%(3 条语句中执行了 2 条),这时你就要关注一下有哪些代码没有被测试了。

JavaScript 中的测试覆盖率工具基本都用的是 Istanbul。Istanbul 已经基本集成到了流行的测试运行器中,只需改一下配置就可以启用。

出个题:下面这段代码在开启了测试覆盖率工具 Istanbul 之后会报错 _gVxdceSfe is not a function,你知道原因了吗?

function code() {
  window.alert('hi')
}

const script = document.createElement('script')
script.textContent = '(' + code.toString() + ')()'
document.head.appendChild(script)

测试运行环境

测试代码写好了,接下来就要决定我们的代码应该在哪个环境运行了。测试的运行环境一般有两种:Node.js 和浏览器。

用于测试 JavaScript 的测试运行器本身就是用 Node.js 开发的,所以测试代码在 Node.js 里运行是最快的——但如果我们的代码用到了浏览器 API 呢?

这时候,社区出现了两种解决方案:

  1. 在 Node.js 里模拟浏览器 API,如 jsdom
  2. 通过 Node.js 操纵真实的浏览器来运行测试,如 SeleniumPuppeteer

第一点的好处是代码实际上是在 Node.js 里运行的,所以速度比第二点快,缺点是 jsdom 可能跟真实的浏览器有些许差异,也不能在多个不同版本的浏览器中测试,所以多用于单元测试。

第二点的好处就是代码是在真实的浏览器里运行的,可以做到在多个浏览器的多个版本中运行测试,覆盖面更广,但缺点也显而易见:跟直接在 Node.js 运行测试相比,它比较慢,运行一次测试会花费更多时间。这种方法多用于集成测试。

这里面我们引入了两个新的概念:单元测试集成测试。在介绍它们之前,我先简单解介绍一下 Selenium 和 Puppeteer。

这两个工具的功能都是为了通过代码来操纵浏览器,准确点说,它们并不是专门为了运行测试而设计出来的,比如你可以用它们来操纵浏览器并自动填写表单、或者写一个爬虫来爬网站等,但大多数人会用它们来做网页的集成测试。

Selenium 和 Puppeteer 最大的不同是:Selenium 支持几乎所有浏览器,并且支持多个编程语言,而 Puppeteer 只支持 Chrome / Chromium 浏览器,编程语言只支持 Node.js。

相关阅读 - Is Puppeteer replacing Selenium/WebDriver?

无头(Headless)浏览器指的是当你在操纵浏览器时,并不会真的弹出来一个浏览器页面,这对于运行测试或写爬虫来说很有用,因为在这个过程中你并不关心网页显示成什么样子,特别是当你的测试或爬虫是运行在服务器上的时候。

一开始,最流行的无头浏览器是 PhantomJS,但自从 Chrome 从 v59 开始支持无头模式之后,PhantomJS 就停止维护了。

同样的,测试运行器一般都集成了不同的测试环境。

单元测试与集成测试

在前面的例子中,我们对 myIsNumber() 做的测试就是单元测试——也就是说,我们会单独测试不同的代码(即"单元"),每个单元之间的测试是相互独立的。

有了单元测试的好处就是,当我对代码做了修改之后,我可以运行单元测试来确保这次改动不会破坏掉以前的功能。

但是,软件在实际运行的时候,所有代码都是集成在一起运行的,我们需要确保它们集合在一起也是正常运行的,这个时候就需要用到集成测试了。

集成测试其实很好理解,公司里的测试妹子 QA 在测试网站是否运行正常时,做的就是集成测试:他点了一个按钮,就应该显示一个结果,如果链路中的任意一个环节(前端代码、后端代码或者数据库)出了问题,显示出来的结果都是不正确的。而有了 Selenium 和 Puppeteer 这类工具之后,我们就可以编写代码来取代人工点击的测试方式了。

据我的理解,端到端测试似乎跟集成测试是同一个概念,如有有误欢迎指正。

主流测试运行器对比

虽然测试涉及到的工具和概念很多,但当我们需要写测试时,测试运行器才是我们首先需要选择的,其它工具基本都会被集成进测试运行器里。

就我个人所见,目前主流的三个测试运行器是 Mocha、Jest 与 Karam。这三个测试运行器基本上都对以上介绍的工具做了集成,但它们都有不同的侧重点。

Mocha

Mocha 主要用来做 Node.js 代码的单元测试,例如用 Express 写的后台;你也可以用它来做 API 的集成测试,例如通过代码调用注册用户的 API,然后检查数据库里有没有生成符合预期的数据。

Jest

Jest 主要用来做前端代码的单元测试(配合 jsdom),特别是用来测试 React 组件。Jest 的最大特点是 0 配置即可上手使用。注意,虽然 Jest 通过 jsdom 模拟了浏览器环境,但你要清楚你的代码是跑在 Node.js 里的。

另外,你也可以用 jest-poppeteer 做集成测试,例如,像划词翻译这样的扩展程序就可以用它来做集成测试。我目前正在尝试中,有结果的话会另写一篇文章来分享心得。

Karam

Karam 只能用来做单元测试。需要注意的是,它会把测试代码跑在真实的浏览器里,也就是说它不能测试 Node.js 代码。

注意:虽然 Jest 集成了 Poppeteer,但它的运行方式跟 Karam 是有本质上的不同的。

通过 Karam - How it works 可以得知,Karam 会把测试代码打包(类似于 Webpack 的形式)成一个在浏览器里可以运行的 js 文件并生成一个引用地址(例如 http://localhost:6742/my-test-bundle.js),然后操纵浏览器利用 Githubissues.

  • Githubissues is a development platform for aggregating issues.