本文为Algolia公司软件工程师的实践分享,Algolia公司总部位于旧金山,打造“搜索即服务”平台,为商家提供站内搜索引擎定制。
以下为作者观点:
不久前,我(作者)看到社区里有个说:你会用哪三个词吓唬产品经理。在这些精彩的回复中,有 "还有一件事"、"差不多完成了"、"那是遗留代码",还有可能被低估的 "我测试过了"。最后一句话让我记忆犹新,因为它可能是最可怕的一句话。
它引出了更多的问题:你是如何测试的?是否涵盖了所有的边缘情况?测试是否与实际使用情况相符?测试是否只检查了预期结果?测试是否涵盖了更改是否会破坏应用程序中的其他功能?
下面就根据我们团队的经验,来回答这些问题,本文中主要探讨自动化测试的两种主要类型。
单元测试 Unit testing
在开始构建单元测试之前,我们不妨退一步问问为什么需要单元测试。
一般来说,我们要确保应用程序正常运行!单元测试只是验证程序是否正常运行的一小段代码。要理解单元测试是如何做到这一点的,首先要认识到现代开发涉及模块化(将功能拆分成尽可能小的部分)。一旦我们将大型任务分割成这些小函数,其中许多函数将恰好是纯函数:在输入相同的情况下,总是返回相同输出的确定性函数,不会影响函数之外的任何事物,也不会受到函数之外任何事物的影响。
这些特性使得纯函数的测试变得简单明了,因为我们只需模拟这些函数的使用方式,然后检查输出是否与预期一致即可。这就是我们所说的 "单元测试":用假数据使用我们要测试的函数几次,确保得到正确的结果。例如,以著名的 Sigmoid 函数为例,它是用 Python 编写的,下面有一个简单的单元测试:
import math
def sigmoid(x):
return 1 / (1 + math.exp(-x))
def test_sigmoid():
assert sigmoid(0) == 0.5
assert 0.9999 < sigmoid(10) < 1
assert 0.2689 < sigmoid(-1) < 0.269
assert sigmoid(-100000000000000000) == 0
果然,最后一行揭示了我们代码中的一个错误。谁会想到 Python 的数学库 math library 无法处理像 -100000000000000000 这样的数字呢?
事后看来,这对我来说是显而易见的:我们输入 math.exp 的正数版本略微超出了 double 的范围,但我之前并没有想到这一点!这个小错误充分说明,我们不可能了解我们工具的所有知识。尽管这里的公式在技术上是正确的,但我使用的工具无法处理处理如此大的数字的边缘情况。
值得庆幸的是,如果我们给 math.exp 一个负数(输入 sigmoid 时输入的是正数,因为我们给 math.exp 输入的是-x),math.exp 可以很好地处理这个问题,因为结果只是四舍五入为 0,但如果 x 是正数,我们就需要用稍微不同的方法来编写函数:
def sigmoid(x):
if x >= 0:
return 1 / (1 + math.exp(-x))
else:
ex = math.exp(x)
return ex / (1 + ex)
现在,我们的单元测试返回时不会触发任何警报!如果我们的项目经理碰巧想到了一个我们没有想到的边缘情况,那么在测试函数中添加另一个断言也是轻而易举的事。
如果我们的测试函数并不纯粹呢?这里有一个办法:重构一个新的纯函数,尽可能减少不纯函数中的内容。例如,这个 JavaScript 函数在运行凯撒密码(Caesar cipher)之前从 DOM 获取一个字符串值:
const runCaesarCipherOnInput = () => {
const input = document.getElementById("input");
const result = document.getElementById("result");
const key = 4;
result.innerText = input
.value
.toUpperCase()
.split("")
.map((letter, i) => {
const code = input.charCodeAt(i);
if (65 <= code && code <= 90) {
let n = code - 65 + key;
if (n < 0) n = 26 - Math.abs(n) % 26;
return String.fromCharCode((n % 26) + 65);
} else return letter;
})
.join("");
};
这很有效!但是,由于所有这些原因,它很难进行单元测试。它不是确定性的(也就是说,对于给定的输入,函数并不总是返回相同的输出),而且还包含一些超出函数范围的副作用。甚至 .map 调用中的内部循环函数也不纯粹!我们该如何解决这个问题呢?
让我们先试着把所有导致函数不纯的东西都清除掉,只留下一些容易测试的功能,然后再把这些部分重新构建成原始函数。
const caesar = (msg, key, rangeStart=65, rangeEnd=91) => msg
.split("")
.map(letter => shiftLetter(letter, key, rangeStart, rangeEnd))
.join("");
const shiftLetter = (letter, key, rangeStart, rangeEnd) => {
const rangeLength = rangeEnd - rangeStart;
const code = letter.charCodeAt(0);
if (rangeStart <= code && code < rangeEnd) {
let n = code - rangeStart + key;
if (n < 0) n = rangeLength - Math.abs(n) % rangeLength;
return String.fromCharCode((n % rangeLength) + rangeStart);
} else return letter;
};
const runCaesarCipherOnInput = () => {
const input = document.getElementById("input");
const result = document.getElementById("result");
const key = 4;
result.innerText = caesar(input.value.toUpperCase(), key);
};
请注意,在这个示例中,我们仍有两个不纯函数:第3行输入 .map 的函数和 runCaesarCipherOnInput。不过,这些函数非常简单,不需要进行测试!它们实际上并不执行任何逻辑(它们的存在主要是为了向纯函数提供参数),常量声明和 DOM 读取也非常简单易懂,因此我们可以放心地认为它们会做我们想做的事情。如果使用 for 循环而不是 map,我还可以做得更多,但这里的重点是,创建单元测试通常不需要开发人员做出大的改动。这次小小的重写达到了同样的目的,它更容易阅读,而且现在是可测试的--像 caesar 和 shiftLetter 这样的重要逻辑位都是纯函数,所以我们可以像以前一样为它们编写断言测试:
这些函数应该放在与逻辑对应的地方!为了更方便地进行批量测试,将单元测试打包成逻辑组,甚至使用专门的单元测试框架来处理模板,可能会有所帮助。
但是,如果我们要测试的东西不那么容易封装在函数中,会发生什么情况呢?使用简单的单元测试来测试 UI 元素和整个工作流程就不那么简单了。下一步该怎么做?请看下面的内容:模拟整个浏览器。
浏览器模拟 Browser emulation
在上文中,我们谈到了单元测试如何帮助我们轻松测试我们的函数,以及如何让使用单元测试的思维方式首先帮助我们编写更纯粹、更简单、更模块化的函数。
但是,当我们需要测试一个不适合函数的系统时会发生什么呢?如果我们不能依赖函数的输出来判断它是否工作,该怎么办?如果我们的函数依赖于用户界面设置的状态呢?在这些情况下,我们很难准确模拟用户如何浏览应用程序的复杂性--如果没有一个有用的抽象或简化方法供我们测试,我们可能只能启动一个网站的实时实例,假装自己是用户。
我们肯定不能将这一过程自动化,对吗?这样做似乎太手工,太依赖人脑了。事实证明,解决方案并不在于自动化应用程序内部的任何操作,而在于自动化浏览器的输入,模拟你的鼠标和键盘,这样你测试的应用程序就无法将模拟器与真人区分开来。这一过程被称为浏览器自动化。Chromium 项目提供了一个名为 Puppeteer(https://pptr.dev/)的应用程序接口,我们将使用它来实现浏览器自动化。
Puppeteer - 一个 headless 浏览器
第一步是用开发实例启动并运行我们的网站,因为我们想在投入生产前测试这些东西。由于这里只是演示,我将在 Algolia 的生产主页上运行此测试。
接下来,我们将设置一个 Node.JS 程序。为此选择一个合理的位置(可能是项目根目录下的一个文件夹,以便与构建过程集成)。在该文件夹中,您需要创建一个调用所有测试的 index.js 文件。这样,只需从 repo 的根目录运行 node test,就能运行所有测试,还能深入文件夹运行更具体的测试。
现在,在某些情况下,在构建过程结束时将其作为构建后命令可能是合理的,但这些将是更复杂的测试,你可能希望手动运行,所以如果你在本地开发和测试,最好将其放在那里,让你的开发人员在实际要运行 Puppeteer 测试时手动运行 node test 或 node test/specific-test.js。如果你的应用程序具有更复杂的构建流程,涉及本地测试较少而云中开发实例较多,那么建立一个无服务器项目(Vercel 和 Netlify 都擅长此道)可能会更容易,将其设置为以 lambda 函数读取测试,然后修改测试,以便在你 ping 该端点时运行。实际上,测试并不需要与应用程序在同一地点运行(因为测试是完整的客户端),所以你可以用一些按钮制作一个小小的图形用户界面,在云中触发每个测试。
运行 npm install puppeteer 后,你需要一些模板代码。下面是一个示例测试的结构,它将以我的身份登录 Algolia 并获取某个应用程序的凭据:
const puppeteer = require('puppeteer');
const login = async ({width, height}) => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('<https://algolia.com>');
await page.setViewport({
width,
height,
deviceScaleFactor: 1
});
// test logic here
await browser.close();
};
module.exports = async () => {
login({
width: 375,
height: 667
});
login({
width: 1150,
height: 678
});
};;
简单介绍一下:
-
第一行只是导入 Puppeteer。Puppeteer 由 Chromium DevTools 团队运行,因此基本上是该生态系统的原生部分。在这里导入 Puppeteer 会链接到 Chromium 的无头版本(即假装有图形用户界面的版本),由于它是与 Chromium 同步开发的,因此该版本能保证与 Puppeteer 配合使用。
-
第一个函数名为 login,用于运行我们的测试。现在这里没有任何测试逻辑,只有启动无头 Chrome 浏览器、访问 Algolia 网站并将视口设置为我们传入函数的内容的模板。最后,我们关闭浏览器,使其不会在内存中保持打开状态。
-
然后,我们导出一个函数,在不同尺寸的屏幕上运行相同的测试。
要从测试文件夹中的 index.js 内运行该测试,我们只需运行 require("./login")(); 即可。有了这种单行测试设置,我们甚至可以将测试分组成批,并通过命令行上的标志(如 node test --dashboard )来触发这些批次。这里的自定义功能是无限的,因此请根据你的应用需求进行调整。
在我的应用中,只要测试失败,我就会抛出错误,而不用担心批处理的问题,因为我的测试还不够多,集中精力在这里没有意义。不过在你的应用程序中,让这些测试函数返回测试结果而不只是在测试失败时抛出错误可能会有所帮助,因为这样你就能根据从 index.js 运行的测试打印出更详细的信息。现在,我的目标是让一切都静默运行,除非出现我们需要修复的问题。
让我们深入了解一下实际的测试逻辑,它可以说比你想象的要简单:
// step 0. accept cookies so the site works like normal
await page.waitForSelector('a[href="/policies/cookies/"] + button');
await page.click('a[href="/policies/cookies/"] + button');
// step 1. wait for the log in link to appear and click on it
if (width < 960) {
await page.waitForSelector('[data-mobile-menu-button]');
await page.click('[data-mobile-menu-button]');
}
await page.waitForSelector('a[href="/users/sign_in"]');
await page.click('a[href="/users/sign_in"]');
// step 2. wait for the email input to appear on the new page, focus on it, and type the email from .env
await page.waitForSelector('input[type=email]');
await page.focus('input[type=email]')
await page.keyboard.type(process.env.email);
// step 3. focus on the password input, and type the password from .env
await page.focus('input[type=password]')
await page.keyboard.type(process.env.password);
// step 4. click the log in button
await page.click("form#new_user button[type=submit]");
// step 5. if we're on mobile, we'll need to click one more button to indicate we'd like to stay on mobile
if ((await page.url()).includes("mobile")) {
await page.waitForSelector('.continue-button');
await page.click('.continue-button');
}
// step 6. wait for the application selector to appear and click it
await page.waitForSelector('#application-select');
await page.click("#application-select");
// step 7. click on the application with the name in .env
await page.$$eval(
'div > span.options',
(options, applicationName) => {
const matchedOptions = options.filter(option => option.innerText.split("\\n")[1] == applicationName);
if (matchedOptions.length) {
matchedOptions[0].click();
} else {
console.log(`Application ${applicationName} does not exist`);
}
},
process.env.applicationName
);
// step 8. wait for the api keys button to appear on the new page, and click it
await page.waitForSelector('#overview-api-keys-link');
await page.click("#overview-api-keys-link");
// step 9. wait for the page to load and get the application id and public api key for this project
await page.waitForSelector('#api-keys-page-heading');
const [applicationID, publicAPIKey] = await page.$$eval(
'input[readonly]',
inputs => inputs.slice(0, 2).map(input => input.value)
);
// step 10. log the results of our test
if (applicationID != process.env.applicationID)
throw `Application ID is not correct. Test produced "${applicationID}", but it should have been "${process.env.applicationID}"`;
if (publicAPIKey != process.env.publicAPIKey)
throw `Public API key is not correct. Test produced "${publicAPIKey}", but it should have been "${process.env.publicAPIKey}"`;
// step 11. close the browser
await browser.close();
这并不是一个微不足道的例子--如果你阅读了过程中每一步上面的评论,你就不会感到太惊讶了。从字面上看,这只是模仿用户在真实浏览器中的行为,同时考虑到 Algolia 主页处理移动端与桌面端的不同之处。与之配套的是一个 .env 文件,我在其中定义了登录时使用的电子邮件和密码、目标应用程序的名称(用户将使用该名称找到正确的应用程序),以及测试应返回的 publicAPIKey 和 applicationID。如果一切顺利,则不会输出任何信息。如果出现故障,它会抛出一个描述性错误,这样我们就能在构建完成前及时修复。
那么,我们在这个系列中学到了什么?测试主要有两种类型:
-
单元测试:这是简单、纯粹、不与外界交互的函数的完美测试。即使是不纯粹的函数中的纯粹部分,也可以隔离开来,这样就可以在测试代码的同一文件中使用简单的断言对其进行单独测试,这是一种很好的做法。
-
浏览器模拟:对于较大的工作流程,需要让你的测试真正假扮成用户,因此我们启动了名为 Puppeteer 的无头 Chromium 版本,"傀儡 "一个假用户并走完整个流程,浏览器甚至不知道它在与另一个程序交互。
应该注意的是,这些方法并不相互排斥--在 Algolia 面板上向开发者显示正确 API 凭据的这一特殊功能非常重要,因此需要用这两种方法进行测试。我们已经编写了一个 Puppeteer 程序,从用户的角度对整个工作流程进行演练,但除此之外,对我们从 API keys 页面搜刮的水合过程进行分解也很有帮助。如果我们在该页面中分离出尽可能多的纯函数,就可以通过单元测试对它们进行测试,确保该流程的绝大部分都不会发生静默中断。由于要从数据库中提取数据并显示在视图中,因此可能无法做到完全纯净,但这正是使用两种测试方法的好处:我们不必使用任何一种方法获得 100% 的覆盖率。只要能同时覆盖所有内容,我们就能对应用程序的耐用性和长期可持续性充满信心。
最后感谢每一个认真阅读我文章的人,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走:【文末小卡片领取】
这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!