Puppet-UI-测试指南-全-
Puppet UI 测试指南(全)
原文:
zh.annas-archive.org/md5/14384a67fc81a5daa39d994d90a0d620译者:飞龙
前言
Puppeteer 是由 Google 开发的一款多用途浏览器自动化工具。你将看到 Puppeteer 以多种不同的方式被使用。它被用于网页抓取、任务自动化、内容生成、网页监控和 UI 测试。
本书专注于 UI 测试,但不会止步于此。例如,我们有一章专注于内容生成,另一章则专注于网页抓取。如果你从头到尾阅读这本书,你将能够将 Puppeteer 应用于所有领域。
当我发现市场上没有关于 Puppeteer 的书籍时,这真的激励了我:我想这本书要涵盖整个 Puppeteer API。到本书结束时,你将了解整个 Puppeteer API。
本书面向的对象
如果你是一位寻求更好、更现代的工具来完成工作的质量保证专业人士,这本书就是为你准备的。
现在,UI 测试不仅限于 QA 团队。有一股新的前端开发者浪潮正在寻找测试他们 UI 组件的工具。如果你是一位在日常工作中使用 JavaScript 和 Node.js 的网页开发者,并且你想测试你的 UI 组件,这本书也适合你。
网页开发者也将从本书中学到如何使用浏览器自动化工具来自动化截图、生成 PDF 文件或进行网页抓取等任务。
本书涵盖的内容
第一章,开始使用 Puppeteer,为本书奠定了基础。它将通过介绍这个工具并让你熟悉基本知识来帮助你开始使用 Puppeteer。你还将学习如何在 JavaScript 中编写异步代码。
第二章,自动化测试和测试运行器,涵盖了端到端测试的基础知识和不同类型测试之间的区别。在章节的后半部分,我们将介绍创建和组织测试项目以及开始使用测试运行器。
第三章,浏览网站,将带你开始编写测试代码。你将学习如何启动浏览器,导航到页面,并进行一些断言。然后,你将了解如何将测试发布到云端进行测试。
第四章,与页面交互,全部关于交互。一旦你到达一个页面,你如何测试它?你如何模拟用户交互?本章将带你了解与页面交互的最常见方式。本章还涵盖了一些基本的 HTML 概念,这样你就可以充分利用 Puppeteer 提供的所有工具。
第五章, 等待元素和网络调用,教你如何等待你在测试页面上的不同场景——等待页面加载并准备就绪,等待按钮启用,等待 Ajax 调用完成,等等。本章涵盖了 Puppeteer 提供的所有处理这些场景的工具。
第六章, 执行和注入 JavaScript,展示了 Puppeteer 的最佳特性之一:轻松注入 JavaScript 代码。在这一章中,我们将暂时离开端到端测试的世界,深入使用通用工具进行网页自动化。
第七章, 使用 Puppeteer 生成内容,扩展了 Puppeteer 的使用范围,展示了如何使用 Puppeteer 创建内容。我们将从学习截图的创建及其在回归测试中的应用开始。然后,我们将涵盖 PDF 生成,最后,我们将学习如何动态创建页面。
第八章, 环境模拟,解释了 Puppeteer 提供的所有用于模拟不同场景的工具。它将向你展示如何模拟移动设备、不同的屏幕分辨率、各种网络速度、地理位置,甚至视力缺陷等问题。
第九章, 爬虫工具,揭示了网络爬取的神秘面纱,展示了如何利用它来为你带来优势。你将学习如何使用 Puppeteer Cluster 创建爬虫并并行运行任务。
第十章, 评估和提升网站性能,展示了 Puppeteer 如何帮助开发者评估和提升他们网站的性能。我们将学习如何提取和分析你在开发者工具中可以看到的所有性能指标。本章还提供了对 Google Lighthouse 的精彩介绍,以及如何自动化其报告并将其集成到你的测试中。
我还希望这本书能给你的工具箱增添更多工具,而不仅仅是 Puppeteer。在本书的学习过程中,你将了解其他工具,例如 GitHub Actions、Visual Studio Code、Checkly 及其 Puppeteer 录制器,以及其他许多工具。
为了最大限度地利用这本书
本书假设你有一些 JavaScript 的知识,但不需要成为专家。所有示例都将运行在 Node.js 上。如果你有这个框架的经验,你会发现开始更容易。如果你没有,不要担心;我们不会深入探讨 Node.js.
我们将使用的所有工具都是跨平台的。您将能够使用任何流行的操作系统(如 Windows、macOS 或 Linux)跟随本书的代码。在第一章“使用 Puppeteer 入门”中,我们将看到如何安装 Node.js 和 Visual Studio Code。如果您已经安装了这些工具,请注意 Puppeteer 依赖于 Node 10.18.1+。
如果您正在使用这本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/UI-Testing-with-Puppeteer。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还提供其他丰富的代码包,这些代码包来自我们的书籍和视频目录:github.com/PacktPublishing/。请查看它们!
免责声明
本书中的许多示例将基于我们大多数人每天都会使用的真实网站。我们将测试 GitHub.com、StackOverlow.com、天气频道和 PacktPub 等网站。这意味着您将学习如何使用最新技术自动化真实网站。然而,这也意味着一些代码示例最终可能会失败。这可能是由于网络条件、服务器问题或网站重新设计。然而,我们的主要目标是您能够学习这些概念并将它们应用到您自己的解决方案中,而不仅仅是这些变化。
下载颜色图像
我们还提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781800206786_ColorImages.pdf。
或者,您也可以在以下位置找到本书的颜色图像:github.com/PacktPublishing/UI-Testing-with-Puppeteer/blob/master/ColorImages.pdf
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、函数、变量、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL 和用户输入。以下是一个示例:“每个 HTML 文档都将包含在<html>元素中。”
代码块设置如下:
const productId = config.productToTestId;
const productDiv = await this.page.$(`[data-test-product-id="${productId}"]`);
const stockElement = await productDiv.$('[data-test-stock]');
const priceElement = await productDiv.$('[data-test-price]');
任何命令行输入或输出都应如下编写:
~ % /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --headless --remote-debugging-port=9222 --crash-dumps-dir=/tmp
page.click,该函数将返回一个Promise。
提示或重要注意事项
看起来是这样的。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对这本书的任何方面有疑问,请在邮件主题中提及书名,并通过 customercare@packtpub.com 给我们发送邮件。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您能向我们报告。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现任何形式的我们作品的非法副本,我们非常感谢您能提供位置地址或网站名称。请通过版权@packt.com 与我们联系,并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评价
请留下您的评价。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 公司可以了解您对我们产品的看法,并且我们的作者可以查看他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问packt.com。
第一章:第一章:Puppeteer 入门
我记得第一次听说浏览器自动化。一个朋友告诉我,他们的 QA 团队正在使用“自动化”进行测试。这对我来说听起来很神奇。人们使用“自动化”测试网站。几年后,我了解到自动化并不是一种魔法药水,而是一种强大的工具,不仅适用于 QA,也适用于开发者,因为我们开发者喜欢自动化,对吧?
正因如此,在本章的第一部分,我想向你展示浏览器自动化是如何工作的,以及是什么让 Puppeteer 独一无二。在本章的后半部分,我们将回顾一些将在本书的其余部分以及你的自动化之旅中非常有用的异步技术。
本章将涵盖以下主题:
-
浏览器自动化是什么?
-
介绍无头浏览器
-
Puppeteer 用例
-
设置环境
-
我们的第一个 Puppeteer 代码
-
JavaScript 中的异步编程
浏览器自动化是什么?
如果你去维基百科上查找“自动化”这个词,它会告诉你它是指“一种需要最小人工辅助的过程或程序”。如果你是开发者,或者只是一个极客,我敢打赌你喜欢编写脚本来自动化任务。你也许还会创建环境变量,这样你就不必输入长的路径,甚至创建酷炫的 Git 命令,这样你就不需要记住创建新分支所需的所有步骤。
当我第一次得到我的 Mac 时,我发现了一个名为 Automator 的应用程序。我爱上了它。你可以通过拖放来自动化任务和连接应用程序。如果你使用 macOS 并且从未玩过 Automator,请尝试一下!但 Automator 并不是唯一的应用程序。市场上有很多工作流应用程序,例如 Hazel 或 Alfred。
自动化甚至存在于云中,并且对公众开放。例如,IFTTT 和 Zapier 这样的应用程序允许用户自动化日常任务。你可以创建自动化任务,比如“当我发布在 Instagram 上时,在 Twitter 上分享相同的图片”,这一切都可以通过你的手机完成。普通人进行自动化,这真是太棒了!
我们还有邮件规则。大多数邮件客户端,甚至是网页客户端,都允许你创建规则,这样你就可以根据条件标记邮件为已读,给它们贴标签,甚至删除它们。这也是自动化的一种。
也许你已经将它提升到了下一个层次,为你的日常任务编写了一个应用程序。你有一个每月需要发送给老板的报告。这个报告是许多 CSV 文件的结果。你只是写了一个小程序,使用你喜欢的语言为你生成这个报告。
简而言之,自动化意味着使用一个应用程序为我们执行重复性任务。正如我们所看到的,这并不一定涉及到编写该应用程序的代码。因此,现在我们可以这样说,浏览器自动化就是告诉应用程序为我们自动在浏览器中执行重复性任务。
好的,这是一个简单的声明。但这是如何实现的呢?当你自动化一个应用程序时,你将使用某种应用程序程序接口(API)来完成这个任务。例如,当你编写一个 bat/bash 文件时,你使用命令行参数作为接口。如果你使用 IFTTT,它使用 Twitter 和 Instagram 的 HTTP API 来获取图片并创建推文。你需要某种 API,某种与你要自动化的应用程序交互的方式。我们该如何与浏览器交互呢?这是一个好问题。
要使事情变得稍微复杂一些,我们还需要考虑我们有两个应用程序需要自动化:浏览器本身和网站。我们不仅想要打开浏览器,创建一个新标签页并导航到页面,我们还想要访问该页面并执行一些操作。我们想要点击按钮,或者在输入元素中输入一些文本。
自动化浏览器听起来很具挑战性。但幸运的是,我们有几位杰出的人士为我们做了出色的工作,并创建了像 Selenium 和 Puppeteer 这样的工具。
Selenium 和 Puppeteer
在 Google 上快速搜索将显示,Selenium 是市场上最好的 UI 测试工具之一,如果不是最好的。我认为很多人可能会问的问题可能是:为什么我应该选择 Puppeteer 而不是 Selenium?哪一个更好?
你需要知道的第一件事是,Puppeteer 并不是为了与 Selenium 竞争而创建的。Selenium 是一个跨语言、跨浏览器的测试工具,而 Puppeteer 是为了作为一个多用途自动化工具而创建的,以利用 Chromium 的全部功能。我认为这两个都是伟大的自动化工具,但它们以两种不同的方式处理浏览器自动化。它们在两个重要的方面有所不同,这些方面定义了浏览器自动化库的目标受众:
-
工具与浏览器之间的接口
-
工具与用户之间的接口
让我们先来了解一下 Selenium 是如何工作的。
Selenium 的方法
为了自动化市场上大多数浏览器,Selenium 编写了一个名为WebDriver的规范(一个 API),W3C 随后将其接受为标准(www.hardkoded.com/ui-testing-with-puppeteer/webdriver),并要求浏览器实现该接口。Selenium 将使用这个 WebDriver API 与浏览器交互。如果你查看前一个 URL 上的论文,你会找到两个反复出现的词:测试和简单。换句话说,他们定义了一个专注于测试和简单的 API,并要求浏览器实现该接口。在我看来,跨浏览器测试是 Selenium 的主要功能。
什么是 API?
API 是一组类、函数、属性和事件,这些是我们可以使用库的方式。API 对于库的成功至关重要,因为它将决定你可以用它做什么以及与库交互的难易程度。
Selenium 向用户暴露的 API 也被视为 WebDriver 规范的一部分,它遵循相同的理念:专注于测试和简洁。这个 API 在用户和所有不同的浏览器之间提供了一个抽象层,并为开发者提供了一个易于使用的接口,以帮助他们编写测试。
Puppeteer 的方法
Puppeteer 不需要考虑跨浏览器支持的问题。尽管有人尝试在 Firefox 上运行 Puppeteer,但重点在于获取 Chromium 的所有开发者工具并将其提供给用户。本着这个目标,Puppeteer 可以访问比 Selenium 使用的 WebDriver API 暴露的工具多得多的工具。
它们与浏览器通信的方式的差异也反映在 API 中。Puppeteer 提供了一个 API,将帮助我们充分利用 Chromium 的所有功能。我认为强调 Puppeteer 是用 JavaScript 创建的很重要,因此 API 会比来自跨语言哲学的 Selenium 的 API 感觉更自然。
Puppeteer 不需要请求任何人实现 API,因为它利用了 Chromium 的无头能力。现在让我们看看什么是无头浏览器。
介绍无头浏览器
什么是无头浏览器?不,它不是来自恐怖电影的东西。无头浏览器是一种浏览器,你可以通过特定的协议和特定的通信传输启动并与之交互,而不涉及任何用户界面。这意味着你将有一个活跃的进程(或者像我们今天所知道的,有多个进程),但将没有“窗口”供你与浏览器交互。我认为“无窗口浏览器”会更准确。
可用的无头浏览器
Chromium 和 Firefox 都支持无头浏览器模式。重要的是要提到,在撰写这本书的时候,Firefox 的无头模式仍然是实验性的。与 Selenium 提供的六个浏览器相比(www.hardkoded.com/ui-testing-with-puppeteer/selenium-browsers),这听起来可能不太好,但正如你可能已经注意到的,我没有说 Chrome,我说的是 Chromium。Chromium 是 Chrome 在底层使用的引擎。但 Chrome 并不是唯一使用 Chromium 的浏览器;在过去的几年里,许多浏览器开始使用 Chromium 引擎。以下是一些基于 Chromium 的浏览器的例子:
-
Google Chrome
-
微软 Edge,也称为 Edgium,为了避免与基于 Trident 的旧版微软 Edge 混淆
-
Opera
-
Brave
这要好得多。我们可以自动化至少五种浏览器。但是,有两个主要浏览器没有无头支持:Microsoft Internet Explorer 和 Safari。Safari 的情况很有趣。正如 Chromium 是 Chrome 背后的引擎一样,Webkit 是 Safari 的引擎,尽管 Safari 不支持无头模式,但为了测试目的,有一些带有无头支持的 Webkit 构建。Microsoft Playwright 有自己的 Webkit 构建以支持跨浏览器自动化。
你想第一次看到无头浏览器吗?
让我们试试这个:
如果你已安装 Chrome,请获取可执行文件的完整路径,并传递以下命令参数:--headless --remote-debugging-port=9222 --crash-dumps-dir=/tmp:
~ % /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --headless --remote-debugging-port=9222 --crash-dumps-dir=/tmp
小贴士
如果你是一名 macOS 用户,Chrome 可执行文件将位于“Google Chrome.app”伪文件中。正如你所见,它是:“Google Chrome.app/Contents/MacOS/Google Chrome”。
执行该命令后,你应在控制台得到类似以下内容:
DevTools listening on ws://127.0.0.1:9222/devtools/browser/e7e52f93-8f1e-491c-b718-94ae7a8e81b7
现在,我们有一个通过 WebSocket 在ws://127.0.0.1:9222上等待命令的无头 Chrome 浏览器。
Firefox 也提供了无头模式:
~ % /Applications/Firefox.app/Contents/MacOS/firefox --headless
*** You are running in headless mode.
它没有说太多,但请相信我,现在我们有一个在无头模式下运行的 Firefox 浏览器。
如我之前所述,无头浏览器没有用户界面。与浏览器交互的唯一方式是使用浏览器创建的传输方式,在本例中是 WebSocket,并通过某种协议发送消息。在 Chromium 和 Firefox 的情况下,它是 Chromium DevTools 协议。
Chromium DevTools 协议
如果你是一名网页开发者,我 100%确信你已经使用过 Chrome DevTools。如果你不知道我在说什么,你可以通过点击右上角的三个点按钮打开 DevTools,然后转到“更多工具 > 开发者工具”。你将得到类似这样的东西:

Chrome DevTools
使用这个神奇的工具你可以完成的事情令人印象深刻:
-
检查 DOM。
-
评估 CSS 样式。
-
运行 JavaScript 代码。
-
调试 JavaScript 代码。
-
查看网络调用。
-
测量性能。
好消息是,是Chromium 开发者协议(从现在起我们将称之为CDP)驱动了大多数 DevTools 的功能。而且,同样的 CDP 也是无头浏览器用来与外界交互的协议。
CDP 听起来完美。我们可以与浏览器交互并完成我提到的所有事情。你可以创建一个 Node.JS 应用程序来启动浏览器,并通过 WebSocket 开始发送 CDP 消息,但这将相当复杂且难以维护。这就是 Puppeteer 发挥作用并提供与浏览器交互的人性化界面的地方。
介绍 Puppeteer
Puppeteer 不仅仅是一个知道如何打开浏览器、发送命令并响应来自该浏览器消息的 Node.js 包。在撰写本书时,Puppeteer 支持 Chromium 和 Firefox,但 Firefox 的支持仍被视为实验性的。我认为现在是时候去 Puppeteer 仓库 (www.hardkoded.com/ui-testing-with-puppeteer/puppeteer-repo) 查看自那时以来是否有所变化了。
此外,还有一些社区项目在其他语言中实现了 Puppeteer。你可以找到 Puppeteer-Sharp (www.hardkoded.com/ui-testing-with-puppeteer/puppeteer-sharp) 用于 .NET 或 Pyppeteer (www.hardkoded.com/ui-testing-with-puppeteer/pypeteer) 用于 Python。
当你使用 Puppeteer 时,实际上你使用的不只是一个 JavaScript 库。许多人称之为“Puppeteer 雕像”:

Puppeteer 雕像
Puppeteer 雕像由三个组件组成:
-
无头浏览器是运行我们想要自动化的页面的引擎。
-
Chromium DevTools 协议允许任何外部用户与浏览器交互。
-
Puppeteer 提供了一个 JavaScript API,用于通过 CDP 与浏览器交互。
我认为 Puppeteer 价值所在的是其模型清楚地表示了浏览器结构:
Puppeteer 对象模型

Puppeteer 模型
让我们看看这些对象在浏览器内部代表什么。
浏览器
浏览器是主要的类。它是 Puppeteer 连接到浏览器时创建的对象。这里的关键词是 connect。Puppeteer 将使用的浏览器可以由 Puppeteer 本身启动。但这也可能是已经运行在你本地机器上的浏览器,甚至可能是运行在云端的浏览器,如 Browserless.io (www.hardkoded.com/ui-testing-with-puppeteer/browserless)。
浏览器上下文
一个浏览器可以包含多个上下文。上下文是一个浏览器会话(不要与浏览器窗口混淆)。最好的例子是隐身模式或私密模式,这取决于浏览器,它会在同一个浏览器进程中创建一个隔离的会话。
页面
页面是一个浏览器中的标签页,甚至是弹出页面。
框架
框架对象比看起来更重要。每个页面至少有一个框架,称为主框架。在这本书中我们将学习的大多数页面操作实际上是对主框架的调用;例如,page.click 调用 mainframe.click。
框架是一个树。一个页面只有一个主框架,但一个框架可以包含许多子框架。
工作者
工作者是与 Web Workers 交互的模型。这不是本书中我们将讨论的功能。
执行上下文
执行上下文是 Chromium 用来隔离页面和浏览器扩展的机制。每个框架都将有自己的执行上下文。内部,所有涉及执行 JavaScript 代码的框架函数都将使用执行上下文在浏览器内部运行代码。
涉及的其他对象还包括ElementHandles和JSHandles,但我们在本书的后面章节会讨论它们。
既然我们已经了解了一些 Selenium 和 Puppeteer 之间的区别,现在回顾 Puppeteer 的许多可能用例正是一个完美的时机。
Puppeteer 用例
记住,Puppeteer 和 Selenium 之间的主要区别在于 Selenium 是为端到端测试设计的。相比之下,Puppeteer 被设计为一个 API,以利用 DevTools 的全部功能,这意味着除了端到端测试之外,还有其他可以使用 Puppeteer 的用例,正如我们现在将要看到的。
任务自动化
在网络上,我们做了许多可以自动化的工作。例如,你可以下载报告、填写表格或检查航班价格。你也可能想检查你网站的运行状况、监控其性能或检查你的网站是否运行正确。在第六章**,执行和注入 JavaScript中,我们将看到如何使用 Checkly 在生产环境中监控你的网站。
网络爬取
大多数库的作者可能不会愿意说你可以使用他们的库来进行网络爬取。网络爬取因其非法声誉而闻名。但在第九章**,爬取工具中,我们将看到如何正确地进行网络爬取,而不会遭到封禁或起诉。
内容生成
生成内容并不是如果你需要考虑可能的用例时,会想到的用例。但 Puppeteer 是一个生成两种类型内容的优秀工具:
-
屏幕截图:为什么你需要使用应用程序来截图呢?想想缩略图或预览。想象一下,你想创建一个付费墙,显示你网站内容的一部分,但以模糊图像的形式。你可以使用 Puppeteer 对你的网站进行截图,将其模糊处理,并使用那张图片。
-
PDF 文件:发票是 PDF 生成的绝佳例子。想象一下,你有一个电子商务网站。当用户完成购买时,你会向他们展示一个设计精美、布局合理的发票,但你需要通过电子邮件发送给他们那份确切的发票。你可以使用 Puppeteer 导航到那个发票页面并将其打印成 PDF。你也可以使用你的着陆页生成 PDF,并将其用作宣传册。
在第七章**,使用 Puppeteer 生成内容中,我们将讨论这个用例以及如何使用截图来编写 UI 回归测试。
端到端测试
我认为 Puppeteer 对于测试现代网络应用来说非常棒,因为它接近浏览器。API 感觉非常好,现代,并且是为 JavaScript 开发者设计的。它让你可以轻松执行 JavaScript 代码,并给你访问 Chromium 所有功能的权限。但我也必须说,Selenium 的端到端测试工具非常出色。Puppeteer 甚至无法与 Selenium Grid 提供的功能相提并论。选择哪个工具适合你取决于你自己。
理论已经足够了。现在是时候开始并设置我们的环境了。
设置环境
Node.js 和 Puppeteer 的好处是它们是跨平台的。我的本地环境是 macOS Catalina 10.15.6。但如果你使用 Windows 或 Linux 环境,你不会看到太大的区别。
时间是技术书籍的最大敌人。在撰写这本书的时候,我正在使用 Node.JS 12.18.3 和 Puppeteer 7。我非常确信,当你阅读这本书的时候,新的版本已经发布。但不要因此感到气馁;我们预期这种情况会发生。这就是为什么我鼓励你现在就去查看这本书的 GitHub 仓库(github.com/PacktPublishing/ui-testing-with-Puppeteer)。如果你看到有什么东西不工作或者已经改变,请在那个仓库中创建一个 issue。我们将尽力保持其更新。
运行我们的第一个 Puppeteer 代码只需要两样东西:Node.JS 和 Puppeteer。让我们从 Node.JS 开始。
Node.js
为了这本书的目的,你需要了解的关于 Node.js 的唯一一件事是,它是一个运行时,允许我们在浏览器之外运行 JavaScript 代码。
需要强调的是,我们想要自动化的网站不一定需要在 Node.js 上运行。你不需要知道编写网站的编程语言,也不需要知道网站运行的平台,但如果你能了解这些细节,这可能会给你一些编写更好的自动化代码的想法。例如,如果你知道该网站是一个 ASP.NET Webforms 项目,你就会知道它使用一些隐藏的输入来执行 postbacks。如果你了解客户端框架,如 Vue 或 React,这一点会更加明显。
如我之前提到的,我们将安装 Node.JS v12.18.3(或更高版本)。这个过程相当简单:
-
前往官方网站:
nodejs.org/。 -
下载 LTS 版本。LTS 代表 长期支持。
-
按照你通常在平台上安装的方式运行安装程序:


Node.js 安装
如果你想要查看安装是否成功,你可以在终端中执行 node --version:
~ % node --version
v12.18.3
Visual Studio Code
你不需要任何特殊的代码编辑器来编写 Node.js 应用程序。但 Visual Studio Code 是一个很好的编辑器。它是免费的,跨平台的,你不仅可以用它来编写 JavaScript 代码,还可以用它来编写许多其他语言的代码。
你可以在 code.visualstudio.com/ 下载它。它甚至不需要在 macOS 上运行设置。它只是一个你复制到 Applications 文件夹的应用程序:
![Visual Studio Code]

Visual Studio Code
现在我们已经安装了 Node.js 以及代码编辑器,我们可以创建我们的第一个应用程序。
我们的第一段 Puppeteer 代码
我们首先需要创建一个文件夹,我们的 hello-puppeteer 项目将位于该文件夹中。我将使用终端,但你可以使用你感到更舒适的方式。我们的项目将被称为 hello-puppeteer:
> mkdir hello-puppeteer
> cd hello-puppeteer
我们现在需要初始化这个全新的 node.js 应用程序。在 node.js 中,我们使用 npm init 命令创建新应用程序。在这种情况下,我们将传递 -y 参数,这样它就会使用默认值创建我们的应用程序:
> npm init -y
Wrote to /Users/neo/Documents/Coding/hello-puppeteer/package.json:
{
"name": "hello-puppeteer",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
这个输出并没有说明太多。它显示它创建了一个包含一些默认值的 package.json 文件。现在,我将使用 touch 命令创建一个 index.js 文件。同样,你可以以你感到最舒适的方式执行此操作:
> touch index.js
touch 应该已经创建了我们的应用程序的入口点。但在编码我们的应用程序之前,我们需要安装 Puppeteer。
安装 Puppeteer
大多数框架,如果不是所有框架,都有一种方式来发布和重用不同作者编写的组件。在 Node.js 中最流行的包管理器是 npm init 来创建我们的应用程序。由于 Puppeteer 是在 NPM 上发布的包,我们可以使用 npm install 命令下载和安装它。
如果你不想在应用程序之间跳转,你可以在 Visual Studio Code 内部打开一个终端。如果你还在终端中,你可以使用以下命令打开 Visual Studio Code:
> code .
这将打开 Visual Studio Code。一旦进入,你将能够从 终端 菜单启动一个新的终端,如下面的截图所示:
![Visual Studio Code 内部的终端]

Visual Studio Code 内部的终端
打开终端后,我们可以使用 npm install 安装 Puppeteer:
> npm install puppeteer@">=7.0.0 <8.0.0"
Downloading Chromium r848005 - 128 Mb [========= ] 44% 5.3s
我想在这里强调两点。由于本书基于 @">=7.0.0 <8.0.0",这意味着我们想要大于或等于 7.0.0 且小于版本 8.0.0 的最新 Puppeteer 版本。通过强制使用此版本,你将能够使用与我相同的版本来跟随本章中的示例。
Puppeteer 版本控制
Puppeteer 遵循 语义化版本控制规范(SemVer)来为其发布版本进行版本控制,这意味着版本号中的三个数字遵循一定的规则。主版本号(第一个数字)的变化意味着 API 中发生了重大变化。当一个包更改主版本号时,它会告诉你新版本可能会破坏你的代码。次版本号(第二个数字)的变化意味着他们添加了新功能,同时保持向后兼容。最后,修订号的变化意味着他们修复了一个错误,同时保持向后兼容。
如果你看到 Puppeteer 的版本是 8、9 或 10,这并不意味着这本书现在已经过时了。这意味着他们改变了某些东西,破坏了其他人的代码。例如,从版本 6 到版本 7 的变化只是他们在截图方式上做的一些改动。
在现实生活中,你可以使用可用的最新版本。其次,你可能已经注意到下载的包包含了一个特定的 Chromium 版本,在这个例子中是 r848005。这并不意味着你从互联网上下载的任何 Chromium 版本都无法与你的代码一起工作。但是,记住,Puppeteer 通过 Chrome DevTools 协议与浏览器交互,因此它需要一个 Puppeteer 预期的方式反应的 Chromium 版本。在 Puppeteer v7.0.1 的情况下,它需要 Chromium 90.0.4403.0,并且不能保证任何其他版本的 Chromium(无论是新版本还是旧版本)都能与你的当前 Puppeteer 版本一起工作。这并不意味着它不会工作。这意味着它没有保证。你需要进行实验并查看。你可以在 API 页面上检查每个 Puppeteer 版本应使用哪个 Chromium 版本(www.hardkoded.com/ui-testing-with-puppeteer/puppeteer-api)。
Puppeteer 中的“Hello World”
每种语言都有自己的“Hello World”程序。Puppeteer 的“Hello World”程序将是导航到 en.wikipedia.org/wiki/%22Hello,_World!%22_program 并对页面进行截图。让我们看看它看起来会是什么样子:
const puppeteer = require('puppeteer');
(async function() {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://en.wikipedia.org/wiki/%22Hello,_World!%22_program');
await page.screenshot({ path: './screenshot.png'});
browser.close();
})();
这就是我们在这个小脚本中做的事情:
-
我们使用
require导入 Puppeteer 库。 -
启动一个新的浏览器。
-
在那个浏览器中打开一个新的页面(标签页)。
-
导航到维基百科页面。
-
捕获屏幕截图。
-
关闭浏览器。
我喜欢 Puppeteer 开始使用起来既简单又容易。现在是时候运行它了。使用你用来运行 npm install 的相同终端,现在运行 node index.js:
> node index.js
一个 Chromium 浏览器打开了,导航到维基百科,然后自行关闭。你没有看到它,因为这是一个无头浏览器,但它确实发生了。现在,如果你检查你的工作目录,你应该有一个名为 screenshot.png 的新文件:

屏幕截图
我们的代码按预期工作。我们从维基百科获得了我们的截图。
我敢打赌你注意到了在我们的小型 hello puppeteer 示例中使用了四个 awaits。异步编程在 Puppeteer 中扮演着重要角色。现在让我们来谈谈 JavaScript 中的异步编程。
JavaScript 中的异步编程
通常,程序是同步运行的,这意味着每一行代码都是依次执行的。让我们以这两行代码为例:
const x = 3 + 4;
console.log(x);
这两行代码将按顺序执行。3 + 4 的结果将被分配给 x 常量,然后使用 console.log 在屏幕上打印变量 x。console.log 函数必须在 x 被分配后才能开始执行。
但有些任务,比如网络请求、磁盘访问或任何其他 I/O 操作,可能需要很长时间,我们不一定想等待这些任务完成后再继续执行我们的代码。例如,我们可以开始下载一个文件,在文件加载的同时执行其他任务,然后在下载完成后检查该文件。异步编程将允许我们在不阻塞代码的情况下执行这些长时间运行的任务。
异步函数会立即返回一个 Promise 以避免在等待任务时阻塞你的代码。这个 Promise 是一个对象,它可以处于以下三种状态之一:
-
Pending:这意味着异步任务仍在进行中。
-
Fulfilled:这意味着异步任务成功完成了。
-
Rejected:这意味着异步任务失败了。
假设我们有一个名为 downloadAFileFromTheInternet 的函数。等待一个任务完成的常见方式是使用 await 关键字:
await downloadAFileFromTheInternet();
需要强调的是,这里的 await 关键字并不是等待函数本身;它是在等待该函数返回的 Promise。这意味着你还可以将那个 Promise 赋值给一个变量,并在代码的稍后位置等待它:
const promise = downloadAFileFromTheInternet();
// some code
await promise;
或者你也可以根本不等待这个 Promise:
downloadAFileFromTheInternet();
如果你想了解更多关于异步 JavaScript 的知识,可以查看 Steven Hancock 的 Asynchronous JavaScript Deep Dive 视频教程(www.packtpub.com/product/asynchronous-javascript-deep-dive-video/9781800202665)。
Puppeteer 依赖于异步编程技术,因为 Puppeteer 与 Chrome DevTools 之间的通信是异步的。毕竟,Chrome DevTools 与浏览器之间的通信也是异步的。想想当你点击一个链接时幕后会发生什么:

点击时间线
当你调用 page.click 时,该动作的结果不会立即出现。正如我们所见,幕后有许多事情在进行。当你调用 page.click 时,你需要做之前提到的事情之一:等待它;将承诺保存在变量中并在稍后等待它;或者根本不等待它。
现在我们对异步编程有了更多的了解,我想回顾一下本书中我们将使用的五个实用工具。
Promise.all
Promise.all 是一个函数,它期望一个 promise 数组,并返回一个当 所有 promise 都 解决或拒绝 时才会解决的 promise。是的,一个 promise 可以是解决的,成功完成,或者拒绝,这意味着它失败了。
一个常见的场景是点击一个链接,并等待页面导航到下一页:
await Promise.all([
page.click('a'),
page.waitForNavigation()
]);
这个 promise 将等待链接的点击和 waitForNavigation promise 被解决或拒绝。
Promise.race
与 Promise.all 类似,Promise.race 期望一个承诺数组,但在这个情况下,只要任何一个承诺被解决,它就会解决。
典型用法是用于超时。我们想要截图,但只有当它少于 2 秒时:
await Promise.race([
page.screenshot(),
new Promise((resolve,reject)=>{
setTimeout(()=>{
reject(new Error('Too long!!!'));},2000);
})]);
在这种情况下,如果 screenshot 承诺超过 2,000 毫秒,数组中作为第二个元素的承诺将被拒绝,拒绝该承诺。
履行我们的承诺
你在我们的上一个例子中看到了如何创建一个承诺,返回那个承诺或将其分配给一个变量,然后履行它。
当你想要等待一个事件发生时,这很棒。我们可以创建一个承诺,当页面关闭时它将被解决:
const promise = new Promise((x) => page.on('close', x));
// …
await promise;
这种 await 是相当危险的。如果承诺永远不会解决,你的代码将会挂起。我建议使用这些承诺与 Promise.race 和超时一起使用。
在这本书中,我们将看到许多承诺。也许现在一些像“履行我们的承诺”这样的食谱看起来很奇怪,但我们会大量使用它们。
摘要
在第一章中,我们涵盖了大量的内容。我们学习了浏览器自动化以及 Selenium 和 Puppeteer 之间的区别。然后我们看到 Puppeteer 不仅限于端到端测试,并回顾了一些用例场景。然后我们亲自动手编写了我们的第一个 Puppeteer 脚本。在章节的最后部分,我们介绍了许多我们将在本书中使用的异步技术。
在下一章中,我们将专注于端到端测试。我们将回顾市场上可用的工具,并考虑如何组织我们的代码以创建可靠的端到端测试。
第二章:第二章:自动化测试和测试运行器
在 第一章 使用 Puppeteer 入门 中,我们介绍了这本书的第一个基本支柱:浏览器自动化和无头浏览器。在本章中,我们将介绍第二个支柱:UI 测试。我们了解到 Puppeteer 不仅关于测试,但这并不意味着它不是一个出色的工具。
在本章中,我们将学习测试自动化的基础知识。我们将看到 UI 测试和端到端测试之间的区别。如果你之前尝试在 Node.js 中编写测试,你可能遇到过一些奇怪的名字:Mocha、Jest、Jasmine、AVA 或 Chai。如果你不习惯这些工具,这会感觉相当令人不知所措。我们将看到哪些工具适合我们。
本章我们将涵盖以下主题:
-
自动化测试简介
-
测试运行器主要功能
-
可用的测试运行器
-
创建我们的第一个测试项目
-
组织我们的代码
一旦我们理解了这些基础概念,并学习了测试运行器的工作原理,我们就能深入探索 Puppeteer API。
技术要求
你将在 GitHub 仓库(github.com/PacktPublishing/UI-Testing-with-Puppeteer)的 Chapter2 目录下找到本章的所有代码。
自动化测试简介
测试是软件开发中的基本任务。即使你认为自己是糟糕的测试员,或者甚至糟糕的开发者,当你编写你的应用程序时,你也会进行一些测试。至少,你会打开应用程序以查看它是否按预期工作。
可能你稍微有点条理性,你有一个测试计划,至少在你的脑海中。你知道当你编写表单时,你必须验证一些常见的场景:
-
尝试保存一个空字段的表单。
-
尝试使用良好数据保存。
-
尝试输入错误的数据。你可能会在数字字段中输入文本,无效的日期等等。
经验更丰富的开发者将涵盖所有可能的场景。他们将根据这些场景编写代码,然后相应地进行测试。
然后我们来到了这本书中推动性的词汇:我们自动化事物。我们希望自动化我们的测试。我们不想忘记任何场景或不得不反复测试同一件事。
正如你将注意到的,我还没有提到质量保证(QA)分析师,因为我想强调测试不是仅限于 QA 团队的事情。参与测试过程的人包括以下:
-
后端开发者
-
前端开发者
-
QA 分析师
-
经理(产品或项目经理)
我们需要知道存在不同类型的测试。某些类型的测试将由开发人员和 QA 分析师执行。其他测试将专门针对开发人员或 QA 分析师。
Mike Cohn 在他的书 Succeeding with Agile(Addison-Wesley Professional)中介绍了他非常受欢迎的测试金字塔:

Mike Cohn 的测试金字塔
尽管迈克的书籍已经超过 10 年了,但这个金字塔仍然有效。
这个金字塔基于三个特征:
-
测试数量
-
独立性
-
速度
我只有一件事反对这个金字塔:这个词UI。现代应用程序越来越依赖于客户端代码,“UI”代码。例如 React、Angular 和 Vue.js 这样的框架允许开发者编写可重用的组件。现在许多应用程序的大部分业务规则都在客户端运行。
前端开发者不应该局限于这个金字塔的顶端。他们应该能够为他们自己的 UI 代码编写单元测试和服务测试。这可能看起来是一个小的变化,但我认为这很重要。随着这种范式转变,我们得到了一个看起来是这样的金字塔:

新金字塔
现在我们有了更好的理解,让我们来谈谈这个金字塔的不同层级。
单元测试
单元测试是金字塔的基础。你在单元测试中覆盖的业务逻辑越多,你将需要覆盖的服务或 UI 测试的地面就越少。
正如我们在金字塔中可以看到的,单元测试需要快速且独立。这意味着一个好的单元测试不应该依赖于环境或任何其他功能。有时候说起来容易做起来难。例如,如果你想测试发票的总金额等于其项目的总和,你应该能够在代码中测试这个特定的功能,而不需要启动一个 web 服务器或从数据库获取数据。
哪些角色使用单元测试?
后端开发者:当然,单元测试是为他们准备的。如果可能的话,他们会遵循测试驱动开发(TDD)的过程。TDD 是软件开发中的一种技术,其中测试是在编写任何源代码之前编写的。一旦编写了测试,开发者将编写源代码以使它们通过。
前端开发者:在以前,编写单元测试几乎是不可行的。如果没有合适的工具,你就不能正确地完成你的工作。但现在,许多现代库支持单元测试。如果你使用 React 和 Redux,你会发现 Redux 有为你组件编写单元测试的方法(https://www.hardkoded.com/ui-testing-with-puppeteer/redux-unit-tests)。
这还不是全部。同样地,后端开发者需要考虑如何使他们的代码可测试,如果前端开发者使用现代框架开始创建小型且可测试的组件,他们应该能够使用 Puppeteer 来编写 UI 单元测试。这就是为什么测试金字塔顶部的“UI”不再有任何意义。现在我们可以编写UI 单元测试。
我们可以运行一个小测试,渲染一个组件并测试,例如,它“渲染一个文本框,当我输入一个值时,下面的标签会改变”,或者“如果我传递一个包含 10 个项目的列表,将渲染 10 个元素。”
我们将 UI 测试移到了测试金字塔的底部。
质量分析师目前还没有参与。单元测试是关于测试内部代码的。
那么关于经理呢?如果你是开发者,我相信你会把这段话展示给你的老板。经理不会编写单元测试,但他们需要知道编写单元测试和投入时间的重要性。
这些是你(或你的老板)需要了解的四个好处。
单元测试展示了代码的工作方式
单元测试解释了代码的工作方式。当我审查代码时,我首先审查单元测试。如果我发现单元测试写着,例如,“创建订单应该发送电子邮件”。我可以先阅读这个测试,然后检查这个规则是如何实现的。
业务分析师或项目经理可以阅读这些测试,看看是否有任何未覆盖的场景或缺失的验证。
单元测试使重构成为可能
我使用“可能”这个词是冒了点风险。但我相信这是真的。如果你没有单元测试来支持你的更改,你就不能重构你的代码。记住,重构是在不改变特定输入结果的情况下改变代码的实现。单元测试保证了这一点。
单元测试防止回归
回归是应用程序预期行为的不自觉变化。如果我们有一套好的测试,它们将防止我们在实现新功能或修复错误时破坏应用程序的任何行为。
我如何确保其他开发者不会来破坏我刚刚编写的宝贵功能?通过编写单元测试。单元测试是你未来版本的自己,强制执行代码应该如何工作。“创建订单应该发送电子邮件”——没有人能够打破这个规则。
当我审查代码时,单元测试的变化对我来说是一个红旗。我并不是说单元测试不应该改变。但如果一个测试改变了,必须有解释。现在,“创建和排序应该发送电子邮件”显示发送的电子邮件计数为 2。这是正确的吗?我们还要发送另一封电子邮件吗?或者我们遇到了回归?请注意单元测试的变化。
测试金字塔上升的时间。
服务测试
服务测试也被称为集成测试。这些测试将检查你的代码如何与其他组件交互。当我们谈论组件时,我们指的是以下内容:
-
数据库
-
应用程序中的其他组件
-
外部服务
前端开发者还需要将他们的代码与以下内容集成:
-
其他 UI 组件
-
CSS 文件
-
REST API
正如我们之前提到的,当我们上升测试金字塔时,测试会变得更慢且更不稳定。这是应该的。你将连接到真实的数据库或与真实的 REST API 交互,这将使用真实的网络调用。这也意味着你的测试将期望环境以某种方式响应。例如,你期望数据库有一些数据集准备好使用,或者 REST API 可用。
这就是为什么在单元测试层有更多测试,你就不需要编写太多的集成测试。
让我们以发送电子邮件的类为例,你能为它编写一个集成测试吗?当然可以。你设置一个本地电子邮件服务器,它会将电子邮件写入一个临时文件夹,因此在你创建订单后,你可以检查那个文件夹,看看电子邮件服务器是否处理了你应用应该发送的电子邮件。但是,正如你所看到的,这类编排比小型单元测试更难编写。
为什么我们需要集成测试?为什么我们只编写单元测试而不编写集成测试?
好吧,你需要测试你的集成。你的代码不会在孤立的环境中运行。如果你正在测试后端,你需要看到数据库如何响应你插入的数据,或者一个 SQL 查询是否返回了你预期的数据。
如果你是一名前端开发者,你将在这里投入大部分时间,检查你的组件如何在页面上交互,或者生成的 HTML 如何影响 DOM 中的其他元素。你需要测试你的组件如何与真实的 REST 端点渲染,而不是使用虚拟的 JSON 文件。
哪些角色使用集成测试?
CreateOrder,我得到了一个新的Order对象。但现在,我需要测试当我向/orders发送POST请求时,数据库中是否创建了一个订单。
前端开发者将创建测试以检查页面上所有不同的组件如何相互交互。再次强调,这是在测试金字塔中的 UI 测试。
质量保证分析师将创建与后端和前端开发者创建的测试类似的测试,但视角不同。
开发者和质量保证分析师创建相同类型的测试,但视角不同。
开发者将创建测试以支持他们的工作,这样他们就可以检查是否破坏了任何东西。而且,正如我们之前提到的,他们需要测试以便将来能够重构代码。
质量保证分析师将创建测试以确保应用程序质量符合利益相关者。
质量保证分析师可以在这一层实现一种有趣的测试类型:视觉回归测试。这些测试在我们想要检查应用程序样式是否发生了任何视觉变化时使用。我们不想检查是否有按钮,或者那个按钮是否工作。我们想检查按钮看起来是否和之前一样。我们如何实现这一点?通过比较图像。这种技术基于以下四个步骤:
-
我们截取一个作为基准的屏幕截图:![基准图像
![图像]()
基准图像
-
我们在代码中进行了变更。
-
我们再次截取屏幕截图:![变更后的图像
![图像]()
变更后的图像
-
我们比较这两张图像:

差异
这种类型的测试可能相当不稳定。我敢打赌,你看到过页面在加载时有时会“移动”,所以你必须非常确定页面何时准备好截图。但这是可以做到的。另一个缺点是,对于你得到的每一个错误,你必须分析这个更改是否是回归(一个错误做出的更改)或者我们是否处于新的基线的存在。
管理者的角色仍然很重要。他们需要为开发者提供实施所需集成测试的工具和时间。他们还将帮助 QA 分析师确定要测试的集成。
因此,我们来到了金字塔的顶端,端到端测试。
端到端测试
你也可能发现这些测试被称为E2E测试。端到端测试的目标是确保应用程序在整个工作流程中按预期工作。大多数应用程序将会有多个工作流程。这意味着它将需要多个端到端测试来覆盖所有可能的工作流程或场景。
让我们以购物车应用为例。这些可能是我们的测试:
-
单元测试:
a) 传递一个购物车对象,
AddToCart组件渲染一个“查看购物车”链接,如果产品在数组中。 -
集成测试:
a) 前往产品页面并点击“添加到购物车”。链接变为“查看购物车”。
b) 前往结账页面。点击结账按钮后,它会被禁用。
-
一个测试购物车流程的端到端测试:
a) 前往产品页面,点击添加到购物车,然后点击查看购物车。
b) 你应该到达结账页面。点击结账。
c) 你应该被重定向到收据页面。
d) 收据应该显示添加到购物车的产品。
e) 价格应该是产品价格。
我们处于金字塔的顶端。这意味着这将是最慢和最不稳定的测试。
为什么是最不稳定的?检查工作流程。那里可能会发生许多不好的事情。添加到购物车端点可能比预期的要花更多的时间。滚动到结账按钮可能因为仅仅几像素的失败而失败。您的数据库可能处于意外的状态。也许用户已经购买了该产品,所以添加到购物车按钮没有被启用。
关于角色呢?
这是质量分析师的领域。这是他们需要利用 Puppeteer 提供的所有功能来制作可靠测试的地方。但开发者也扮演着重要的角色,帮助 QA 团队高效地完成工作。正如我们将在下一章中看到的,开发者可以留下提示,以便 QA 团队能够找到他们需要的组件。
我希望金字塔的图片现在更清晰了。我们需要大量的小型和隔离的单元测试,许多集成测试来测试我们的页面,最后,一组良好的端到端测试,检查工作流程的健康状况。
这是著名的测试金字塔,但我们如何编写测试?在哪里编写它们?我们如何运行测试?
首先,我们需要了解测试运行器需要我们提供什么。
测试运行器功能
没有测试运行器,世界会是什么样子?假设你不知道什么是测试运行器,而你想编写一个单元测试。这可能吗?我认为是可能的。例如,假设我们有一个小的Cart类:
class Cart {
constructor() {
this._cart = [];
}
total() {
return this._cart.reduce((acc, v) => acc + v.price, 0);
};
addToCart(item) {
this._cart.push(item);
};
}
module.exports = Cart;
如果我们想测试它,我们可以运行一些像这样的代码:
const Cart = require('./cart.js');
const c = new Cart();
c.addToCart({ productId: 10, price: 5.5});
c.addToCart({ productId: 15, price: 6.5});
if(c.total() !== 12)
console.error('Nooo!!!');
else
console.log('Yes!!!!!');
测试基本上是一段测试我们代码的代码。这会工作吗?是的。这是一个单元测试吗?是的。它会扩展吗?绝对不会。这个文件将变得巨大且难以维护。跟踪失败的任务将是不可能的。我们需要一个工具来帮助我们扩展,并帮助我们保持测试的可维护性。我们需要一个测试运行器。
在探索可能的测试运行器之前,我想回顾一下我们对测试运行器的期望。我们需要在测试运行器中具备哪些功能?
易于学习和运行
我们有很多东西要学习。我们需要学习 Node 和 React;我们甚至不得不买一本关于 Puppeteer 的书。我们希望有一个简单易用的测试运行器。
按功能分组测试
我们希望将测试按功能、组件或工作流程分开。大多数测试运行器都有一个describe函数,帮助我们分组测试。
如有必要,忽略测试
如果测试变得嘈杂,我们希望跳过测试,但我们不想删除它。
只运行一个测试
在调试时,能够只运行一个测试非常重要。想象一下,你有超过 1,000 个测试(是的,你将会有超过 1,000 个测试)。如果你想修复仅一个测试,你不想运行所有这些测试。你只想运行你正在工作的那个。
断言
断言是必不可少的。断言是一个表达式,用于检查我们正在测试的程序是否按预期工作。你还记得我用来检查购物车是否按预期工作的console.log和console.error吗?嗯,断言比那要好得多。我们想用断言检查什么?这是一个可能的列表:
-
一个值是否等于测试值。
-
一个值是否为 null 或非 null。
-
字符串或列表是否包含一个值。我们可能有一个巨大的文本块,我们只想检查它是否包含某些字符串,或者数组中的某个项。
-
我们是否预期某些东西会失败,因为有时,我们可能会预期某些代码会失败。
设置和清理环境的工具
在开始测试之前,我们需要确保我们的应用程序处于某种状态。例如,在购物车测试中,我们希望在开始测试之前确保客户尚未购买该产品。
还有一些可能需要执行的技术设置。在我们的案例中,我们需要在每个测试之前准备好 Puppeteer 和浏览器。
另一个重要的概念是,测试应该是独立的,并且彼此分离。这意味着一个测试的结果不能影响其他测试。这就是为什么,在大多数情况下,需要在每个或所有测试之后进行清理。
报告
我们想查看哪些测试通过了,哪些测试失败了。我们期望测试运行器至少在终端中显示一份良好的报告。如果我们能够以其他格式获取结果,比如 JSON、XML 或 HTML,那就更好了。
我们还可以提到很多其他功能,但这些是我们开始之前需要了解的最重要功能。
现在我们来看看市场上有哪些测试运行器能够满足我们提出的要求。
可用的测试运行器
网球拍有很多种类型。有些拍子能给你更多的控制力。其他拍子能给你更多的力量。如果你刚开始学习如何打网球,你不会感觉到任何区别。如果你比较一把便宜的拍子和一把专业的拍子,你会有感觉。但你不会说出为什么一个比另一个好。你只能说它“感觉更好”。
测试运行器也是一样。有些测试运行器提供一些功能。其他运行器提供其他功能。但对我们来说,现在最重要的是找到一个提供所有必需功能的测试运行器,以便我们编写自动化测试。
另一个需要提到的重要事情是,这本书不是关于“使用 Puppeteer 与 X”。在这一章之后,我们将选择一个测试运行器,但这不需要是你使用的测试运行器。想法是你可以选择最适合你的,或者你团队目前正在使用的。也有可能在你阅读这本书的时候,一个更好的测试运行器已经变得流行。你应该能够将你在本书中学到的概念应用到那个测试运行器上。
这些是目前市场上最常见的测试运行器。
Jest
根据 Jest 网站(jestjs.io/)的介绍,“Jest 是一个简单易用的 JavaScript 测试框架,专注于简洁性。”非常不错的介绍。Facebook 维护这个项目,目前在 GitHub 上拥有超过 32,000 个星标。我并不是说这是使一个项目成为好项目的因素,但了解一个项目背后的团队及其社区支持水平是一些需要考虑的事情。
Jest 拥有我们之前提到的所有功能,比如使用describe进行分组测试,每个测试都是一个it或test函数。你可以使用describe.skip、it.skip或test.skip来跳过测试。你可以使用describe.only、it.only或test.only来运行单个测试。你还有beforeEach、afterEach、beforeAll和afterAll,用于运行设置和清理代码。
它还有一些与其他运行器区分开来的特性。它有一个快照工具。快照工具会处理一个 React 组件,并以 JSON 的形式返回某种 DOM 表示,这将允许我们测试由组件创建的 DOM 是否发生了变化。这是不是一种 UI 测试?当然是的!
在评估测试运行器时,还需要考虑可用的插件。例如,有一个名为jest-puppeteer的包,它帮助我们集成测试与 Puppeteer。你不需要使用jest-puppeteer。它只是一个辅助工具。
还有一个名为jest-image-snapshot的包,由美国运通维护,它提供了一套工具来执行视觉回归测试。在这种情况下,如果你想编写视觉回归测试,我建议你使用这些包之一。管理所有的截图基线可能相当繁琐。
Mocha
Mocha 是另一个流行的框架。它是一个拥有超过 19,000 颗星的社区项目。值得一提的是,Puppeteer 团队使用 Mocha。
Mocha 也有像 Jest 一样的函数。它有一个describe函数来分组测试。测试是it函数。你可以使用describe.skip或it.skip跳过函数,使用describe.only或it.only来运行单个测试。你还有beforeEach、afterEach、beforeAll和afterAll,来运行设置和清理代码。
你也会发现许多针对 Mocha 的插件。你可以找到mocha-puppeteer和mocha-snapshots。
你在网上会经常看到的一个食谱是 Mocha + Chai。Chai是一个断言库,它扩展了测试运行器提供的断言。它让你能够以非常具体的方式表达断言:
foo.should.be.a('string');
foo.should.equal('bar');
foo.should.have.lengthOf(3);
tea.should.have.property('flavors').with.lengthOf(3);
还有许多其他的测试运行器,例如由 Pivotal Labs 开发的 Jasmine,拥有超过 15,000 颗星,AngularJS 团队开发的 Karma,拥有超过 11,000 颗星,还有社区项目 AVA,拥有超过 18,000 颗星,列表还在继续。
正如我在本节开头提到的,我们只需要一个好的网球拍,也就是说,一个好的测试运行器。当你成为专家时,你将能够从一个测试运行器切换到适合你需求的另一个测试运行器。为了本书的目的,我们将使用Mocha + Chai。
创建我们的第一个测试项目
我们将创建一个 Node 应用程序,就像我们在第一章中创建我们的第一个应用程序一样,使用 Puppeteer 入门。我们将创建一个名为OurFirstTestProject的文件夹(你将在技术要求部分提到的Chapter2目录中找到这个目录),然后在那个文件夹内执行npm init -y:
> npm init -y
响应应该是这样的:
{
"name": "OurFirstTestProject",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
现在是时候安装我们将要使用的包了:
-
Puppeteer 7
-
Mocha(任何版本)
-
Chai(任何版本)
让我们运行以下命令:
> npm install puppeteer@">=7.0.0 <8.0.0"
> npm install mocha
> npm install chai
对于这个第一个演示,我们将使用网站www.packtpub.com/作为测试用例。让我们保持我们的测试简单。我们想要测试页面标题是否为Packt | Programming Books, eBooks & Videos for Developers。
重要提示
我们用于此测试的网站可能已经改变。在测试此代码之前,请访问 www.packtpub.com/ 并检查标题是否仍然相同。这就是为什么在接下来的章节中,我们将下载网站到本地,以避免这些可能的问题。
我们提到我们会使用 describe 来分组我们的测试。但将测试分离到不同的文件中也会帮助我们整理代码。你可以选择每个文件中有一个或多个 describe 函数。让我们创建一个名为 home.tests.js 的文件。我们将把所有与主页相关的测试放在那里。
虽然你可以在任何你想的地方创建文件,但 Mocha 默认会抓取 test 文件夹中的所有测试,所以我们将创建一个 test 文件夹,然后在其中创建 home.test.js 文件。
我们将会有以下内容:
-
home.tests.js包含主页测试 -
一个带有标题测试的
describe函数 -
一个测试
"Title should have Packt name"的it函数。 -
另一个测试
"Title mention the word Books"的it函数。
结构应该看起来像这样:
const puppeteer = require('puppeteer');
const expect = require('chai').expect;
const should = require('chai').should();
describe('Home page header', () => {
it('Title should have Packt name', async() => {
});
it('Title should mention Books', async() => {
});
});
让我们分解这段代码:
-
我们在第 1 行导入了 Puppeteer。
-
第 2 行和第 3 行是关于导入不同类型的断言样式,
expect没有使用括号,而should使用了。我们现在不需要知道为什么。但为了清楚起见,这不是一个错误。 -
那么 Mocha 呢?我们遗漏了 Mocha 吗?嗯,Mocha 是测试运行器。它将是我们在
package.json中稍后要调用的可执行文件。我们不需要在我们的代码中使用它。 -
很有趣地看到,
describe和it都只是接受两个参数的简单函数:一个字符串和一个函数。你能将一个函数作为参数传递吗?是的,你可以! -
我们传递给
it函数的函数是async。我们无法在未标记为async的函数中使用await关键字。记住,Puppeteer 很大程度上依赖于异步编程。
现在我们需要启动一个浏览器并设置所有这些测试需要运行的环境。我们可以这样做:
it('Title should have Packt name', async() => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.packtpub.com/');
// Our test code
await browser.close();
});
小贴士
现在不要尝试学习 Puppeteer API。我们将解释所有这些命令是如何在 第三章 浏览网站 中工作的。
这段代码将完美运行。然而,有两件事可以进行优化:
-
我们会一遍又一遍地重复相同的代码。
-
如果测试过程中出现失败,浏览器不会关闭,留下很多打开的浏览器。
为了避免这些问题,我们可以使用 before、after、beforeEach 和 afterEach。如果我们将这些函数添加到我们的测试中,这将是我们执行的顺序:
-
before -
beforeEach -
it('Title should have Packt name') -
afterEach -
beforeEach -
it('Title should mention Books') -
afterEach -
after
这不是一个规则,但我们可以在这种情况下这样做:
-
before:启动浏览器。 -
beforeEach:打开一个页面并导航到 URL。 -
运行测试。
-
afterEach: 关闭页面。 -
after: 关闭浏览器。
这些钩子,这就是 Mocha 称这些函数的方式,看起来是这样的:
let browser;
let page;
before(async () => {
browser = await puppeteer.launch();
});
beforeEach(async () => {
page = await browser.newPage();
await page.goto('https://www.packtpub.com/');
});
afterEach(async () => {
await page.close();
});
after(async () => {
await browser.close();
});
这里要提到的一点是,我们可以执行所谓的await page.close()或browser.close()的结果。所以,我们可以这样做:
afterEach(() => page.close());
after(() => browser.close());
这不是我喜欢做的事情,因为如果某处失败,你希望知道在哪里以及为什么。但既然这只是测试的清理代码,不是生产代码,我们可以承担这个风险。
现在我们的测试已经打开了一个浏览器,读取了我们想要测试的 URL 的页面。我们只需要测试标题:
it('Title should have Packt name', async() => {
const title = await page.title();
title.should.contain('Packt');
});
it('Title should should mention Books', async() => {
expect((await page.title())).to.contain('Books');
});
我在这里使用了两种不同的风格。
在第一种情况下,我将title异步函数的结果赋值给一个变量,然后使用should.contain来检查标题是否包含单词"Packt"。在第二种情况下,我只是评估了((await page.title())。我在那里添加了一些额外的括号以供说明。你不会在最终的示例中看到它们。
第二个区别是,在第一种情况下,我使用的是should风格,而在第二种情况下,我使用的是expect风格。结果将是相同的。这仅仅关乎哪种风格让你感觉更舒适,或者对你来说更自然。甚至还有一个第三种风格:assert。
我们已经拥有了运行测试所需的一切。还记得npm init为我们创建了一个package.json文件吗?现在是时候使用它了。让我们设置test命令。你应该有类似这样的内容:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
我们需要告诉npm运行npm test:
"scripts": {
"test": "mocha"
},
是时候运行我们的测试了!在终端中运行npm test:
npm test
我们应该会有第一个错误:
1) Home page header
"before each" hook for "Title should have Packt name":
Error: Timeout of 2000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves.
这很糟糕,但还不算太糟糕。Mocha 默认会验证我们的测试应该少于 2,000 毫秒。对于一个独立的单元测试来说,这听起来是合理的。但 UI 测试可能需要超过 2 秒的时间。这并不意味着 UI 测试不应该有超时。在package.config文件中设置的启动设置中,我们设置了--timeout命令行参数。我认为 30 秒可以是一个合理的超时时间。因为它期望的值是毫秒,所以应该是30000。让我们在package.config文件中做出这个更改:
"scripts": {
"test": "mocha --timeout 30000"
},
小贴士
命令行参数不是设置超时的唯一方式。你可以在describe函数内部调用this.Timeout (30000),或者使用配置文件配置超时(mochajs.org/#configuring-mocha-nodejs)。
一旦我们设置了超时,我们可以通过再次运行npm test来尝试我们的测试:

测试结果
Mocha 不仅运行了我们的测试,还打印了一份相当不错的报告。在这里我们可以看到 Mocha 运行的所有测试、最终结果和耗时。这里提供了许多测试运行器的不同选项。例如,Mocha 有一个--reporter标志。如果你访问 https://mochajs.org/,你会看到所有可用的报告器。我们可以使用list报告器,它显示了每个测试的耗时。我们可以在package.config文件中添加它:
"scripts": {
"test": "mocha --timeout 30000 --reporter=list"
},
通过这个更改,我们可以得到更好的报告:
![使用列表报告器的测试结果
![图 2.07_B16113.jpg
使用列表报告器的测试结果
这个项目看起来不错。如果你只有几个测试,这已经足够了。但如果我们将要使用许多页面进行大量测试,这段代码就无法扩展。我们需要组织我们的代码,以便我们更高效,并且可以重用更多代码。
组织我们的代码
我们的第一项测试相当简单:我们只是检查页面标题。但让我们看看首页:
![Packtpub 首页
![图 2.08_B16113.jpg
Packtpub 首页
我们希望测试那里有许多操作:
-
搜索现有书籍。
-
搜索不存在的书籍。
-
当购物车为空时检查。
-
在我们添加产品时检查购物车。
以搜索测试为例。我们每次都会做同样的步骤:
-
点击搜索框。
-
输入文本。
-
点击搜索按钮。
我们将在所有的搜索测试中重复做同样的事情。有时有一种误解,即测试代码不是生产代码,所以代码可以很乱。因此,人们会一遍又一遍地复制粘贴测试,重复代码并硬编码值。这最终导致难以维护的测试。当测试难以维护时,它们往往会降低优先级。开发者失去了,质量保证分析师失去了,最终,客户也失去了。
我们将看到两种改进测试代码的技术:页面对象模型(POM)和测试数据配置。
介绍页面对象模型
POM 是一种设计模式,它将帮助我们分离测试代码和测试将执行的交互实现。
一起构建我们的HomePageModel。该页面上可能有哪些交互?
-
前往(到页面)
-
获取页面标题
-
搜索
-
登录
-
查看购物车
-
前往结账
-
订阅
干得好!我们刚刚创建了我们的第一个页面模型。这就是它的样子:
module.exports = class HomePageModel {
go() {}
title() {}
search(searchValue) {}
signIn() {}
viewCart(){}
gotoCheckout(){}
subscribe(){}
}
让我们关注前两个函数:go函数,它将导航到首页,以及title函数,它将返回页面标题。
我们将在这里重用大量代码。如果我们想开始使用这个模型,我们需要做两件事:在这里实现标题获取,并将 Puppeteer 页面传递给这个模型:
export default class HomePageModel {
constructor(page) {
this.page = page;
}
// Unused functions…
async go() {
await this.page.goto('https://www.packtpub.com/');
}
async title() {
return await this.page.title();
}
}
现在的问题是将这个类导入我们的测试中,使用require。我将把这个类放入测试文件夹内的POM(页面对象模型)文件夹中。一旦我们创建了文件,我们就导入它:
const HomePageModel = require('./pom/HomePageModel.js');
我们在 describe 内部声明一个变量:
let homePageModel;
我们在beforeEach钩子中创建这个类的实例:
beforeEach(async () => {
page = await browser.newPage();
homePageModel = new HomePageModel(page);
await homePageModel.go();
});
现在,我们只需简单地用homePageModel.title替换我们正在使用的page.title:
(await homePageModel.title()).should.contain('Packt');
如我之前在本章中提到的,UI 测试帮助我们查看重构是否破坏了我们的代码。让我们再次运行npm test以确认我们没有破坏任何东西:
![第一次重构后的测试结果
![图 2.09_B16113.jpg
第一次重构后的测试结果
剩下的唯一一件事是我们可以为之自豪的第一个项目。我们需要消除我们的硬编码值。我们只写了两个测试,但我们有三个硬编码值:网站 URL 以及Packt和Books这两个词。
对于这些测试,我们可以保留这些硬编码的值。但如果你有不同的环境呢?你需要使 URL 动态。如果你的网站是一个通用的电子商务网站呢?品牌名称将取决于你正在导航的测试。
还有许多其他用例:
-
测试用户和密码
-
要测试的产品
-
要使用的关键词
我们可以创建一个config.js文件,包含所有环境设置,并只返回我们在环境变量中获取的设置。如果没有设置,我们返回本地版本:
module.exports = ({
local: {
baseURL: 'https://www.packtpub.com/',
brandName: 'Packt',
mainProductName: 'Books'
},
test: {},
prod: {},
})[process.env.TESTENV || 'local']
如果这看起来有点令人畏惧,不要担心,它并不那么复杂:
-
它返回一个包含三个属性的对象:
local、test和prod。 -
在 JavaScript 中,你可以通过使用
object.property或将对象视为字典:object['local']来访问属性。 -
process.env允许我们读取环境变量。在这本书中,我们不会使用环境变量,但我想要展示最终的解决方案。 -
最后,我们将根据
TESTENV变量返回local、test或prod属性,如果没有设置环境变量,则返回'local'。
我敢打赌,到现在为止,你已经知道我们可以使用require调用访问这个对象:
const config = require('./config');
从那里开始,使用config变量而不是硬编码的值。我们还需要将此配置传递给页面模型,因为我们那里有一个硬编码的 URL。
在进行所有这些更改后,我们的测试应该看起来像这样:
const puppeteer = require('puppeteer');
const expect = require('chai').expect;
const should = require('chai').should();
const HomePageModel = require('./pom/HomePageModel.js');
const config = require('./config');
describe('Home page header', () => {
let browser;
let page;
let homePageModel;
before(async () => browser = await puppeteer.launch());
beforeEach(async () => {
page = await browser.newPage();
homePageModel = new HomePageModel(page, config);
await homePageModel.go();
});
afterEach(() => page.close());
after(() => browser.close());
it('Title should have Packt name', async() => {
(await homePageModel.title()).should.contain(config.brandName);
});
it('Title should mention Books', async() => {
expect(await homePageModel.title()).to.contain(config.mainProductName);
});
});
如果我们移除所有未使用的函数,我们的最终页面模型将看起来像这样:
module.exports = class HomePageModel {
constructor(page, config) {
this.page = page;
this.config = config;
}
async go() {
await this.page.goto(this.config.baseURL);
}
async title() {
return await this.page.title();
}
}
正如你所见,我们不需要实现复杂的设计模式来使我们的测试可重用且易于维护。我认为是时候开始我们的测试了,我们将在第三章“浏览网站”中这样做。
摘要
在本章中,我们从自动化测试的基础开始。Mike Cohn 的金字塔帮助我们理解了不同类型的测试。我们还为这个金字塔赋予了一个新的外观,展示了从前端开发者角度应该如何使用它。我们还明确指出,开发人员和 QA 分析师都是这个金字塔的一部分,但有不同的视角。
在本章的第二部分,我们变得更加实用,并探讨了测试运行器。这里的一个学习点是,我们使用了 Mocha 作为测试运行器,但本章中你学到的所有内容都应适用于任何测试运行器;也就是说,我们使用了 Mocha,但我们可以使用任何其他测试运行器。
在我们的测试中,我们使用了许多 Puppeteer API。在下一章中,我们将深入探讨这些 API,并看看我们如何在不同的场景中使用 Puppeteer。
第三章:第三章:浏览网站
我们已经为本书的其余部分奠定了基础。在第一章 Puppeteer 入门中,我们学习了浏览器自动化和无头浏览器。第二章 自动化测试和测试运行器是关于自动化测试和测试运行器。现在是时候更实际一些了。在本章中,我们将学习关于 UI 测试,但是在现实世界中。
在接下来的章节中,我们将选择一个用 Vue.js 制作的开源网站进行测试,但我们也将浏览许多其他公共网站。我希望你学习到可以帮助你测试任何框架的网站的技术。
我还想与你分享一些工具,这样你就可以带着一个完整的工具箱完成这本书。在本章中,我们将学习如何将我们的代码推送到 GitHub 并使用 GitHub Actions 运行测试。
在上一章中,我们创建了一个测试项目,并运行了一些测试,但没有过多关注我们使用的 Puppeteer API。在本章中,我们将再次创建一个测试项目,但这次我们将更深入地了解 Puppeteer 在每个 API 上能提供什么。
本章我们将涵盖以下主题:
-
介绍本章的测试网站
-
创建 Puppeteer 浏览器
-
浏览网站
-
使用响应对象
-
持续集成简介
到本章结束时,我们将测试一个真实网站,将其推送到 GitHub,并自动运行我们的测试,学习许多新的 API。让我们看看本章我们可以使用哪些测试网站。
技术要求
你可以在 GitHub 仓库(github.com/PacktPublishing/ui-testing-with-puppeteer)下的Chapter3目录中找到本章的所有代码。我们将把Chapter3作为所有演示的基础路径。在Chapter3目录内,你会找到三个子目录:
-
vuejs-firebase-shopping-cart包含测试网站。 -
init是你可以用来跟随本章的目录。 -
demo包含本章的最终代码。
介绍本章的测试网站
在本章中,我们将测试一个用 Vue.js 制作的网站。Thang Minh Vu (me.coddeine.com/)编写了一个很好的 Vue.js 示例:vuejs-firebase-shopping-cart (github.com/ittus/vuejs-firebase-shopping-cart)。
小贴士
当你在 GitHub 或任何其他类似 GitHub 的网站上寻找项目时,你需要注意该项目使用的许可证。代码是开源的并不意味着你可以随意使用它。本项目使用的是MIT 许可证,这是最宽松的许可证之一。这个许可证基本上表明你可以无限制地使用代码,包括但不限于使用、复制、修改、合并、发布、分发、再许可和/或销售软件副本,并允许软件提供者这样做。
由于我们不希望你处理 firebase 设置,我在 GitHub 上分叉了这个项目(在 GitHub 上创建了一个副本),移除了所有的 firebase 代码。你可以在这个章节中使用的代码的基本结构在init目录中找到。你只需要在基本文件夹中运行npm install,然后在vuejs-firebase-shopping-cart文件夹中运行以下命令。
> cd init
> npm install
> cd vuejs-firebase-shopping-cart/
> npm install
> npm run build
> npm run serve
在终端中,你应该已经收到了成功消息和网站现在运行的 URL:

网站运行中
现在我们应该在端口8080上运行一个不错的网站。

示例网站运行中
我们需要两个终端来完成这个项目。在一个终端中,我们将运行网站。在第二个终端中,我们将启动我们的测试。
如果你正在使用 VS Code,请注意终端标签页有一个加号按钮。如果你点击那个按钮,将创建一个新的终端。你可以通过按钮左侧的选择列表在终端之间切换。

新终端选项
让我们使用之前使用的相同命令在一个终端中运行网站:
> cd vuejs-firebase-shopping-cart
> npm run build
> npm run serve
你应该得到类似以下的内容:
DONE Compiled successfully in 5523ms
1:42:33 PM
App running at:
- Local: http://localhost:8080/
- Network: http://192.168.86.64:8080/
Note that the development build is not optimized.
To create a production build, run npm run build.
现在让我们在另一个终端中运行测试:
npm test
在这里,你应该得到类似以下的内容:
> mocha --timeout 30000 --reporter=list
Login Page Should have the right title: 3ms
1 passing (875ms)
在上一章中,我们没有过多关注我们如何使用 Puppeteer。我们只知道如果我们执行browser = await puppeteer.launch();,我们会得到一个新的浏览器。如何?不知道。好吧,现在是时候更深入地了解 Puppeteer 是如何工作的了。
创建 Puppeteer 浏览器
launch函数的签名不是launch(),而是launch(options)。由于 JavaScript 给予我们的自由度,我们可以直接不传递那个参数,launch函数将获取options作为undefined。
使用 Puppeteer.launch 函数
根据官方文档,以下是 Puppeteer 7 支持的Puppeteer.launch的所有选项(github.com/puppeteer/puppeteer/blob/v7.0.0/docs/api.md#puppeteerlaunchoptions):
-
product: 要启动哪个浏览器。在这个时候,这将是chrome或firefox。 -
ignoreHTTPSErrors: 在导航期间是否忽略 HTTPS 错误。当你想自动化具有无效或缺失 SSL 证书的网站时,此选项将变得很有用。这将防止 Chromium 在这些情况下返回无效证书页面。 -
headless: 是否以无头模式运行浏览器。默认为true,除非devtools选项为true。 -
executablePath: 运行浏览器可执行文件的路径,而不是捆绑的 Chromium。 -
slowMo: 通过指定的毫秒数减慢 Puppeteer 操作。这很有用,可以让你看到正在发生的事情。 -
defaultViewport: 为每个页面设置一致的视口。默认为 800x600 视口。null禁用默认视口。视口是一个具有以下属性的对象:a)
width: 以像素为单位的页面宽度。b)
height: 以像素为单位的页面高度。c)
deviceScaleFactor: 指定设备缩放因子。d)
isMobile: 是否考虑meta viewport标签。e)
hasTouch: 指定视口是否支持触摸事件。f)
isLandscape: 指定视口是否处于横幅模式。 -
args: 传递给浏览器实例的附加参数。 -
ignoreDefaultArgs: 如果为true,则不使用puppeteer.defaultArgs()。如果提供了一个数组,则过滤掉给定的默认参数。 -
handleSIGINT: 在按下 Ctrl +C 时关闭浏览器进程。 -
handleSIGTERM: 在接收到SIGTERM信号时关闭浏览器进程。 -
handleSIGHUP: 在接收到SIGHUP信号时关闭浏览器进程。 -
timeout: 等待浏览器实例启动的最大时间(以毫秒为单位)。默认为30000(30 秒)。传递0将禁用超时。 -
dumpio: 是否将浏览器进程的stdout和stderr管道连接到process.stdout和process.stderr。 -
userDataDir: 用户数据目录的路径。 -
env: 指定对浏览器可见的环境变量。 -
devtools: 是否为每个标签自动打开 DevTools 面板。如果此选项为true,则headless选项将设置为false。 -
pipe: 通过管道连接到浏览器而不是 WebSocket。默认为false。 -
extraPrefsFirefox: 可以传递给 Firefox 的附加首选项。
这是一份很长的列表,我知道。但我不想只写我认为有趣的特性。我想让你全面了解 launch 选项。现在,让我们谈谈你需要了解的选项。
无头模式
我认为 headless 选项是最常用的。记得我告诉过你我们要使用无头浏览器吗?我不会说我骗了你,但我确实骗了你。无头模式是默认模式,但实际上,你可以通过将 headless 设置为 false 来启动浏览器,这被称为 "有头模式"。有头模式在调试自动化代码时很有用,因为你将看到浏览器中的情况。我敢打赌这将是默认的本地设置。这就是你可以在有头模式下启动浏览器的方式:
const browser = await puppeteer.launch({ headless: false });
这行代码将启动一个几乎看起来像正常浏览器的浏览器。

Headful 模式下的浏览器
如你所见,这是一个完整的运行中的浏览器。唯一的区别是,你将看到一个横幅,上面写着“Chrome 正在被自动化测试软件控制。”如果有人问你,不,你不能移除那个横幅。我相信,在互联网上到处都是钓鱼和黑客活动的情况下,告诉潜在的用户浏览器后面有一个应用程序在控制和监控浏览器活动是很重要的。
用户数据目录
userDataDir,在启动浏览器之前,Puppeteer 将创建一个新的目录。然后当浏览器关闭时,它将删除它。这意味着会话或我们存储在 cookies 中的任何内容都不会在测试运行之间保留。
在 UI 测试中,我们可能想要使用这个选项来检查网站是否如预期地使用了本地存储(例如,cookies)。网站是否记得登录用户?购物车是否被保留?
可执行路径
executablePath选项在 UI 测试中并不常见。大多数测试将使用 Puppeteer 下载的浏览器运行。尽管如此,这个选项在任务自动化或抓取时使用得很多,当你想要使用你通常使用的浏览器,或者在某些持续集成环境中,你想要运行已经下载的浏览器时。
如我们在第一章中看到的,“使用 Puppeteer 入门”,Puppeteer 保证与特定版本的 Chromium 兼容。在 Puppeteer 7.0.0 的情况下,Chromium 版本是 90.0.4403.0。这并不意味着它不能与任何其他版本一起工作,但它没有保证。
提示
如果你是一名 macOS 用户,Chrome 可执行文件将位于应用程序包Google Chrome.app内部。例如,/Applications/Google Chrome.app/Contents/MacOS/Google Chrome。
如果我们想要使用我们通常使用的确切相同的浏览器,仅使用executablePath选项是不够的。请记住,如果我们不传递一个,Puppeteer 将创建一个新的用户数据目录。我们需要传递浏览器使用的用户数据目录。在 Windows 中应该是%LOCALAPPDATA%\Google\Chrome\User Data,在 Mac 中是~/Library/Application Support/Google/Chrome,在 Linux 中是~/.config/google-chrome。如果你想双重检查这个值,你可以使用你的浏览器导航到chrome://version/。在那里你会看到当前的配置文件路径。你需要从 MacOS 中删除默认目录。
提示
如果你打算使用自己的浏览器,你可以安装 puppeteer-core 而不是 Puppeteer。puppeteer-core 不会下载浏览器,这样可以加快安装时间并节省磁盘空间。
默认视口
如果你尝试了 headful 模式,你可能看到如下内容:

无默认视口的 Headful 模式
不,网站没有出问题。如果我们不传递 defaultViewport,Puppeteer 将默认为 800x600 的视口。如果你想知道视口是什么,根据维基百科,“视口是整个文档的可视部分。”
视口是 UI 测试的一个重要组成部分。用户体验专家和设计师会付出很大的努力,试图为用户使用设备提供最佳体验。前端开发者使用 CSS 断点来确定根据视口大小显示哪种布局。Rico Sta. Cruz 在他的博客文章 我应该使用哪些媒体查询断点? (ricostacruz.com/til/css-media-query-breakpoints) 中发布了这个出色的断点列表:
-
移动设备在竖屏模式下:从 320px 到 414px。
-
移动设备在横屏模式下:从 568px 到 812px。
-
竖屏表格:从 768px 到 834px。
-
横屏表格:从 1024px 到 1112px。
-
笔记本电脑:从 1366px 到 1440px。
-
桌面显示器:1680px 到 1920px。
你不需要很多设备来测试这个。只需打开一个浏览器并更改窗口大小。

Packtpub.com 使用的不同断点
如果你查看那张截图,你会看到该网站根据视口显示或隐藏不同的元素。在大的视口中,它会显示一个大的搜索栏,假设是桌面体验。当它检测到小的视口,假设是移动设备,它会隐藏搜索栏并显示汉堡按钮。
在编写我们的测试时,我们需要考虑所有这些变化。
小贴士
而不是试图猜测视口大小,询问你的前端团队他们使用哪些断点。但请记住,许多错误正是在这些精确的断点出现的,测试断点,并检查它们是否合适。
当我们讨论移动仿真时,我们将在第八章“环境仿真”中更深入地探讨这个话题。这里有一个最后的技巧。如果我们传递 null,视口将适应窗口大小,就像在正常浏览器中预期的那样。
产品
你是说我们可以用 Puppeteer 自动化 Firefox 吗?是的,我们可以。尽管这仍然是实验性的。这是“实验性”的官方定义:“目前官方对 Firefox 的支持是实验性的。与 Mozilla 的持续合作旨在支持常见的端到端测试用例,开发者期望跨浏览器覆盖。Puppeteer 团队需要用户的反馈来稳定 Firefox 支持,并将缺失的 API 提到我们的注意。”我的非官方定义是:“它使用 Firefox 的夜间构建版本,长期支持似乎没有保证。”
声明除外,如果你想启动 Firefox 浏览器,你首先需要安装 Puppeteer 并设置 PUPPETEER_PRODUCT 变量:
PUPPETEER_PRODUCT=firefox npm install puppeteer@7.0.0
然后你可以将 Firefox 设置为产品:
browser = await puppeteer.launch({ product: 'firefox' });
浏览器参数
args 选项是一个可以传递给浏览器的参数或 标志 的数组。有超过 1,400 个标志 (www.hardkoded.com/ui-testing-with-puppeteer/chrome-flags)。涵盖所有 1,400 个标志是不可能的。
--no-sandbox 是最常见的标志。根据官方文档:为了保护主机环境免受不受信任的网页内容的影响,Chrome 使用多层沙盒。为了正确工作,主机应首先配置。
这里的关键短语是 "主机应首先配置。" 你可能需要创建一个具有正确权限的用户,以便在更受限的环境中(如使用 –no-sandbox 标志)使用 Puppeteer,这将绕过沙盒系统。
其他常见标志如下:
-
--window-size用于设置窗口大小。 -
--proxy-server和--proxy-bypass-list用于设置代理设置。
另有一个选项称为 extraPrefsFirefox。您可以使用此属性来设置 Firefox 标志。希望您不需要处理这些标志太多。
移动选项
deviceScaleFactor、isMobile、hasTouch 和 isLandscape 将帮助我们设置移动仿真。我们将在 第八章 环境仿真 中更深入地介绍这些选项。
如您所见,puppeteer.launch() 远不止这些,还有许多其他功能供您继续挖掘。
实践中的选项
现在我们来看看如何在现实世界中应用这些新特性。我们现在可以做的第一件事,以及你应该从现在开始做的,是从配置文件中加载选项对象。记住,我们拥有的配置类是 JavaScript 代码。我们可以在其中添加一个名为 launchOptions 的属性,并将其传递给 launch 函数。如果你不想填充它,你甚至不需要这样做,但它将存在,随时可以使用。
您的配置文件将看起来像这样:
module.exports = ({
local: {
baseURL: 'http://localhost:8080/',
launchOptions: { headless: false }
},
})[process.env.TESTENV || 'local'];
现在,当我们使用 local 配置运行这些测试时,它将以全屏模式启动浏览器。下一步是将此选项传递给 launch 函数:
before(async() => {
browser = await puppeteer.launch(config.launchOptions);
});
现在我们尝试编写一个真实的测试。我们想测试在关闭浏览器后登录操作是否被持久化。
这些是步骤:
-
使用用户数据目录打开浏览器。
-
检查我们是否已注销(检查登录按钮是否显示 登录)。
-
登录。
-
检查我们是否已登录(检查注销按钮是否显示 注销)。
-
关闭浏览器。
-
打开浏览器。
-
我们应该已经登录。
我们将无法在其他测试中重用我们正在使用的浏览器,因为我们需要创建自己的用户数据目录:
it('It should persist the user', async() => {
const userDataDir = fs.mkdtempSync('profile');
const options = config.launchOptions;
options.userDataDir = userDataDir;
let persistentBrowser = await puppeteer.launch(options);
let persistentPage = await persistentBrowser.newPage();
let loginModel = new LoginPageModel(persistentPage, config);
await loginModel.go();
(await loginModel.logState()).should.equal('Login');
await loginModel.login(config.username, config.password);
(await loginModel.logState()).should.equal('Logout');
await persistentBrowser.close();
persistentBrowser = await puppeteer.launch(options);
persistentPage = await persistentBrowser.newPage();
loginModel = new LoginPageModel(persistentPage, config);
await loginModel.go();
(await loginModel.logState()).should.equal('Logout');
await persistentBrowser.close();
deleteFolderRecursive(userDataDir);
});
这个测试比其他测试要长,因为我们需要创建两次浏览器、页面和模型。忽略 loginState 和 login 函数的工作原理。我们将在接下来的章节中介绍这些函数。
非常令人印象深刻,所有这些功能都可以隐藏在单行代码中。现在让我们看看如何提高我们的导航技能。
在网站中导航
如果你看看如何在浏览器中导航到不同的页面,基本上有四种方式:
-
你在地址栏中输入 URL 或使用书签。
-
你使用浏览器函数来后退、前进或重新加载页面。
-
你在页面上点击元素。
-
你正在浏览的网站将重定向到另一个页面。
goto 函数模拟了第一个选项,即导航到网站。我们使用它来导航到我们想要测试的页面:
await this.page.goto(this.config.baseURL + 'login');
现在,猜猜看?goto 的签名不是 goto(url),而是 goto(url, options)。你会发现这个模式反复出现——一个具有一个或多个必需参数(或没有)的函数,然后是一组额外选项。
幸运的是,goto 期望的选项并不像我们在 launch 选项中看到的那样多。它只有三个选项:
-
timeout: 最大导航时间(以毫秒为单位)。 -
waitUntil: 当何时认为导航成功。 -
referrer: 引用者头部值。
让我们逐一分析这些选项。
超时
你会在许多函数中看到 timeout 参数。Puppeteer 将超时分为两组:导航超时和通用超时(这不是官方名称;我只是这样命名,以便更容易理解这些概念)。
如果我们想在所有导航调用中设置默认超时,我们可以在配置文件中创建一个属性并在每个地方使用它。这听起来是个不错的想法,但有一个更好的解决方案。我们可以在配置文件中使用一个属性,但不是将它们传递给每个函数,而是调用 page.setDefaultTimeout(timeout) 或 page.setDefaultNavigationTimeout(timeout)。
page 对象将存储传递给这些函数的超时时间,并将其用作默认值。
重要提示
如果你没有向函数传递超时时间,也没有设置默认超时,Puppeteer 将将超时设置为 30 秒(30,000 毫秒)。
如果我们在本地测试网站,等待 30 秒来加载页面听起来好像很多。让我们将时间减少到 5 秒。我们可以在配置文件中添加一个新的属性:
local: {
baseURL: 'http://localhost:8080/',
timeout: 5000,
},
然后,我们可以使用默认超时来设置该值:
page.setDefaultTimeout(config.timeout);
小贴士
你不需要 await setDefaultTimeout 或 setDefaultNavigationTimeout,因为它们不是 async。
下一个选项是 goto 中最有趣的一个选项。
waitUntil
你可能会想,“等待什么?”想象一下,当你执行类似 await page.goto('https://www.packtpub.com/') 的操作时,Puppeteer 会在命令发送到浏览器后立即解析 promise。你运行的下一个命令将得到一个空页面,因为它需要一些时间才能准备好使用。我敢打赌你在等待页面加载时看到过白屏。你必须 等待 页面准备好。

空页面
等待页面准备就绪在浏览器自动化中是关键。我看到的大多数 Stack Overflow 问题都与这个问题相关:你怎么知道页面已经准备好了?我希望当你完成这本书时,你能掌握这个主题。在第五章,“等待元素和网络调用”,我们将探讨许多技术来回答这个问题,但page.goto给我们提供了这个第一个工具:waitUntil选项。
waitUntil支持四个选项:
第一个选项,也是默认选项,是load。如果你传递load(或没有选项),当load事件触发时,promise将被解析。根据 Mozilla 的说法,load事件在整个页面加载时触发,包括所有依赖资源,如样式表和图像。
第二个选项是domcontentloaded,它依赖于DOMContentLoaded事件。根据 Mozilla 的说法,DOMContentLoaded在初始 HTML 文档已完全加载和解析时触发,无需等待样式表、图像和子框架完成加载。
最后两个选项与网络相关。networkidle0将在过去 500 毫秒内没有更多网络连接时解析承诺。另一方面,networkidle2将在过去 500 毫秒内不超过 2 个网络连接时解析承诺。
哪个更好?一般来说,默认选项就足够好,而且相当安全。如果你在DOM加载后有很多AJAX调用,并且你想等待页面停止从服务器加载数据,你可能需要切换到网络选项。我们将在第五章,“等待元素和网络调用”中了解更多关于这一点。
什么是 AJAX 调用?
AJAX 调用变得如此流行,以至于许多开发者停止了将它们称为 AJAX 调用。你也可能听到这被称为“调用端点”或“调用(REST)API”。
但基本上,它是对服务器进行的一个异步调用,用于获取更多数据或将数据发送到服务器。别担心,我们将在第五章,“等待元素和网络调用”中深入探讨这一点。
什么是 DOM?
文档对象模型(DOM)是浏览器基于服务器发送的 HTML 或由 JavaScript 创建的 HTML 构建的对象表示。
记住,一个 HTML 页面不过是通过网络发送的文本文件。浏览器加载这个文本,构建模型(DOM)表示,然后浏览器引擎渲染这个 DOM。在那个时刻,浏览器可以说发生了load事件。
这带我们来到了最后一个选项:引用者。
引用者
引用者是一个浏览器发送到服务器的 HTTP 头,告知服务器请求该资源的页面。
你可以查看,如果你去www.packtpub.com/,打开开发者工具,并在网络标签下检查任何 CSS 文件。

来源网址
有趣的事实,HTTP 头被称为referer是因为 HTTP 规范中的一个错误。
你什么时候会用到这个选项呢?嗯,这个选项并不常见,但有些页面可能会根据来源网址改变行为。一些网站可能会将其用作验证:“只有从该网站进入时,此页面才能进行导航。”你可以通过使用referer选项强制这种情况。
在本节开头,我们提到其他类型的导航是浏览器返回、前进和重新加载的功能。Puppeteer 为所有这些动作提供了一个 API:
-
page.goBack(options) -
page.goForward(options) -
page.reload(options)
这些函数的行为与page.goto相同。你不需要传递一个网址,因为它可以从动作本身推断出来。goBack和goForward基于浏览历史,而reload将使用相同的网址。
另一个区别是,它们不支持referer选项,因为它将使用第一次导航中使用的相同的来源网址,因为这些操作是过去重复执行的导航。
但这还不是全部;goto隐藏了一个惊喜。好吧,它并不是隐藏的;它是文档化的。接下来我们需要了解的是,goto有一个返回值。它返回一个response对象。
使用响应对象
响应是网络中的一个重要概念。对于浏览器发送到服务器的每个请求都有一个相应的响应。
goto返回一个response是有意义的。它发起了一个请求,结果是相应的响应。
我们可以用响应做很多事情。我们不会在本章中涵盖所有功能。但这些都是我们可以用作goto动作响应的最相关函数。
获取响应网址
如果我知道我想要去的网址,为什么还要知道网址呢?
服务器可能会将你重定向到另一个页面。例如,如果你在隐身/私密模式下打开浏览器并导航到mail.google.com/,你会看到服务器将你重定向到accounts.google.com/signin。
我不是说你应该总是检查响应网址以防万一,但你必须知道你正在测试的网站可能会那样表现。一个常见的场景是登录检查。你导航到一个页面,如果响应网址是登录页面,你执行登录操作,然后你可以返回到上一个页面并继续测试。
获取响应状态码
每个响应都有一个 HTTP 状态码。它告诉你服务器如何响应你的请求。状态码被分为五个类别。这些是根据维基百科的定义(en.wikipedia.org/wiki/List_of_HTTP_status_codes):
1xx 信息响应:请求已接收,正在继续处理。你不需要处理这些。
2xx 成功:请求已成功接收、理解并被接受。 所有 2xx 代码之间有一些差异,但您必须知道的是,2xx 代码意味着一切顺利。状态码 200 是最常见的。
3xx 重定向:为了完成请求,需要采取进一步的操作。
当服务器想要重定向用户时,这不是服务器操作,而是浏览器需要执行的操作。服务器告诉浏览器:“你请求了 mail.google.com/,但你必须转到 https://accounts.google.com/signin。” 浏览器需要获取新的 URL 并执行另一个请求。
最常见的状态码是 301 – 永久重定向 和 302 – 临时重定向。301 告诉浏览器旧的 URL 不再使用,浏览器应始终使用新的 URL。302 是最常用的。它告诉浏览器应暂时跳转到新的 URL。这就是登录场景的情况。
4xx 客户端错误:请求包含语法错误或无法满足。
4xx 代码被称为 "是你的错" 错误。请求中存在某些问题。4xx 代码列表非常庞大。对于这些场景,我认为大家最熟悉的代码是闻名世界的 404,它告诉您资源 未找到。您可能遇到的其它错误是 401 – 未授权 和 403 – 禁止访问,它们与安全问题相关。
5xx 服务器错误:服务器未能满足显然有效的请求。
5xx 代码被称为 "是服务器的错" 错误。最常见的是 500,这意味着服务器已失败。我希望您永远看不到这个,但如果您尝试抓取一个网站并得到 503,这意味着 服务器不可用,这意味着服务器开始拒绝您的请求或您使服务器关闭。
函数 response.status() 将返回与响应关联的状态码。
如果您只想知道响应是否成功,有一个快捷方式:response.ok()。此函数将在状态码在 200 到 299 之间时返回 true。
让我们通过实现以下测试来测试这些功能:“管理员页面应将您重定向到登录页面”。在 test 目录中,您会发现我们有一个 admin.tests.js 文件,我们可以在这里放置我们的管理员页面测试。为了测试重定向,我们可以这样做:
it('Should redirect to the login page', async() => {
const response = await pageModel.go();
response.status().should.oneOf([200, 304]);
response.url().should.contain('login');
});
最终状态可以是 response.url() 来获取该响应的 URL。
如果我想检查我是否有效地从管理员页面重定向了?
嗯,这有点棘手。我们提到每个响应都与一个请求相关联。Puppeteer 使用response.request()函数公开这一点。我们不会立即深入研究request对象,但你现在需要知道的是,请求包含了一个请求经过的所有重定向的列表。Puppeteer 使用redirectChain()函数来表示它们。有了这个,我们就有了整个重定向映射。

重定向链
这可能听起来很复杂,但一旦你开始尝试,你就能理解这个概念。最终的代码将看起来像这样:
it('Should redirect to the login page', async() => {
const response = await pageModel.go();
response.status().should.equal(200);
response.url().should.contain('login');
response.request().redirectChain()[0].response().status().should.equal(302);
response.request().redirectChain()[0].response().url().should.contain('admin');
});
我们从 Puppeteer 的launch函数和导航中学到了很多。正如我在本章开头提到的,我还想分享一些可以添加到你的工具箱中的工具。让我们谈谈持续集成。
连续集成简介
如果有一个工具可以保证不会有一行代码破坏你正在测试的功能,那岂不是很好?
这就是持续集成(CI)的含义。持续集成是在将更改引入代码库之前运行测试代码的实践。Atlassian 给出了一个很好的定义(www.atlassian.com/continuous-delivery/continuous-integration):持续集成(CI)是自动化将多个贡献者的代码更改集成到单个软件项目的实践。它是 DevOps 的最佳实践之一,允许开发者频繁地将代码更改合并到中央仓库中,然后运行构建和测试。在集成之前,使用自动化工具来断言新代码的正确性。
让我们回顾一个理想的流程:
-
我们将代码库存储在源代码控制仓库中。它可能是 GitHub、Gitlab、Bitbucket,或者是一个本地服务器,该服务器托管一个 Git 服务器。
-
开发者从主代码库中创建一个新的分支。
-
我们在那个分支上做了一些更改。
-
然后,它创建一个拉取请求或合并请求。开发者请求他们的更改被审查并合并到主代码库中。
-
其他开发者将审查更改,但持续集成过程也将在该分支上运行。
-
如果开发者批准更改并且持续集成运行成功,代码将准备好合并到主代码库中。
这听起来像是一个理想的世界,对吧?尽管生活不会总是那么完美,我们可以实现这一点。如果你在开始新项目时实施这个工作流程,那么遵循它将会很容易。在一个持续的项目中实施所有这些将会更具挑战性。我的建议是逐步进行这些更改,不要过多地影响生产力。
市场上有很多持续集成工具。大多数都有入门级的免费层和一些付费层。你将在它们之间看到的以下主要区别:
-
对私有仓库的支持:一些持续集成工具只提供免费层用于公共仓库。
-
并行运行的数量:如果你有一个相当大的团队,同时打开了多个 Pull Requests,这将非常重要。
-
计算能力:他们可能会在更高等级上提供更好的虚拟机。
-
报告:你将找到不同类型的报告。
这些是 2021 年最受欢迎的持续集成工具;还有很多其他的,但这些都是你会在周围看到的:
-
Travis CI
-
Circle CI
-
AppVeyor
-
Jenkins
-
GitHub Actions
我们将使用 GitHub Actions 测试我们的代码,仅仅因为我们只需要一个 GitHub 账户,我们就可以从我们的仓库做所有事情。
首先,让我们在 GitHub 上创建一个新的仓库。如果你没有 GitHub 账户,你可以在github.com/join创建一个。一旦你有了账户,你可以在 https://github.com/new 创建一个仓库。
![创建新的仓库
![img/Figure_3.10_B16113.jpg]
创建新的仓库
这里一个重要的事情是要选择.gitignore模板,这样我们就不提交node_modules文件夹。
一旦创建了仓库,你就可以使用代码按钮获取 Git URL。
![Git 远程 URL
![img/Figure_3.11_B16113.jpg]
Git 远程 URL
我们现在将把这个 Git 仓库克隆到一个新的文件夹中,把我们的工作代码复制到那里,并将其推送到GitHub:
> git clone https://github.com/kblok/ch3-demo.git
> cd ch3-demo
注意,你需要使用你的Git URL,而不是我的。
接下来,我们需要把我们的当前代码复制到那个文件夹中。确保你在复制这些项目时删除任何额外的git文件夹。之后,我们需要运行这三个命令来提交我们的代码并将其推送到 GitHub:
> git add .
> git commit -m "First commit"
> git push origin
现在是设置 CI 的时候了。GitHub actions 中的 CI 任务位于.github/workflows目录内的 YAML 文件。这是我们需要的步骤:
-
检出分支。
-
构建网站。
-
构建测试包。
-
启动网站。
-
运行测试。
以下示例并不假装是 GitHub Actions 中运行 Puppeteer 测试的规范方式。有许多不同的实现方式。让我们在.github/workflows目录内创建一个名为test.yml的 YAML 文件(你可以选择任何名称)。文件看起来像这样:
name: CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Install Web Dependencies
working-directory: ./vuejs-firebase-shopping-cart
run: npm install
- name: Install Test Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 'true'
run: npm install
- name: Run Site Test Code
uses: mujo-code/puppeteer-headful@master
env:
TESTENV: 'CI'
CI: 'true'
with:
args: sh ./.github/workflows/test.sh
-
首先,我们说的是我们想要何时运行这个操作。然后,我们设置这个操作在每次
master分支上运行。但也会在每次master分支上运行。这意味着在 Pull Request 合并后也会运行。 -
- uses: actions/checkout@master将检出我们的代码。 -
在
- name: 安装 Web 依赖下,我们构建网站。 -
在
- name: 安装测试依赖下,我们构建测试项目。 -
在
- name: 运行网站测试代码下,我们使用 shell 文件test.sh运行网站和测试,这个文件很简单,如下所示:cd ./vuejs-firebase-shopping-cart npm run serve & npx wait-on http://localhost:8080 cd .. npm test
正如你所见,我在运行测试之前正在等待npm run serve打印出 http://localhost:8080。
你会发现我们正在使用uses: mujo-code/puppeteer-headful@master。在虚拟机中运行浏览器可能具有挑战性。这些虚拟机有许多限制。你需要找到可以帮助你在虚拟机中启动浏览器的食谱。
在这种情况下,mujo-code/puppeteer-headful为我们留下了一个可以使用的浏览器。这就是为什么我们设置环境变量PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 'true',这样当我们运行npm install时,Puppeteer 不会下载浏览器,因为我们将使用现有的一个。
由于我们将使用现有的浏览器,并且我们将在受限环境中运行它,我们需要一组新的启动选项。这就是为什么我设置了环境变量TESTENV: 'CI',并在配置文件中添加了一个新的设置:
CI: {
baseURL : 'http://localhost:8080/',
username: 'admin@gmail.com',
password: 'admin',
launchOptions: {
executablePath: process.env.PUPPETEER_EXEC_PATH,
headless: true,
args: ['--no-sandbox'],
},
timeout: 5000,
},
我将可执行文件路径设置为环境变量PUPPETEER_EXEC_PATH,这是由mujo-code/puppeteer-headful设置的。
在设置好所有这些之后,您将开始在您的拉取请求中看到构建。假设一个开发者来创建一个更改颜色的拉取请求。

一个更改代码片段的拉取请求
如果您转到检查选项卡,您将看到所有操作及其状态。在这种情况下,我们可以看到我们的测试运行正确:

拉取请求中的构建结果
在那里,您将找到所有构建细节,包括所有测试结果。但这些结果也会传播到 GitHub 上的其他页面。您将能够在拉取请求的主页上看到构建结果,甚至在拉取请求列表中。

拉取请求主页面上的构建结果
在那里,您将看不到全部细节,但它会为您提供快速查看,以便您知道拉取请求是否准备好合并。
我知道这可能会让人感到不知所措。把这当作一个想法,看看它看起来是什么样子,可能有哪些可能性,以及您在设置所有这些时可能会遇到的挑战。这并不容易,但值得付出努力。
摘要
正如您所看到的,我们开始深入探索 Puppeteer API。我们了解了在启动浏览器时我们可以使用的所有不同选项。我们还学习了如何导航网站,以及我们从一个页面跳转到另一个页面时拥有的不同选项。我们还看到了之前未提及的新对象,例如Response和Request类。
我希望您觉得持续集成部分很有价值。在云中运行测试有很多工具和不同的方法。这是您工具箱中必须添加的一个基本工具。
在下一章中,我们将更加实用。我们将看到如何与页面交互,从 CSS 选择器到鼠标和键盘模拟。
第四章:第四章:与页面交互
感谢第三章,“网站导航”,我们现在知道如何打开浏览器以及我们启动浏览器和创建新页面所拥有的所有不同选项。我们还了解了如何导航到其他页面。我们学习了 HTTP 响应以及它们与请求的关系。
本章讨论的是交互。在 UI 测试中,模拟用户交互是至关重要的。单元测试中有一个名为** Arrange-Act-Assert(AAA**)的模式。这个模式强制测试代码遵循特定的顺序:
-
Arrange – 准备上下文。
-
Act – 与页面交互。
-
Assert – 检查页面反应。
在本章中,我们将学习如何在页面上查找元素。我们将了解开发团队如何改进他们的 HTML,以便你能够轻松地找到元素。但如果无法更改页面 HTML,我们还将探讨另一组工具来查找所需的元素。
一旦我们找到了一个元素,我们就会想要与之交互。Puppeteer 提供了两组 API:一组是动作函数,如点击、选择或输入。然后我们有一组模拟函数,如鼠标事件或键盘模拟。我们将涵盖所有这些函数。
本章将介绍一个我们尚未提及的新对象:元素句柄。
到本章结束时,我们将把另一个工具添加到我们的工具箱中:Visual Studio Code 调试工具。
本章我们将涵盖以下主题:
-
HTML、DOM 和 CSS 简介
-
查找元素
-
使用 XPath 查找元素
-
与元素交互
-
键盘和鼠标模拟
-
与多个框架交互
-
使用 Visual Studio Code 调试测试
到本章结束时,你将能够模拟大多数类型的用户交互。但首先,我们需要打下基础。让我们来谈谈 HTML、文档对象模型(DOM)和 CSS。
技术要求
你可以在 GitHub 仓库(github.com/PacktPublishing/UI-Testing-with-Puppeteer)的Chapter4目录下找到本章的所有代码。请记住在该目录下运行npm install,然后进入Chapter4/vuejs-firebase-shopping-cart目录并再次运行npm install。
如果你想在遵循本章的同时实现代码,你可以从Chapter3目录中留下的代码开始。
HTML、DOM 和 CSS 简介
如果你不知道 CSS,你就找不到元素;如果你不理解DOM和HTML,你就不会理解 CSS。因此,我们需要从基础知识开始。
我敢打赌你已经听说你可以用 HTML、CSS 和 JavaScript 来构建网站。你可能正在使用不同的服务器端技术。你的前端可能使用像 React 或 Angular 这样的酷技术来实现。但最终,结果将是一个基于 HTML、CSS 和 JavaScript 的页面。
HTML 是页面的内容。如果你访问任何网站,打开 开发者工具,然后点击 元素 选项卡,你将看到页面的内容。你会看到页面的标题。如果你是一个新闻网站,你将看到那里的所有文章。如果你访问一篇博客文章,你将看到那篇文章的文本。
没有使用 CSS,HTML 页面看起来就像记事本中写的文本。CSS 不仅带来了颜色和字体,它还是给页面提供结构的框架。
有趣的事实
Firefox 内置了一个工具可以禁用页面上的所有样式。如果你转到 查看 | 页面样式 并点击 无样式,你将看到没有 CSS 的生活会是怎样的。
最后一个部分是 JavaScript。JavaScript 为页面带来了行为。一旦浏览器解析了 HTML 并构建了 DOM,它就允许我们操作并赋予页面生命。
但是,正如我之前提到的,我们需要回到基础,回到网络的基石。让我们从 HTML 开始。
HTML
HTML 代表 超文本标记语言:超文本是因为 HTML 本身不是内容;HTML 包含内容。标记是因为它使用标签来赋予内容意义。语言是因为,尽管许多开发者不同意,并且对此感到愤怒,但 HTML 是一种语言。
如果我们将 HTML 文件作为数据结构来读取,我们可以说 HTML 是 XML 的 宽松 版本。因此,为了更好地理解 HTML,我们需要查看 XML 的基础知识。
这些是 XML 内容的基本元素:

XML 内容
如果你看看这张图,你几乎已经知道了你需要了解的关于 XML 的所有内容。好吧,也许我在夸张。但这就是想法:
-
你有元素,这些元素表示为
<ElementName>。在我们的例子中,我们有element>和<child-element>。 -
元素可能具有属性,这些属性表示为
AttributeName=" AttributeValue"。在我们的例子中,我们有value="3"和value="4"。 -
元素可能包含其他元素。你可以看到我们在主 element 中有两个 child-element 元素。
-
一个元素通过
</ElementName>结束(闭合),或者用/>结尾而不是>。
XML 解析器对这些规则非常严格。如果你正在尝试解析的 XML 内容违反了其中任何一条规则,解析器将认为整个 XML 无效。无论是缺少闭合元素还是没有引号的属性,解析器都将无法评估 XML 内容。
但是我们会发现,在解析 HTML 内容时,浏览器并不那么严格。让我们看看以下 HTML:

一个损坏的 HTML
这简单的 HTML 会在浏览器中用红色打印出 Hello World。
这是不是有效的 XML?不是。正如你所见,<div> 元素没有闭合。但这是不是有效的 HTML?是的。
重要提示
浏览器试图渲染 损坏的 HTML 的行为并不意味着你应该轻视它。你可能已经听到开发者说,某个特定的错误是由于 缺少关闭的 div。例如,如果 HTML 损坏了,比如缺少关闭的 div,浏览器将尝试猜测渲染该 HTML 的最佳方式。浏览器在尝试修复损坏的 HTML 时所做的决定可能会导致页面按预期工作,或者整个页面布局损坏。
另一个有趣的概念是,XML 规范并没有给元素赋予意义。元素名称、属性以及从该内容产生的信息取决于谁编写了 XML 以及谁在阅读它。
HTML 是具有意义的 XML。在 1993 年,被称为万维网发明者的 Tim Berners-Lee 决定,主要元素将被称为 HTML,并且它将包含一个 BODY。他决定图像将用 IMG 元素表示,段落将是 P 元素,等等。多年来,浏览器和网页开发者遵循并改进了这一惯例,最终形成了我们今天所说的 HTML5。作为社区的一员,我们一致同意 HTML 元素的意义。
我们达成共识,如果我们添加具有值 red 的 text 属性,我们将得到红色的文本,等等。HTML 中有多少种元素类型?很多!好消息是,你不需要知道所有这些。
知识越多,你将越有效率。然而,这些是在页面上最常见到的元素。
文档结构元素
每个 HTML 文档都将包含在一个 <html> 元素内部。这个 HTML 元素将有两个子元素。你将找到的第一个元素是 <head>。在这个 <head> 元素内部,你可以找到元数据元素,例如 <title>,它包含页面标题,以及许多 <meta> 元素,它们包含标准 HTML 不支持的元数据。许多网站使用 <meta> 来强制页面在社交媒体上的显示方式。你将找到的第二组元素是 <link> 元素,包括 CSS 文件,以及 <script> 文件,包括 JavaScript 代码。尽管脚本元素被接受在头部,但大多数网站会在页面底部添加它们的脚本元素以实现更快的渲染。
你将找到的第二个元素是 <body> 元素。页面本身将在这个元素内部。
文本元素
然后我们有基本的文本元素。
<h1>、<h2>、<h3>、<h4>、<h5> 和 <h6> 是标题。如果你有一个文本编辑器,你可能已经看到有很多级别的标题和副标题。
<p> 将表示段落。然后你可能会发现 <span> 元素,它有助于在段落中样式化文本的一部分。
另一种文本元素是<label>。这些标签与输入控件相关联,例如单选按钮,为该控件提供上下文。例如,单选按钮或复选框没有文本;它只是一个勾选或单选。你需要标签来为它们提供上下文:
![Radio buttons with labels]
![img/Figure_4.3_B16113.jpg]
带标签的单选按钮
这个 HTML 有三个标签。Huey为第一个单选选项提供上下文,Dewey为第二个,Louie为最后一个。
我们将要查看的最后一种文本元素是列表元素。列表以父元素表示,<ul>用于无序列表或<ol>用于有序列表,以及<li>元素。你将在菜单栏中看到很多这样的元素。
动作元素
HTML 中有两种主要的动作元素。<a>锚点,也称为链接,最初是为了带你到另一个页面而设计的,但如今它不仅仅局限于这一点,它还可以在页面内触发动作。
第二个元素是<button>,尽管它最初是为了通过 HTTP POST 请求将数据发送到服务器而设计的,但现在它被用于许多其他类型的动作:
重要提示
那些只使用按钮和链接来执行操作的日子已经过去了。由于大多数 HTML 元素都支持点击事件,你会发现一些页面将元素显示为按钮,但实际上,那些按钮是 HTML 元素,例如DIVs。
![Links and buttons at packtpub.com]
![img/Figure_4.4_B16113.jpg]
packtpub.com 上的链接和按钮
许多时候,你不会注意到链接和按钮之间的区别。例如,在packtpub.com网站上,搜索按钮是一个button元素,而购物车按钮实际上是一个anchor。
你的大部分自动化代码将涉及点击这些动作元素。
容器元素
容器元素的作用是分组元素,主要用于布局和样式目的。最受欢迎的元素是DIV。DIV是什么?它可以是一切:项目列表、弹出窗口、页眉,等等。它用于创建元素组。
曾经是容器元素之王的元素是TABLE。正如其名称所暗示的,表格代表一个网格。在TABLE元素内部,你可以有TR元素代表行,TH元素代表表头单元格,TD元素代表行内的列。我提到这是容器元素之王,是因为社区现在已经从表格转向了DIVs,这是由于性能问题、对更复杂布局的需求以及响应性问题。但你在一些使用网格样式显示信息的网站上仍然可能会看到一些表格。
<header>用于网站页眉,<footer>用于页脚,<nav>用于导航选项,<articles>用于博客文章,等等。这些元素的目的在于帮助外部工具(如屏幕阅读器、搜索引擎甚至同一浏览器)理解 HTML 内容。
输入元素
我们需要了解的最后一批元素是输入元素。最常见的输入元素是多功能的input元素。根据type属性,它可以是"text"、"password"、"checkbox"、"file"(上传)等等;列表继续到总共 22 种类型。
然后我们有下拉列表的select元素和表示下拉列表项的option元素。
当然,我们不应该忘记<IMG>元素。没有图片的网站是无法想象的。
重要提示
你现在看到的输入并不都是这些元素之一。为了使输入更用户友好或更美观,你会发现开发者可能会基于许多其他元素构建输入。例如,你可能会找到一个下拉列表,它不是选择元素,而是一个输入元素,加上一个箭头按钮,点击它会显示一个浮动列表。这种控件使网站更美观,但自动化更具挑战性。
HTML 不仅有已知的元素列表,还有已知的属性列表。这些是你最常看到的属性:
-
id: 识别一个唯一元素。它是DOM(我们将在下一节中讨论DOM)中的元素 ID。 -
class: 包含应用于元素的 CSS 类。它接受多个 CSS 类,用空格分隔。 -
style: 分配给元素的 CSS 样式。
HTML 不会限制你可以添加到元素中的属性。你可以添加任何你想要的属性,例如,defaultColor="blue"。一个惯例是使用defaultColor是一个有效的属性,而一般惯例使用data-default-color="blue"代替。
我们感兴趣的另一组属性是role="treeitem"和aria-expanded="true"。
在过去的几段中,DOM 被提到了几次。让我们来谈谈 DOM。
DOM
DOM 是你可以使用 JavaScript 与之交互的 HTML 接口。根据 MDN(www.hardkoded.com/ui-testing-with-puppeteer/dom),它是构成网页上文档结构和内容的对象的数据表示。我们为什么要关心这个?因为我们将要使用相同的工具来自动化我们的页面。
在上一节中,我们提到一个元素可能有一个 ID。你会发现www.packtpub.com/上的搜索输入框具有 ID 搜索,因此你可以使用document.getElementById('search')在 JavaScript 中获取该元素。
你可能想知道:我如何知道按钮的 ID?或者我如何检查 ID 是否有效?记得我们讨论过开发者工具吗?
可以通过点击 Chrome 右上角的三点来打开开发者工具,然后转到更多工具 | 开发者工具。你还可以在 Windows 中使用Ctrl + Shift + J快捷键,或在 macOS 中使用Cmd + Option + I快捷键:

开发者工具
如果你右键点击页面上的任何元素,例如搜索按钮,你会找到 检查 选项,它将在 元素 标签中选中该元素。在那里,你将能够看到该元素的所有属性:

检查选项
你还会经常使用的一个标签是 控制台 标签,在那里你可以运行 JavaScript 代码。如果你在 元素 标签中,并按下 Esc 键,你将得到位于 元素 下的 控制台 标签。从那里,你将能够测试你的代码:

控制台标签
你还会经常使用的一组功能是 document.querySelector 和 document.querySelectorAll。第一个函数返回与 CSS 选择器匹配的第一个元素,而第二个函数返回与 CSS 选择器匹配的元素列表。因此,我们需要了解一些 CSS 选择器。
CSS 选择器
你不需要学习 CSS 就能理解如何设置页面样式,但你应该掌握如何在页面上查找元素。我们可以使用大约 60 种不同的选择器(www.w3schools.com/cssref/css_selectors.asp)来查找元素。我们不会在这里涵盖所有 60 种,但让我们来看看最常见的选择器:
-
通过元素名选择:
选择器:
ElementName。示例:
input将选择<input>元素。 -
通过类名选择:
选择器:
.ClassName。示例:
.input-text将选择包含input-text类的任何元素。如果你查看
www.packtpub.com/上的搜索输入,其类属性为class="input-text algolia-search-input aa-input"。此选择器不会检查类属性是否等于input-text。它必须包含它。 -
通过 ID 选择:
选择器:
#SomeID。示例:
#search将选择具有searchID 的元素。在这种情况下,它确实检查了等式。 -
通过属性选择:
选择器:
[attribute=value]。示例:
[aria-labelledby= "search"]将选择具有aria-labelledby属性且值为search的元素。这是一个使用 ARIA 属性进行自动化的优秀示例。
此选择器不仅限于等式检查(=)。你可以使用 [attribute] 来检查元素是否包含属性,无论其值如何。你还可以使用许多其他运算符。例如,你可以使用 *= 来检查属性是否包含值,或者使用 |= 来检查它是否以某个值开头。
组合选择器
CSS 的好处在于你可以组合所有这些选择器。你可以使用 input.input-search[aria-labelledby=" search"] 来选择具有 input-search 类和 aria-labelledby 属性且值为 search 的输入。
你也可以查找子元素。CSS 允许我们“级联”(这就是CSS中的C代表的意思)选择器。你可以通过添加由空格分隔的新选择器来搜索子元素。让我们以以下选择器为例:
form .algolia-autocomplete input
如果你倒着读,它会选择一个具有algolia-autocomplete类的元素内部的input,而这个元素位于一个form元素内部。注意,我说的是一个具有algolia-autocomplete类的元素内部的input。这不需要是输入元素的直接父元素。
如果你想要严格检查父子关系,你可以用>而不是空格来分隔选择器:
.algolia-autocomplete > input
这个选择器将寻找一个直接父元素是具有algolia-autocomplete类的元素的input。
可能你会想,我为什么要知道所有这些信息?我只是想用 Puppeteer 开始工作!让我告诉你一些事情:你有一半的时间会在开发者工具中度过,你代码中最频繁出现的元素将会是 CSS 选择器。你对 HTML、DOM 和 CSS 了解得越多,你在浏览器自动化方面的技能就会越熟练。
但现在是我们回到 Puppeteer 世界的时候了。
查找元素
现在是我们应用到目前为止所学的一切的时候了。我们需要掌握选择器,因为我们的 Puppeteer 代码将主要关于查找元素和与它们交互。
让我们把电子商务应用中的登录页面恢复回来:
![登录页面
![img/Figure_4.8_B16113.jpg]
登录页面
如果我们要测试登录页面,我们需要找到这三个元素:电子邮件输入框、密码输入框和登录按钮。
如果我们右键点击每个输入并点击检查元素菜单项,我们会找到以下内容:
-
电子邮件的 ID 是
email。 -
密码的 ID 是
password。 -
登录是一个具有
btn和btn-successCSS 类,以及style=" width: 100%;"样式的button元素。
Puppeteer 提供了两个从页面获取元素的功能。$(selector)函数将运行document.querySelector函数,并返回匹配该选择器的第一个元素,如果没有找到元素则返回null。$$$(selector)函数将运行document.querySelectorAll函数,返回匹配该选择器的元素数组,如果没有找到元素则返回空数组。
如果我们想在LoginPageModel类中使用这些新功能来实现login函数,查找登录输入将会很容易:
const emailInput = await this.page.$('#email');
const passwordInput = await this.page.$('#password');
小贴士
要找到登录按钮,你可能认为你可以使用btn-success选择器,你确实可以,但你不应该使用用于样式化按钮的类,因为如果开发团队更改了样式,它们可能会在未来发生变化。你应该尝试选择一个 CSS 选择器来克服设计变化。
让我们重新评估我们的登录按钮。如果您寻找button元素,您会发现您在该页面上有五个按钮,所以button选择器不起作用。但是,我们可以看到登录按钮是唯一具有type="submit"属性的按钮,因此我们可以使用[type=submit]CSS 选择器来找到这个元素。
但[type=submit]选择器太通用。例如,开发者可能会在工具栏中添加一个具有submit类型的按钮,这会破坏我们的代码。但我们可以看到登录按钮位于 ID 为login-form的表单中。因此,现在我们可以创建一个更稳定的选择器。所以,我们可以在登录函数中这样查找登录按钮:
const loginBtn = await this.page.$('#login-form [type=submit]');
现在我们有了测试登录页面所需的一切。但我们不会立即与登录页面交互。让我们转到主页并找到一些更复杂的场景:

主页
假设我们想要测试Macbook Pro 13.3' Retina MF841LL/A产品库存剩余 15 件,价格为$1,199。
首先,一些建议:最好将这些测试代码放在测试金字塔的底层。您可以测试发送这些值的 API 或向数据库发出查询的函数。
但让我们尝试将其作为一个 UI 测试来解决这个问题:

产品 HTML
如果我们查看 HTML,没有东西能帮助我们找到列表中的产品,即使我们能够找到产品,也很难找到该div元素内部的元素。
这里就是开发团队和 QA 团队之间协作变得有价值的地方。开发者如何帮助 QA 团队?使用 data-属性。您的团队可以使用data-test-属性来帮助您找到所需的元素:

带有 data-test 属性的 HTML
如您在 HTML 中看到的,使用这些新属性查找元素将容易得多。这就是我们如何获取测试产品 ID 2的值:
const productId = config.productToTestId;
const productDiv = await this.page.$(`[data-test-product-id="${productId}"]`);
const stockElement = await productDiv.$('[data-test-stock]');
const priceElement = await productDiv.$('[data-test-price]');
通过这四行代码,我们能够找到我们新测试所需的三个元素:产品容器以及包含库存和价格的元素。
在这段代码中,有几个需要注意的地方:
-
首先,记住不要在代码中硬编码值。这就是为什么我们要从我们的配置文件中获取产品 ID。
-
第二,请注意,我们使用
productDiv.$而不是page.$来获取stockElement和priceElement。这意味着传递给该函数的 CSS 选择器将在元素的上下文中进行处理。如果我们使用
page.$$('[data-test-stock]'),我们会得到许多元素,因为每个产品都有一个data-test-stock元素,但因为我们使用productDiv.$('[data-test-stock]'),我们将在productDiv内部得到元素。这是一个重要的资源。 -
这里要强调的最后一件事是,我们的开发团队给了我们
data-test-stock元素中库存数量的信息。当我们需要测试库存时,这会很有用。但请注意,我们不需要使用属性的值,在这种情况下是 15,来获取元素。传递属性作为选择器就足够了。
如果我们没有机会添加这些属性怎么办?还有一个资源——尝试使用 XPath 查找这些元素。
使用 XPath 查找元素
XPath 是一种查询类似 XML 文档的语言。记得我们说过 HTML 是一种放松版的 XML 吗?这意味着我们可以使用某种 XML 查询语言,如 XPath,来遍历 DOM。
在深入探讨 XPath 选择器之前,如果你想尝试 XPath 查询,Chrome DevTools 包含了一组可以在开发者工具中使用的函数 $x,它期望一个 XPath 表达式并返回一个元素数组:

在 Chrome 开发者工具中测试 XPath
如果你打开 $x('//*') 来测试 //* 选择器。
为了更好地理解 XPath 表达式,你需要将你的 HTML 视为 XML 内容。我们将从这个相同的根节点开始遍历这个 XML 文档,即 HTML 属性。
从当前节点选择
选择器://。这意味着“从当前节点,给我所有内容,无论位置。”
示例:$x('//div//a') 将会从根节点返回文档中所有的 div 元素,无论位置如何,以及那些 div 中的所有 a 元素,无论位置如何。
你对“无论位置”的部分感到困惑吗?好吧,现在让我们看看根选择器。
从根节点选择
选择器:/。这意味着“从当前节点,给我所有直接子元素。”
示例:如果我们使用 $x('/div//a'),我们将得到没有结果,因为没有 div 是根对象的子元素。唯一有效的根选项将是 $x('/HTML'),因为 HTML 元素是唯一一个位于主要根对象下的元素。但我们可以做的是 $x('//div/a'),这意味着“给我所有 div 元素,并从那里获取那些 div 的直接子元素 a。”
选择所有元素
选择器:*。这意味着“给我所有元素。”
示例:当我们说“所有元素”时,它将基于前面的选择器。$x('/*') 将只会获取 HTML 元素,因为这表示“所有直接元素”。但 $x('//*') 将会给你页面上所有的元素。
通过属性过滤
选择器:[@attributeName=value]。
示例:$x('//div[@class="card-body"]') 将会获取所有类属性等于 card-body 的 div 元素。这看起来可能和 CSS 中的类选择器相似,但实际上并不相同,因为这个选择器在 div 有多个类时将不会工作。
到目前为止,这似乎就像 CSS,只是语法不同。XPath 有什么如此强大的地方?好吧,让我们来看看一些强大的工具。
事实上,我们用来过滤属性的语法是,实际上,是表达式,也称为谓词。这给了我们不仅使用@attributeName选项,还可以检查许多其他事情的机会。
通过文本过滤
选择器:[text()=value]。
示例:$x('//div[text()="Admin Panel (Testing purpose)"]')将返回所有内容为文本Admin Panel (Testing purpose)的div元素。你甚至可以使其更通用,使用如下,$x('//*[text()="Admin Panel (Testing purpose)"]'),这样你就不必关心它是一个div还是其他类型的元素。
这个函数是人们使用 XPath 的主要原因之一。
包含文本
选择器:[contains(text(), value)]。
示例:通过文本过滤可能很棘手。文本可能在其内容前后有一些空格。如果你尝试使用此命令选择页面上的网格按钮,$x('//*[text()= "Grid"]'),你将不会得到任何结果,因为该元素在单词前后有一些空格。这个contains函数可以帮助我们在单词前后有空格,或者当单词是更大文本的一部分时。这就是我们如何使用此函数的方式:$x('//*[contains(text(),"Grid")]')。
有许多更多的函数。Mozilla 列出了所有可用的函数列表(www.hardkoded.com/ui-testing-with-puppeteer/xpath)。
我们可以使用 XPath 进行非常复杂的查询。让我们看看我们的最后一个例子。我们想要所有价格超过 2,000 美元的元素:
$x('//div[@class="row"]/p[1][number(substring-after(text(), "$")) > 2000]')
哇,让我们看看我们在做什么:
-
使用
//div[@class="row"],我们获取具有row类的DIVs。 -
使用
p[1],我们获取第一个p元素。我们在这里可以使用位置过滤器。 -
我们使用
text()获取文本。 -
由于价格以美元符号开头,我们使用
substring-after将其删除。 -
我们使用
number将文本转换为数字。 -
那么,我们可以检查那个数字是否大于 2,000。
XPath 还有一个特性使其成为一个强大的工具。与 CSS 选择器不同,你可以使用..通过 XPath 选择父元素。
如果我们想返回价格超过 2,000 美元的产品整个主div,我们可以使用以下方法:
$x('//div[@class="row"]/p[1][number(substring-after(text(), "$")) > 2000]/../..')
我们如何在 Puppeteer 中使用 XPath 表达式?你已经知道了如何做:我们有一个$x函数。
让我们回到我们的测试:我们想要测试 Macbook Pro 13.3' Retina MF841LL/A 库存剩余 15 件,价格是 1,199 美元。
如果唯一找到该产品的方法是使用产品名称呢?我们可以这样做:
const productName = config.productToTestName;
const productDiv = (await this.page.$x(`//a[text()="${productName}"]/../..`))[0];
const stockElement = (await productDiv.$('//h6'))[0];
const priceElement = (await productDiv.$(' //div[@class="row"]/p[1]'))[0];
记住 $x 返回一个元素数组。在这种情况下,我们知道它们总是会返回一个元素,所以我们取第一个。
同样地,我们不应该过分依赖设计类来使用 CSS 选择器。我们应尽量避免在 XPath 选择器中过度依赖 HTML 结构。在这段代码中,我们假设了几件事情:
-
我们假设股票是一个
h6元素。 -
我们假设价格将是第一个
p元素。
如果设计团队决定使用 div 而不是 h6 来使库存看起来更好,如果他们把价格包裹在一个 div 元素中以提高移动导航,那么你的测试将会失败。
我们学习了如何从页面获取元素,但重要的是要知道,$、$$ 和 $x 函数并不返回 DOM 中的元素。它们返回称为 元素句柄 的东西。
元素句柄是对页面上的 DOM 元素的引用。它们是一个指针,帮助 Puppeteer 向浏览器发送命令,引用现有的 DOM 元素。它们也是我们与这些元素交互的几种方式之一。
与元素交互
让我们回到我们的登录测试。我们已经有了需要的三个元素:用户输入、密码输入和登录按钮。现在我们需要输入电子邮件和密码,然后点击按钮。
在输入元素上输入
ElementHandle 类有一个名为 type 的函数。其签名是 type(text, [options])。这次 options 类并不大。它只有一个 delay 属性。延迟是 Puppeteer 在字母之间等待的毫秒数。这对于模拟真实用户交互非常有用。
我们的测试的第一部分看起来是这样的:
const emailInput = await this.page.$('#email');
await emailInput.type(user, {delay: 100});
const passwordInput = await this.page.$('#password');
await passwordInput.type(password, {delay: 100});
在这里,我们正在寻找电子邮件和密码元素,然后模拟用户在这些输入上输入。
现在,我们需要点击按钮。
点击元素
ElementHandle 类还有一个名为 click 的函数。我敢打赌你已经明白了这个模式。其签名是 click([options])。你可以简单地调用 click(),这样就能完成任务。但我们也可以使用三种可用的选项:
-
button:这是一个包含三个有效选项的字符串:“left”、“right”或“middle”。 -
clickCount:默认值为1,但你也可以模拟一个没有耐心的用户多次点击同一个按钮,所以你可以通过传递4来模拟用户点击元素四次。 -
delay:这个延迟不是点击之间的时间,而是鼠标按下动作和鼠标抬起之间的时间(以毫秒为单位)。
在我们的情况下,我们不需要使用这些选项:
const loginBtn = await this.page.$('#login-form [type=submit]');
await loginBtn.click();
通过这两行代码,我们终于完成了我们的 login 函数。我们找到了登录按钮,然后点击它。
在下拉列表中选择选项
现在这个网站有一个下拉列表,一个 HTML 中的 SELECT 元素,用于在网格视图和列表视图之间切换:


带有新开关选项的网站
如你所猜,选择选项的函数被称为select,其签名是select(…values)。如果select元素有multiple属性,它是一个值列表。
我们需要了解的下一个关于这个函数的信息是,select期望的值不是你在option中看到的文本,而是值的option。我们可以通过检查元素来看到这一点:

下拉列表选项
在这种情况下,我们很幸运,因为值几乎与可见文本相同,但并不完全相同。如果我们想选择网格项,我们需要使用grid,而不是Grid。
如果我们将option切换到列表模式,我们可以看到元素被添加了list-group-item类:

列表模式下的 HTML
这就是我们可以测试这个功能的方法:
var switchSelect = await page.$('#viewMode');
await switchSelect.select('list');
expect(await page.$$('.list-group-item')).not.to.be.empty;
await switchSelect.select('grid');
expect(await page.$$('.list-group-item')).to.be.empty;
每次我们需要与元素交互时都使用await和page.$需要很多样板代码。想象一下,如果我们有八个输入要填写,那会很多。这就是为什么Page和Frame(如果你正在处理子框架)都有大多数元素处理函数,但它们期望一个选择器作为第一个参数。
因此,假设我们有一段这样的代码:
var switchSelect = await page.$('#viewMode');
await switchSelect.select('list');
这可能就像这样简单:
await page.select('#viewMode', 'list');
你会发现诸如page.click(selector, [options])、page.type(selector, text, [options])以及许多其他交互函数。
我们已经涵盖了最常见的用户交互。但我们可以更进一步,尝试模拟用户如何使用键盘和鼠标与页面交互。
键盘和鼠标模拟
尽管你将能够通过输入或点击元素来测试最常见的场景,但还有一些其他场景,你需要模拟用户如何使用键盘和鼠标与网站交互。让我们以 Google 电子表格为例:

Google 电子表格
Google 电子表格页面有很多键盘和鼠标交互。你可以使用键盘箭头在单元格间移动,或者通过鼠标拖放来复制值。
但这不必那么复杂。假设你在 GitHub.com(http://GitHub.com)的 QA 团队工作,并且你需要测试主页上的搜索框。
由于 GitHub.com 是为开发者设计的,而开发者出于某种奇怪的原因讨厌使用鼠标,开发团队在网站上添加了许多快捷键。我们想要创建一个测试来检查这些快捷键是否按预期工作:

GitHub.com 首页
正如我们所见,搜索输入的快捷键是/。因此,我们需要做以下操作:
-
按下斜杠。
-
输入仓库名称。
-
然后按Enter。
我们将使用Page类公开的Keyboard类作为属性。
第一步是按下斜杠。为了做到这一点,我们将使用,你猜对了,press函数。该函数的签名是press(key, options)。关于press的第一个需要了解的事情是,它是对两个其他函数的快捷方式——down(key, options)和up(key)。正如你所见,你可以得到几乎完整的键盘模拟。
注意,第一个参数不是text而是key。你可以在这里找到支持的完整键列表:www.hardkoded.com/ui-testing-with-puppeteer/USKeyboardLayout。在那里,你可以找到诸如Enter、Backspace或Shift之类的键。press函数有两个可用选项:首先,如果你分配了text属性,Puppeteer 将创建一个具有该值的输入事件。它将像宏一样工作。例如,如果键是p且文本是puppeteer,当你按下p时,你将在输入元素中得到puppeteer。我从未找到这个参数的用法,但它确实存在。down函数也有这个选项。第二个选项是delay,它是键按下和键释放动作之间的时间。
官方的 Puppeteer 文档(www.hardkoded.com/ui-testing-with-puppeteer/keyboard)有一个完美的例子:
await page.keyboard.type('Hello World!');
await page.keyboard.press('ArrowLeft');
await page.keyboard.down('Shift');
for (let i = 0; i < ' World'.length; i++) {
await page.keyboard.press('ArrowLeft');
}
await page.keyboard.up('Shift');
await page.keyboard.press('Backspace');
让我们分析一下这段代码:
-
它输入Hello World!。光标在感叹号之后。
-
它按下左箭头键。记住,
press是key down和key up。所以现在光标在感叹号之前。 -
然后,使用
down,它按下Shift键,但并没有释放这个键。 -
然后,它多次按下左键,使光标到达“Hello”单词之后。但由于Shift键仍然被按下,“World”文本被选中。
-
然后,它使用
up释放了Shift键。 -
当你按下退格键并且有文本被选中时,会发生什么?你将移除整个选择,留下文本Hello!。
现在,我们可以去测试GitHub.com的主页:
const browser = await puppeteer.launch({headless: false, defaultViewport: null});
const page = await browser.newPage();
await page.goto('https://www.github.com/');
await page.keyboard.press('Slash');
await page.keyboard.type('puppeteer')
await page.keyboard.press('Enter');
如果我们回到我们的登录示例,我们可以测试通过按下Enter而不是点击登录按钮来登录。或者,如果控件之间的导航很重要,你可以通过按下Tab从用户输入跳转到密码,然后到登录按钮。
你想玩井字棋吗?让我们用鼠标来玩。
在Chapter4文件夹中,你会找到一个tictactoe.html文件,其中包含一个用React制作的简单的井字棋游戏:

井字棋游戏
如果我们将页面视为一个画布,其中窗口的左上角是坐标(0;0),而右下角是坐标(窗口宽度, 窗口高度),那么鼠标交互就是将鼠标移动到(X;Y)坐标并使用鼠标按钮之一进行点击。Puppeteer 提供了以下功能。
使用 mouse.move(x, y, [options]) 移动鼠标。这个 move 函数中可用的唯一选项是 steps。使用 steps,你可以告诉 Puppeteer 你希望向页面发送多少次 mousemove 事件。默认情况下,它将在鼠标移动动作结束时发送一个事件。
就像键盘一样,你有 up/down 和 press 函数,对于鼠标,你有 up/down 和 click。
鼠标有一个键盘没有的额外动作,那就是 wheel。你可以使用 mouse.wheel([options]) 来模拟鼠标滚动。这个选项有两个属性:deltaX 和 deltaY,它们可以是正数或负数,表示 CSS 像素中的滚动值。
让我们回到我们的井字棋游戏。我们将进行一个简单的测试:玩家 1 将使用第一行,玩家 2 将使用第二行,所以玩家 1 将在三步之后获胜。由于这是一个画布,我们需要知道哪些坐标需要点击。
我们可以使用开发者工具的样式部分来获取这些坐标。如果我们查看 body,我们会看到一个 20 像素的边距,这将使 (20;20) 成为起点:

Body margin
我们还知道每个方块是 32 像素 x 32 像素,所以方块的中间应该是 delta + (32 / 2)。让我们测试一下:
const startingX = 20;
const startingY = 20;
const boxMiddle = 16;
// X turn 1;
await page.mouse.click(startingX + boxMiddle, startingY + boxMiddle);
// Y turn 1;
await page.mouse.click(startingX + boxMiddle, startingY + boxMiddle * 3);
// X turn 2;
await page.mouse.click(startingX + boxMiddle * 3, startingY + boxMiddle);
// Y turn 2;
await page.mouse.click(startingX + boxMiddle * 3, startingY + boxMiddle * 3);
// X turn 3;
await page.mouse.click(startingX + boxMiddle * 5, startingY + boxMiddle);
expect(await page.$eval('#status', status => status.innerHTML)).to.be('Winner: X');
因此,我们知道井字棋网格从坐标 (20,20) 开始,从这里我们可以通过简单的数学计算找到画布中的正确坐标。第一个框将在坐标 (startingX + boxMiddle; startingY + boxMiddle) 处点击。如果我们想点击第二行,将是三个中间方块,startingX + boxMiddle * 3,以此类推,直到我们知道我们有一个赢家。
不要担心最后的 $eval。我们很快就会到达那里。
但这不仅仅适用于游戏。许多现代 UI 可能需要一些鼠标交互,例如,可悬停下拉菜单或菜单。我们可以在 W3Schools 网站上看到一个例子 (www.w3schools.com/howto/howto_css_dropdown.asp):

可悬停下拉菜单
要能够点击下拉菜单中的任何项目,我们首先需要将鼠标悬停在按钮上,然后链接到选项:
await page.goto("https://www.w3schools.com/howto/howto_css_dropdown.asp");
const btn = await page.$(".dropbtn");
const box = await btn.boundingBox();
await page.mouse.move(box.x + (box.width / 2), box.y + (box.height / 2));
const option = (await page.$x('//*[text()="Link 2"]'))[0];
await option.click();
如您所见,我们不需要猜测 boundingBox,它返回位置 (x 和 y) 以及元素的大小(宽度和高)。
有没有更简单的方法?是的,我们可以简单地使用 await btn.hover(),这将悬停在元素上。我想给你一个完整的例子,因为有时 UI 组件对鼠标位置非常敏感,所以你需要将鼠标放在精确的位置才能得到期望的结果。
是时候进行一个附加环节了。让我们谈谈调试。
使用 Visual Studio Code 进行调试测试
许多开发者认为调试是最后的手段。其他人会在他们的代码中充满 console.log 消息。我认为调试是一个生产力工具。
调试是通过逐步运行应用程序来尝试找到错误的过程。
我们有两种在调试模式下启动测试的方法。第一种选项是从 终端 标签创建一个 JavaScript 调试终端。这将创建一个新的终端,就像我们之前做的那样,但在这个情况下,当你从该终端运行命令时,Visual Studio 将启用调试器:

从终端进行调试
第二种选项是转到 launch.json 文件。你也可以在 .vscode 文件夹内手动创建该文件:

从运行标签创建 launch.json
一旦我们有了文件,我们就可以创建一个新的配置,这样我们就可以在终端中运行 npm run test:
{
"version": "0.2.0",
"configurations": [
{
"name": "Test",
"request": "launch",
"runtimeArgs": [
"run",
"test"
],
"runtimeExecutable": "npm",
"skipFiles": [
"<node_internals>/**"
],
"type": "pwa-node"
},
]
}
哪一个最好?嗯,如果你将在这个项目上工作很多天,创建 launch.json 文件会更有效率;一旦创建,你只需按 F5,就会进入调试模式。终端选项只是更容易启动。
一旦你设置好了一切,就是创建你想要调试器停止的行的 断点,然后就是利用 Visual Studio Code 提供的所有工具:

Visual Studio Code 调试模式
在那里,你会找到以下内容:
-
在行号左侧,你会找到断点。你可以通过点击行号左侧来创建或删除断点。
-
你可以在窗口的左下角找到完整的断点列表。从那里,你可以暂时禁用断点。
-
在窗口的右上角,你会找到调试操作:暂停、播放、进入/退出和停止按钮。
-
在左侧面板中,你会找到两个有用的部分:变量,你可以自动获取当前作用域中所有变量的值。下一个面板是观察,你可以添加你想要在运行代码时查看的变量或表达式。
摘要
这一章内容很多。我们以对 HTML、DOM 和 CSS 的简要但完整的介绍开始这一章。这些概念对于创建高质量的测试至关重要。然后,我们学习了大量的 XPath,虽然它不是一个非常流行的工具,但它非常强大,将帮助你面对 CSS 选择器不足的情况。
在本章的第二部分,我们介绍了与页面交互的最常见方法。我们不仅学习了如何与元素交互,还涵盖了键盘和鼠标模拟。
希望你喜欢工具部分。使用 Visual Studio Code 进行调试是添加到你的工具箱中的好工具。
在下一章中,我们将等待一些事情。在网络上,事情需要时间。页面需要时间加载。页面上的某些操作可能会触发网络调用。下一章很重要,因为你将学习如何让你的测试更加稳定。
第五章:第五章:等待元素和网络调用
我不会说我老了,但我在 90 年代末开始上网。所以是的,我老了。当时,有时你需要等待超过一分钟才能加载一个页面。你可能正在想,“如果你打开了 10 个标签页,那就无法使用了。” 好吧,浏览器当时没有标签页!下载一个单独的 MP3 文件可能需要一个小时。
在 2000 年代初,网络进入了企业界,我们开始使用网站开发商业应用。但那是一个来自 IT 部门的决策。旧的终端应用难以更新和引入新功能,桌面应用难以分发。Web 应用是 IT 部门的解决方案,给用户留下了速度慢且不友好的 Web 应用。
开发者当时尽力使用他们拥有的工具。页面主要是通过 ASP 3.0 或 PHP 等工具在服务器端生成的。AJAX 用于小任务,例如根据国家选择加载状态列表,而无需重新加载整个页面。
在 2000 年代后期,谷歌推出了 Gmail,向世界展示了网络应该如何看起来。但对于开发者来说,这个门槛太高了。对于仅仅尝试构建 CRUD 页面的开发者来说,开发这类应用是不可想象的。
现在,我们的网络看起来不同了。开发者现在能够为更直接的场景创建丰富的体验。
但在这些年里,有一件事没有改变:你必须等待。
你必须等待网站加载、数据刷新、新页面打开、表单提交。你必须等待。
等待正确的时机采取行动是避免不稳定测试的关键。不稳定测试有时通过,有时失败。你必须将不稳定测试视为一个错误,不是在应用中,而是在你的测试中。不稳定测试会带来许多问题:
-
他们是浪费时间。没有人愿意将带有红色测试的拉取请求合并。因此,他们会反复运行测试,直到所有测试都变为绿色。
-
不稳定的测试是虚假的警报。假设开发者不知道测试是不稳定的。他们可能会尝试寻找一个不存在的错误。
-
不稳定测试损害了测试的声誉。声誉的损失始于跳过一次不稳定测试。如果你有更多不稳定测试,团队可能会将你的测试移动到夜间流程。如果你的测试持续不稳定,它们可能会从 CI 流程中移除。你失去了,你的团队也失去了。
等待正确的时机采取行动是制作稳定 UI 测试的关键。
在本章中,我们将学习 Puppeteer 提供的工具,以便在正确的时间采取行动。我们还将学习不同的技术和方法,以便你知道如何等待页面准备就绪、输入可见或请求被发起,以及其他许多事情。
本章将讨论等待,这是网络自动化的一个关键主题。我还想向你展示一个 Puppeteer 录制器,这样你就可以在你的工具箱中添加一个额外的工具。在本章中,我们将涵盖以下主题:
-
等待页面加载
-
等待元素
-
等待网络调用
-
等待页面事件
-
奖励:无头录制器
让我们从开始讲起。你是如何知道页面已经加载完成的?
技术要求
你可以在 GitHub 仓库(github.com/PacktPublishing/UI-Testing-with-Puppeteer)的Chapter5目录下找到本章的所有代码。请记住在那个目录下运行npm install,然后进入Chapter5/vuejs-firebase-shopping-cart目录再次运行npm install。
如果你想要在遵循本章内容的同时实现代码,你可以从Chapter4目录中留下的代码开始。
等待页面加载
在第三章中,我们讨论了通过网站导航。我们涵盖了诸如goto、goBack、goForward和reload等函数。这些函数中的一个选项是waitUntil选项。这个选项将帮助我们确定我们调用的函数何时会解决。让我们快速回顾一下。我们有四个选项:
-
domcontentloaded,它依赖于DOMContentLoaded事件。 -
load:如果你传递这个选项,当load事件被触发时,promise将被解决。 -
networkidle0将在过去 500 毫秒内没有更多网络连接时解决promise。 -
networkidle2将在过去 500 毫秒内没有超过 2 个网络连接时解决promise。
让我们看看这些选项如何与内容丰富的网站,如shop.mango.com/gb一起工作。我们将看到根据使用的哪个waitUntil,哪些内容已经准备好了:

芒果主页
需要最早解决的选项是DOMContentLoaded:

DOM 内容加载
那个页面根本就没有准备好。这意味着DOMContentLoaded就毫无用处了吗?嗯,在这个情况下确实是这样的。如果你用同样的方法来处理维基百科,页面非常直接,它会自动准备好:

维基百科中的 DOM 内容加载
返回芒果页面。等待load事件给我们带来了页面的所有内容:

页面加载后
背景视频还没有准备好。订阅弹出窗口也没有出现。但是,如果我们想要与菜单栏交互,使用登录操作,或者测试 cookie 横幅,页面就已经准备好了。
很难找到一个页面,其中networkidle0和networkidle2的行为不同,以至于你必须在这两者之间选择。在这种情况下,我们将得到一个几乎完整的页面:

使用 networkidle0 和 networkidle2 的页面
视频尚未播放,因此如果你想要截图或生成 PDF 文件,正如我们将在第七章中看到的,使用 Puppeteer 生成内容,这还不够。但我们可以说是准备好的进行测试。
那么,哪个更好?我们应该安全起见,始终使用networkidle0吗?难道那不应该成为默认设置吗?
这是我们需要找到平衡的地方。我们可以在操作之间等待 10 秒钟,这样就不会有任何不可靠的测试。但是,如果你有 1,000 个测试(记住,你将有超过 1,000 个测试),每个测试有 10 个操作,这意味着整个测试套件将需要近 14 个小时才能运行。
为了减少不可靠性,我们需要在等待时间过长和过快之间找到平衡。
有时候从服务器获取 DOM 就足够了。如果我们正在测试维基百科,我们的链接会在DOMContentLoaded事件中为我们准备好。如果我们想测试我们的主页,并且等待DOMContentLoaded,图片可能还没有准备好,但我们会从服务器获取股票和价格值。我们不需要更多。
设置正确的waitUntil将使你的代码更不可靠,但除非你测试一个像维基百科这样的简单网站,否则这还不够。
使你的代码最稳定的有效方法是等待我们想要与之交互的元素。
等待元素
在对元素进行操作之前,你需要确保两件事:首先,该元素确实存在,它存在于 DOM 中;其次,你可以对该元素进行操作。换句话说,它对用户是可见的。让我们看看我们如何等待我们的元素准备好。
你应该在某种网络调用之后等待选择器。你跳转到一个页面,等待选择器,然后进行操作。你点击一个按钮,等待选择器,然后进行操作。
在某些情况下,你需要等待的选择器很容易找到。在我们的登录页面上,我们需要等待用户名输入。在其他情况下,例如我们的主页,我们可能需要等待包含所有产品的div元素。这稍微复杂一点,但仍然直接。
但如果我们想测试 Mango 的时事通讯弹出窗口呢?也许弹出窗口的 HTML 已经在页面上,但它不可见。这就是我开始考虑等待作为一种艺术的地方。这不仅仅是自动化一个页面。这不仅仅是关于工具。你需要找到正确的选择器,使你的自动化代码稳定。
我们有两个函数可以帮助我们等待元素:waitForSelector和waitForXPath。这两个函数具有相似的签名。waitForSelector(selector, [options])期望一个 CSS 选择器和options对象。waitForXPath(XPath, [options])期望一个 XPath 表达式和options对象。
这些是在options参数中可以设置的可用属性:
-
timeout:我们将在所有等待函数中找到这个选项。我们不希望我们的测试卡住。这是导致测试不可靠的另一个原因。如果达到超时时间,承诺将被拒绝。如果我们没有传递超时,函数将使用通过page.setDefaultTimeout(timeout)设置的超时。如果没有使用setDefaultTimeout,它将默认为30 秒。 -
visible:如果visible设置为 true,Puppeteer 不仅会检查元素是否存在于 DOM 中,还会检查它是否可见。我们可能需要在我们的新闻通讯弹出窗口中使用它。默认情况下不会执行此检查。同样,这取决于你的场景,你可能想要检查或不要检查。 -
hidden:如果hidden设置为 true,Puppeteer 将检查元素是否不可见或元素是否不在 DOM 中。这个选项在处理加载动画时很有用。你知道当加载动画隐藏时页面正在读取。Twitter.com是一个很好的例子:

Twitter.com 上的加载动画
waitForSelector和waitForXPath都会返回一个可以解析为以下内容的承诺:
-
ElementHandle:这个元素句柄将是最终匹配 CSS 选择器或 XPath 的元素。 -
空值:当
hidden设置为 true,且元素未在 DOM 中找到时。
有四种等待元素的方法。这并不是关于哪个是最好的。这些方法将帮助你在不同的场景中。
等待一个等待函数
你可以在stackoverflow.tests.js文件中找到本节使用的代码。
如果我们访问 Stack Overflow (stackoverflow.com/questions),我们会发现页面右侧有职位发布。但正如我们所见,这是在页面加载之后加载的。

在加载时列出和在加载后列出
假设我们想测试页面默认有一个职位列表。
我们可以使用$$获取LI元素,然后检查列表是否为空:
const jobs = await page.$$('.jobs li');
expect(jobs).not.be.empty;
实际上,在良好的网络环境下,这通常能起作用,但它也可能变得不可靠。我们需要做的是在检查列表之前等待元素加载。我们可以做的是在调用$$之前调用waitForSelector函数:
await page.waitForSelector('.jobs li');
const jobs = await page.$$('.jobs li');
expect(jobs).not.be.empty;
如我之前提到的,waitForSelector返回一个ElementHandle。它使用document.querySelector。这就是为什么我们不能使用waitForSelector的结果。
但如果我们想检查标题是否是 waitForSelector 函数:
const title = await page.waitForSelector('#hireme .header .grid--cell.fl1');
expect(await title.evaluate(e => e.innerText)).to.contain('job');
我想这是第二次使用 evaluate 函数了。耐心——这将在下一章中到来。
如果你想知道为什么我们没有在职位列表示例中使用 waitForSelector 的结果,结果是 waitForSelector 使用 document.querySelector 来评估 CSS 表达式。这将使 waitForSelector 只返回一个项目。
与 waitForXpath 一样,这也会发生。与 $x 不同,它返回一个元素数组。waitForXpath 只会返回一个元素。
waitForSelector 和 waitForXPath 大多数时候都能帮到你,但还有其他我们可能需要考虑的场景。例如,我们可能需要检查网络调用。我们可能想要等待一个请求被发送或响应被接收。让我们看看如何完成这个任务。
等待网络调用
在 第三章 中,浏览网站,我们讨论了 请求 和 响应。每次页面导航都是从向页面发送请求开始的。服务器随后处理该请求并发送响应。该响应通常是包含需要请求的资源的一个 HTML 页面。服务器将再次处理这些请求并发送多个响应。
但这还不算全部。现代应用程序会根据用户操作向服务器发送请求。以 Google Maps 为例:用户移动鼠标,页面就需要请求新的地图图片,而无需重新加载整个页面。
我们不在 Google Maps 团队工作,但许多用户报告说,主页有时在登录后不会加载产品图片。因此,我们可以编写一个测试来检查 它应该加载一个图片。哦……你以为我们要测试 Google Maps?不是这次,抱歉。
在这种情况下,我们可以使用 waitForResponse(urlOrPredicate, [options])。让我们来分解这些参数:
-
urlOrPredicate可以是一个包含我们想要等待的 URL 的字符串。但它也可以是一个函数。这个函数应该期望一个响应,这将是你想要检查并返回一个真值的结果。 -
在这个函数中,我们唯一的选择是
timeout。这个属性的条件与waitForSelector中的相同:如果没有传递,Puppeteer 将使用page.setDefaultTimeout(timeout),如果没有使用该函数,默认为 30 秒。
让我们编写我们的测试。我们需要登录并等待产品图片。为了完成这个任务,我们将使用 Arrange, Act, Await 方法。
Arrange, Act, Await
这个名字来源于我们之前在 第四章 中讨论的 arrange, act, assert 模式,与页面交互。
使用这种模式,我们试图防止竞态条件,这是异步编程中常见的问题,也是不稳定的原因。异步编程中的竞态条件是指你试图同时执行两个或多个任务,其中一个任务的速度(太快或太慢)导致另一个任务永远无法完成。
让我们以这个测试为例:
await loginModel.go();
await loginModel.login(config.username, config.password);
await page.waitForResponse(config.productImage);
首先,注意一点。我们没有使用断言。waitForResponse承诺解决的事实就足以让我们知道测试是成功的。
这里还有一个重要的概念是waitForResponse的行为与waitForSelector不同。当我们使用waitForSelector时,函数会在我们等待的元素已经在 DOM 中时解决。但与waitForResponse不同,如果我们等待的响应已经发生,我们的waitForResponse将会超时。
我们那里的代码存在不稳定的隐患。如果我们的服务器在登录后响应页面过快,图片可能在我们等待之前就已经被发送了。为了解决这个问题,我们需要先获取承诺,然后再等待它。这就是我们如何修改代码的方式:
await loginModel.go();
const promise = page.waitForResponse(config.productImage);
await loginModel.login(config.username, config.password);
await promise;
注意,我们不是在等待waitForResponse返回的承诺,而是将那个承诺赋值给一个变量。我们调用waitForResponse,保留那个承诺,然后采取行动(登录)。之后,我们等待那个承诺,希望它在登录操作完成后某个时刻被解决。你可以在这个login.tests.js文件中找到这个测试。在那里,测试被命名为Should load image after login。
就像我们使用waitForResponse一样,我们也可以使用waitForRequest。
如果我们想要检查浏览器是否向服务器发送请求,我们会使用waitForRequest而不是waitForResponse。因为这个函数也期望一个函数作为参数,我们可以检查不仅 URL,还可以请求的内容。
假设我们在天气频道(weather.com/)工作。我们想要检查浏览器是否发送了我们的位置。我们发现页面正在调用redux-dal。我们想要等待那个请求,解析params对象。

天气频道
我们将使用火速发射,无需关注的方法来解决这个问题。
火速发射,无需关注
你可以在本节中使用的代码在weather.tests.js文件中找到。
当我们调用一个返回承诺但又不等待该承诺,甚至不关心该承诺结果的函数时,我们称之为“火速发射,无需关注”。这是一个军事术语,指的是一种发射后无需进一步引导的导弹。在我们的情况下,我们的“导弹”是承诺,我们发射了它们,但我们不关心它们的结局。
让我们看看火速发射的方法会是什么样子:
const promise = page.waitForRequest(r => r.url().includes('redux-dal'));
page.goto('https://weather.com/');
const request = await promise;
const json = JSON.parse(request.postData());
expect(json[0].params.geocode).not.be.empty;
这里有很多新事物需要学习。
我们触发并忘记goto操作。我们调用goto,但不会等待它完成。执行触发并忘记意味着我们不会关心 promise 是否解析或失败。在这种情况下,我们关心request promise。如果goto失败,waitForRequest将失败,测试将失败。
我们在这里可以看到的第二个新功能是,我们正在使用谓词等待一个请求,一个期望请求并返回一个真值的函数:r => r.url().includes('redux-dal')。
我们在这里可以学到的最后一件事是,我们正在使用由waitForRequest promise 解析的请求。一旦我们得到请求,我们使用postData提取负载,解析它,并评估内容。
我们必须处理的最后一个网络调用功能是waitForNavigation。想象一下waitForNavigation是goto函数没有 URL 参数的形式。它是waitForNavigation([options])。选项与goto相同。我们可以使用这个函数等待由我们执行的一个操作触发的导航。
以 Packtpub 网站(www.packtpub.com/)为例。我们想要搜索一本书,按Enter键,然后等待页面跳转到结果页面。
对于这个测试,我们将使用我们的第四种方法:Promise.all。
Promise.all
你可以在packpub.tests.js文件中找到本节使用的代码。
根据场景,Promise.all可以是 Act, Arrange, Await 的快捷方式。实际上,我会保留后者用于更复杂的场景,并在需要同时等待两个任务时使用Promise.all。
我们的测试代码将使用Promise.all看起来像这样:
await page.goto('https://www.packtpub.com/');
const search = await page.$('#search');
await search.type('Puppeteer');
await Promise.all([
page.waitForNavigation(),
search.press('Enter')
]);
const textResult = await page.$eval('[data-ui-id="page-title-wrapper"]', e => e.innerText);
expect(textResult).to.be.equal(`Search results for: 'Puppeteer'`);
第一部分相当直接。我们访问网站,获取搜索输入,并输入“Puppeteer”。但是,我们会在同一个await语句中等待两个 Promise。我们等待导航完成和press函数。
虽然在Promise.all内部得到一个竞争条件会很奇怪,但我感觉在all函数的第一个参数中添加wait函数会更安全。
正如我在第一章中提到的,使用 Puppeteer 入门,Promise.all将等待所有 Promise 完成。它也会在任何一个 Promise 失败时立即解析。
现在我们知道了如何等待元素和网络调用。但让我告诉你一个小秘密:waitForRequest和waitForResponse只是页面提供的请求和响应事件的包装器。Puppeteer 会创建一个 Promise,开始监听一个事件,然后在满足条件时解析 Promise。好消息是我们可以使用这种方法等待许多其他事件。
等待页面事件
事件是当某个事情发生时类发送的消息。作为消费者,你可以在Chapter5目录下的page-event-demos.js文件中找到这些事件。要运行这个演示,你只需要运行node page-event-demos.js。
这就是如何在没有waitForResponse的情况下监听响应:
page.on('response', response =>
console.log('Response URL: ' + response.url()));
await page.goto('https://www.packtpub.com/');
在第一行,我们表示我们想要监听response事件,当新的响应到达时,我们想要在控制台打印 URL。然后,我们调用goto函数,所有的响应都将开始写入控制台。
使用箭头(=>)是编写单行函数的简单方法。但是,如果你打开一个括号,你可以编写更复杂的函数,如下所示:
page.on('response', response => {
if(response.request().resourceType() === 'image') {
console.log('Image URL: ' + response.url());
}
});
await page.goto('https://www.packtpub.com/');
如果你想要重用函数,你可以将其传递到那里:
const listenToImages = response => {
if(response.request().resourceType() === 'image') {
console.log('Image URL from function: ' + response.url());
}
};
page.on('response', listenToImages);
正如你所见,我们可以创建一个函数,将其分配给一个变量——在这个例子中,是listenToImages——然后将其传递给page.on函数。如果你传递一个函数,你将能够移除那个监听器:
page.removeListener('response', listenToImages);
removeListener函数将listenToImages函数从response事件中分离。
你需要给你的工具箱添加一个新功能。你可以使用once来监听一个事件,只发生一次:
page.once('response', r => console.log(r.url()));
once将你的函数附加到事件上,并在第一个事件到达时立即将其移除。请注意,once不会评估你函数的结果。你将无法阻止once在第一个事件到达时立即移除你的监听器。
我们现在可以尝试创建自己的waitForResponse函数。我们将使用我们在第一章中提到的方法,使用 Puppeteer 入门:履行我们的承诺。我们可以创建一个承诺,然后当等待的条件满足时,我们将解决它:
await loginModel.go();
const promise = new Promise(resolve =>
page.on('response', r => {
if (r.url() === config.productImage) {
resolve(r);
}
}));
await loginModel.login(config.username, config.password);
await promise;
在这段代码中,我们创建了一个承诺,当resolve函数被调用时,它将被解决。在函数内部,我们附加到响应事件,当 URL 匹配时,我们调用resolve传递那个响应。
在这种情况下,使用waitForResponse函数会更简单。但是,有些事件没有waitFor函数,你需要使用这种方法来等待它们。让我们看看我们有哪些页面事件可用。
关闭事件
当页面关闭时,会触发close事件。如今,弹出窗口并不常见,主要是因为它们对移动设备不友好。但我们仍然可以找到一些情况。例如,当你想要将账户添加到现有的Gmail账户时。

Gmail 中的弹出窗口
你需要监听该页面的close事件,以了解向导过程是否完成。
但这又引出了另一个问题。你如何到达那个页面?如果我们正在测试Gmail页面并点击创建账户链接,我们如何获取弹出窗口?
弹出事件
当页面打开新标签页或窗口时,将触发popup事件。我们可以这样做:
const [newPage] = Promise.all([
new Promise(resolve => page.once('popup', resolve)),
page.click('someselector')
]);
我们在这里可以学到的一个新东西是promise.all返回所有响应的数组。因为我们只关心第一个承诺的响应,所以我们创建了一个只有一个元素的数组[newPage]。
如果你想要监听新页面,无论是什么触发了新页面,你也可以监听浏览器事件。
目标创建事件
当在浏览器内部创建一个新的目标(页面)时,会触发targetcreated事件。我们可以做如下操作:
const [newPage] = Promise.all([
new Promise(resolve => browser.once('targetcreated', resolve )),
page.click('someselector')
]);
在大多数情况下,这将以与popup事件相同的方式工作。但了解你也有这个工具可用是好的。
让我们回到页面事件。
控制台事件
console事件将在浏览器控制台打印新行时被触发。与response事件以包含所有信息的response对象一样,console事件将给我们一个带有以下函数的消息类:
-
使用
text()函数传递文本消息。 -
type(),它将帮助我们识别消息的类型。最常见类型有:'log', 'debug', 'info', 'error,' 和 'warning'。 -
location(),给出消息的来源。 -
由于
console.log可以期望对象作为参数,我们可以使用args()函数访问这些元素句柄。
你可以使用此事件来检查测试过程中没有 JavaScript 错误。
对话框事件
dialog事件很重要,因为对话框会停止页面的执行。有许多类型的对话框,每种都需要我们以不同的方式做出反应。我们可以使用type()函数知道对话框类型。让我们看看不同的对话框类型以及我们如何对它们做出反应。
警告类型
Alert是一个只有dialog.accept()的对话框:

警告
确认类型
Confirm是一个带有dialog.accept()或dialog.dismiss()来取消的对话框:

确认
提示类型
prompt对话框在当今并不常见。它类似于confirm对话框,但会提示输入,你可以通过传递一个字符串给accept函数来通过:

提示
beforeunload类型
你会看到beforeunload,询问你是否想要在不保存更改的情况下离开网站。它的工作方式与confirm对话框相同。你可以以与prompt对话框相同的方式与这个对话框交互:

卸载前对话框
让我们以一个新的工具来结束这一章:无头记录器。
无头记录器
无头记录器是由Checkly(www.checklyhq.com/)开发的 Chrome 扩展。此扩展将记录你在页面上的操作,并根据这些操作生成 Puppeteer 代码。我认为这是一个获取 Puppeteer 测试初稿的绝佳工具,并从这里开始编写最终代码。
你可以通过访问 Chrome Web Store(chrome.google.com/webstore)并搜索Headless Recorder来下载此扩展:

无头记录器
一旦安装,你将在浏览器右上角找到一个记录器图标。从那里,你将有一个记录按钮,它将开始捕获你在页面上执行的所有操作:

记录选项
一旦你完成测试操作的执行,你点击停止,你将得到几乎可以使用的代码:

无头记录器结果
我说几乎准备好了,因为录音机无法猜测你的真实意图。它只是一个指南。正如你所见,有选择器,例如 .mb-3:nth-child(1) > .thumbnail > .card-body > .row > .col-6 > .btn. 录音机不知道你点击某个链接背后的意图是什么。但这是一个好的开始,并且当你的测试需要多个步骤时,它可以帮助你。
摘要
在本章中,我们学习了易失性测试的概念,并看到了许多技术和工具来防止在我们的测试套件中存在易失性测试。
当我们在学习这些等待工具时,我们甚至没有注意到就看到了许多页面事件。现在你不仅可以等待选择器和网络调用,还可以处理对话框和弹出窗口。
最后的部分很短,但正如承诺的那样,我们现在在工具箱中又多了一个工具,一个无头记录器。
在下一章中,我们将深入了解更高级的工具,并学习如何在浏览器中执行 JavaScript。
第六章:第六章:执行和注入 JavaScript
在过去的几章中,我们学习了大多数基本 Puppeteer 功能,从正确创建浏览器和页面,到查找元素并与它们交互。
现在是时候深入了解更强大的工具了。在本章中,我们将了解 Puppeteer 如何让我们能够在浏览器中执行 JavaScript 代码。
这听起来可能像是一种黑客手段或最后的手段工具。有时确实是。但这也是一个可以帮助我们执行 Puppeteer API 未提供的操作的工具。
在 Node 端执行和浏览器中执行的代码之间的通信有时可能很棘手。我们将学习如何高效地与双方进行通信。
正如我们在上一章中所做的那样,我们将向我们的工具箱中添加另一个工具。我们将在Checkly上运行我们的代码。
本章我们将涵盖以下主题:
-
执行 JavaScript 代码
-
使用 JavaScript 代码操作句柄
-
等待函数
-
展示本地函数
-
使用 Checkly 运行我们的检查
到本章结束时,你将能够通过执行 JavaScript 代码从你正在自动化的页面中获得更多。
技术要求
你可以在 GitHub 仓库(github.com/PacktPublishing/UI-Testing-with-Puppeteer)的Chapter6目录下找到本章的所有代码。请记住,在那个目录上运行npm install,然后进入Chapter6/vuejs-firebase-shopping-cart目录并再次运行npm install。
如果你想在遵循本章内容的同时实现代码,你可以从Chapter5目录中留下的代码开始。
让我们开始吧。
执行 JavaScript 代码
你可能会问的第一个问题是:“我为什么要运行 JavaScript 代码?Puppeteer 不应该给我所有我需要的 API 吗?”嗯,是的,也不完全是。
在探讨不同的可能用例之前,让我们看看这个功能是如何工作的。
JavaScript 中的变量作用域
使 JavaScript 如此灵活的一点是函数是一等公民。你可以声明函数,将它们分配给变量,并将它们作为参数传递。你甚至可以从其他函数中返回函数,就像这个例子一样:
function getFunc() {
let word = 'world';
return function() {
console.log('Hello ' + word);
}
}
getFunc()();
那段代码很有趣。getFunc返回另一个函数。当我们执行getFunc()()时,我们正在调用getFunc返回的函数。
这段代码将在控制台打印出'Hello world'。有趣的部分在于,getFunc返回的函数能够在其作用域内保持变量word。
你甚至可以做一些更复杂的事情,例如,将一个参数传递给getFunc,然后在getFunc将返回的函数内部使用该参数:
function getFunc(name) {
return function() {
console.log('Hello ' + name);
}
}
getFunc('world')();
getFunc('mars')();
这段代码将打印出'Hello world'和'Hello mars'。这被称为getFunc,在第一种情况下,返回的函数将与字符串'world'捆绑在一起,在第二种情况下,与'mars'捆绑在一起。
我们不会深入探讨这个功能的内部。但你需要知道,这并不是 Puppeteer 中函数的工作方式。
让我们尝试在 Puppeteer 中使用闭包:
const browser = await puppeteer.launch({headless: false, defaultViewport: null});
const page = await browser.newPage();
const name = 'world';
await page.evaluate(() => alert('Hello ' + name));
browser.close();
在这种情况下,我们有一个传递给evaluate函数的函数,该函数正在使用变量name,该变量在我们刚刚创建的函数的作用域内。但这就是我们得到的结果:

Puppeteer 中的变量作用域
如你所见,名称没有传递到 alert 中。对我们这些开发者来说,一个重大问题是代码看起来很好。如果你看代码,代码是完美的。它与我们的先前的例子没有太大不同。但那里有些不同的地方。一旦你理解了这一点,你将能够回答 Stack Overflow 上的许多问题。
首先,page.evaluate的签名是page.evaluate(pageFunction[, ...args]),其中pageFunction可以是字符串或函数。第二个参数是传递给pageFunction的可选值列表。
你可以传递evaluate函数。表达式是像你可以在 DevTools 控制台中编写的语句一样的东西。例如,一个简单的字符串,返回document对象的URL属性:
console.log(await page.evaluate('document.URL'));
page.evaluate会将表达式document.URL发送到浏览器,浏览器将评估它。一旦浏览器评估了表达式,它将把它发送回 Puppeteer,page.evaluate将返回结果。在这种情况下,about:blank。
当你想评估简单的表达式时,表达式是完美的。但你可以使用 JavaScript 函数达到相同的结果:
console.log(await page.evaluate(() => document.URL));
如你所见,传递表达式更为直接,但你可以使用函数编写更复杂的代码,而且,同样重要的是,你将获得代码编辑器的自动完成功能。
这里的关键概念是使用page.evaluate toString()将函数放入变量中。让我们试试看:
console.log((() => alert('Hello ' + name)).toString());
这将在控制台中打印出函数的字符串值:
() => alert('Hello ' + name)
如果 Puppeteer 接收这个函数,使用toString将其转换为字符串,并发送到浏览器,那么name变量的值在这个过程中就会丢失。
当你将函数发送到浏览器上下文中进行评估时,你需要确保函数使用的所有值已经在浏览器中或作为参数传递。这就是我们可以修复我们代码的方法:
const browser = await puppeteer.launch({headless: false, defaultViewport: null});
const page = await browser.newPage();
const name = 'world';
await page.evaluate((n) => alert('Hello ' + n), name);
browser.close();
如我们所见,我们将name变量作为evaluate函数的args参数的一部分传递。现在 Puppeteer 知道它必须序列化该函数并也发送args。现在浏览器将能够执行该函数,并传递这些参数。
小贴士
注意我将变量名重命名为n。这不是必需的,但这个做法将帮助你避免这类作用域错误。现在你,以及你的 IDE,都知道在alert函数中使用的n变量是该函数传递的参数。
这个evaluate函数不仅可在page和frame类中使用,也可在JSHandle和ElementHandle类中使用。让我们探索一下,一旦我们在 Puppeteer 中获得了ElementHandle或JSHandle,我们如何执行 JavaScript 代码。
使用 JavaScript 代码操作控件
我们在第四章中讨论了ElementHandle,与页面交互。让我们回顾一下这个概念。ElementHandle是我们代码中的一个变量,指向我们正在自动化的页面内的DOM元素。现在,我们需要知道ElementHandle实际上是一个JSHandle。
与ElementHandle是一个指向浏览器中元素的变量,document.URL和 DOM 元素,如document.activeElement是一个 DOM 元素有视觉表示,那只是全部。因此,我们可以说ElementHandle(一个 DOM 元素)也是一个JSHandle(一个 JavaScript 变量)。继承 101。
我们之前使用像$或$x这样的函数来获取ElementHandles。现在我们也可以使用evaluateHandle,它的工作方式与evaluate相同,但 Puppeteer 知道我们想要一个指向浏览器中变量的指针,一个句柄,因此它将返回一个对象,该对象将代表浏览器中的那个变量。
让我们回到我们的登录测试。我们获取密码输入的方式很简单:
const passwordInput = await this.page.$('#password');
但是,让我们想象一下,开发者想要创建一个超级安全的动态创建元素的登录。但是,他们告诉我们他们正在将密码输入存储在window.passwordInput变量中。我们可以使用evaluateHandle获取该输入:
const passwordInput = (await page.evaluateHandle(() => window.passwordInput)).asElement();
在那里,evaluateHandle将返回一个JSHandle,我们可以使用asElement函数将其转换为ElementHandle。如果你必须找到无法使用 CSS 选择器或 XPath 选择器找到的元素,你现在有了第三个工具:你可以使用一个 JavaScript 函数。
evaluateHandle函数不仅限于 DOM 元素或简单变量。你还可以返回,甚至创建对象以供以后访问。你将在Chapter6/demos.js文件中找到此代码:
const counter = await page.evaluateHandle(() => {
window.counter = { count: 2};
return window.counter;
});
await counter.evaluate((c, inc) => c.count += inc, 3);
await page.evaluate(() => alert(window.counter.count));
如果你运行这个,你会看到这个结果:

评估结果
在第一个evaluateHandle中,我们创建一个具有count属性的对象,将其分配给window对象中的counter属性,然后返回该对象。
通过使用window对象,我们清楚地表明我们正在使用一个全局变量。如果我们在这个函数内部声明一个变量,我们将在执行函数后丢失它。尽管这不是一个好的做法,但我们可以通过将window.counter改为counter来将counter声明为全局变量:
const counter = await page.evaluateHandle(() => {
counter = { count: 2};
return counter;
});
在第二步中,我们学习如何使用evaluate函数,但在这个JSHandle的上下文中。该函数的工作方式与page类中的evaluate函数相同。但在这里,它将JSHandle作为第一个参数传递:
await counter.evaluate((c, inc) => c.count += inc, 3);
如你所见,该函数期望两个参数:c和inc。但我们只传递了3,这是第二个参数inc,因为第一个参数c是我们的JSHandle。
我们也可以有一个没有额外参数的函数。例如,我们可以在函数中硬编码3:
await counter.evaluate(c => c.count += 3);
你也可以将JSHandle对象作为参数传递给page类的evaluate函数。所以,这将等同于前面的例子:
await page.evaluate(c => c.count += 3, counter);
这为我们打开了使用 Puppeteer 可以做很多事情的大门。让我们看看一些例子。
从元素获取信息
检查页面对动作的反应是非常关键的。例如,如果你向购物车添加一个项目,你希望检查项目数量是否增加。
如果我们看看我们的HomePageModel类,这就是我们解决getStock函数的方法,它帮助我们检查股票价格:
async getStock(productName) {
const productDiv = (await this.page.$x(`//a[text()="${productName}"]/../..`))[0];
const stockElement = (await productDiv.$x('./h6'))[0];
return await stockElement.evaluate(e => e.innerText);
}
我们使用这段代码来学习 XPath 表达式。在前两行中,我们正在获取产品div,然后从那里获取stock元素。之后,我们使用evaluate函数来获取该元素的文本。
我认为这些功能应该成为 Puppeteer API 的一部分。但与此同时,你可以开始构建你的实用函数。我们可以从一个返回innerText值的通用函数开始:
async getInnerText(el) {
return await el.evaluate(el => el.innerText);
}
这个函数将期望一个元素作为参数,并返回innerText属性。innerText属性返回DOM元素的文本内容,包括所有后代元素。但你也可以为其他常见属性创建新的实用函数:
-
innerHtml返回元素内的 HTML 内容。 -
outerHTML返回包括元素本身的 HTML 内容。 -
如果你想要获取输入元素的
value,你需要使用value和innerText。
你可以在 MDN 网站上查看元素属性的完整列表(www.hardkoded.com/ui-testing-with-puppeteer/element)。你还会发现一些元素具有特定的属性。例如,当类型是复选框时,输入框有disabled或checked属性。
如果你不想与ElementHandles打交道,你也可以使用page.$eval。这个函数的签名是page.$eval(selector, pageFunction[, ...args])。页面将使用选择器获取ElementHandle,然后执行evaluate,传递pageFunction和args。
在这个前提下,我们可以替换在LoginPageModel.js文件中的logState函数内的这一行:
return await this.page.evaluate(() => document.querySelector('#navbarTop .nav-link').innerText);
用更简单的代码行:
return await this.page.$eval('#navbarTop .nav-link', el => el.innerText);
这行代码更容易阅读,因为你在一边有选择器,另一边有要执行的函数。注意,pageFunction必须始终将元素作为第一个参数。
但使用evaluate函数不仅是为了获取信息。我们还可以改变页面行为,对元素进行操作。
对元素进行操作
您可以使用evaluate函数从元素中获取值并对这些元素进行操作,以便您可以强制特定的场景。
这可能听起来像黑客式的方法,但有时到达特定场景所需的步骤不值得付出努力。这就像那些烹饪电视节目,他们在烘焙蛋糕,突然他们拿出一个已经烤好的蛋糕,然后向你展示如何添加奶油。
这些类型的快捷方式不仅节省了您的时间,还减少了在需要等待许多事情发生才能采取行动的长时间过程中可能出现的潜在问题。
我们将学习的第一项操作是强制执行click操作。等等,Puppeteer 中不是有click函数吗?我们确实有click函数。click函数的好处是它模拟了用户点击。但为了实现真正的模拟,被点击的元素需要是可见的并且可操作的(可点击的)。有时我们不想冒元素隐藏导致测试失败的风险。我们可以采取捷径,使用click函数强制点击:
await el.evaluate(el => el.click());
在这里,我们不是调用el.click(),而是在浏览器内部调用click函数。
与属性一样,这种方法不仅适用于click函数。您可以使用它强制blur事件或使用setSelectionRange函数在输入框中选择文本。
您不仅可以通过函数对元素进行操作。您还可以设置属性。例如,您可以通过编程方式禁用登录页面上的电子邮件输入。让我们看看这是如何实现的:
const emailInput = await page.$('#email');
await emailInput.evaluate(e => e.disabled = true);
在这里,我们获取元素,然后设置禁用属性。使用这个方法,您也可以设置元素的innerText。例如,您可以将一个非常长的产品描述更改为查看页面如何对长产品标题做出反应。
有时,我们想要测试的是“黑客式”的方法。我们的网站是否准备好应对聪明的用户?
执行服务器规则
随着丰富网络应用程序的兴起,出现了一种新的不良做法:仅在客户端编写业务规则。
我们首先需要在服务器上编写重要的业务规则,然后是客户端。假设在我们的购物车应用程序中,我们需要验证用户是否有权进行购买。我们编写了那个业务规则,但我们所做的只是添加了一个"is-disabled" CSS 类到结算按钮上。如果我是聪明的用户,我就可以打开开发者工具,移除那个类,然后点击按钮。如果我们服务器上没有相同的规则,用户可能会轻易绕过我们的业务规则。
我们可以编写自己的“应在服务器上验证”测试。让我们获取复选框,移除 CSS 类,然后尝试点击它:
const checkoutBtn = (await page.$x('//button[contains(text(),"Checkout")]'))[0];
checkoutBtn.evaluate(el => el.classList.remove('is-disabled'));
await checkoutBtn.click();
在这里,我们获取结算按钮,然后以编程方式移除is-disabled CSS 类。这将启用按钮,然后我们可以点击它。之后,我们应该进行一些验证,以检查业务规则是否在服务器端得到执行。
当我们无法使用 CSS 选择器或 XPath 表达式找到元素时,evaluate 函数也可以帮助我们。
使用 JavaScript 查找元素
我们可以使用 CSS 选择器在 90%以上的情况下找到 DOM 元素,如果使用属性选择器,则更多。XPath 表达式帮助我们覆盖另外 9%的情况。但是,还有 1%的情况我们需要更详细的方法。例如,有些属性没有以 CSS 选择器可以工作的方式暴露出来。让我们以输入文本的情况为例。

亚马逊中的输入框
如果输入框被渲染为单词 puppeteer(这里的渲染指的是它在 HTML 内容中有值),属性选择器 [value=puppeteer] 将会起作用。但如果值变为,例如,node,则 [value=node] 选择器将不会工作,而第一个选择器 [value=puppeteer] 仍然会返回一个 DOM 元素。
一些属性没有作为 HTML 属性暴露,因此我们无法在 CSS 选择器或 XPath 表达式中使用它们。例如,IMG 元素有一个名为 naturalWidth 的属性。这个属性将返回图像的原始大小。使用这个属性,我们可以编写一个测试来检查我们主页上的所有图像是否正在加载。如果一个图像的 naturalWidth 为 0,这意味着该图像没有加载。你可以在 Chapter6/test/homepage.tests.js 文件中的测试 'Should load all images' 中找到此代码:
const images = await page.evaluateHandle(() =>
Array.from(document.querySelectorAll('IMG')).filter(e => !e.naturalWidth));
(await images.evaluate(e => e.length)).should.equal(0);
在这个测试中,我们使用 document.querySelectorAll('IMG') 获取所有的 IMG 元素。然后我们需要将其包裹在 Array.from 中,这样我们就可以过滤这些元素。然后我们调用 filter 函数,要求具有 naturalWidth 值的元素:!e.naturalWidth。
这里有一些重要的事情需要你注意。我们使用 evaluateHandle 执行的函数返回一个元素列表。但 evaluateHandle 将返回一个元素句柄。它将返回指向浏览器中该数组的指针。因此,如果我们需要获取该数组的 length,我们需要调用 evaluate 并请求 length 属性。
这种情况下,你需要找到一个平衡点。有时拥有一个大的 evaluate 函数但一次性完成所有操作会更简单。在这种情况下,这可以通过一个异步调用来解决:
(await page.evaluateHandle(() =>
Array.from(document.querySelectorAll('IMG')).filter(
e => !e.naturalWidth).length)).should.equal(0);
现在我们正在一次性完成所有操作。我们查询图像,过滤它们,并检查长度。
我们已经学习了如何执行 JavaScript 代码以及如何操作元素,但还有更多。我们还可以将 JavaScript 代码用作等待函数。
等待函数
我们在第五章,“等待元素和网络调用”中学习了关于许多等待函数的内容。我们学习了等待网络事件、等待DOM元素可见或隐藏。我们还涵盖了我们可以等待的许多页面事件。但就像 CSS 选择器不能涵盖所有情况,XPath 表达式也不能涵盖所有其他场景一样,等待函数也是如此。
有一些场景我们需要更多。现在我们有waitForFunction。
这是waitForFunction函数的签名:page.waitForFunction(pageFunction, options, ...args)。
第一个参数是pageFunction。它的工作方式与evaluate函数相同。它可以是 JavaScript 函数;它也可以是字符串;它可以期望参数,等等。
第三个参数args是可以发送给函数的参数。这是一个可选的值列表。
我没有忘记第二个参数。第二个参数是options参数。options对象有两个设置:
-
第一个属性是,正如你所猜到的,是
timeout。它与我们看到的第五章中不同等待函数的默认值相同:默认为 30 秒,然后你可以使用page.setDefaultTimeout(timeout)更改或覆盖该值。 -
第二个属性是很有趣的一个:
polling选项。这个选项决定了 Puppeteer 将执行我们的函数的频率。我们有三种可能的选择:a) 默认选项是
raf。requestAnimationFrame。根据 Mozilla,requestAnimationFrame方法告诉浏览器你希望执行动画,并请求浏览器在下次重绘之前调用指定的函数来更新动画。该方法接受一个回调作为参数,在重绘之前调用 (www.hardkoded.com/ui-testing-with-puppeteer/raf)。这是你可以使用的最频繁的轮询。b) 可用的第二个选项是
mutation。这个选项将使用MutationObserver。根据 Mozilla,MutationObserver接口提供了监视 DOM 树变化的能力 (www.hardkoded.com/ui-testing-with-puppeteer/MutationObserver)。c) 最后一个选项是一个
number。这个数字将是一个函数执行的毫秒间隔。
当我们谈论使用 JavaScript 查找元素时,我们提到有许多场景 CSS 选择器或 XPath 表达式不足以满足需求。但我认为,有时evaluateHandle或waitForFunction调用比复杂的 XPath 表达式更容易阅读。
让我们以 Packt 购物车为例:

Packt 购物车
当我们向购物车添加新书时,购物车数量不会立即更新。如果我们查看网络标签,会看到一个网络调用到“添加”端点,然后购物车才会更新。
我们可以通过多种方式等待项目数量更新。我们可以等待项目列表通过 CSS 选择器更新。我们也可以等待带有 URL“添加”的网络响应。但我们也可以简单地等待数字变化。
这里还有一个挑战。当我们向购物车添加一个项目时,我们需要将其关闭。但是弹出窗口正在移动。好消息是waitForFunction可以等待动画完成。
重要提示
我以为这会是一个简单的例子,结果却相当复杂。但我认为这很好。你将在现实生活中遇到这类问题,你需要解决它们。
我将逐个解释测试部分。你将能够在packpub.tests.js文件中看到整个测试。
我们需要解决的第一件事,而且不幸的是,这是我们今天经常看到的事情,是 Cookies 通知。让我们看看我们如何使用 Puppeteer 等待通知横幅:
await page.goto('https://www.packtpub.com/tech/javascript/');
const cookieLink = await page.waitForSelector('.accept_all', { timeout : 1000}).catch(e => e);
if (cookieLink) {
await cookieLink.click();
}
我们首先要做的是访问我们想要测试的页面。然后我们可能会,也可能不会,看到 Cookies 横幅。问题是 Cookies 横幅可能需要一点时间才能显示。因此,我们等待'.accept_all'选择器,即.catch(e => e)。如果我们最终得到了那个 Cookies 按钮,我们就点击它。
一旦关闭了 Cookies 横幅,我们需要等待页面准备好进行操作。我们不在乎页面使用哪个客户端库,但似乎需要一点时间才能准备好操作。我发现的一件事是,当购物车准备好时,它将设置购物车按钮的类为empty。另一件事是我们知道的是add-to-cart类。我们可以添加一个Promise.all并等待这两个条件:
await Promise.all([
page.waitForSelector('.counter.qty.empty'),
page.waitForSelector('.add-to-cart')
]);
这个步骤很简单。我们需要等待选择器.counter.qty.empty,这是空购物车按钮,以及.add-to-cart,这是添加到购物车按钮。
接下来,我们需要设置我们的等待承诺:
const cartIsOnePromise = page.waitForFunction(() => document.querySelector('.counter.qty .counter-number').innerText.trim() === '1');
const cartIsTwoPromise = page.waitForFunction(() => document.querySelector('.counter.qty .counter-number').innerText.trim() === '2');
这看起来很复杂,但实际上并不复杂。我们设置了两个承诺。第一个承诺将在购物车计数器的文本(使用innerText属性)等于 1 时解决。我们希望在我们点击第一个产品后,在某个时间点解决。在那里,我添加了一个trim函数,这样我们就移除了任何额外的空格。
第二个承诺与第一个相同。但它将在购物车数量等于 2 时解决。
现在我们有了我们的等待承诺,是时候点击第一个产品了:
const addToCartButtons = await page.$$('.add-to-cart');
await addToCartButtons[0].click();
在这里,我们正在获取所有的添加到购物车按钮并点击第一个。
现在我们来到了有趣的部分。我们需要等待结账弹出窗口出现并完成其华丽的动画:
await page.waitForFunction(async () => {
const element = document.querySelector('.block-minicart');
let currentHeight = element.getBoundingClientRect().height;
let stopMovingCounter = 0;
await new Promise((resolve) => {
const stoppedMoving = function() {
if (element.getAttribute('style') !== 'display: block;') {
setTimeout(stoppedMoving, 20);
}
if(element.getBoundingClientRect().height > 0 && currentHeight === element.getBoundingClientRect().height) {
stopMovingCounter++;
} else {
stopMovingCounter = 0;
currentHeight = element.getBoundingClientRect().height;
}
if(stopMovingCounter === 10) {
resolve();
}
setTimeout(stoppedMoving, 20);
};
stoppedMoving();
});
return true;
});
真的很吓人,对吧?让我们分析这个函数,因为它是一个实用的方法。
我们的 wait for 函数需要在结账弹出窗口可见且停止移动时解决。我们如何知道它已经停止移动了呢?嗯,我们可以每 20 毫秒检查一次元素的高度,如果在 10 次检查后高度保持不变,我们可以假设它已经停止移动。
我们首先做的事情是获取元素,使用 getBoundingClientRect 获取初始高度,并将计数器设置为 0。
一旦我们有了这个,我们将 await 一个承诺,但这个承诺将在浏览器内部解决。在这个承诺内部,我们将创建一个名为 stoppedMoving 的函数,并调用它。
在那个函数内部,我们首先会检查元素是否可见。如果不是,我们将在 20 毫秒后再次调用该函数。
然后我们检查当前高度。如果高度已改变,我们重置计数器,并重新开始。如果过去 10 次没有改变,我们将通过调用 resolve() 解决承诺。
我们在那里做的最后一件事是在 20 毫秒后调用相同的函数。最终,该函数将被解决,或者由于 waitForFunction 超时而失败。
这里的数字是相对的。你不需要等待 20 毫秒或等待 10 次。你可以选择适合你示例的数字。
一旦我们知道弹出窗口已打开且没有移动,我们可以使用以下代码关闭它:
await page.click('#btn-minicart-close');
就像调用 click 一样简单,我们传递了关闭按钮的选择器。
现在,我们可以等待购物车数量更新为 1:
await cartIsOnePromise;
在这里,我们正在等待我们之前构建的承诺。当我们到达这里时,承诺可能已经解决;我们不在乎。如果承诺已经解决,await 将立即解决。如果没有,我们将等待。
最后,我们点击第二个产品并等待第二个承诺:
await addToCartButtons[1].click();
await cartIsTwoPromise;
在这里,我们抓取了列表中的第二个产品,点击了它,并等待购物车数量变为 2。同样,我们不在乎那个承诺是否已经解决。
如果所有承诺都已解决,就没有什么可以断言的了。我们知道一切按预期工作。
我打赌你在这一节之后需要休息一下。一旦你准备好了,我们将看到我们可以用函数做的一件事。我们将让浏览器在 Node 端调用函数。
暴露本地函数
使用 Puppeteer,你不仅可以执行浏览器内的代码,还可以从浏览器调用回你的 Node 应用。exposeFunction 函数允许我们在浏览器中注册 Node 函数。
这是 exposeFunction 的签名:page.exposeFunction(name, puppeteerFunction):
-
第一个参数是
name。这将是浏览器内的函数名。 -
puppeteerFunction是一个函数,其风格和功能与我们在这章中学到的所有函数相同。
当从 MutationObserver 调用时,这个特性非常完美。
例如,我们不必反复执行函数,等待结账柜台的变化,我们可以创建一个MutationObserver来通知我们当 HTML 节点中的值发生变化时。让我们看看代码会是什么样子:
let observer = new MutationObserver(list => console.log(list[0].target.nodeValue));
observer.observe(
document.querySelector('.counter.qty .counter-number'),
{
characterData: true,
attributes: false,
childList: false,
subtree: true
});
在这段代码中,我们声明了一个观察者,它期望一个callback函数。那个callback函数的第一个参数将是一个突变列表。那个突变有一个target对象,我们可以从那里获取nodeValue。如果你想了解突变记录的完整属性列表,可以访问 Mozilla 文档(www.hardkoded.com/ui-testing-with-puppeteer/MutationRecord)。
那个观察者不会做太多。我们需要告诉它观察特定元素的变化,在我们的例子中,是一个具有选择器.counter.qty .counter-number的元素。因此,我们调用observe,传递counter元素,作为第二个参数,我们将告诉observe函数我们想要监听哪些变化。在这种情况下,我们只关心characterData变化,并且我们还想监听subtree(子元素)的变化。这意味着文本变化。
因此,现在,我们可以复制我们之前的测试,并将cartIsOnePromise和cartIsTwoPromise替换为类似以下内容:
const reachedToTwo = new Promise((resolve) => {
page.exposeFunction('notifyCartChange', i => {
if(i ==='2')
resolve();
})
});
await page.evaluate(() => {
let observer = new MutationObserver(list => notifiyCartChange(list[0].target.nodeValue));
observer.observe(
document.querySelector('.counter.qty .counter-number'),
{
characterData: true,
attributes: false,
childList: false,
subtree: true
});
});
我们创建一个承诺,reachedToTwo。在承诺构造函数中,我们将公开一个名为'notifyCartChange'的函数。我们将公开的函数期望一个参数,如果参数等于'2',我们将解决承诺。
那个exposeFunction函数将允许我们使用evaluate调用在声明的MutationObserver内部调用notifiyCartChange。
对于最后一步,我们将旧的await替换为新的承诺:
await reachedToTwo;
如果一切如预期进行,notifyCartChange将被调用两次,一次是值'1',然后是值'2',第二次调用将解决我们在测试末尾添加的reachedToTwo承诺。
这可能听起来像是愚蠢的、过于复杂的代码,但想象一下你可以用exposeFunction和MutationObserver做什么。你可以通过监听传入的变化来测试聊天应用,以及许多其他复杂的场景。
在结束这一章之前,是时候在我们的工具箱中添加另一个工具了。
使用 Checkly 运行我们的检查
这是一个我想展示给你的额外工具,你不应该错过尝试它的机会。Checkly (www.checklyhq.com/)是一个可以帮助你监控网站的平台。以下截图显示了Checkly网站:

Checkly 网站
一旦你在Checkly中创建了一个账户,你将能够上传你的测试(或检查),Checkly将每隔一定时间运行这些检查,并返回报告。首先,它会报告检查是否通过,其次,它会报告运行所需的时间。
你还将能够测试你的网站 API,而无需运行浏览器。这是巨大的。这就像拥有你自己的、个人的质量守卫。
让我们去 www.checklyhq.com/ 开始我们的试用。按照以下步骤开始使用 Checkly:
-
一旦你输入你的电子邮件、电话号码和账户名称,你将获得一个包含一些示例的第一个仪表板,如下所示:
![Checkly 仪表板]()
Checkly 仪表板
-
你现在可以删除这两个示例,创建你自己的购物车测试。我们可以创建一个浏览器测试:
![新的测试对话框]()
新的测试对话框
-
现在,给你的检查命名为 "购物车号检查"。你可以从
Chapter6/checkly.js文件中复制代码:

第一次检查
注意,我们在这里留下了浏览器和页面创建。一旦我们复制了代码,我们就可以点击 运行脚本 按钮来检查代码是否正确。最后,我们需要选择我们的数据中心位置,点击 保存检查,我们就会有一个平台自动检查我们网站的运行状况。
如果你的团队能够承担得起,Checkly 将带你进入下一个层次。现在,是时候总结一下了。
摘要
我们在本章中介绍了 Puppeteer 最强大的功能之一。大多数网络自动化工具都允许你以某种方式运行 JavaScript 代码,但 Puppeteer 使其变得超级容易实现。
我们在本章开始时讨论了一些基本的 JavaScript 概念。我们学习了变量作用域和闭包。这有助于我们理解变量和闭包在 Puppeteer 中的工作方式(或工作不正常的方式)。如果你了解了这些差异,你将能够回答 Stack Overflow 上 20% 的 Puppeteer 问题。
然后,我们学习了 JSHandles 和 ElementHandles。你不会看到社区中大量使用这些类,但如果你知道如何使用它们,它们将非常有帮助,现在你知道了。
waitForFunction 完成了我们的 "等待" 工具箱。你将大量使用那个等待函数。我们还学习了如何使用 MutationObserver 暴露函数和监听 HTML 变化。在 UI 测试中,暴露函数和监听 HTML 变化并不常用,但它是网络爬取的一个优秀工具,这是我们将在 第九章 "爬取工具" 中讨论的一个大主题。
通过本章,我们已经完成了 Puppeteer 的基础知识。你现在拥有了开始进行端到端测试所需的大部分工具。
我希望你对 Checkly 的兴奋程度和我第一次看到这个平台时的感觉一样。Checkly 是一个仪表板,它不仅可以帮助 QA 团队,还可以帮助开发团队。它将帮助你的团队发现问题和甚至发现改进网站性能的新机会。
在下一章中,我们将看到一些自动化工具中你不会期望的功能。我们将看到如何使用 Puppeteer 生成内容。
第七章:第七章: 使用 Puppeteer 生成内容
当我在 2019 年推出 Puppeteer-Sharp (https://github.com/hardkoded/puppeteer-sharp) 时,我惊讶地发现,两个主要的使用场景是内容生成和网页抓取。
在 Node.js 世界中,情况并没有太大不同。许多开发者也在 Node.js 中使用 Puppeteer 进行内容生成和网页抓取。
如果你是一名质量保证分析师,你将学习如何使用截图来创建回归测试。但请记住,不要止步于此;其他部分将为你展示使用 Puppeteer 在这个主题上可以完成的所有事情。如果你不太关注网页开发,请与你的开发团队分享这一章。不,不要分享——请他们购买这本书。那会更好。
网页开发者会喜欢这一章。我们将看到如何将 Puppeteer 作为内容生成工具用于你的网站。
在本章中,我们将涵盖以下主题:
-
拍摄截图
-
使用截图进行回归测试
-
生成 PDF 文件
-
创建 HTML 内容
到本章结束时,你将达到一个新的水平。你将学会如何将 Puppeteer 作为测试工具和内容生成器使用。
让我们开始吧。
技术要求
你可以在 GitHub 仓库 (github.com/PacktPublishing/UI-Testing-with-Puppeteer) 的 Chapter7 目录下找到本章的所有代码。请记住在那个目录下运行 npm install,然后进入 Chapter7/vuejs-firebase-shopping-cart 目录并再次运行 npm install。
拍摄截图
当我谈论 Puppeteer 或 Puppeteer-Sharp 时,我会首先提到截图功能。不要问我为什么,可能是因为我觉得使用它很有趣,或者可能是因为很难解释为什么我们需要截图。
作为一名网页开发者,你可以使用截图完成许多事情。你首先会发现的一个流行用例是改进你的 Open Graph 信息。
根据他们的网站 (https://ogp.me/),“Open Graph 协议使任何网页都能成为社交图中的丰富对象。例如,这被用于 Facebook,允许任何网页具有与 Facebook 上任何其他对象相同的功能。”
Open Graph 是使社交媒体帖子(在 Twitter 或 Facebook 上)在人们分享你的网站 URL 时看起来很漂亮的东西。我们不会在这本书中讨论社交媒体上的产品定位。但你需要知道的是,如果你正在开发一个公共网站,用户想要在社交媒体上分享你的内容,有人会要求你改进 Open Graph 信息:

没有 Open Graph 信息的帖子
你不希望当你在社交媒体上分享你的产品时,你的网站看起来像前面的截图。你希望你的链接看起来像 Amazon,有一个好的描述和一张大图,就像以下截图所示:

社交媒体上的 Amazon 帖子
在你的帖子中添加图片就像在你的产品页面的 HTML 头部添加一个名为og:image的元属性一样简单:
<head>
<title>The Rock (1996)</title>
<meta property="og:title" content="The Rock" />
<meta property="og:type" content="video.movie" />
<meta property="og:url" content="https://www.imdb.com/title/tt0117500/" />
<meta property="og:image" content="https://ia.media-imdb.com/images/rock.jpg" />
</head>
这几行代码会让你的帖子在社交媒体上看起来更美观。
这与截取屏幕截图有什么关系呢?嗯,有时候获取帖子的图片很简单。在购物车中,图片就是产品图片——小菜一碟。但有时候,获取 URL 的图片并不那么容易。让我们以这个来自伟大的 HolyJS 会议的帖子为例:

HolyJS 帖子
如果你访问那个帖子(www.hardkoded.com/ui-testing-with-puppeteer/holyjs-post),你不会找到那个推文中使用的图片。你会看到罗马的照片,但不会找到带有照片、会议标志或演讲标题的图片。他们可能已经手动创建了那张图片。你不需要有很棒的 Photoshop 技能来做这件事。但如果你要为 HolyJS 会议的所有演讲创建数百条推文,我敢打赌,用几分钟的时间编写一个 Puppeteer 脚本会更有生产力。
我们可以有一个内部页面,通过传递一个演讲 ID 来导航。一旦加载,我们截取一个屏幕截图,并将该图像保存在某种存储中。
但在深入 Puppeteer 的代码之前,让我先展示一个新工具。你知道你可以使用 Chromium 来截取全页屏幕截图吗?
如果你打开Chrome的开发者工具,然后在 macOS 上按Cmd + Shift + P或在 Windows 上按Ctrl + Shift + P,会弹出一个命令菜单列表,就像在 VS Code 中一样:

使用 Chromium 截取屏幕截图
你会在那里找到大量的命令。你想找点乐子吗?打开 3D 查看器。我会给你 3 分钟。
好的,回到工作。如果你在命令菜单列表中输入screenshot,你会得到四个选项。
第一个选项是捕获区域截图。这个选项的工作方式类似于在 macOS 中按Cmd + Shift + 4或在 Windows 的截图工具中的矩形截图。当你选择这个选项时,光标会变成一个十字。你选择你想要截取屏幕截图的区域,然后释放鼠标,就可以下载图片了。
第二个选项是 捕获全尺寸截图。这个功能非常酷,我甚至想用大号字体、粗体、红色和斜体来强调这个选项,但我不认为我的编辑器会允许我这样做。捕获全尺寸截图 将捕获整个页面的截图,甚至包括屏幕外的部分。我记得其他工具试图通过在滚动页面时多次截图来完成这个任务,结果很糟糕。说实话,我听说有人对这个选项有问题,但总的来说,结果相当不错。你可以用这个工具进行市场营销或报告错误,以便你可以展示整个页面。
第三个选项也非常出色。现在我不知道哪一个是我最喜欢的。捕获节点截图 与 元素 选项卡一起使用。你转到 元素 选项卡,通过点击选择一个元素,然后选择 捕获节点截图 选项,你将得到该元素的截图。这比尝试使用捕获区域选项选择页面的一部分要好得多。
最后一个选项只是 捕获截图。它将捕获页面的可见部分。是的,我知道,与其他选项相比,这听起来很无聊,但它仍然很有用。
我认为我无需告诉你这个好消息,因为你已经知道了。我们可以使用 Puppeteer 的 screenshot 函数完成所有这些操作。
Page 类和 ElementHandle 类都具有这个功能。如果你在 ElementHandle 上调用 screenshot 函数,你将使用 Chrome 中的 捕获节点截图 选项。
函数的签名非常简单,只是 screenshot([options]),这意味着只需调用 screenshot() 就足够了。但 options 对象有许多有趣的属性。让我们来看看它们:
-
path是你将最常使用的属性之一。如果你传递一个路径,你的截图将被保存在那里。无论你是否传递路径,结果图像都将由screenshot函数返回。 -
使用
type选项,你可以确定你想要的是type,Puppeteer 将从path推断类型。如果你既没有传递type也没有传递path,它将默认为 png。 -
如果你将类型(无论是使用
type选项还是path选项)设置为quality选项,它必须是一个从 0 到 100 的值。它将确定 jpeg 图像的质量。 -
然后我们有
fullPage。这个选项是一个布尔选项,它将帮助我们执行 捕获全尺寸截图 操作。 -
clip属性是一个对象,它将帮助我们执行 x 坐标的x,y 坐标的y,然后使用width和height确定区域的大小。 -
使用
omitBackground属性,你可以将页面的默认(白色)背景更改为透明。重要提示
omitBackground更改页面的默认背景。如果页面有自定义背景,即使是使用background-color: white的白色背景或图像,此选项将不起作用。 -
最后一个可用的属性是
encoding,它将确定screenshot函数的返回类型。如果你传递base64,它将返回一个 base64 字符串。如果你传递binary或未设置任何值,它将返回一个 Node.jsBuffer对象。
是时候看看一些代码了。让我们创建一个脚本并尝试复制 Chrome 提供的四个选项。你还可以在screenshots.js文件中看到这段代码:
const browser = await puppeteer.launch({headless: false, defaultViewport: null});
const page = await browser.newPage();
await page.goto('https://www.packtpub.com/');
await page.screenshot({ path: 'normal-only-viewport.png'});
await page.screenshot({ path: 'full-page.png', fullPage: true});
await page.screenshot({
path: 'clip.png',
clip: {
x: 300,
y: 150,
width: 286,
height: 64
}
});
const firstBook = await page.$('.tombstone');
await firstBook.screenshot({ path: 'first-book.png'});
我们可以看到代码中表达的四种操作。如果我们只传递path,将fullPage设置为 true,我们将得到fullPage,我们传递一个clip,我们将得到从page.$('.tombstone')获取的ElementHandle,我们得到捕获节点截图。
小贴士
找到正确的clip将会相当棘手。页面会根据窗口大小改变布局,这可能会破坏你试图使用的固定位置。我建议尝试捕获节点截图而不是使用clip。如果没有可用的元素,我会尝试根据其他元素的定位来构建clip。
我敢打赌,网络开发者会发现截图功能的更多用例。但如果你是 QA 分析师,我们现在将学习如何使用截图来执行 UI 回归测试。
使用截图进行回归测试
我们在第二章中简要讨论了 UI 回归测试,自动化测试和测试运行器。现在是我们变得实际的时候了。首先,让我们回顾一下回归的概念。当你看到错误报告时,你会经常听到这个词。如果一个用户说“我在 X 功能中发现了回归”,这意味着之前以某种方式工作的事物现在已经改变了。这可能是一个错误,应用程序中的错误,或者是一个未报告的行为变化。
我们可以说,UI 回归是我们检测到页面或组件在视觉上发生了变化。我想再次强调。它可能是因为错误或未报告的样式变化而改变的。
为了证明回归,你需要证据。到目前为止,我们一直在测试行为,我们的证据是代码:“如果我输入用户名、密码,然后点击登录按钮,我应该能够登录。”
为了证明 UI 回归,我们的证据将是截图。UI 回归测试将包括以下步骤:
-
我们需要做的第一件事是截图当前状态。
-
第一次运行测试时,我们没有可以比较截图的东西。我们没有历史,没有证据。我们没有可以测试的东西。但现在,我们有下一次运行的证据。
-
如果我们有证据,我们将比较当前的截图与基线,如果图像不同,我们将失败。
就这些。很简单。但是当我们有差异时会发生什么?当我们进行端到端测试失败时,我们首先会看看这是否是我们测试中的错误。如果我们的测试按预期工作,那么这个失败最终会变成一个错误报告。
但是,与 UI 回归测试相比,这有点不同。我们需要评估结果,以检查是否发现了错误,或者基线是否已改变。我们总是在页面上得到 UI 更改,所以我们需要看看这些更改是否是期望的。如果更改是期望的,我们就需要删除我们的基线并创建一个新的基线图像。
那就是我们的蛋糕。现在,我们需要哪些工具来烘焙这个蛋糕?还有,我们对这些工具有什么要求?我们需要四个要素:
-
一个测试运行器。我们已经讨论了我们需要从测试运行器中得到什么,并且我们看到 Mocha 和 Jest 符合我们的期望。
-
一个截图工具。截图工具需要稳定。稳定的意思是,它需要在相同的情况下始终返回相同的截图。这听起来很显然,但 UI 回归测试是易变测试的王者。我们需要一个工具,它可以持续提供相同的截图。Puppeteer 在这方面做得很好。
-
一个存储基线的位置。这里我们不是在谈论一个工具。但我们需要文件组织得井井有条,这样就可以轻松地找到和删除基线,并找到结果比较。
-
一个用于比较图像的工具。这个工具与截图工具一样重要。我们不希望出现误报。我们不需要一个工具告诉我们,仅仅因为一个像素不是与基线完全相同的白色,就认为一切都不对。这个工具应该允许我们通过某种阈值来决定我们希望它对更改有多敏感。它还应该支持抗锯齿像素,以减少图像渲染中的差异。"Pixelmatch"(
www.hardkoded.com/ui-testing-with-puppeteer/pixelmatch)是 Node.js 中最受欢迎的图像比较包。
如您所见,实现这一点不应该很难。但是市场上许多工具为我们解决了所有这些样板代码。再次强调,这并不是关于我告诉你们哪个是最好的工具。你们必须寻找适合你们自己的工具。我发现Project Awesome(www.hardkoded.com/ui-testing-with-puppeteer/awesome-regression-testing)有一个巨大的回归测试工具列表。在那个网站上,我发现了differencify(www.hardkoded.com/ui-testing-with-puppeteer/differencify)。我喜欢它,因为它简单,并且涵盖了前面提到的所有要求。我不太喜欢它作为 Puppeteer 和我们的中间层,但它确实完成了工作;我可以忍受这一点。
我们可以创建一个名为 "Should visually match" 的测试,并在其中使用 differencify。你可以在 homepage.tests.js 文件中找到这个测试。让我们看看如何实现它:
it ('Should visually match', async() => {
const target = differencify.init({
chain: false,
testName: 'Home' });
await target.launch();
const page = await target.newPage();
await page.setViewport({ width: 1600, height: 1200 });
await page.goto(config.baseURL);
const image = await page.screenshot();
const result = await target.toMatchSnapshot(image);
await page.close();
await target.close();
expect(result).to.be.true;
});
它看起来几乎就像一个正常的 Puppeteer 测试。但是有一些区别。让我们看看它们:
它从声明一个名为 target 的变量并给它分配 differencify.init 的结果开始。我们不会深入探讨 differencify 的内部机制,因为我们不关心这些。在那个 init 调用中唯一重要的是我们在那里设置了测试名称,正如我们稍后将会看到的,这个名称将被用来命名图像。
之后,它看起来就像纯 Puppeteer 代码,除了我们调用 target.launch(); 而不是 puppeteer.launch();。
在截取屏幕截图时,我们需要做的一件重要事情是设置 视口。视口将决定我们将使用的屏幕截图的大小。即使你截取了全页面的屏幕截图,视口也会决定那个图像的宽度。
除非你想要检查页面在特定动作之后的样式,否则 UI 回归测试将只访问一个页面,等待页面加载并稳定,然后截取屏幕截图。这里的稳定意味着你不想在资源加载一半时截取屏幕截图,例如,图片还在加载。
页面加载后,我们使用 page.screenshot() 截取屏幕截图,然后调用 await target.toMatchSnapshot(image)。这个函数将负责如果不存在则创建基线图像,如果存在,则进行图像比较。
当我们第一次运行测试时,测试将会通过,因为,再次强调,没有基线。我们将会注意到的一个重要事情是,differencify 在 differencify_reports 目录内创建了基线。你可以在 Chapter7/differencify_reports 目录内看到完整的目录结构。
现在,我们已经有了基线。希望这个测试将会显示绿色,除非那个页面上有变化。让我们尝试破坏它。我们将打开 vuejs-firebase-shopping-cart/src/components/Header.vue 文件并将 color .navbar-btn 元素改为蓝色:
.navbar-btn {
color: blue;
}
这是一个典型的 UI 回归测试场景。也许你想要改变登录页面上的按钮颜色,但你没有意识到 navbar-btn 类也被用于主页。
如果我们运行测试,我们会得到以下输出,告诉我们测试已经失败:
1) Home Page
Should visually match:
AssertionError: expected false to be true
+ expected - actual
-false
+true
这并没有说太多。它只是简单地说明图像并不相同。但如果我们去differencify_reports目录,我们会看到differencify创建了两个新文件:Home 1.current.png位于differencify_reports/__image_snapshots__/__current_output__下,显示了最新的截图。第二个图像是Home 1.differencified.png,位于differencify_reports/__image_snapshots__/__differencified_output__下。你可以在上述目录中查看差异化的图像,或者通过链接www.hardkoded.com/ui-testing-with-puppeteer/differencified查看检测到的更改位置。在这种情况下,你会看到它突出显示了单词"__current_output__目录并评估出了什么问题。
总结本节,UI 回归测试并不是每个项目都需要。如果你使用像bootstrap或tailwindcss这样的 CSS 框架,UI 回归的可能性很低。还有一些项目,页面样式不被视为错误。如果某个框比预期低几个像素,利益相关者不会在意。
我确实认为它是一个为使用自定义 CSS 的前端开发者提供的优秀工具。有了 UI 回归测试,前端开发者可以衡量他们更改的影响。这就像是 CSS 的单元测试。
在下一章中,我们将讨论设备模拟。通过设备模拟加上 UI 回归测试,你将能够检查你的网站在移动设备上的外观。
现在是时候学习另一种使用 Puppeteer 生成内容的方法了。现在是生成 PDF 文件的时候了。
生成 PDF 文件
我们已经离开了 QA 领域,将再次进入开发世界。
当我谈到 PDF 生成时,我遇到了我在截图部分提到过的同样的问题:“我为什么要使用 Puppeteer 生成 PDF?”
首先要提到的场景是将 PDF 作为网站输出格式。我不知道你是否经历过我经历过的。我不得不构建一个电子商务应用程序。我构建了产品列表、结账流程,甚至收据页面。然后出现了需求:“我们需要发送一个包含该收据的 PDF 格式的电子邮件。”这是一个估算破坏者。没有简单的方法从头开始创建 PDF 文件。
然后你找到一个生成 PDF 的库,你对它很满意。但利益相关者告诉你,它需要看起来完全像收据页面。你的估计再次被扔进了垃圾桶。应该有一个简单的方法来生成 PDF 文件。
也许它不是一个收据。你有没有收到过要求通过电子邮件发送每日报告作为 PDF 的请求?你可能会使用一些庞大、复杂且昂贵的报告工具,只是为了那个每日的电子邮件。
第二个场景是 PDF 文件作为独立的产品。你销售文档吗?财务报告?你可以使用 PDF 生成工具自动生成这些内容。
我敢打赌你已经知道你可以使用 Chrome 的打印工具将任何页面保存为 PDF 文件:

在 Chrome 中另存为 PDF
你可以转到任何页面,按 Cmd + P 或 Ctrl + P,然后选择 另存为 PDF 而不是选择真正的打印机。然后点击 保存,你就能得到你的 PDF。
我想我不用告诉你这一点,但正如你可能已经猜到的,Puppeteer 使用这个相同的工具来生成 PDF 文件。
有一些基本的知识你需要了解。如果你知道了这些,你将能够回答 Stack Overflow 上的许多问题。下面是关键点:PDF 生成不是作为截图,而是作为打印操作。
现在我们已经看到了 打印 对话框,这听起来可能很显然。但重要的是你要知道,视口(窗口大小)不会决定 PDF 的生成方式。页面大小将决定这一点。
设计师和前端开发者可以使用媒体查询打印 (www.hardkoded.com/ui-testing-with-puppeteer/mediaqueries) 来确定页面应该如何打印。
让我们看看如何使用 @media print 来更改页面样式:
<html>
<head>
<style>
body {
color: blue;
font-size: 16px;
}
@media print {
body{
color: black;
font-size: 32px;
}
}
</style>
</head>
<body>
Hello world;
</body>
</html>
正如我们所看到的,如果你浏览这个 HTML 内容(你将在存储库中找到这个代码作为 mediaprint.html),你会发现 Hello world 以 16px 的大小渲染为蓝色。但如果你点击 打印,浏览器将添加来自 @media print 部分的所有 CSS 样式,将字体大小改为 32px 并将颜色改为黑色。在下一节中,我们将学习如何在页面没有打印样式的情况下添加打印样式。现在,你需要知道的是,我们正在打印内容。
如果页面中使用的 @media print 样式对你不起作用,有一种方法可以绕过这个功能。你可以使用以下代码强制使用媒体类型 screen:
page.emulateMediaType('screen')
如果你在生成 PDF 之前调用 emulateMediaType,浏览器将忽略 @media print。
我认为我不用告诉你这一点,但我们将要使用的生成 PDF 文件的函数叫做 page.pdf。与 screenshot 不同,没有 elementHandle.pdf,因为你不能只打印一个元素。
签名很简单,就是 page.pdf([options]),但我们有很多可用的选项。让我们从在打印对话框中找到的设置映射的选项开始。
在选择 landscape 后,打印对话框中你将看到的第一个选项是一个布尔值,它将告诉浏览器你是否想要以横向方向生成 PDF。
下一个选项是 pageRanges。这是一个字符串,你可以传递类似 '1-5, 8, 11-13' 的内容。如果你不设置这个属性,它将像在打印对话框中设置了 所有页面 一样工作。
如果你点击 格式,这是一个接受以下选项的字符串:
-
Letter: 8.5 英寸 x 11 英寸 -
Legal: 8.5 英寸 x 14 英寸 -
Tabloid: 11 英寸 x 17 英寸 -
Ledger: 17 英寸 x 11 英寸 -
A0: 33.1 英寸 x 46.8 英寸 -
A1: 23.4 英寸 x 33.1 英寸 -
A2: 16.54 英寸 x 23.4 英寸 -
A3: 11.7 英寸 x 16.54 英寸 -
A4: 8.27 英寸 x 11.7 英寸 -
A5: 5.83 英寸 x 8.27 英寸 -
A6: 4.13 英寸 x 5.83 英寸
Puppeteer 还提供了两个额外的选项:width 和 height。如果你不满足于这些格式,你可以使用这两个属性设置自定义尺寸。这些属性接受一个数字或字符串。如果你使用字符串,你可以传递带有单位的值,例如 px 用于像素,in 用于英寸,cm 用于厘米,或 mm 用于毫米。
接下来是 scale,这是打印页面时使用的缩放比例。你会在打印对话框中看到它是一个从 10% 到 200% 的百分比。在这里,它将是一个从 0.1 到 2 的十进制值。
在 缩放 之后,你会找到 每页页数。在 Puppeteer 中我们没有这个设置。
在“页边距”选项中,下一个选项是一个具有四个属性的对象:
-
top -
right -
bottom -
left
所有这些属性都接受一个数字或字符串,支持与 width 和 height 相同的单位。
在页边距之后,打印对话框提供了一组额外的选项。第一个是页眉和页脚。这是 Puppeteer 中一个非常有趣的功能。Puppeteer 不仅提供了一个 displayHeaderFooter 布尔属性,还提供了一个 headerTemplate 属性和一个 footerTemplate 属性。这意味着你可以设置你想要的页眉和页脚的样式。Puppeteer 甚至会使用以下类填充元素:
-
date: 格式化的打印日期 -
title: 文档标题 -
url: 文档位置 -
pageNumber: 当前页码 -
totalPages: 文档中的总页数
下一个选项是 printBackground 属性。它将告诉浏览器你想要打印背景图形。默认情况下这是 false,因为这个选项是为打印机设计的,你不想浪费你的墨粉在背景上。但是你应该考虑是否需要打开这个选项。让我们以维基百科为例:

Wikipedia without the background graphics checked
正如你所见,如果你没有检查 SPAN 元素,维基百科的标题就会缺失。如果你不知道这个标志,你可能需要花几分钟时间挠头,试图找出你的代码哪里出了问题。现在你知道你必须考虑 printBackground。
有一个选项你不会在打印对话框中看到,那就是 preferCSSPageSize。这个属性默认是 false,它会告诉浏览器在打印页面时优先考虑页面上的 @page size 声明,而不是 width/height 或页面 format。开发者可以使用 @page size (www.hardkoded.com/ui-testing-with-puppeteer/pagesize) 来设置打印时首选的页面大小。
最后一个选项是最重要的。函数的输出与 screenshot 函数中的方式相同。如果你设置了 path 属性,将在该路径下生成一个文件。无论如何,函数的返回值将是一个包含 PDF 文件二进制表示的 Buffer(https://nodejs.org/api/buffer.html)。
现在来看看一些代码。在以下代码中,您可以在pdfdemo.js文件中看到,我们将使用我们学到的选项打印 www.wikipedia.org:
const browser = await puppeteer.launch({
headless: true,
defaultViewport: null});
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org/');
await page.pdf({
path: './headers.pdf',
printBackground : true,
displayHeaderFooter : true,
headerTemplate: `
<span style="font-size: 12px;">
This is a custom PDF for
<span class="title"></span> (<span class="url"></span>)
</span>`,
footerTemplate: `
<span style="font-size: 12px;">
Generated on: <span class="date"></span><br/>
Pages <span class="pageNumber"></span> of <span class="totalPages"></span>
</span>`,
margin:{
top:'100px',
bottom: '100px'
}
});
await browser.close();
在这段代码中,首先要注意的是,pdf函数仅在无头模式下工作。如果您在完整模式下调用pdf,您将得到一个错误:协议错误(Page.printToPDF):PrintToPDF 未实现。
第二件事要注意的是,如果您想使用页脚和页眉模板,您需要设置一个边距。根据我的个人经验,我不会在这里使用非常复杂的模板。事情可能会变得很糟糕,难以调试。
代码中最后要提到的是模板中使用的 CSS 类。如您所见,我留空了 SPAN,如<span class="title"></span>,以便浏览器可以用真实数据替换它们。
此代码将生成以下headers.pdf文件:

PDF 输出
如您所见,我们现在有一个带有标题和 URL 的自定义页眉,一个带有日期和页码的自定义页脚,并且由于我们设置了printBackground,我们得到了维基百科的标志。
您可能会认为这就结束了,但事实并非如此。我们还有另一种生成内容的方法。我们将实时构建我们的页面。
创建 HTML 内容
在本节中,我们将看到一些简单但很有用的功能。您将能够从本节中的demohtml.js文件中跟踪代码。大多数时候,您会使用 HTTP 协议导航页面,就像我们使用维基百科一样。如果您打开mediaprint.html文件,您就是使用伪协议“file”导航到该页面的。尽管它不是一个真正的协议,但您应该知道,使用 Puppeteer,您也可以使用类似 file:///some/folder/of/my/computer/mediaprint.html 的 URL 导航本地文件。
因此,如果您想生成一个社交图片,比如我们在第一部分看到的 HolyJS 会议图片,您可以在您的网站上创建一个页面,使用 Puppeteer 导航到该页面,截图,并在您的社交帖子中使用该图片。
您也可以将此文件存储在本地,并使用file://协议导航该文件。
我想在本节向您展示的是,您不一定需要在您的文件系统中有一个文件来生成那个社交图片。您可以在某些外部源中拥有所需的 HTML,例如内容数据库,将 HTML 加载到一个空页面中,然后截图。
我们可以使用setContent函数来做这件事。签名相当简单:page.setContent(html[, options]),其中html是要加载的 HTML,options对象支持您可能已经知道的两个选项:timeout和waitUntil。我们需要timeout和waitUntil属性,因为我们加载的 HTML 可能涉及网络请求,我们需要等待它们。
假设我们被分配了创建社交媒体帖子的任务。内容团队告诉我们需要使用来自contentdb组件的socialPostTemplate内容模板。我们可以这样做:
const puppeteer = require('puppeteer');
const content = require('./contentdb');
(async () => {
const browser = await puppeteer.launch({
headless: true,
defaultViewport: null});
const page = await browser.newPage();
await page.setContent(content.socialPostTemplate);
await page.screenshot({path:'fromhtml.png'});
await browser.close();
})();
我们使用require函数加载contentdb。然后我们调用newPage,这将给我们一个空白的画布,即about:blank页面。一旦我们有了空白的画布,我们使用setContent加载 HTML,然后截图并关闭页面。
一旦你调用setContent,页面将完全可用。这意味着你甚至可以调用evaluate函数来自定义和填充该模板的值。
需要考虑的一点是,setContent函数将覆盖所有页面内容。你将无法使用setContent来追加内容。
在创建新内容时,还有两个函数会很有用。第一个是page.addScriptTag(options),它将允许你将脚本标签注入到任何页面中。这些是可用的选项:
-
你可以传递
url,从 URL 注入 JavaScript 文件。 -
你也可以使用
path来从本地文件注入 JavaScript 文件。 -
如果你将脚本存储在内存变量中,你可以使用
content并将整个脚本设置在那里。 -
最后,你可以传递
type,这是你可以设置到脚本元素的脚本类型(www.hardkoded.com/ui-testing-with-puppeteer/scriptelement)。
当你想注入新的功能,而这些功能不能仅通过evaluate函数调用解决时,你可以使用这两个函数。
我们还有page.addStyleTag(options)。它与addScriptTag类似,但不是注入脚本,而是可以注入 CSS 文件或内容。addStyleTag函数具有与addScriptTag相同的选项,除了type选项,这不是用于添加 CSS 文件的链接元素的合法选项。如果我们回到 PDF 生成,你可以使用以下代码在生成 PDF 文件之前注入 CSS 内容:
await page.addStyleTag({
content : `
.search-input {
display: none !important;
}`
});
await page.pdf({…});
使用这段代码,我们在生成 PDF 之前隐藏了搜索输入框。这是一个相对简单的更改,但想象一下在现实场景中你能设置的所有事情。
摘要
在本章中,我们介绍了许多我最喜欢的功能。使用浏览器自动化工具创建内容是一个意外的用途。
我们学习了如何生成截图,用于 UI 回归测试,并为我们的网站生成内容。我们还学习了如何生成 PDF 文件以及所有可用的选项。到本章结束时,我们学习了如何动态生成页面。
在本章中,我们还看到了许多 Chrome 中可用的功能。我希望你在那里学到了一些新技巧。
在下一章中,我们将把我们的测试提升到新的水平。我们将学习如何通过模拟不同的移动设备和网络条件来测试我们的网站。
第八章:第八章:环境模拟
我很幸运在互联网变得流行之前就开始使用它了。当Windows 95 Plus上市时,我还是个青少年。大多数人可能记得Windows 95 Plus是因为它附带了一些酷炫的主题,甚至还有太空飞行员弹球机游戏。但这个版本的 Windows 带来了一款新的软件应用,这个名字至今仍被人们所熟知,有人喜欢有人讨厌。Windows 95 Plus附带的是Internet Explorer 1.0 (IE 1.0)。
我的第一次互联网连接是一个当地报纸与读者共享的免费电话号码。我设法说服我爸爸给我买了一个调制解调器。速度是 36.6 kbps。今天我的速度测试下载速度达到 150 Mbps,上传速度达到 30 Mbps,比我在青少年时期得到的速度快了超过 4,000 倍。
我不太记得我电脑的具体配置了。但我记得我使用过 15 英寸 800x600 分辨率的显示器,然后升级到了 17 英寸 1,024x768 分辨率。LED 显示器?不可能!那是什么?当时有一些体积庞大、刺眼的 CRT 显示器。
SEO?Google?当时没有人知道这些词。我记得我最喜欢的搜索引擎是AltaVista。
我为什么要告诉你这些?因为那时候,互联网体验是一致的。它很慢,非常慢,很丑,非常丑,而且有限,非常有限。但这对每个人来说都是一样的。如果你当时是开发者,你知道你必须为 IE 1.0 开发一个网站,以便在 800x600 的屏幕上显示,而且你的页面需要超过一分钟才能下载。但你不会考虑那么多。你会很高兴地用Microsoft FrontPage创建你的页面,将其推送到某个服务器,并让全世界都知道你的网站。
但现在情况不同了。生态系统比以往任何时候都更加多样化,我们需要准备好测试我们可能遇到的所有不同场景。最终,我们的工作是尊重所有客户,并努力了解他们的环境。
本章我们将涵盖以下主题:
-
理解浏览器市场份额
-
模拟移动设备
-
模拟网络条件
-
模拟本地化
-
其他模拟
到本章结束时,你将能够站在用户的角度,模拟他们体验你的网站的方式。
让我们开始吧。
技术要求
你可以在 GitHub 仓库(github.com/PacktPublishing/UI-Testing-with-Puppeteer)的Chapter8目录下找到本章的所有代码。请记住在那个目录下运行npm install,然后进入Chapter8/vuejs-firebase-shopping-cart目录再次运行npm install。
理解浏览器市场份额
在深入探讨 Puppeteer 提供的所有模拟功能之前,我想讨论一下现在浏览器市场份额的分布情况。我相信这将清楚地展示测试和模拟不同场景的重要性。
浏览器多年来的流行度
在过去的 25 年里,我们经历了许多变化。浏览器被大量采用和废弃。让我们看看 Nick Routley 在他的帖子《互联网浏览器市场份额(1996–2019)》中制作的这张表格(www.visualcapitalist.com/internet-browser-market-share/)):

虽然我使用了Netscape Navigator,但我并没有在 1995 年的顶峰时期在那里。但我记得只有Internet Explorer才是当时唯一重要的浏览器的日子。
2008 年,当Google Chrome推出时,社区正经历着浏览器疲劳,这导致大量用户转向Google Chrome和Firefox,这两个浏览器在 2010 年达到了峰值。
如果你没有看 Nick 帖子上的视频,不要错过。我从视频到达 2013 年 Q1 的时刻截了一张图:

2013 年的市场份额
那年对开发者来说是一个挑战。你有了四种不同的浏览器引擎,它们的行为不同,处理 CSS 样式的方式不同,拥有不同的 JavaScript 功能。那是一团糟。但我认为那是一个健康的网络,没有明确的市场主导者。
2020 年浏览器的流行度
根据StatCounter (gs.statcounter.com/),现在的形势非常不同:

根据 StatCounter,2020 年 12 月的浏览器市场份额
许多人称Google Chrome为新的 Internet Explorer。当你考虑到Edge和Opera使用 Chromium 引擎时,Chrome 的主导地位变得更加重要。当一个浏览器的市场份额达到这些水平时,这对开发者来说是个好事,但对网络来说却不是。
如果我们看看操作系统呢?
操作系统市场份额
操作系统在浏览器的工作中扮演着至关重要的角色。它们负责提供字体并与硬件交互,以及其他事情。大多数浏览器都是跨平台的,尽管它们试图在操作系统之间提供相同的使用体验,但它们并不总是以相同的方式工作。这就是为什么了解操作系统市场份额的分布如此重要的原因:

根据 StatCounter,2020 年 12 月的操作系统市场份额
我真的觉得这很令人惊讶。几乎 55%的互联网消费是在移动设备上,超过 39%是在 Android 上。这些值应该让我们重新思考我们开发和测试网站的方式。
我们可以最后看看屏幕分辨率。
屏幕分辨率分布
屏幕分辨率是我们试图理解整个网络生态系统时另一个重要的因素。在第三章“浏览网站”,我们讨论了开发者如何根据屏幕分辨率更改页面布局。让我们看看根据StatCounter的屏幕分辨率是如何分布的:

2020 年 12 月根据 StatCounter 的屏幕分辨率市场份额
屏幕分辨率的分布非常疯狂。不仅有各种各样的分辨率,我们还有一个占 41%的“其他”。我们远远没有达到 800x600 的标准。
我想留给你的信息是,互联网生态系统比以往任何时候都更加多样化。那个所有东西都是 IE、800x600、通过拨号连接的世界已经一去不复返了。尽管有一个主导的浏览器,但我们有许多可能的场景、移动设备和屏幕分辨率,我们还没有讨论网络速度。我们有 Wi-Fi、4G、3G 或 GPRS。
我们有时会犯错误,认为所有用户都有疯狂快速的互联网和 27'' 4K 显示器,我们无法理解他们为什么对我们网站感到沮丧。
你了解你的用户吗?你知道他们是否在街上用手机访问你的网站吗?你希望你的网站被全球使用吗?你知道有些国家是从右到左书写的,或者那里没有 4G 覆盖吗?
是时候换位思考移动用户了。让我们看看我们如何模拟移动设备。
模拟移动设备
我要介绍的第一种模拟类型是移动模拟。在本节中,我们将介绍 Puppeteer 可以模拟的三个元素:视口、触摸屏和用户代理。我们必须记住,这是一个试图模拟移动设备的浏览器。Puppeteer 和 Chromium 无法模拟任何硬件限制或某些手机提供的任何其他特定功能。幕后没有真正的设备;它只是一个试图向您展示该设备屏幕上网站外观的浏览器。
如我之前提到的,55%的互联网流量来自移动设备。我们之前章节中看到的大部分多样性都在移动世界。
让我们来看看移动世界的一些浏览器分布情况:

根据 StatCounter 的移动设备浏览器市场份额
移动市场主要被Chrome和Safari分割。你应该知道的是,在iOS中,唯一可用的浏览器引擎是WebKit/Safari。在iOS中,你可以使用Chrome、Edge或Firefox等浏览器,但它们不能提供自己的浏览器引擎。它们必须使用WebKit。他们唯一能提供的是在该引擎之上的功能。你会在那些浏览器中看到的主要功能是桌面和移动浏览器之间的同步。
让我们看看移动设备上的屏幕分辨率:

根据 StatCounter,移动设备上的屏幕分辨率
你需要 10 种屏幕分辨率才能达到 50%的市场份额,而在桌面上的分辨率只有三种,分别是 1,920x1,080、1,366x768 和 1,536x864。在移动世界中,屏幕分辨率高度多样化。在那个图表中,你应该注意到的另一件事是那些相当低的分辨率。谁会买一个分辨率为 360x640 的手机?没有人。我们将在下一节中找出那些 360x480 用户到底是谁。
因此,现在是时候讨论 Puppeteer 在模拟移动设备时考虑的元素了。
视口
在这本书中,我们已经讨论了很多关于视口的内容。现在是时候给出视口的明确定义了。
我喜欢通过解释它不是什么来解释视口。视口不是屏幕分辨率。它不是浏览器窗口的大小。最后,它也不是页面的大小。视口是浏览器用来渲染页面的屏幕的矩形部分。从用户的角度来看,视口是你可以看到的页面部分:

视口
我喜欢前面的可视化,不是因为我自己做了它,而是因为它清楚地显示了视口和屏幕尺寸之间的差异。页面可以非常大。它可以比你能看到的长得多。更重要的是,如果你是社交媒体用户,你也会知道页面可以有“无限”的高度,因为像Facebook和Twitter这样的网站会在你接近页面底部时加载新内容。尽管这种情况不常见,页面也可能比视口宽。当Windows 8发布时,曾经有一股水平滚动页面的潮流。Microsoft Azure门户仍然使用水平布局来显示其内容。
当你尝试模拟设备屏幕时,需要考虑的第二个元素是像素比。
像素比
如果我告诉你,三星 Galaxy S20,屏幕分辨率为 1,440x3,200,其视口为 360x800?
不,这并不是一个打字错误,也不是一个错误。这是那个美丽手机上的浏览器视口。这是怎么可能的?让我们看看在没有设置像素比率的 Samsung S20 上网页会如何显示:

没有像素比率的 Galaxy S20
想象一下,如果浏览器尊重屏幕的真实分辨率。这将难以阅读,因此你需要缩放分辨率。你需要告诉浏览器使用一个比例来缩放页面,使其更易于使用。在三星 Galaxy S20 的情况下,像素比率为 4,将视口设置为 360x800,这是一个简单的数学计算(1,440/4)x(3,200/4)。如果我们使用像素比率 4 来导航页面,我们会看到类似这样的内容:

携带正确像素比率的 Galaxy S20
现在我们有一个具有巨大分辨率的 S20,但页面渲染的方式使我们能够阅读它们。
如果你想知道我是如何模拟不同设备的,现在是时候使用工具了!
如果你打开了开发者工具(我希望到现在你不需要我再告诉你如何操作),你会找到一个名为切换设备模拟的按钮:

切换设备模拟选项
如果你点击那个按钮,你将激活设备模拟模式。从那里,你将能够选择任何设备进行模拟,或者创建新的设备。你还可以更改缩放。请注意,这个缩放不会影响视口;它只是用于缩放模拟器。最后,你将有一个选项来模拟不同的网络速度。我们将在模拟网络条件部分讨论这一点。
Puppeteer 考虑的下一个元素是触摸屏。
触摸屏
如果设备有触摸屏,浏览器将为开发者提供一套额外的工具,触摸事件 (www.hardkoded.com/ui-testing-with-puppeteer/touchevents)。单次点击将被处理为点击事件。但浏览器提供了处理多指交互的机会。让我们看看 Chromium 如何显示触摸屏模拟:

触摸模拟
如果你使用设备模拟,你会看到 Chromium 会使用黑色圆圈来模拟点击。
这就带我们来到了 Puppeteer 用来模拟设备使用的最后一件事:用户代理。
用户代理
用户代理是网络中难以根除的糟糕决策之一。用户代理是一个字符串(文本),在每次请求中发送到服务器,用于标识浏览器/应用程序、操作系统、供应商及其版本。
根据 MDN (www.hardkoded.com/ui-testing-with-puppeteer/userAgent),格式应该是这样的:
User-Agent: <product> / <product-version> <comment>
如果你打开 Chrome 中的 DevTools 并输入navigator.userAgent,你会得到类似这样的内容:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
在这里唯一明确的是,我正在使用 Mac,搭载英特尔处理器,操作系统版本为 11_0_1。同样真实的是,我正在使用 Chrome/87.0.4280.88。其余的都是补丁之后的补丁,所以用户不会收到“您的浏览器不兼容”的消息。因此,如果服务器检查 Mozilla,用户代理将匹配,但它不是 Mozilla。你能告诉我 KHTML, like Gecko 是什么吗?正如你所见,用户代理系统已经损坏了。
许多开发者会使用用户代理来确定另一端是哪种设备。以 iPad 上的用户代理为例:
Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1
如果开发者想检查用户是否在使用 iPad,他们可以检查用户代理是否包含单词 iPad。但如果他们想检查用户是否使用 Safari 呢?如果他们寻找单词 Safari,在 iPad 上会起作用,但如果你查看 Chrome 的用户代理,它也有 Safari 这个词,所以我们可能会认为 Chromium 是 Safari。用户代理是一团糟。
如果 Puppeteer 想要正确地模拟设备,它需要在两个地方更改 User-Agent。首先,它需要更改发送到服务器的 User-Agent 请求头(www.hardkoded.com/ui-testing-with-puppeteer/userAgent)。其次,因为开发者也可以通过 navigator.userAgent 属性从他们的 JavaScript 代码中访问 User-Agent,浏览器需要更改该属性的值。通过这些更改,服务器和客户端都将获得一个真实设备会发送的用户代理。
现在是时候看看我们如何在我们的 Puppeteer 代码中应用所有这些了。
使用 Puppeteer 模拟移动设备
你可以通过调用 page.emulate(options) 来切换模拟模式。我真心认为那里的名字 options 是错误的。与其他我们在本书中看到的选项相比,这个 options 参数是必需的。该对象将包含 Puppeteer 模拟设备所需的所有必要数据:
-
viewport是第一个属性,它包括视口的定义和一些其他内容:a)
width,表示视口宽度。b)
height,表示视口高度。c)
deviceScaleFactor,这是我们之前讨论过的像素比。d)
isMobile是一个布尔属性,将使浏览器考虑元视口标签。你可以在 MDN 网站上了解更多信息(www.hardkoded.com/ui-testing-with-puppeteer/viewportMetaTag)。e)
hasTouch是一个布尔值,将启用触摸支持。f)
isLandscape是一个布尔值,将模拟横向模式的设备。 -
userAgent属性将允许我们在请求头和 JavaScript 中更改用户代理,正如我们在上一节中看到的。
我有一些好消息和一些坏消息要告诉你。先说好消息。puppeteer类有一个名为devices的属性。它是一个包含 70 多个设备的字典。我们可以在代码中这样做:
const iPhone = puppeteer.devices['iPhone 6'];
await page.emulate(iPhone);
这是好消息。坏消息是,你必须查看源代码才能知道可用的设备列表:www.hardkoded.com/ui-testing-with-puppeteer/DeviceDescriptors。这并不理想。另一种选择是获取你拥有的任何 Puppeteer 代码,并打印devices对象的键:
console.log(Object.keys(puppeteer.devices));
如果你这样做,你将获得 Puppeteer 中包含的所有设备。另一个坏消息是,设备列表并没有像你预期的那样及时更新。但我认为这是有道理的。首先,因为每个月我们都会得到新的设备,保持该列表更新将是一项艰巨的任务。其次,你想要自动化的网站不需要在每台设备上测试。我认为你应该能够使用提供的设备获得良好的测试覆盖率。
如果你确实需要测试特定的设备,你可以在网上浏览规格,并手动传递设备设置。yesviz.com(https://yesviz.com/devices.php)有一个设备视口的良好列表。你可以在DeviceAtlas(https://deviceatlas.com/blog/list-of-user-agent-strings)找到用户代理列表。
如果我们想模拟一个不在设备列表中的 iPhone 12 设备,我们可以这样做:
await page.emulate({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1',
viewport: {
width: 360,
height: 780,
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
isLandscape: false,
},
});
在撰写这本书的时候,我找不到 iPhone 12 发送的用户代理,所以我使用了 iPhone XR 的。但如果你将此用作测试工具,这将是询问你的开发团队他们正在检查哪些值的问题,这样你就可以测试团队使用的不同用户代理。
这就引出了下一个问题。我们如何应用所有这些新概念?我们应该如何测试移动模拟?
测试移动用户体验
首先,你需要检查行为变化。优秀的开发者会竭尽全力,根据他们推断你正在使用的设备,为你提供最佳体验。让我们看看www.packtpub.com网站在移动设备上的样子:


iPhone X 上的 packtpub 网站
如我们所见,体验完全改变。顶部菜单变成了汉堡菜单,现在你需要点击放大镜来搜索书籍。
如果你关心 UI 回归,你需要识别不同的布局变化。网格可能变成列表,部分内容可能被完全删除以简化 UI,适应屏幕内容,并使用户体验更加出色。
你可能会想,“好吧,但我该如何测试所有这些?我应该测试哪些设备,全部吗?”为了知道应该测试哪些设备,我们需要了解一点关于断点的知识。不,不是调试断点,媒体查询断点。媒体查询断点是开发者可以使用它来根据视口宽度或高度应用不同 CSS 样式的地方。
你还记得我们讨论生成 PDF 文件时提到,开发者可以使用@media print来确定打印页面所使用的样式吗?嗯,@media print并不是我们唯一的选择。我们还可以做类似这样的事情:
/* Extra small devices (phones, 600px and down) */
@media only screen and (max-width: 600px) {...}
/* Small devices (portrait tablets and large phones, 600px and up) */
@media only screen and (min-width: 600px) {...}
/* Medium devices (landscape tablets, 768px and up) */
@media only screen and (min-width: 768px) {...}
/* Large devices (laptops/desktops, 992px and up) */
@media only screen and (min-width: 992px) {...}
/* Extra large devices (large laptops and desktops, 1200px and up) */
@media only screen and (min-width: 1200px) {...}
这个例子是从w3schools网站(www.hardkoded.com/ui-testing-with-puppeteer/breakpoints)上取的。我们可以看到,开发者可以根据视口的宽度设置特定的样式。从功能上讲,如今我们谈论五种类型的设备类别:
-
竖向模式的移动电话(最多 600 px)
-
纵向模式的平板电脑(最多 900 px)
-
横向模式的平板电脑(最多 1,200 px)
-
桌面(最多 1,800 px)
-
大型桌面(大于 1,800 px)
这些数字是相对的,你应该与开发团队合作,了解他们使用的断点,并尝试使用这些断点测试边缘情况。
所以,也许你与开发团队会面后,你会发现,根据他们使用的断点,你应该测试以下设备:
-
iPhone 6
-
iPad
-
纵向模式的 iPad
-
一个视口为 1,280x1,080 的桌面
我们可以改进我们的 UI 回归测试,并测试这些设备:
it('Should visually match', async() => {
for(const device of ['iPhone 6', 'iPad', 'iPad landscape', ''])
{
const target = differencify.init({ chain: false, testName: 'Home ' + device });
await target.launch();
const page = await target.newPage();
if(device) {
await page.emulate(puppeteer.devices[device]);
} else {
await page.setViewport({ width: 1600, height: 1200 });
}
await page.goto(config.baseURL);
const image = await page.screenshot();
const result = await target.toMatchSnapshot(image)
await page.close();
await target.close();
expect(result).to.be.true;
}
});
这里,我抓取了前一章的相同代码,但将其包裹在一个for循环中,该循环将遍历我们选择的四个设备。我们将假设空字符串是默认值。如果我们得到循环中的设备,我们调用emulate函数。如果没有,我们设置我们之前的视口。
最后,如果你想模拟用户点击,你可以将click函数的调用替换为tap函数的调用。tap函数的工作方式与click函数类似,但它将使用触摸屏模拟而不是鼠标模拟。
现在我们有了针对移动设备的 UI 回归测试。
小贴士
设备模拟不仅用于 UI 测试。Web 开发者可以利用这个功能来检查页面在不同设备上的外观。就像我们编写这个测试一样,你可以创建一个小脚本,它可以遍历许多设备,导航页面,并截图。然后你可以检查是否有东西出错了。
如果你想要测试页面的移动设备行为,它将与我们之前编写的不同 Puppeteer 测试没有太大区别。你可以为移动设备创建一个新的测试文件,并将测试添加到那里。你可以创建一个homepage.iPhone.tests.js文件,并在beforeEach函数中做如下操作:
beforeEach(async () => {
page = await browser.newPage();
await page.emulate(puppeteer.devices['iPhone 6']);
page.setDefaultTimeout(config.timeout);
pageModel = new LoginPageModel(page, config);
await pageModel.go();
})
在这里唯一的新事物是对 emulate 函数的调用。从那里,就取决于您来评估您想要为 iPhone 编写的哪些测试,以及哪些测试不需要。例如,您可能想要测试布局变化,但像价格或库存检查这样的测试无论在什么设备上都应该保持一致。
在本节中,我们学习了如何模拟不同的视口、用户代理和触摸设备。但还有更多。让我们继续到带宽模拟。
模拟网络条件
网络是计算机科学中的一个具有挑战性的主题。如果您告诉一个网络工程师 Chromium 模拟了 4G 网络,他们会要求您向他们展示它如何模拟无线电塔和天气条件。Chromium 并不假装模拟网络,而是模拟网络条件。Chromium 将范围限制在影响 Web 开发的三个变量:下载速度、上传速度和延迟。仅此而已。
现在您可以在 Chromium 上执行模拟网络条件。您可以打开开发者工具并转到网络选项卡,您将找到一个名为限制的下拉列表,默认选中在线选项,如下面的截图所示:

在 Chromium 上模拟网络条件
如果您点击那个下拉列表,您将找到三个其他选项:快速 3G、慢速 3G和离线。另一个酷炫的功能是您将能够添加自定义配置文件。在那里,您将被询问我们之前提到的三个变量:下载、上传和延迟,以及提供一个名称,以便您能够识别您的新配置文件。
模拟不同的网络条件并不是您想要添加到每个 UI 测试中的东西。我们希望我们的测试尽可能快。但它是一个很好的工具,可以按需执行测试。例如,假设您的电子商务网站的一个用户报告说,当他们使用 4G 时无法完成结账流程。公司不希望让移动用户感到被排除在外,因此他们改进了网站以更好地在 4G 上运行。现在我们必须编写一个测试来确保页面将在 4G 上工作。
您可以通过调用 page.networkConditions(networkConditions) 来模拟不同的网络条件,其中 networkConditions 是一个具有以下属性的对象:
-
download: 下载速度(字节/秒)。-1 禁用下载限制。 -
upload: 上传速度(字节/秒)。-1 禁用上传限制。 -
latency: 从发送请求到接收到响应头部的最小延迟(毫秒)。
puppeteer 对象有一个名为 networkConditions 的属性,它提供了两个网络设置:'Slow 3G' 和 'Fast 3G'。这是您如何根据官方文档使用它们的示例:
const puppeteer = require('puppeteer');
const slow3G = puppeteer.networkConditions['Slow 3G'];
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.emulateNetworkConditions(slow3G);
// other actions...
await browser.close();
})();
但我们并不局限于 Puppeteer 提供的两个选项。我们可以创建自己的设置,或者在 GitHub 上找到示例。例如,porchmark 项目 (www.hardkoded.com/ui-testing-with-puppeteer/porchmark) 有一个很棒的列表。该项目采用 MIT 许可证,因此我们可以自由使用它。
我们可以从那个项目中获取一些值,然后在我们自己的代码中使用它们来模拟不同的网络条件:
const NETWORK_PRESETS = {
GPRS: {
download: 50 * 1024 / 8,
upload: 20 * 1024 / 8,
latency: 500,
},
Good3G: {
download: 1.5 * 1024 * 1024 / 8,
upload: 750 * 1024 / 8,
latency: 40,
},
Regular4G: {
download: 4 * 1024 * 1024 / 8,
upload: 3 * 1024 * 1024 / 8,
latency: 20,
}
};
export default NETWORK_PRESETS;
在那个仓库中还有更多。你可以在这里看到完整的列表:www.hardkoded.com/ui-testing-with-puppeteer/porchmark-presets。如果你想合并那个文件,你需要将 downloadThroughput 属性重命名为 download,将 uploadThroughput 重命名为 upload,并删除 offline 属性。你还会在这个章节的项目中找到 networkPresets.js 文件,其中已经完成了所有替换。
我们可以用所有这些信息在我们的 “Good 3G” 网络上测试我们的登录。我们可以去我们的 login.tests.js 文件并添加这个测试:
it('Should login on 3G', async() => {
await page.emulateNetworkConditions(NetworkPresets.Good3G);
await pageModel.login(config.username, config.password);
await page.waitForSelector('.thumbnail.card');
});
这与 emulateNetworkConditions 函数的测试是相同的。如果我们不想使用 NetworkPresets 文件,我们可以硬编码我们的网络条件。让我们看看我们如何使用自己的设置调用 page.emulateNetworkConditions:
it('Should login on 3G with custom settings', async() => {
await page.emulateNetworkConditions(
{
download: 750 * 1024 / 8,
upload: 250 * 1024 / 8,
latency: 100,
});
await pageModel.login(config.username, config.password);
await page.waitForSelector('.thumbnail.card');
});
最终结果将是相同的。你还可以有一个固定的预设,并将其添加到我们现有的 config.js 文件中。
我们在本章中已经覆盖了很多内容。在本节中,我们学习了如何模拟不同的网络条件。我们还学习了如何执行 DevTools 协议中的方法,这些方法在 Puppeteer API 中没有暴露。现在是时候学习本地化了。
模拟本地化
我喜欢这个话题。也许是因为英语不是我的母语,所以我看到并感受到了当一个网站未能尊重其他文化时的痛苦。
关于本地化是什么,国际化是什么,以及它们之间的区别,有很多争论。虽然我敢打赌,关于我是否应该将两者视为整体会有争论,但我们将把它们视为整体。
当我们谈论本地化时,我们是在说一个网站应该尊重其受众:
-
它应该尊重他们的语言。
-
它应该尊重他们的文化,例如他们如何阅读数字,如何排序信息,以及如何阅读内容。
-
它应该尊重他们的信仰。例如,绿色/好 红色/坏 在每个文化中可能都不适用。
本地化是一个特性。
理想情况下,网络上的每个网站都应该考虑本地化。但本地化可能是一个相当昂贵的特性来实现。有很大可能性,你的公司不是 Google 或 Amazon,你无法为每种文化本地化你的网站,所以你需要了解你的受众。
你可能会想:“我只是个 QA 分析师。我应该关心这个吗?”让我告诉你:你应该成为公司中捍卫和尊重客户文化的第一人。
让我分享一些关于明确受众范围的真实生活例子。
我发现很多次,在欧洲购买当地火车票可能会有困难。它们只使用当地语言,或者网站的英文版本非常糟糕。范围显而易见。这个网站是为当地人准备的。如果你是游客,就去欧洲铁路。
有一个来自美国的同事想在智利网站上购买机票。他去了那个网站,发现票价为 186.992 美元。他认为机票价格是 186 美元和 992 美分。实际上,那是十八万六千九百九十二智利比索。该网站没有考虑到我的朋友读数字的方式。他不是该网站受众的一部分。
从另一方面来看,如果你去www.google.com,你总是会得到你偏好的语言的网站。全世界都是他们的受众。
如果我访问www.kayak.com,我会得到以我本地货币的价格,因为我属于他们的受众。这是你将在 Kayak 网站上找到的完整国家列表:

Kayak 的受众
这可能看起来像是在 Kayak 上的一串国家列表。但实际上,这是他们受众的定义。
如果你不知道你网站的受众,就要求它,并在你的测试中捍卫它。
在进入代码之前,还有一件关于本地化的事情你需要知道。没有一种方法可以实施它,Puppeteer 也不会涵盖每个场景。但是,让我们看看我们能够用 Puppeteer 做什么。
模拟地理位置
使用 Puppeteer,我们将能够通过 Geolocation API(www.hardkoded.com/ui-testing-with-puppeteer/geolocalization)更改页面使用的地理位置。我仔细选择了这些词。你将无法完全模拟地理位置。大多数网站使用基于 IP 的地理位置。这意味着当网站从你的设备收到请求时,它会抓取 IP,并根据他们服务器上的 IP 到国家表推断出你的国家。换句话说,你无法在Netflix.com上更改国家。
那么,我们能效仿什么呢?你将能够模拟客户端地理位置,比如maps.google.com或者甚至是google.com的搜索本身。
假设我们想让谷歌告诉我们在哪里吃饭,但是在巴黎。我们可以这样做:
const browser = await puppeteer.launch({ headless: false, defaultViewport: null});
const page = await browser.newPage();
const context = browser.defaultBrowserContext();
await context.overridePermissions('https://www.google.com/', ['geolocation']);
await page.setGeolocation({latitude: 48.8578349, longitude: 2.3249841});
await page.goto('https://www.google.com/');
await page.type('[name="q"]', 'where to eat');
await page.keyboard.press('Enter');
await browser.close();
让我们来看看这段代码。你可以在这个wheretoeat.js文件中找到这个脚本。我们已经知道puppeteer.launch和browser.newPage的作用。第三行有一些新内容:browser.defaultBrowserContext。好吧,这对我们来说是个新词,但不是什么大问题。它将给我们之前一行获得的新页面的上下文。
下一行确实有一些有趣的内容:context.overridePermissions。这个函数允许我们绕过 Chromium 执行的许多权限检查。如果你第一次打开 Google 并搜索“在哪里吃饭”,你将得到类似以下的内容:

地理位置权限请求
那个窗口不是你可以用 Puppeteer 点击的。由于我们无法点击它,Puppeteer 提供了 context.overridePermissions 来告诉浏览器我们想要自动授予哪些权限。签名相当简单:browserContext.overridePermissions(origin, permissions),其中 origin 是我们想要授予权限的页面(URL),而 permissions 是一个字符串数组,可以接受以下值之一:
-
geolocation
-
midi
-
midi-sysex
-
notifications
-
push
-
camera
-
microphone
-
background-sync
-
环境光传感器
-
accelerometer
-
gyroscope
-
magnetometer
-
accessibility-events
-
clipboard-read
-
clipboard-write
-
payment-handler
你不需要记住所有这些值;只需在你收到权限请求时查看此列表,并知道要使用哪个值。
下一行是一个有趣的内容:await page.setGeolocation({latitude: 48.8578349, longitude: 2.3249841})。这个函数也非常简单。它只期望一个包含三个属性的对象:latitude,这是一个介于 -90 和 90 之间的数字;longitude,这是一个介于 -180 和 180 之间的数字;以及 accuracy。
高级技巧
如果你想知道一个地方的坐标,你可以去 Google 地图 (www.google.com/maps) 并搜索一个地方。生成的 URL 将给出坐标。例如,如果你搜索巴黎,URL 应该是 https://www.google.com/maps/search/Paris/@48.8590448,2.3257917,14.49z;48.8590448 将是纬度,2.3257917 是经度。
在设置地理位置后,我们可以导航到 Google,输入在哪里吃饭,结果将是巴黎的吃饭地点:

在巴黎哪里吃饭
Puppeteer 允许我们模拟不仅是一个位置,还是一个时区。让我们看看我们如何使用 Puppeteer 环游全球。
模拟时区
找到一个时区模拟的使用案例并不容易,但有一些。你可以模拟时区来测试某个特定时区的应用程序,即使你不在那里。在下一章讨论抓取时,这将很有帮助。
一个有趣的检查是测试你的应用程序是否正确保存数据,无论时区如何。
模拟时区的功能相当直接:page.emulateTimezone(timezoneId),其中timezoneId是 ICU 时区。Chromium 在其源代码中也有一个 ICU 时区列表。你可以通过以下链接找到它:www.hardkoded.com/ui-testing-with-puppeteer/metazones。
如果你想测试这个功能,你可以尝试更改你的时区,并访问一个显示你当前日期的网站。你可以在timezones.js文件中遵循以下脚本:
const browser = await puppeteer.launch({ headless: false, defaultViewport: null});
const page = await browser.newPage();
await page.emulateTimezone('Europe/London')
await page.goto('https://www.unixtimestamp.com/');
await browser.close();
我们将时区设置为"Europe/London",然后访问www.unixtimestamp.com/,它以不同的格式显示日期和时间。这不是一个花哨的功能,但将来可能会有用。
我们在本本地化主题上要讨论的最后一件事是语言。
模拟语言
在实现本地化时,开发者首先考虑的是将网站以用户的语言提供。但我们的生态系统中有一个问题。让我们来谈谈开发者实现本地化的四种常见方法。
你会看到一些网站会根据你的 IP 地址显示内容。如果它检测到你的 IP 来自西班牙,它会显示西班牙语内容。如果你在法国,它会显示法语网站。如果你生活在一个有五种官方语言的国家怎么办?它只会选择一种。正如我们在讨论地理位置时提到的,我们无法模拟基于 IP 地址的语言更改。
开发者还可以提供基于域的解决方案。如果你访问www.amazon.es,你会看到西班牙语内容。如果你访问www.amazon.fr,你会看到法语内容。这将很容易测试。你只需要创建一个语言域映射并在测试中使用它。
第三,开发者可能会提供一个基于偏好的解决方案。如果你访问www.amazon.com,你会看到英文内容,但会在某个地方看到一个下拉列表来更改你的语言。这个方案也容易测试。你可以在测试数据库中为每种语言设置一个用户,然后使用他们来测试网站在不同语言下的表现。
我要在这里讨论的最后一种选项,尽管可能还有更多,但我认为这是推断用户语言的正确方式:通过读取Accept-Language 头信息的值。根据 MDN (https://www.hardkoded.com/ui-testing-with-puppeteer/Accept-Language),"Accept-Language 请求 HTTP 头信息告知服务器客户端能够理解的语言以及首选的 locale 变体。(我们所说的语言是指自然语言,如英语,而不是编程语言。)然后服务器通过内容协商选择一个提议,使用它,并通过 Content-Language 响应头通知客户端其选择。”
浏览器告诉服务器您更喜欢哪种语言。当您导航到一个页面,以及之后的每次请求,浏览器都会添加Accept-Language头,以便服务器可以相应地操作。
当您安装一个浏览器时,它将有一个默认的语言列表,基于您选择的下载选项或操作系统的语言。但您可以去偏好设置页面更改那个语言列表。如果您进入浏览器的偏好设置,您应该能够找到一个语言部分。您应该能够看到类似以下内容:

微软 Edge 的语言设置
在那里,我有三种语言:英语(美国)、英语和西班牙语。所有这三种语言都设置在Accept-Language头中。遗憾的是,根据 Paul Reinheimer(https://twitter.com/preinheimer)的说法,2017 年顶级 10,000 个网站中只有 7.2%支持Accept-Language(https://wonderproxy.com/blog/accept-language/)。这意味着尽管我们有根据用户偏好推断语言的工具,但大多数网站都不会使用它。我希望随着时间的推移,这种情况会有所改变。我们如何使用Accept-Language头测试语言?这并不难:
const browser = await puppeteer.launch({headless: false, defaultViewport: null});
const page = await browser.newPage();
await page.setExtraHTTPHeaders({
'Accept-Language': 'fr'
});
await page.goto('https://www.google.com/');
await browser.close();
这是一种您可以在法语中访问谷歌网站的方法。我们将在下一章中更多地讨论page.setExtraHTTPHeaders。但您需要知道的是,您将能够更改服务器收到的Accept-Language头。
我认为我们现在已经看到了 Puppeteer 提供的最相关的模拟功能。但我不想让您错过任何东西。让我简要地展示您一些更多的模拟工具。
其他模拟
为了总结本章,我想与您分享三个额外的模拟功能。
第一个与无障碍性相关。本地化和无障碍性是两个与人类相关的话题。它们谈论的是整合,关于不让任何人掉队,甚至不包括网络。我相信您的网站可能会排除某些文化(请根据上下文阅读这些话)。您可能会说:“我不打算向这个国家销售我的产品,所以我不需要将我的网站翻译成 X。”正如我们所说的,本地化可能会很昂贵。但我们确实需要设计网站以实现包容性。我认为,就像我们强制购物中心为轮椅提供斜坡一样,我们也应该强制网站实现无障碍。我可以写很多关于这个话题的页面,但这不是本书的目的。但我鼓励您在微软的网站上阅读关于包容性设计的内容:www.microsoft.com/design/inclusive/。我将用该网站上的这句话作为结束语:
排除发生在我们使用自己的偏见解决问题时。
我花时间写这些段落在一个 UI 测试书中,因为我相信质量保证是保护并包括所有网络用户的最后一道防线。
Puppeteer 不会涵盖您应该做的每一个可访问性检查,但它将帮助您模拟不同的视觉缺陷。您可以通过调用 page.emulateVisionDeficiency(type) 函数来模拟以下视觉缺陷:achromatopsia(全色盲)、deuteranopia(绿色盲)、protanopia(红色盲)、tritanopia(蓝黄色盲)和 blurredVision,以检查模糊视觉。让我们看看我们如何使用这个新功能:
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.packtpub.com/');
await page.emulateVisionDeficiency('achromatopsia');
await page.screenshot({ path: 'achromatopsia.png' });
await page.emulateVisionDeficiency('blurredVision');
await page.screenshot({ path: 'blurred-vision.png' });
await browser.close();
如果您运行这个检查,您将得到以下结果:

Packtpub 在模糊视觉模拟下
您可以编写这样的检查,并在仪表板上共享它们,这样设计和开发团队都可以看到网站对视力障碍人士的易用性。
我接下来要分享的下一个模拟是关于模拟媒体功能。函数是 page.emulateMediaFeatures(features)。它接受一个您想要更改的 name/value 特性数组。这是 Puppeteer 支持的两个特性:
-
prefers-colors-scheme,这将帮助您在dark和light模式之间切换 -
prefers-reduced-motion,这将使用reduce选项或no-preference减少 CSS 动画
这可能几年前不是一个流行的功能。但现在,许多网站都在追逐暗黑模式的潮流。这是您如何测试暗黑模式的方法:
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://duckduckgo.com/');
await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
await page.screenshot({ path: 'dark.png' });
await browser.close();
如您所见,我们可以简单地导航到一个页面,并模拟 prefers-color-scheme 的值为 dark。
本章中我想分享的最后一种模拟是媒体类型模拟。我们在讨论 PDF 生成时谈到了媒体类型。我们有两种媒体类型:screen 和 print。如果您需要测试页面打印效果,这是一个非常出色的功能。您可以使用它来测试可能会被用户打印的收据页面。
函数是 page.emulateMediaType(type),其中类型是一个字符串,可以是 screen 或 print:
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://github.com/puppeteer/puppeteer');
await page.emulateMediaType('print');
await page.screenshot({ path: 'print.png' });
await browser.close();
我们前往 Puppeteer 仓库,看看如果我们即将打印页面,它会是什么样子。
摘要
这又是一个内容丰富的章节。我希望您喜欢它,就像我喜欢写它一样。
我们以讨论互联网生态系统开始本章。我们讨论了世界如何向移动体验转变。这是一个很好的时机,去联系您公司负责分析的人,看看本章中我们看到的数据图表是否代表了您的网站用户。
然后我们转向移动模拟。在那里,我们学习了视口、像素比、移动分辨率的基本概念,触摸屏模拟和用户代理。
我们还学习了如何模拟不同的网络条件。我们将在 第十章 中看到,评估和改进网站性能,速度在移动体验中的重要性。
在本章的结尾,我们讨论了本地化模拟。我们学习了如何模拟地理位置、时区和语言。
我不想在本章中遗漏任何模拟内容。这就是为什么我们还学习了其他可用的模拟。我们涵盖了可访问性、暗黑模式和媒体类型模拟。在当前这个充满暗黑模式炒作的时代,测试暗黑模式将变得更加重要。
下一章将介绍爬取(Scraping)。我们将揭开爬取概念的神秘面纱,并学习一些新技巧,这样你就可以不仅用 Puppeteer 进行测试,还能用于其他有趣的事情。
第九章:第九章:抓取工具
在 第一章 使用 Puppeteer 入门 中,我们讨论了网络自动化的不同用途。在所有这些用例中,网络抓取是让开发者最兴奋的一个。当我谈论自动化时,我知道当我开始谈论任务自动化时,我会得到观众的全部注意力,但当我深入到 网络抓取 这个话题时,我会得到更多的关注。
请不要误解我,我认为 UI 测试很重要。正如我们在前面的章节中看到的,这不仅仅是运行自动化测试,也是照顾客户。但网络抓取有那种有趣的火花,一种黑客的感觉,我不想在这个话题上遗漏任何内容。
几个月前,我读了一本关于网络抓取的书,书中在书的结尾处包含了一个关于 UI 测试的章节。我们将做同样的事情,但方向相反。这是一本以抓取章节结尾的 UI 测试书。
我们将从这个章节的开始,通过定义和揭秘网络抓取。它只是针对黑客的吗?它甚至合法吗?我们还将讨论抓取伦理,比如什么时候可以抓取,什么时候不可以。
章节的第二部分将讨论使用 Puppeteer 可用的不同抓取工具。
本章我们将涵盖以下主题:
-
网络抓取简介
-
创建抓取器
-
并行运行抓取器
-
如何避免被检测为机器人
-
处理身份验证和授权
到本章结束时,你将能够将你在本书的学习过程中学到的所有概念应用于一个全新的网络自动化领域。
让我们开始吧。
技术要求
你可以在 GitHub 仓库(github.com/PacktPublishing/UI-Testing-with-Puppeteer)的 Chapter9 目录下找到本章的所有代码。请记住在那个目录下运行 npm install。
网络抓取简介
介绍一个新概念的最佳方式是通过给出一些具体和直接的定义。让我们首先定义 数据抓取。根据维基百科 (www.hardkoded.com/ui-testing-with-puppeteer/data-scraping),“数据抓取是一种计算机程序从另一个程序的人类可读输出中提取数据的技术。”任何从计算机输出的信息都可以被提取和处理。最早的抓取器被称为“屏幕抓取器”。屏幕抓取器就像一个可以捕获屏幕的应用程序。然后,通过运行 光学字符识别(OCR),它从该图像中提取文本以进行进一步处理。
网络抓取将这个想法提升到了新的水平。网络抓取是一种使用软件从一个或多个网站中提取数据的技巧。
你可能会想:这甚至合法吗?亚马逊是一个公开的网站。我可以自由地浏览网站;为什么我不能运行一个脚本来提取已经公开的数据呢?好吧,这取决于。让我与你分享一些现实世界中的场景,这些场景与网络爬取有类似的伦理困境。
第一种情况
一个小型杂货店老板去一个大商场比较他们的产品和价格。她不能拿一箱牛奶走,不付钱,但她可以四处走动,记下产品价格,然后把这份清单带到她的店里,比较价格。价格不是产品。她没有偷任何东西。而且,商场太大,他们无法控制每个四处走动记笔记的人。但如果同一个人去下一个街区的小杂货店开始记笔记呢?我敢打赌店主已经认识她了,而且她记笔记的行为太明显了,这可能会威胁到他们的生意。这是非法的吗?不是。但她可能会陷入纠纷。
第二种情况
一些高端家具店不允许你在店内拍照。
最后一种情况
你在赌场不能数牌!他们会把你踢出去,并且终身禁止你进入。
这些是现实生活中的“爬虫”。人们在现实世界中试图提取信息。爬取网络与此类似。只要满足以下条件,你就能爬取一个网站:a) 网站欢迎(隐式或显式)爬虫,b) 在爬取时你的态度是考虑周到的,以及 c) 你所爬取的内容是被允许的。让我们来分析这些概念。
网站是否允许爬虫?
对于这个问题,第一个反应可能是:“不!网站所有者为什么要允许爬虫?”但这并不一定正确。如果你拥有一家酒店呢?如果一个聚合网站爬取你的预订页面,然后在他们的网站上显示这些结果,并附上链接回到你的网站,他们将从这中获得一些利润,而你将吸引更多客户:双赢。或者,如果你拥有一家如维基百科或政府网站这样的非营利网站,爬取可能不是问题。作为一个非营利网站,你不应该太在意机器人来到你的网站提取数据,除非它们影响了你网站的性能。但如果你网站的内容是关于歌词,你不会希望任何人来到你的网站提取歌词。歌词是你的产品,你的资产。
在本章中,我们将看到许多绕过一些验证的技术,但我的个人规则是:如果网站不想被爬取,我就不会爬取它。不意味着不。
那么,我们如何知道我们是否可以爬取一个网站呢?网站的所有者可以通过至少四种不同的方式来表达这一点。
条款和条件
在爬取一个网站之前,你应该首先检查其条款和条件。网站所有者可以非常明确地表示他们不希望被爬取。
使用条款和条件是我们经常在安装应用程序时忽略的那块大文本。我敢打赌你也收到过邮件告诉你一个网站已经更改了其使用条款和条件,而你只是说“随便吧”然后存档了那封邮件。
但我们不应该这样做。根据 iubenda (www.hardkoded.com/ui-testing-with-puppeteer/iubenda-terms),"'使用条款和条件'是服务提供者与其用户之间合同关系的规范文件。使用条款和条件实际上就是一份合同,其中所有者明确了其服务的使用条件。"有时我们可能会认为,当我们在一个网站上购买数字内容(软件、音乐、电子书)时,我们就拥有了那个产品,而实际上,如果你阅读了使用条款和条件,你只是购买了使用该产品的权利,但并不拥有它。
使用条款和条件还说明了你可以在网站上做什么。许多网站都对此非常明确。以 www.ebay.com 的使用条款和条件为例:
3. 使用 eBay
在使用或访问服务的过程中,你将不会:
使用任何机器人、蜘蛛、抓取器或其他自动化手段访问我们的服务以任何目的;
绕过我们的机器人排除头信息,干扰我们服务的运行,或对我们的基础设施施加不合理或不成比例的大负载。
正如我们所见,eBay 表述得非常明确。你不能抓取他们的网站。eBay 诉 Bidder's Edge (www.hardkoded.com/ui-testing-with-puppeteer/ebay-vs-edge) 是 2000 年代的一个著名案例。eBay 指控 Bidder's Edge 的抓取活动构成了对 eBay 财产的侵犯 (www.hardkoded.com/ui-testing-with-puppeteer/Trespass-to-chattels)。换句话说,Bidder's Edge 的抓取影响了 eBay 的服务器。我敢打赌你不想与 eBay 对簿公堂。
现在,让我们来看看 Ryanair 的使用条款和条件 (www.hardkoded.com/ui-testing-with-puppeteer/ryanair-terms):
使用任何自动化系统或软件(无论是由第三方操作还是其他方式),从本网站提取任何数据用于商业目的(“屏幕抓取”)是严格禁止的。
Ryanair 也不喜欢抓取器,但它说“出于商业目的”,这意味着你可以编写你的抓取器代码来寻找你下一次度假的最佳价格。
如果使用条款和条件没有明确说明抓取器,网站所有者可以通过 robots.txt 文件表达他们与抓取器的关系的另一种方式。
robots.txt 文件
维基百科再次给出了对 robots 文件的精彩定义。根据维基百科(www.hardkoded.com/ui-testing-with-puppeteer/Robots-exclusion-standard),robots 排除协议"是网站用来与网络爬虫和其他网络机器人通信的标准。该标准指定了如何通知网络爬虫哪些网站区域不应被处理或扫描。网络爬虫通常被搜索引擎用来对网站进行分类。”
那个定义中的关键词是“通知”。网站所有者可以在 robots 文件中表达哪些网站部分可以被抓取。大多数网站只使用robots.txt文件来告诉搜索引擎它们可以在哪里找到用于抓取的 Sitemap:
User-agent: *
Sitemap: https://www.yoursite.com/sitemap.xml
这两行简单的代码告诉搜索引擎,如谷歌,获取那个 Sitemap 文件并抓取那些页面。但你可以在这个文件中找到更复杂的定义。例如,维基百科上的robots.txt文件有超过 700 行!这告诉我们该网站被大量抓取。让我们看看这个文件中我们可以找到的一些示例:
# Please note: There are a lot of pages on this site, and there are
# some misbehaved spiders out there that go _way_ too fast. If you're
# irresponsible, your access to the site may be blocked.
我喜欢这个文件以一条信息开始!他们希望我们来到这个页面阅读它。下一部分很有趣:
# Crawlers that are kind enough to obey, but which we'd rather not have
# unless they're feeding search engines.
User-agent: UbiCrawler
Disallow: /
User-agent: DOC
Disallow: /
User-agent: Zao
Disallow: /
# Some bots are known to be trouble, particularly those designed to copy
# entire sites. Please obey robots.txt.
User-agent: sitecheck.internetseer.com
Disallow: /
在这里,维基百科通知说,他们不希望与用户代理UbiCrawler、DOC、Zao和sitecheck.internetseer.com相关的抓取器抓取网站。并且文件以适用于所有用户代理的通用规则结束:
User-agent: *
Allow: /w/api.php?action=mobileview&
Allow: /w/load.php?
Allow: /api/rest_v1/?doc
Disallow: /w/
Disallow: /api/
Disallow: /trap/
Disallow: /wiki/Special:
…
他们基本上说,除了某些 URL(如/w/、/api/等)之外,所有剩余的(User-agent: *)都可以抓取整个网站。
如果我们在条款和条件或robots.txt文件中找不到任何有用的信息,我们可能在页面响应中找到一些线索。
你是人类吗?
我打赌现实生活中没有人问过你你是不是人类,但许多网站总是问我们这个问题:

reCAPTCHA 机器人检测
最初只是一个简单的“输入你看到的单词”的挑战,变成了越来越复杂的挑战:

复杂的 CAPTCHA
我们应该检查第一行的第三个方块吗?谁知道……但他们的目标是明确的。以一种友好的方式,使用 UI,他们想将抓取器踢出去。
当一个网站放置一个像之前截图中的 CAPTCHA 时,这并不总是因为他们不想被抓取。也许他们想保护他们的用户。他们不希望恶意机器人测试用户名和密码或被盗的信用卡号码。但意图是明确的:那个页面是为人类准备的。
我把这些验证称为“友好”的方式将机器人踢出去。但您也可能从服务器获得一些不友好的响应:

页面返回 403 HTTP 错误
如果你通常可以访问的页面在抓取时返回 403 错误,这意味着服务器认为你的行为是对服务器的攻击,并禁止了你的 IP。
这是个坏消息。这意味着你将无法使用当前的 IP 访问该网站。禁令可能是几个小时或永远。这也意味着,如果你在组织内部与其他计算机共享公共 IP,那么没有人将能够访问该网站。有两种方法可以解决这个问题。一种是通过联系网站所有者,道歉并请求他们从禁止列表中移除你的 IP。或者你可以继续糟糕地玩,尝试更改你的公共 IP 或使用代理。但如果你被抓到一次,你将被抓到两次。
网站告诉你不要抓取的最后一种方式是提供 API。
使用网站 API
在这个背景下,API 是网站公开的一组 URL,这些 URL 在返回页面而不是返回数据时,通常以 JSON 格式返回数据:


维基百科 API
这里的问题是,如果你可以通过点击 URL 获取所有需要的信息,为什么还要浪费时间解析 HTML 元素,等待网络调用等等?
我认为这是一种很好的方式,让网站告诉你:“嘿,别抓取我,这里是你可能需要的数据。欢迎你来这里获取你需要的信息。”
API 还给了网站所有者设置规则的机会,例如速率限制(每秒或每分钟可以发送多少请求)、API 公开的数据以及对于消费者来说将保持隐藏的内容。
这些是一些网站可以传达我们可以做什么和不能做什么的方式。但我们对被抓取网站的看法也很重要。
我们的态度
这部分相当简单,可以简化为两个词:友好。你必须想到,在你试图抓取的页面另一边,有像你一样的人,他们的目标是保持网站运行。他们必须为服务器和网络带宽付费。记住,他们不是你的敌人。他们是你的朋友。他们拥有你需要的数据,所以请友好对待。
你应该首先查看可用的 API。如果一个网站公开了包含你想要的数据的 API,那么就没有必要抓取该网站。
接下来,你应该考虑你的抓取速率/速度。虽然你可能希望尽可能快地抓取网站,但我建议尽量使抓取过程接近真实用户交互。在本章的后面部分,我们将看到用于并行抓取页面的工具,但我会非常小心地使用这些工具。
许多使用 Puppeteer 的抓取器会忘记一件事。你应该始终表明自己是一个机器人。在下一节中,我们将看到这个想法是告诉服务器你是一个机器人而不是真实用户。
我们最后需要考虑的是评估我们正在提取的数据。
我们正在抓取哪些数据?
我认为这是常识。我们在提取受版权保护的数据吗?我们在提取网站的资产吗?例如,如果我们去一个歌词网站并提取歌词,我们就是在剥夺网站的目的。但如果我们去航空公司的网站查看航班价格,价格并不是公司的资产。他们卖的是航班,而不是价格。
另一件事要考虑的是我们提取的数据将用于什么。我们应该考虑我们的行为是否会增强抓取的网站,例如,在酒店预订网站的情况下,他们可能会获得更多客户,或者威胁它,例如,如果我们抓取歌词来创建我们自己的歌词网站。
诚实地讲,我们今天所知道的许多网站都使用了爬虫技术来播种他们的网站。你可能会在房地产网站上看到这一点。谁会想去一个空荡荡的房地产网站呢?没有人。所以,这些网站会通过其他网站的帖子来播种,以吸引新客户。
理论已经足够了。让我们创建一些爬虫。
创建爬虫
让我们尝试从 Packt 网站抓取书籍价格。条款和条件对爬虫没有任何说明(www.hardkoded.com/ui-testing-with-puppeteer/packtpub-terms)。但是robots.txt文件有一些明确的规则:
User-agent: *
Disallow: /index.php/
Disallow: /*?
Disallow: /checkout/
Disallow: /app/
Disallow: /lib/
Disallow: /*.php$
Disallow: /pkginfo/
Disallow: /report/
Disallow: /var/
Disallow: /catalog/
Disallow: /customer/
Disallow: /sendfriend/
Disallow: /review/
Disallow: /*SID=
他们不希望我们去那些页面。但是网站有一个相当庞大的sitemap.xml,超过 9,000 行。如果robots.txt是爬虫的“不要去这里”的标志,那么sitemap.xml就是“请查看这个”的标志。这些是sitemap.xml文件上的第一个条目:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
<url>
<loc>https://www.packtpub.com/web-development</loc>
<lastmod>2020-12-15T12:22:50+00:00</lastmod>
<changefreq>daily</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://www.packtpub.com/web-development/ecommerce</loc>
<lastmod>2019-09-13T07:29:53+00:00</lastmod>
<changefreq>daily</changefreq>
<priority>0.5</priority>
</url>
...
</urlset>
基于这个 XML,我们将构建一个爬虫。爬虫是一个程序,它将导航网站并抓取所有页面。这是我们计划:
-
我们将构建一个书籍类别 URL 数组。为了使运行更短,我们将只抓取类别页面,例如
www.packtpub.com/web-development。我们还将限制列表为 10 个类别,这样我们就对服务器友好。 -
一旦我们得到这个列表,我们将导航每个页面,获取书籍链接。我们不希望其中出现重复,所以我们需要小心。
-
一旦我们得到书籍列表,我们将导航每个书籍页面,获取平装书的定价以及电子书和平装书的单独定价:

书籍详情
- 这些价格将被收集到一个 JSON 数组中,并在过程结束时发送到磁盘。
让我们开始吧!你可以在这个部分的crawler.js文件中找到这段代码。我们将获取sitemap.xml文件并获取前 10 个类别:
const https = require('https');
(async function() {
const sitemapxml = await getSitemap();
})();
function getSitemap() {
let resolve;
const promise = new Promise(r => resolve = r);
https.get('https://www.packtpub.com/sitemap.xml', function(res) {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => resolve(body));
});
return promise;
};
在这里,我们使用原生的 https 包下载 sitemap.xml 文件。我们创建一个当调用 resolve 函数时将会解决的 Promise。然后我们调用 get 函数来获取文件。我们在 data 事件中收集正在下载的信息,并在接收到 end 事件时,通过返回我们收集的 body 字符串来解决 Promise。理解代码的流程可能需要一些时间,但一旦习惯了,它就非常直接。
sitemapxml 变量是一个字符串。我们首先需要解析 XML,然后从中获取一个 JavaScript 模型。xml2js 将为我们完成大部分工作。让我们在终端中使用 npm install 安装该模块:
npm install xml2js
一旦我们有了这个模块,我们就可以开始在代码中使用它了:
const xmlParser = require('xml2js').parseString;
我们可以通过调用 xmlParser 函数来解析 sitemap:
(async function() {
const sitemapxml = await getSitemap();
const categories = await getCategories(sitemapxml);
})();
function getCategories(sitemapxml) {
let resolve;
const promise = new Promise(r => resolve = r);
xmlParser(sitemapxml, function (err, result) {
const output = result.urlset.url
.filter(url => url.loc[0].match(/\//g).length === 3)
.slice(0, 10)
.map(url => url.loc[0]);
resolve(output);
});
return promise;
};
正如你所见,我们正在使用之前用过的相同的 Promise 模式。当我们调用 xmlParser 时,我们将在刚刚传入的回调函数的结果参数中获取解析后的 result。一旦我们得到结果,我们就准备输出。在查看 sitemap.xml 文件的同时阅读代码可能会有所帮助,以获取更多上下文。我们从 result.urlset.url 数组中获取 URL 元素。然后,我们使用带有三个斜杠的 loc(例如 www.packtpub.com/web-development)来 filter URL 元素。然后,我们使用 slice 函数仅获取前 10 个元素。最后,我们使用 map 函数仅返回结果 URL,返回一个包含类别 URL 的字符串数组。
现在是时候使用 Puppeteer 了。我们将导航从 sitemap.xml 中获取的每个类别,并返回书籍 URL。我们不会只抓取类别页面的第一页。我将这个功能留给你作为作业。
让我们从创建一个将在整个程序中使用的浏览器开始:
(async function() {
/*Previous code*/
const books = [];
const page = await getPuppeteerPage();
})();
async function getPuppeteerPage() {
const browser = await puppeteer.launch({
headless: false,
slowMo: 500
});
const userAgent = await browser.userAgent();
const page = await browser.newPage();
await page.setUserAgent(userAgent + ' UITestingWithPuppeteerDemoBot');
return page;
}
我敢打赌你对这段代码非常熟悉。在这里我们能看到的第一个新事物是我们正在传递 slowMo 选项。这意味着在 Puppeteer 执行的每个动作之后,我们将等待 500 毫秒。我们还将使用 userAgent 函数从浏览器中获取用户代理。然后,我们获取那个字符串,并追加 ' UITestingWithPuppeteerDemoBot',这样出版商的服务器管理员就会知道那是我们。
让我们开始抓取吧!
(async function() {
/*Previous code*/
for(const categoryURL of categories) {
const newBooks = await getBooks(categoryURL, page);
if(newBooks) {
books.push(...newBooks);
}
}
page.browser().close();
})();
async function getBooks(categoryURL, page) {
try {
await page.goto(categoryURL);
await page.waitForSelector('a.card-body');
return await page.evaluate(() => {
const links = document.querySelectorAll('a.card-body');
return Array.from(links).map(l => l.getAttribute('href')).slice(0, 10);
});
}
catch {
console.log(`Unable to get books from ${categoryURL}`);
}
}
我们将在主函数中遍历 categories 列表,并调用 getBooks,传递 categoryURL 和一个 Puppeteer 页面。该函数将返回一个包含书籍 URL 的列表,我们将使用 push 函数将其追加到我们的 books 数组中。我们使用 slice(0, 10),所以只返回前 10 个项目。
我们将所有代码包裹在一个 try/catch 块中,因为我们不希望如果某个类别失败时代码会失败。
现在我们来看看getBooks函数。它看起来相当直接。我们进入categoryURL,使用a.card-body CSS 选择器等待一个元素。这个选择器将给我们书籍的 URL。一旦书籍加载,我们将调用evaluate,这样我们就可以获取所有带有a.card-body的链接,然后,使用map函数,我们将返回链接的href属性,这将给我们 URL。
抓取书籍不会有太大不同:
(async function() {
/*Previous code*/
const prices = [];
for(const bookURL of books) {
const price = await getPrice(bookURL, page);
if(price) {
prices.push(price);
}
}
fs.writeFile('./prices.json', prices);
page.browser().close();
})();
async function getPrice(bookURL, page) {
try
{
await page.goto(bookURL);
await page.waitForSelector('.price-list__item .price-list__price');
return await page.evaluate(() => {
const prices = document.querySelectorAll('.price-list__item .price-list__price');
if(document.querySelectorAll('.price-list__name')[1].innerText.trim() == 'Print + eBook') {
return {
book: document.querySelector('.product-info__title').innerText,
print: prices[1].innerText,
ebook: prices[2].innerText,
}
}
});
}
catch {
console.log(`Unable to get price from ${bookURL}`);
}
}
在这里,我们正在应用这本书中学到的所有内容。我们进入一个页面,等待选择器,然后我们将调用evaluate函数,它将返回一个对象。我们还没有以这种方式使用evaluate函数。
在evaluate内部,我们使用.price-list__item .price-list__price CSS 选择器获取价格,使用.product-info__title CSS 选择器获取书籍标题。然后,如果产品名称是"Print + eBook",因为该网站还提供视频,我们返回一个具有三个属性的对象:book、print和ebook。
最后要强调的是,我们将代码包裹在try/catch块中。如果我们抓取一本书失败,我们不希望整个程序失败。
主要函数将收集这些结果,然后使用fs.writeFile将它们保存到文件中。为了使用该函数,您需要在程序的第一行添加const fs = require('fs');来导入fs。
如果一切如预期进行,我们将得到一个prices.json文件,内容如下:
[
{
"book": "Kubernetes and Docker - An Enterprise Guide",
"print": "$39.99",
"ebook": "$5.00 Was $27.99"
},
{
"book": "The Docker Workshop",
"print": "$39.99",
"ebook": "$5.00 Was $27.99"
},
]
我们有我们的第一个抓取器。从那里,您在文件系统中有了数据,可以由其他工具进行分析。
这可以做得更好吗?是的,可以。我们可以尝试看看是否可以进行一些并行抓取。
并行运行抓取器
我不是因为我编写了它才这么说,但我们的抓取器结构相当好。每个部分都被分离到不同的函数中,这使得很容易识别哪些部分可以并行运行。
我不想重复,但请记住,我们正在抓取的网站,在这个例子中,Packt,是我们的朋友,甚至是我的出版商。我们不会影响网站;我们想看起来像正常用户。我们不希望并行运行 1,000 次调用。我们不需要这样做。所以,我们将尝试以谨慎的方式并行运行我们的抓取器。
好消息是,我们不需要编写并行架构的代码来解决这个问题。我们将使用一个名为puppeteer-cluster(www.npmjs.com/package/puppeteer-cluster)的包。这是这个库根据 npmjs 上的描述所做的工作:
-
处理爬取错误
-
在崩溃的情况下自动重启浏览器
-
如果任务失败,可以自动重试
-
提供不同的并发模型可供选择(页面、上下文、浏览器)
-
使用简单,小样板
-
提供进度视图和监控统计信息(见以下代码片段)
听起来很有前景。让我们看看我们如何实现它。首先,我们需要安装这个包:
npm install puppeteer-cluster
这将使我们的包准备好使用。你可以在crawler-with-cluster.js文件中找到这一节的代码。让我们通过在我们的代码第一行调用require来在我们的爬虫中导入集群:
const { Cluster } = require("puppeteer-cluster");
现在我们已经导入了Cluster类,我们可以在主函数中创建一个新的集群:
const cluster = await Cluster.launch({
concurrency: Cluster.CONCURRENCY_PAGE,
maxConcurrency: 2,
retryLimit: 1,
monitor: true,
puppeteerOptions: {
headless : false,
slowMo: 500
}
});
Cluster.launch函数有很多选项,但我认为现在我们只需要了解这些选项:
-
concurrency将告诉集群我们想要使用的隔离级别。默认值是Cluster.CONCURRENCY_CONTEXT。这些都是可用的选项:a) 使用
Cluster.CONCURRENCY_CONTEXT,每个作业将有自己的上下文。b) 使用
Cluster.CONCURRENCY_PAGE,每个作业将有自己的页面,但所有作业将共享相同的上下文。c) 使用
Cluster.CONCURRENCY_BROWSER,每个作业将有自己的浏览器。 -
maxConcurrency将帮助我们设置我们想要同时运行多少个任务。 -
通过
retryLimit,我们可以设置集群在任务失败时将运行任务多少次。默认值是 0,但我们将给它一次重试任务的机会,将其设置为 1。 -
如果我们将
monitor选项设置为true,我们将得到一个漂亮的控制台输出,显示当前进程。 -
我们在这里要讨论的最后一个选项是
puppeteerOptions。集群将把这个对象传递给puppeteer.launch函数。
包说明中提到的一件事是它支持错误处理。让我们添加示例中他们有的错误处理:
cluster.on('taskerror', (err, data, willRetry) => {
if (willRetry) {
console.warn(`Encountered an error while crawling ${data}. ${err.message}\nThis job will be retried`);
} else {
console.error(`Failed to crawl ${data}: ${err.message}`);
}
});
这看起来相当稳固。当任务失败时,集群将触发一个taskerror事件。在那里我们可以看到错误、数据和是否将重试操作。
我们不需要改变我们下载和处理sitemap.xml的方式。那里没有需要改变的地方。但一旦我们有了类别,而不是调用getBooks函数,我们将使用queue来完成这个任务:
for(const categoryURL of categories) {
cluster.queue(categoryURL, getBooks);
}
我们正在告诉集群我们需要通过传递那个categoryURL来运行getBooks。
我还有更多好消息。我们的爬取函数几乎准备好在集群中使用——几乎准备好。我们需要改变四件事:
async function getBooks({page, data}) {
const userAgent = await page.browser().userAgent();
await page.setUserAgent(userAgent + ' UITestingWithPuppeteerDemoBot');
await page.goto(data);
await page.waitForSelector('a.card-body');
const newBooks = await page.evaluate(() => {
const links = document.querySelectorAll('a.card-body');
return Array.from(links).map(l => l.getAttribute('href')).slice(0, 10);
});
for(const book of newBooks) {
cluster.queue(book, getPrice);
}
}
首先,我们对签名做了一点修改。不再是期望(page, categoryURL),而是期望一个具有page属性和data属性的对象,其中page将是集群创建和管理的页面,而data属性将是我们在排队任务时传递的categoryURL实例。
提示
传递给queue函数的第一个参数不需要是 URL。甚至不需要是字符串。你可以传递任何对象,函数将获取该对象在data属性中的内容。
我们必须做的第二件事是在页面创建时添加对setUserAgent的调用,因为页面是由集群本身创建的。
然后,我们不是返回书籍列表,而是向队列中添加了更多任务,但在这个情况下,我们排队的函数是getPrice,传递了book URL。
我们最后需要做的就是移除try/catch块,因为集群会为我们处理这一点。
现在是时候更新getPrice函数了:
async function getPrice({ page, data}) {
const userAgent = await page.browser().userAgent();
await page.setUserAgent(userAgent + ' UITestingWithPuppeteerDemoBot');
await page.goto(data);
await page.waitForSelector('.price-list__item .price-list__price');
prices.push(await page.evaluate(() => {
const prices = document.querySelectorAll('.price-list__item .price-list__price');
if(document.querySelectorAll('.price-list__name')[1].innerText.trim() == 'Print + eBook') {
return {
book: document.querySelector('.product-info__title').innerText,
print: prices[1].innerText,
ebook: prices[2].innerText,
}
}
}));
}
我们几乎做了同样的事情。我们更改了签名,添加了对setUserAgent的调用,移除了try/catch,并且不再返回价格,而是在函数内部将价格推送到prices数组中。
最后,我们需要等待集群完成其工作:
await cluster.idle();
await cluster.close();
对idle的调用将等待所有任务完成,然后close函数将关闭浏览器和集群。让我们看看这一切是否都能正常工作!
> node crawler-with-cluster.js
== Start: 2020-12-29 08:50:14.475
== Now: 2020-12-29 08:51:28.078 (running for 1.2 minutes)
== Progress: 6 / 70 (8.57%), errors: 0 (0.00%)
== Remaining: 13.1 minutes (@ 0.08 pages/second)
== Sys. load: 6.1% CPU / 95.3% memory
== Workers: 2
#0 WORK https://www.packtpub.com/security
#1 WORK https://www.packtpub.com/all-products
puppeteer-cluster的输出非常出色。我们可以看到已消耗的时间、进度以及工作者正在处理的内容。
到目前为止,我们一直在遵守规则。但如果我们想避免被检测为抓取器呢?让我们找出答案。
如何避免被检测为机器人
在提到抓取伦理之后,我犹豫是否要添加这一节。我认为我在说当所有者说不的时候,我的观点已经很明确了。但如果我要写一个关于抓取的章节,我认为我需要向你展示这些工具。然后,如何使用你到目前为止所学的信息,就取决于你了。
不希望被抓取的网站,如果正在被积极抓取,将会投入大量时间和金钱来尝试避免被抓取。如果抓取器不仅损害了网站的性能,还损害了业务,那么这种努力将变得更加重要。
负责处理机器人的开发者不会仅仅依赖于用户代理,因为我们看到,这很容易被操纵。他们应该只依赖于评估来自一个 IP 地址的请求数量,因为我们也看到,抓取器可以减慢其脚本,模拟一个感兴趣的用户的操作。
如果网站不能通过检查用户代理和监控流量峰值来阻止抓取器,他们将会尝试使用不同的技术来捕捉抓取器。
他们会首先引入 CAPTCHA。但是,正如我们将在本节中看到的,抓取器可以解决其中的一些。
然后,他们会尝试评估请求之间的时间间隔。你在 500 毫秒后点击了链接吗?你在不到 1 秒内填写了表单吗?你可能是一个机器人。
他们还可以添加 JavaScript 代码来检查你的浏览器功能。你没有插件吗?连 Chrome 默认提供的都没有?你没有设置语言吗?你可能是一个机器人。
最后,他们将会设置陷阱来捕捉你。例如,Packt 知道你可能正在使用a.card-body CSS 选择器抓取链接。他们可能会添加一个带有该选择器的隐藏链接,但那样的话,链接的 URL 可能是 https://www.packtpub.com/bot-detected。如果你访问了 bot-detected URL,你就会被发现。在表单的情况下,他们可能会添加一些隐藏的输入字段,一个典型的用户不会完成这些字段,因为它们是隐藏的。如果服务器收到了那个隐藏输入字段中的值,抱歉——你又再次被捕捉到了。
这是一个猫捉老鼠的游戏。老鼠总是会试图找到新的方法偷偷进入,而猫会努力填补墙上的漏洞。
话虽如此,让我们看看如果我们是游戏中这只老鼠,我们有哪些工具可用。
安东尼·瓦斯特尔有一个出色的机器人检测演示页面(arh.antoinevastel.com/bots/areyouheadless)。你可以在这个部分的bot.js文件中找到这段代码。让我们尝试使用 Puppeteer 截取该页面的屏幕截图:
const puppeteer = require('puppeteer');
(async function() {
const browser = await puppeteer.launch({});
const page = await browser.newPage();
await page.goto('https://arh.antoinevastel.com/bots/areyouheadless');
await page.screenshot({ path : './bot.png'});
browser.close()
})();
简单的 Puppeteer 代码。我们以无头模式打开浏览器,导航到页面,并截图。让我们看看截图的样子:

安东尼·瓦斯特尔的机器人检测
安东尼抓住了我们。我们被检测为机器人,但这并不是游戏的结束。我们还有一些事情可以做。让我们首先引入puppeteer-extra(github.com/berstend/puppeteer-extra)。这个puppeteer-extra包允许我们向 Puppeteer 添加插件。这个包将允许我们使用puppeteer-extra-plugin-stealth插件(www.npmjs.com/package/puppeteer-extra-plugin-stealth)。在这个游戏中,这个包就像是一个老鼠大师。它将添加所有技巧(或许多技巧),这样我们的代码就不会被检测为机器人。
我们首先需要做的是在终端中安装这两个包:
npm install puppeteer-extra
npm install puppeteer-extra-plugin-stealth
现在我们可以替换这一行:
const puppeteer = require('puppeteer');
我们用这三行代码替换它:
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
在那里,我们导入puppeteer,但来自puppeteer-extra。然后,我们导入隐蔽插件并使用use函数安装它。就这样!

安东尼·瓦斯特尔的机器人检测绕过
puppeteer-extra-plugin-stealth包并不是坚不可摧的。正如我之前提到的,这是一个猫捉老鼠的游戏。还有很多其他的额外工具可以使用。你可以在包的存储库中看到完整的列表(www.hardkoded.com/ui-testing-with-puppeteer/puppeteer-extra-packages)。在那里,你可以找到puppeteer-extra-plugin-anonymize-ua,它将在所有页面上更改用户代理,或者puppeteer-extra-plugin-recaptcha,它将尝试解决 reCAPTCHA(www.google.com/recaptcha/about/)挑战。
如果我们不至少稍微谈谈如何处理授权,那么一个爬取章节就不会完整。
处理授权
认证和授权是网络开发中的一个广泛话题。认证是网站如何识别你的方式。简单来说,就是登录。另一方面,授权是在你认证后可以在网站上执行的操作,例如,检查你是否可以访问特定页面。
有许多种认证模式。我们在本书中介绍了最简单的一种:用户名和密码登录页面。但事情可能会更复杂。测试与 Facebook 或单点登录的集成可能相当具有挑战性,但它们将涉及自动化用户交互。
有一种认证方法,您无法通过自动化 DOM 来执行——HTTP 基本认证:

HTTP 基本认证
这种登录弹出窗口现在并不流行。事实上,我认为它们从未流行过。但您可能已经看到过它们,如果您设置过路由器。这个模态框就像我们在第五章中看到的对话框,等待元素和网络调用。Puppeteer 无法帮助我们完成这种认证,因为没有 HTML 可以自动化。幸运的是,自动化这个过程很容易。您可以在authentication.js文件中找到这个部分的代码:
(async function() {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.authenticate({username: 'user', password: 'password'});
await page.goto('https://ptsv2.com/t/ui-testing-puppeteer/post');
await page.screenshot({ path : './authentication.png'});
browser.close()
})();
我们要认证进入 https://ptsv2.com/t/ui-testing-puppeteer/post 的唯一事情是在调用goto之前调用authenticate函数。authenticate函数期望一个包含两个属性的对象:username和password。
一旦我们完成认证,我们需要在每次请求中告诉服务器我们是谁,这样它们才能授权我们(或拒绝)执行某些任务。从理论上讲,Web 服务器是无状态的。除非 1)它们在响应中注入一些信息,使用 cookie,或者 2)我们告诉它们,否则它们没有方法知道我们是谁。最常见的方式是通过 HTTP 头。但这可以通过传递一个作为查询字符串参数或作为 HTTP POST 数据一部分的键来解决。
当您想要更改认证数据时,您需要从其他地方获取这些信息。您可能需要打开浏览器,登录您想要抓取的网站,并从那里提取认证数据,然后您可以在您的抓取器中使用这些数据。
假设您想要抓取 Packt 网站,但这次您想在登录状态下抓取。因此,您打开浏览器,登录,然后您可以使用像Export cookie JSON file for Puppeteer这样的工具扩展(您可以在 Chrome 网络商店中用这个名字找到它)来导出网站生成的所有 cookie。一旦我们有了包含所有 cookie 的名为account.packtpub.com.cookies.json的 JSON 文件,您可以将该文件复制到您的工区,并执行如下操作:
const puppeteer = require('puppeteer');
const cookies = require('./account.packtpub.com.cookies.json');
(async function() {
const browser = await puppeteer.launch({defaultViewport : { width: 1024, height: 1024}});
const page = await browser.newPage();
await page.setCookie(...cookies);
await page.goto('https://account.packtpub.com/account/details');
await page.waitForSelector('[autocomplete="given-name"]');
await page.screenshot({ path : './cookies.png'});
browser.close()
})();
代码中的新元素是对setCookie函数的调用。该函数期望一个 cookie 列表。由于我们所有的 cookie 都在一个 JSON 文件中,我们加载该 JSON 文件并将内容传递给setCookie函数。让我们看看文件中 cookie 的样子:
{
"name": "packt_privacy",
"value": "true",
"domain": ".packtpub.com",
"path": "/",
"expires": 1611427077,
"httpOnly": false,
"secure": false
}
结构相当简单直接。你不需要使用扩展并从 JSON 文件中加载 cookie。你可以调用 setCookie 函数,传递一个包含 name、value、domain、path 和 expires 属性的对象(后者是秒为单位的 Unix 时间),以及是否为 httpOnly,以及是否应该标记为 secure。
现在是时候看看我们如何处理使用 HTTP 头实现的授权了。你可能会发现一些网站使用 authorization HTTP 头来传递某种用户标识符。authorization 头看起来可能像这样:
Authorization: <type> <credentials>
根据 MDN (www.hardkoded.com/ui-testing-with-puppeteer/authentication-schemes),你可以找到以下类型:Basic、Bearer、Digest、HOBA、Mutual 和 AWS4-HMAC-SHA256。如果这些名字听起来很吓人,请不要担心。你很可能只会看到 Bearer 类型。凭证是什么呢?嗯,这就是你在编写爬虫代码时需要找出的事情。你需要查看当你真正使用网站时发送了什么信息,并尝试模仿它。
在我们的例子中,我们将使用 Basic,因为这与之前看到的相同 HTTP 基本认证。当你使用认证弹出窗口登录时,浏览器将通过传递 basic 和 username:password(Base64 编码)来发送授权头。在我们的例子中,用户名是 user,密码是 password。因此,我们可以使用任何可用的 Base64 编码器,例如,www.base64encode.net/,并将 user:password 编码为 base64:dXNlcjpwYXNzd29yZA==。
我们可以通过两种方式注入这个头信息。第一种是使用 setExtraHTTPHeaders 函数。你可以在 header-inject.js 文件中看到这个代码:
(async function() {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setExtraHTTPHeaders({
authorization: 'basic dXNlcjpwYXNzd29yZA=='
});
await page.goto('https://ptsv2.com/t/ui-testing-puppeteer/post');
await page.screenshot({ path : './authentication-header.png'});
browser.close()
})();
setExtraHTTPHeaders 期望一个对象,其中属性名是头名称,值是头值。这里我们添加了 authorization 头,其值为 'basic dXNlcjpwYXNzd29yZA=='。就这样。Puppeteer 将将这个头添加到页面将发出的每一个请求中。
但如果我们试图抓取的网站需要授权头,而不是每个请求都需要,那会是什么情况?嗯,这会很棘手,但并不难。你可以参考 header-inject2.js 文件中的代码:
const puppeteer = require('puppeteer');
(async function() {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', r => {
const overrides = {
headers: r.headers()
};
if(r.url() == 'https://ptsv2.com/t/ui-testing-puppeteer/post')
overrides.headers.authorization = 'basic dXNlcjpwYXNzd29yZA==';
r.continue(overrides);
});
await page.goto('https://ptsv2.com/t/ui-testing-puppeteer/post');
await page.screenshot({ path : './authentication-header.png'});
browser.close()
})();
我们首先告诉 Puppeteer 我们想要拦截页面将发出的每一个请求。我们通过调用 setRequestInterception 函数,并将第一个参数设置为 true 来做到这一点。然后我们开始监听 request 事件。如果请求满足我们需要的条件,在这种情况下,如果它与我们的 URL 匹配,我们创建一个具有 headers 属性的 overrides 对象,然后调用请求对象的 continue 函数。我们不能覆盖头信息。overrides 对象还可以有 url、method(HTTP 方法)和 postData 属性。
请求对象还有一个名为 abort 的函数。使用这个函数,你可以取消那个请求。例如,你可以检查请求是否为图片,然后 abort 它。结果将是一个没有图片的网站。
重要提示
如果你调用 setRequestInterception,你需要实现一个 request 事件监听器。并且你需要对每个你监听的请求进行 continue 或 abort。
正如我在打开这个部分时提到的,这并不涵盖所有不同的身份验证和授权方案,但它将覆盖超过 90%的情况。现在是我们总结的时候了。
摘要
虽然这不是一本关于爬取的书,但我们在这里覆盖了很多内容。我希望第一章能给你一个关于爬取是什么的好印象,以及涵盖了你可以做什么和不应该做什么。我们还学习了如何创建自己的爬虫。我们在不到 100 行代码内创建了一个爬虫。我们向工具箱中添加了两个新工具:puppeteer-cluster 和 puppeteer-extra。我们在本章的最后学习了一点点关于身份验证和授权,几乎为你提供了开始爬取世界所需的一切。
如果在阅读本章之前你对爬取并不那么兴奋,我希望这能成为激发你开始创建自己的爬虫的火花。如果你已经了解爬取,我希望本章能为你提供更多作为专业人士爬取的工具。
我们下一章和最后一章将关于性能以及我们如何使用 Puppeteer 来衡量它。
第十章:第十章:评估和改进网站性能
许多事情可以使一个网站成功或完全失败。在 第九章 中,我们讨论了没有内容就无法发布的房地产网站。在许多网站上,内容是排名第一的特性。Amazon.com 可能是世界上最棒的网站,但如果它没有你要找的书,你将去其他地方。
对于其他网站,功能是排名第一的特性。像 Trello.com 这样的网站之所以成功,是因为你可以轻松直观地将卡片从一个列表移动到另一个列表。但功能不仅仅是关于丰富的网页。如果我们回到 Amazon 网站,该网站非常直接。它没有使用任何酷的 UI 框架,但它有一个出色的搜索和周密的导航规划。
网站设计也可以被视为一个特性。虽然一些网站,如 www.google.com,可能看起来简单且专注于内容传递,但你也可以看到其他网站,如 www.apple.com,在设计上投入了大量资金。你可以在 www.apple.com 上看到设计是排名第一的特性。
但大多数网站都会共享同一个特性:速度。速度是一个特性。在规划网站时,利益相关者可能会争论他们是否想要投资于丰富的客户端。他们可以讨论是否应该雇佣设计师。但如果你询问速度,只有一个答案:“我们需要网站快速。”
在本章中,我们将学习如何使用性能指标来解决可能出现在网站上的几个问题。我们将查看功能、速度,以及我们如何使用 Google Lighthouse 测量这些关键性能点。
我们将在本章中涵盖以下主题:
-
性能问题
-
开始使用 Google Lighthouse
-
追踪页面
-
分析代码覆盖率
到本章结束时,你将能够在你的网站上实施性能指标,并帮助开发团队提高网站的性能。
让我们开始吧。
技术要求
你将在 GitHub 仓库 (github.com/PacktPublishing/UI-Testing-with-Puppeteer) 的 Chapter10 目录下找到本章的所有代码。请记住在那个目录中运行 npm install,然后进入 Chapter10/vuejs-firebase-shopping-cart 目录并再次运行 npm install。
性能问题
如我在引言中提到的,速度是一个特性。你可能会被要求制作一个简单的网站,但没有人会要求你制作一个速度慢的网站。网站需要快速。性能是成功的关键。性能提升用户满意度;它导致高转化率和更高的收入。让我与你分享一些事实:
-
为了性能而重建 Pinterest 页面提高了 15%的转化率(
www.hardkoded.com/ui-testing-with-puppeteer/pinterest-case)。 -
通过减少显示评论所需的 JSON 响应大小,Instagram 看到了印象的增加(
www.hardkoded.com/ui-testing-with-puppeteer/instagram-case)。 -
每次页面加载改善 100 毫秒,沃尔玛的收入就增加了 1%(
www.hardkoded.com/ui-testing-with-puppeteer/walmart-case)。
正如你所见,性能把钱放进你的口袋。
还有另一件事,我相信你会同意我的观点:无论是什么网站,无论他们卖什么或提供什么,性能都是移动体验中的首要功能。当你在大街上时,你不会在乎风格;你不会在乎功能。你首先需要的是网站能够快速加载。你需要测量移动端的性能。
这个功能的问题在于,一般来说,人们不知道如何衡量速度。如果我们回到其他功能,它们很容易衡量。讨论内容很容易。内容很容易衡量:
-
我希望带着内容发布网站。
-
有多少项?
-
超过 1,000。
功能性通常是你可以在规范上写下来的东西:
-
我想要一个具有出色搜索体验的电子商务网站。
-
那是什么意思?
-
它应该支持拼写错误,我应该能够搜索单词的一部分。
设计关乎你是否愿意在外观和感觉上投入精力:
-
我们需要一个设计出色的网站。
-
好吧,我们需要三个设计师。
-
完美。
但速度很难讨论:
-
网站需要快。
-
多快?
-
我不知道……快?
-
但你认为它应该有多快?
-
我不知道……更快?
第二个问题是,我们倾向于对性能问题做出反应。直到我们意识到它很慢,我们才意识到需要快。
第三,速度是期望和比较的问题。用户会说网站很慢。他们也会说他们使用 Google Drive,而且它要快得多。开发者会回答说,在他们看来网站似乎很快,当然,他们也没有谷歌的预算。
最后一个问题是我们不知道如何测试网站的性能。我们会收到用户的错误报告,说网站很慢,质量保证分析师会抓取那个错误报告来验证它,但分析师用来验证那个问题的工具是什么?去网站检查它是否“感觉”慢。
那是灾难的完美鸡尾酒:没有衡量方法,没有计划——一切都关于感觉和不同的期望。
我们不可能在一本关于网络自动化的书中解决所有这些问题。但,我们将看到一些策略和工具,帮助你和你的团队测量和改进你网站的性能。让我与你分享一些开始的建议。
首先,选择你需要测量的内容。如果是整个网站,那也行。但我会从最受欢迎的页面开始。从主页开始。然后,继续进行网站的流程。例如,对于一个电子商务网站,你想要测试产品详情和结账页面。询问负责分析的人哪些页面带来更多的转化,并专注于这些页面。
其次,定义页面最大加载时间。你可以这样说,主页在任何情况下都不应超过 30 秒加载。这是一个很好的使用Checkly的例子,我们在第六章中看到的,执行和注入 JavaScript。你可以编写一个测试来检查页面在生产环境中是否不超过 30 秒加载,并在Checkly上持续进行该检查。我们将在本章后面看到如何实现这一点。一旦你设置了这项检查,你和你的团队可以设定更严格的目标。例如,搜索页面不应超过一秒加载。
第三,测量性能下降。很多时候,设定一个限制是困难的,它可能变成一场猜测游戏。你可以从测量性能随时间如何演变开始。网站是变快了还是变慢了?我们是变得更好还是更差了?这是一个很好的方法,但它需要做更多的工作。你需要开始存储随时间变化的数据,并构建一些可视化这些信息的东西。
最后,使用你在本书中学到的工具。我们谈到了 Checkly,但记得我们在第八章中学习的所有模拟技术吗?你可以为不同的网络速度设定不同的目标。
这就是你可以用来测量网站性能的所有方法。在本章中,我想向你展示你可以使用哪些工具来实现这些想法。让我们从 Google Lighthouse 开始,这是一个我们可以用来测量几个重要指标的工具。
开始使用 Google Lighthouse
正如我们在上一节中看到的,确定“快”有多快并不容易。谷歌想出了一个主意。他们开发了 Lighthouse,“一个开源的、自动化的工具,用于提高网页质量。你可以对任何网页运行它,无论是公开的还是需要认证的。它有性能、可访问性、渐进式网页应用、SEO 等方面的审核”(www.hardkoded.com/ui-testing-with-puppeteer/lighthouse)。
Lighthouse 将抓取你选择的网站,应用它认为重要的指标和推荐列表,并给你一个从 0 到 100 的评分。它将在五个类别下分析网站:
-
性能: 最受欢迎的类别。Lighthouse 将测量网站优化程度,即网站准备与用户交互的速度有多快。
-
可访问性: 我希望看到开发者更多地关注这个类别。在这里,Lighthouse 将评估网站的易访问性。
-
最佳实践: 这是另一个受欢迎的类别。Lighthouse 将评估一些良好的实践,并将其纳入网站中。
-
SEO: 这个类别常被负责营销的人士使用。一些公司甚至有 SEO 专家来关注这个。这个类别是关于网站对搜索引擎的优化程度。你可能会同意或不同意其他类别的测量方式,但在这里,谷歌告诉你:“这是我们衡量你网站的方式。”如果你想确保你的网站出现在谷歌的第一页,你希望得到 100 分的评分。
-
渐进式 Web 应用: 如果网站是渐进式 Web 应用,这个类别将评估该渐进式 Web 应用的各个方面。
重要提示
渐进式 Web 应用(PWAs)是准备作为原生应用程序安装的网站。许多 PWAs 具有离线功能,并试图接近原生应用程序的体验。
在这一章中,我们将只关注性能类别。但在深入了解性能类别之前,让我们看看我们如何运行这个工具。Lighthouse 有四种版本,我们将在接下来的章节中介绍。
作为 Chrome DevTools 的一部分
如果你打开 DevTools,你会找到一个Lighthouse标签。如果你找不到,你可以通过点击工具右上角的三个点,然后转到更多工具,再找到Lighthouse选项来添加它。你应该会看到类似这样的内容:

Lighthouse 在 DevTools 中
你现在应该有一个带有所有生成报告选项的标签页。这个过程将在本地运行 Lighthouse,这是好的,但这也意味着 Lighthouse 的结果将基于你的硬件、CPU、可用 RAM、网络速度等等。
使用 PageSpeed Insights
由于谷歌发现结果可能会根据你的硬件而波动,所以他们创建了一个 PageSpeed Insights(www.hardkoded.com/ui-testing-with-puppeteer/pagespeed),你可以在那里使用谷歌的硬件运行 Lighthouse。这将使其更加稳定,但即使使用谷歌的硬件,你可能会得到不同的结果。
使用命令行
你也可以从命令行使用 Lighthouse。一开始我对在命令行中拥有 Lighthouse 并不那么兴奋。但后来我意识到,从命令行使用它比打开浏览器、进入 DevTools 等要高效得多。
你可以这样安装 Lighthouse 的-g标志:
npm install -g lighthouse
NPM 全局模块
当你使用-g标志运行npm install时,模块将被安装在共享目录中,而不是在node_modules文件夹中,并且任何应用程序都可以访问它。此外,如果模块提供了一个可执行命令,它将像这个 Lighthouse 模块一样可以从命令行访问。
安装完成后,你将能够从命令行启动lighthouse,传递 URL,以及额外的命令行参数,如--view,这将评估网站后在启动报告。
使用这一行代码,你将能够看到www.packtpub.com的 Lighthouse 结果:
lighthouse https://www.packtpub.com/ --view
想知道结果是什么?我们很快就会知道。
最后一个可用的选项是我们会大量使用的,那就是将节点模块作为我们代码的一部分来使用。
使用节点模块
我们将能够使用节点模块在我们的单元测试中使用 Lighthouse。让我们看看 Lighthouse 存储库中的示例(www.hardkoded.com/ui-testing-with-puppeteer/lighthouse-programmatically):
const fs = require('fs');
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
(async () => {
const chrome = await chromeLauncher.launch({chromeFlags: ['--headless']});
const options = {logLevel: 'info', output: 'html', onlyCategories: ['performance'], port: chrome.port};
const runnerResult = await lighthouse('https://example.com', options);
const reportHtml = runnerResult.report;
fs.writeFileSync('lhreport.html', reportHtml);
console.log('Report is done for', runnerResult.lhr.finalUrl);
console.log('Performance score was', runnerResult.lhr.categories.performance.score * 100);
await chrome.kill();
})();
代码并不复杂。我们使用chrome-launcher模块启动 Chrome 浏览器。然后我们启动lighthouse,传递一个 URL 和一组选项。lighthouse函数将返回一个对象,我称之为runnerResult,它包含一个report属性,其中包含作为 HTML 的报告,还有一个名为lhr(Lighthouse 结果)的属性,其中包含所有结果作为一个对象。我们将使用该属性来断言我们想要得到的最低值。
现在我们知道了如何启动 Lighthouse,让我们看看报告看起来如何。为了避免伤害感情,我们将对同一个 Lighthouse 网站运行 Lighthouse:www.hardkoded.com/ui-testing-with-puppeteer/lighthouse。让我们看看它是否像他们说的那样快。正如我之前提到的,我对命令行工具感到非常舒适,所以我将运行以下命令:
lighthouse https://developers.google.com/web/tools/lighthouse --view
运行之后,我在浏览器中打开了一个新标签页,以下是我得到的结果:

Lighthouse 网站的性能机会
这里,我们有五个机会。让我们逐一分析:
-
正确调整图像大小:灯塔发现了一些比页面上显示的大小更大的图像。
-
以下一代格式提供图像:在这里,灯塔会检查你是否正在使用 JPEG 或 PNG 文件而不是“下一代”格式,如 WebP。我并不是特别喜欢这个机会。尽管如今 WebP 在大多数流行的浏览器中都有支持,但它在一般意义上还不是一种流行的格式。
-
消除渲染阻塞资源:我认为这是一个关键的机会。在这里,Lighthouse 发现许多资源正在阻止页面的首次绘制。关注这个机会将大大提高你的指标。
-
移除未使用的 JavaScript:Lighthouse 发现了一些未被使用的 JavaScript 代码。尽管这个问题 Lighthouse 可以轻松检测到,但开发者解决这个问题的难度并不小。如今,开发者使用打包器将所有 JavaScript 代码打包在一起,将最终代码缩减到页面需要的代码可能具有挑战性。
-
移除未使用的 CSS:这个机会与上一个类似,但它与 CSS 样式相关。
我喜欢这个部分,因为 Lighthouse 不仅告诉你有哪些机会;它还提供了详细的说明,告诉你机会在哪里以及性能提升会是什么样子。让我们看看,例如,当我们点击正确调整图片大小这一行时,我们会得到什么:

正确调整图片大小部分
正如你所见,Lighthouse 向我们展示了哪些图片可以改进以及我们可能获得的潜在节省。你将在每个机会上获得相同类型的详细信息。
Lighthouse 类别中的最后一个部分是诊断部分。
性能诊断
我认为诊断部分是你应该考虑以提高你网站的一些事情列表。正如我之前提到的,性能类别有 13 个诊断,但你将看到这个数字会随着时间的推移而变化。
这就是诊断部分的外观:

Lighthouse 网站的性能诊断
正如你所见,你将获得与机会部分相同级别的详细信息,但这些诊断听起来更像是对你网站进行长期改进的建议。例如,让我们看看最小化主线程工作这一部分:

最小化主线程工作部分
正如你所见,诊断部分报告的内容似乎是有意义的。有一些脚本评估耗时 1,490 毫秒。但这听起来并不像是一个行动号召。它更像是在你需要提高网站性能时需要考虑的事情。
既然我们已经了解了 Lighthouse 是什么,让我们看看如何通过将 Lighthouse 添加到我们的测试中来测试我们网站的性能。
使用 Lighthouse 进行测试
让我们明确一点,Lighthouse 不是一个测试工具。它是一个开发者用来检查他们网站性能的工具。但是,正如我们在本书中多次提到的,QA 的角色是尊重客户。它的目的是确保客户得到团队可以提供的最佳产品。我们将使用 Lighthouse 来确保客户将获得我们能够提供的最快的网站。
我能想到三种测试 Lighthouse 报告的方法:
-
确保一个页面有最低的性能分数。
-
确保一个指标低于阈值。
-
确保不会找到机会。
让我们从检查性能分数开始。
确保一个页面有最低的性能分数
我们可以使用 Lighthouse 进行的第一个测试是确保我们的页面性能不会随着时间的推移而下降。我们将检查我们的页面永远不会低于特定的分数。我们如何选择最低分数?由于我们想要确保我们的网站不会随着时间的推移而退化,让我们查看当前的性能分数并强制执行。让我们转到vuejs-firebase-shopping-cart目录下的repository的Chapter10,然后我们将运行npm run serve并启动 Web 应用程序:
npm run serve
这个命令应该启动网站。一旦启动,让我们打开另一个终端并在主页上运行 Lighthouse:
lighthouse http://localhost:8080/--view
这个过程的得分结果是 30 分(性能)。我们可以将目标分数设置为 25。现在是时候编写我们的测试了。
重要提示
由于 Lighthouse 是本地运行的,你可能会在不同的机器上得到不同的结果。在选择你的分数目标时,你应该考虑这一点。
我们将在homepage.tests.js文件中添加我们的测试。但在创建测试之前,我们需要通过运行以下命令安装lighthouse模块:
npm install lighthouse
这将使 Lighthouse 在我们的测试中可用。下一步是使用require函数导入 lighthouse 模块。让我们在文件顶部添加这一行:
const lighthouse = require('lighthouse');
这将使 Lighthouse 在我们的测试中可用。现在,让我们看看我们的测试将是什么样子:
it('Should have a good performance score', async() => {
const result = await lighthouse(config.baseURL, {
port: (new URL(browser.wsEndpoint())).port,
onlyCategories: ['performance']
});
expect(result.lhr.categories.performance.score >= 0.25).to.be.true;
});
我们仅使用两条语句解决了这个测试。我们首先调用lighthouse,传递我们想要处理的 URL,在这个例子中,是config.baseURL,然后我们传递一个options对象。在那里我们传递它需要用来连接 Puppeteer 所使用的浏览器的port。我们通过new URL(browser.wsEndpoint())).port获取它,然后我们告诉 Lighthouse 我们只想处理performance类别。这里不会涵盖所有可用的选项。你可以查看完整的选项列表,请参阅www.hardkoded.com/ui-testing-with-puppeteer/lighthouse-configuration。
在下一行,我们只是断言性能类别的分数大于或等于 0.25。当你看到报告时,分数范围是 0 到 100。但在 JSON 对象中,范围是从 0 到 1。这就是为什么我们需要使用 0.25 而不是 25。
下一个测试是检查特定的指标。
确保一个指标低于阈值
我们也可以更加具体。例如,无论我们想要检查的性能分数如何,首次内容渲染的耗时不应超过 30 秒。我们的代码将与之前的测试类似:
it('Should have a good first contentful paint metric', async() => {
const result = await lighthouse(config.baseURL, {
port: (new URL(browser.wsEndpoint())).port,
onlyCategories: ['performance']
});
expect(result.lhr.audits['first-contentful-paint'].numericValue).lessThan(30000);
});
在这里,我们可以看到lhr对象也包含一个包含所有指标的audits字典。我们可以获取first-contentful-paint条目的调用,并检查numericValue(以毫秒为单位)是否低于 30,000(以毫秒表示的 30 秒)。
我们如何知道可用的指标有哪些?最简单的方法是在你的测试中添加一个断点,并添加一个监视器来查看result.lhr的值。你会看到类似以下的内容:

Result.lhr 内容
在那里,你将能够看到可用的条目,以及许多其他属性中的numericUnit。
根据这个例子,确保没有找到机会将会很容易。
确保没有找到机会
我认为这是使用 Lighthouse 最可靠的方法。我们在之前的例子中引入了一些任意数字,30 用于分数,30 秒用于指标。现在,假设我们不想获得某个机会;例如,我们不想有任何错误尺寸的图片。我们可以查看审核,尝试找到一个名为user-responsive-images的条目。有了这个条目,我们可以编写以下测试:
it('Should have properly sized images', async() => {
const result = await lighthouse(config.baseURL, {
port: (new URL(browser.wsEndpoint())).port,
onlyCategories: ['performance']
});
result.lhr.audits['uses-responsive-images'].numericValue.should.equal(0);
});
代码与上一个例子相同,但在这里,我们断言指标值应该是 0。这意味着所有图片都正确设置了尺寸。
Lighthouse 所能做到的一切都令人印象深刻,但说实话,你不会看到很多团队将这些想法应用到他们的项目中。如果你能够使用 Lighthouse 测试你网站的性能,这将给你的团队带来很大的价值。
Lighthouse 有点像一个黑盒,你可以调用它,获取值,并做出响应。但如果你想要构建自己的指标呢?如果你想要以更细粒度的方式分析页面的性能呢?现在让我们探索 Puppeteer 提供的所有跟踪功能。
跟踪页面
在本节中,我们将介绍如何使用可以在page.tracing属性中找到的tracing对象来获取性能信息。我在 Stack Overflow 上看到过这个问题不止一次:我如何使用 Puppeteer 获取性能选项卡的信息?答案是:你可以从跟踪结果中获取所有这些信息。有很大可能性你会得到这样的回复:“是的,我这么说,但结果太复杂了。”是的,跟踪结果相当复杂。但我们将尝试在本节中看看我们能从这个对象中得到什么。
如果你打开开发者工具,你应该看到一个性能选项卡,如下所示:

性能选项卡
如你所见,性能选项卡并不是一直在处理信息,因为这是一个成本高昂的过程。你需要开始“记录”跟踪,Chrome 将开始从浏览器收集大量数据,然后你必须停止跟踪过程。
如果你点击第二个按钮,它看起来像是一个刷新按钮,它将自动刷新页面并开始跟踪。如果你点击该按钮并在页面加载时停止跟踪,你将得到类似这样的结果:

性能结果
该面板的详细程度令人印象深刻。你可以看到每一个绘制动作,每一个 HTML 解析,每一个 JavaScript 执行,浏览器为了渲染页面所做的一切。
我们可以使用 tracing 对象得到相同的结果。让我们在我们的 homepage.tests.js 文件中创建一个名为 Should have a good first contentful paint metric using tracing 的测试,但现在我们只添加跟踪调用:
it('Should have a good first contentful paint metric using tracing', async() => {
await page.tracing.start({ screenshots: true, path: './homepagetracing.json' });
await page.goto(config.baseURL);
await page.tracing.stop();
});
代码很简单。我们开始跟踪,我们转到页面,然后我们停止跟踪。
start 函数期望一个 options 对象,它有三个属性:
-
screenshots属性将决定我们是否希望在跟踪期间让 Chromium 捕获屏幕截图。 -
如果你设置了
path属性,跟踪结果将写入该 JSON 文件。 -
最后,你将找到一个
categories属性,你可以在其中传递一个你想要跟踪的属性数组。
没有固定的类别列表,但这些都是我认为对我们更相关的类别:
-
在 rail 类别下,我们将找到许多有用的跟踪,例如 domInteractive、firstPaint 和 firstContentfulPaint。
-
如果你将
screenshots设置为true,你将在 disabled-by-default-devtools.screenshot 类别下找到所有屏幕截图。 -
你会发现很多条目都位于 devtools.timeline 类别下。这个类别代表你在性能时间线中看到的项目之一。
当你调用 stop 函数时,你将在你传递给 start 函数的文件中获取结果,无论你是否传递了路径,stop 函数都将返回一个 Buffer 类型的结果。
生成的 JSON 将是一个具有两个属性的对象:一个包含关于跟踪和浏览器信息的 metadata 对象,以及一个包含所有跟踪信息的 traceEvents 数组。
在我的简单测试示例中,traceEvents 给了我 16,693。那只是导航到页面的结果。我想你现在明白为什么这可能会让一些用户感到害怕。
每个跟踪事件的形状可能会根据类别而变化。但你会发现这些属性:
-
cat将告诉你事件的类别,用逗号分隔。 -
name将给你事件的名称,就像你在 Performance 选项卡中看到的那样。 -
ts将为你提供跟踪时钟,以微秒为单位表示(1 微秒等于 0.000001 秒)。大多数事件都是相对于跟踪开始的时间。 -
pid是进程 ID。我认为你不会关心那个。 -
tid是线程 ID。你也不会关心那个。 -
args将为你提供一个对象,其中包含该事件类型的特定信息。例如,你将获得请求的 URL 和 HTTP 方法。对于截图,你将获得 Base64 格式的图像。
在所有这些信息的基础上,让我们使用跟踪值编写我们的第一个内容渲染测试。我们将编写一个测试,该测试将启动跟踪,导航到页面,然后评估结果。它可能看起来像这样:
it('Should have a good first contentful paint metric using tracing', async() => {
await page.tracing.start({ screenshots: true, path: './homepagetracing.json' });
await page.goto(config.baseURL);
const trace = await page.tracing.stop();
const result = JSON.parse(trace);
const baseEvent = result.traceEvents.filter(i=> i.name == 'TracingStartedInBrowser')[0].ts;
const firstContentfulPaint =result.traceEvents.filter(i=> i.name == 'firstContentfulPaint')[0].ts;
expect((firstContentfulPaint - baseEvent) / 1000).lessThan(500);
});
我们在这里有一些技巧要解释。在停止跟踪后,我们得到结果并解析它。这将给我们一个带有traceEvents属性的result。由于ts是基于跟踪的开始,我们需要找到名为TracingStartedInBrowser的事件的baseEvent。然后我们寻找名为firstContentfulPaint的事件,并最终计算差异。由于它是微秒级的,我们需要将其除以 1,000,以便我们可以将其与我们的目标 500 ms 进行比较。
注意,在这个例子中,我们的目标是 500 ms,而我们在 Lighthouse 示例中使用了 30 秒。这是因为,默认情况下,Lighthouse 会执行多次运行,模拟不同的条件。
我们还可以在这里做的一件事是导出跟踪工具生成的截图,以供以后分析。我们可以在测试的末尾添加类似的内容:
const traceScreenshots = result.traceEvents.filter(x => (
x.cat === 'disabled-by-default-devtools.screenshot' &&
x.name === 'Screenshot' &&
x.args &&
x.args.snapshot
));
traceScreenshots.forEach(function(snap) {
fs.writeFile(`./hometrace-${snap.ts - baseEvent}.png`, snap.args.snapshot, 'base64', function(err) {});
});
在那里,我们正在通过有效的截图过滤截图事件,然后我们只需将所有这些 Base64 快照写入文件系统。有了这个,你将看到页面在加载过程中的渲染情况。你甚至可以用这些图像编写自己的第一个内容渲染算法。
现在你可能想知道你是否应该使用 Lighthouse 或 Puppeteer 的跟踪。我认为每种方法都有其优缺点。Lighthouse 易于使用,正如我们所见,它提供了我们花费大量努力才能构建的指标。使用 Lighthouse,你只需调用lighthouse函数并评估结果。但它可能很慢,即使你只选择一个类别。
另一方面,Puppeteer 的跟踪可能难以阅读和处理,但如果你知道如何从跟踪结果中获取所需的指标,它将比 Lighthouse 快得多。另一个重要的区别是,Lighthouse 只评估页面加载,而使用 Puppeteer 的跟踪,你可以在任何时刻开始跟踪。例如,你可以打开一个页面,开始跟踪,点击一个按钮,然后评估浏览器为了处理该点击所做的事情。最终,这关乎选择适合你工作的正确工具。
Lighthouse 还给我们提供了两个有趣的指标:移除未使用的 JavaScript和移除未使用的 CSS。让我们看看我们如何使用 Puppeteer 解决这些指标。
分析代码覆盖率
在本节的最后部分,我们将了解如何使用 Puppeteer 的 Coverage 类来获取代码覆盖率。代码覆盖率是一个可以应用于任何代码片段的指标。要获取某段代码的代码覆盖率,你需要某种工具来跟踪哪些代码行正在被执行,执行那段代码,并获取跟踪结果。这就像性能跟踪,但不是测量时间,而是测量执行的代码行数。
你可以在 Chrome 的 Coverage 选项卡中查看页面上的代码覆盖率。我默认没有那个选项卡,所以我需要使用 更多工具 选项来添加它,就像下面的截图所示:

Coverage 选项卡
Coverage 选项卡的工作方式类似于 Performance 选项卡。你需要开始跟踪,运行页面,或执行操作,然后停止跟踪以获取结果。
结果将类似于我们在前面的截图中所看到的那样:一个包含资源总字节数和未使用字节数的资源列表。在窗口底部,我们可以看到在跟踪过程中,超过 90%的代码被使用(执行)了。这相当不错。我们可以编写一个测试来确保我们始终拥有超过 90%的代码覆盖率。
Puppeteer 中 JavaScript 和 CSS 代码覆盖率有两套函数。如果你想获取 JavaScript 代码覆盖率,你需要运行 startJSCoverage 来开始覆盖率,并使用 stopJSCoverage 来停止它。startJSCoverage 支持一个带有两个属性的 options 参数:
-
resetOnNavigation是一个布尔属性,我们可以用它来告诉 Puppeteer 如果检测到导航,则重新开始跟踪。 -
reportAnonymousScripts是一个布尔属性,我们可以用它来告诉 Puppeteer 忽略或不禁用动态生成的 JavaScript 代码。
如果我们想获取 CSS 覆盖率,我们需要使用 startCSSCoverage 和 stopCSSCoverage 函数。startCSSCoverage 也期望一个 options 参数,但在这个情况下,它只有 resetOnNavigation 属性。
一旦我们运行覆盖率,stopCSSCoverage 和 stopJSCoverage 都将返回相同类型的值。它们都将返回一个具有这些属性的对象数组:
-
url将给我们资源 URL。 -
content将是 CSS 或脚本内容。 -
ranges将包含一个对象数组,告诉我们哪些代码部分已被执行。每个条目将包含两个属性,start和end,告诉我们文本范围从哪里开始和结束。
现在我们已经拥有了所有这些信息,我们可以编写我们的代码覆盖率测试。让我们来看看:
it('It should have good coverage', async() => {
await Promise.all([page.coverage.startJSCoverage(), page.coverage.startCSSCoverage()]);
await page.goto(config.baseURL);
const [jsCoverage, cssCoverage] = await Promise.all([
page.coverage.stopJSCoverage(),
page.coverage.stopCSSCoverage()
]);
let totalBytes = 0;
let usedBytes = 0;
const coverageTotals = [...jsCoverage, ...cssCoverage];
for (const entry of coverageTotals) {
totalBytes += entry.text.length;
for (const range of entry.ranges) usedBytes += range.end - range.start - 1;
}
const percentUnused = parseInt((usedBytes / totalBytes) * 100, 10);
expect(percentUnused).greaterThan(90);
});
我们开始测试是通过启动两个代码覆盖率。我们将 startJSCoverage 和 startCSSCoverage 放在 Promise.all 中,这样我们就可以等待两个覆盖率都得到确认。然后我们转到页面,之后停止两个覆盖率。这将给我们两个可以连接(因为它们具有相同的形状)的数组,使用 [...jsCoverage, ...cssCoverage]。
一旦我们有了两种覆盖率,我们就可以通过使用entry.text.length来获取资源的总大小,然后通过添加所有范围的长度来获取覆盖率的大小。
结果将给我们提供我们跟踪的总代码覆盖率,我们将检查它是否超过 90%。
与 Lighthouse 相比,这个解决方案的优缺点与我们在上一节看到的是相同的。一方面我们有 Lighthouse,它已经为我们准备好了所有数据。但在这里,我们对自己的测量内容有更多的控制。这个测试相当简单,但你可以通过过滤掉所有你不想测量的资源来改进它。如果测试失败,你还可以将结果下载到文件中并与你的团队分享。
现在是时候总结这一章和这本书了。
摘要
如果你能在你的团队中应用性能测试,你将进入一个全新的层次。
我们从讨论 Lighthouse 开始这一章。我们只覆盖了性能类别。但现在你已经知道了它是如何工作的,我鼓励你继续深入研究其他类别,并思考如何为它们创建测试。我很乐意看到更多关于无障碍性的测试。
我们还学习了如何在测试中使用 Lighthouse。这不是你经常能看到的事情。你将能够使用两行代码测试非常复杂的指标。
大多数开发者都会避开 Puppeteer 的跟踪结果。尽管你可以从那里得到的信息远比我们覆盖的要多,但我们在这章中学习了这样一个强大工具的基础。
页面的尺寸对性能至关重要;这就是为什么我们学习了代码覆盖率及其测量方法。
这也是这本书的结束。当我计划这本书的时候,我的目标是写一本涵盖整个 Puppeteer API 的书,而不只是一本参考书。我认为我们实现了这个目标。我们学习了如何使用 Puppeteer 编写高质量的端到端测试,同时我们也覆盖了 Puppeteer API 的大部分内容。
带着这个目标,我们涵盖了与单元测试严格无关的主题。我们讨论了 PDF 生成和 Web 抓取。我们还涵盖了很多人会避开的话题,比如跟踪模型。
如果你从头到尾阅读这本书,我可以向你保证,你将比这个库的普通用户对 Puppeteer 了解得多得多。
但我也希望你们不仅仅学到了一个 Node 包。在这本书中,我们还学习了网络的基础知识以及如何编写好的测试。我们讨论了互联网生态系统、抓取伦理和网络性能。你们也提升了你们的角色。质量保证(QA)不仅仅是测试网页。它关乎通过提供高质量的软件来尊重用户,让他们能够享受使用。






浙公网安备 33010602011771号