WebDriverIO-测试自动化提升指南-全-

WebDriverIO 测试自动化提升指南(全)

原文:zh.annas-archive.org/md5/e41c77b0cd979c4981a5e9fb51217fa2

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎加入,让我们带着我们的超级英雄主题技术手册,踏上一次非凡的编码之旅!随着每一章的展开,就像一本激动人心的漫画书冒险故事一样,告别平凡。与传统超级英雄不同,你不需要与放射性蜘蛛接触就能解锁你的编码能力。相反,你将装备上使用 TypeScript 的 WebdriverIO 的基本工具,打造一个强大的框架。

对于那些即将成为软件开发测试工程师(SDET)的人来说,跃入设置 JavaScript 编码环境、运行那个开创性的测试并希望获得胜利通过结果的诱惑可能很强。我们曾经有过这样的经历,但后来发现一些关键工具被忽视了,这些工具本可以在一开始就使道路变得顺畅。这就是为什么开篇章节深入探讨了系统规格、工具和配置,为从第一天开始编写优质代码奠定基础。

准备好由那些在 SDET 超级英雄联盟中度过 20 多年的作者们的智慧引导。发现提示、技巧、经验法则和高级技术,这些都有助于你不仅编写更多的测试,而且优雅地应对调试挑战。提升你的测试框架到超级英雄的水平——稳定、可扩展且需要最少的代码维护。准备好释放你的编码超级力量,并在编码超级英雄宇宙中留下你的印记!

本书面向对象

本书是面向所有级别的测试自动化爱好者的超级英雄工具包,从新手到数字领域的资深冠军。它通过 TypeScript 中的 WebdriverIO 和 Jasmine 赋予用户超级力量,提供了一系列代码示例、Jenkins 的高级策略和基于云的自动化策略。无论你是刚刚被测试自动化世界的自动化虫咬,还是一位想要升级腰带装备的披风老将,这本书都是你掌握测试自动化艺术的秘密武器。

本书涵盖内容

第一章超级英雄的腰带——每个 SDET 需要的工具,概述了需要安装的初始准备工具,包括 Node、Yarn 和 VS Code IDE 配置。

第二章孤独堡垒——配置 WebdriverIO,涵盖了设置项目工作空间文件夹,并概述了 WDIO 安装选项以运行我们的第一个测试。

第三章网络增强——WebdriverIO 配置和调试技巧,深入探讨了包文件和 WDIO 配置选项,包括 Mac 和 Windows,以及用于增强日志记录的功能包装器概念。

第四章, 超级速度 – 时间旅行悖论和未兑现的承诺,深入探讨了多线程执行中的挑战,这些挑战可以通过 async 和 await 命令得到解决。

第五章, 替代自我 – 为什么我们需要函数包装器?介绍了辅助文件、交换机对象、一个智能的click()包装器,该包装器利用pageSync()函数并解决与速度相关的时序问题。

第六章, setValue 包装器 – 输入文本和动态数据替换,介绍了带有动态数据标签的setValue()包装器,它提供多种格式的偏移日期。

第七章, 选择包装器 – 在列表和组合框中选择值,介绍了select()包装器,它处理多种类型的下拉元素和高级滚动,以避免对象重叠错误。

第八章, 断言包装器 – 内嵌细节的重要性,介绍了一个带有自定义 Allure 报告和截图的软断言包装器。

第九章, 古代咒语书 – 构建页面对象模型,介绍了带有 xPath 和 CSS 定位符的页面类和原子操作。

第十章, 增强灵活性 – 编写健壮的选择器和减少维护,深入探讨了高级 xPath 技巧和自我修复策略,以减少维护。

第十一章, 回声定位 – 跳过页面对象模型,通过相对元素位置增强了仅通过文本查找元素的三种基本操作。

第十二章, 超级英雄着陆 – 设置灵活的导航选项,介绍了在不同测试环境中运行测试的概念,在这些环境中,元素可能已被删除或尚未存在,而不会失败。

第十三章, 多元宇宙 – 跨浏览器和跨环境测试,介绍了通过横向测试多个操作系统和浏览器的风险和回报,以扩展覆盖范围。

第十四章, 时间旅行者的困境 – 驱动状态端到端用户旅程,讨论了创建不依赖于任何特定页面跟随另一个页面的端到端测试的高级概念,并具有自定义决策点和错误检测。

第十五章, 感知之帽 – 使用 Jenkins 和 LambdaTest 在 CI/CD 管道中运行测试,将测试自动化带回到可以调用在云中生成复杂工件的手动测试人员,他们可以通过简单的描述性语句访问视频捕获回放。

附录TypeScript 错误消息、原因和解决方案终极指南,提供了从多年项目开发中收集的大量错误消息、潜在原因和解决方案。

要充分利用本书

本书涵盖的软件 操作系统要求
WebdriverIO v.8 Windows、macOS 或 Linux
TypeScript v.5.1.6
Java JDK @latest
Node v.18
Yarn @latest
Git @latest
GitHub Desktop 最新版本 GitHub 和 GitLab 的 GUI 前端
SelectorsHub 5.0 免费版 Chrome 扩展程序
EditThisCookie Chrome 扩展程序
VS Code
Belarc Advisor Profiler(可选)免费,单次,个人使用许可 仅限 Windows

作者们试图使用免费工具为读者提供便利。还有其他付费 IDE 可用,它们提供更多编码功能以使生活更轻松。此外,SelectorsHub 的免费版本做得非常出色,但我们推荐付费 Pro 版本,因为它具有高级 Shadow Dom 功能。免费 GitHub 账户是公开的,而付费仓库是私有的。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节将提供链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。

如果您对自动化测试是新手,我们建议您获取与您将要支持的产品开发团队使用的规格相当或更好的机器。有一种常见的误解认为自动化只是“录制和播放”,并且不需要重型资源。有一个简单的事实需要记住:并行浏览器和虚拟机测试需要更多的资源。

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Enhanced-Test-Automation-with-WebdriverIO)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还提供其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们有一个host命令和一个ghost派对。编写这一行代码可能会从ghost字符串中获取host命令。”

代码块设置如下:

Set JOURNEY="Attend Ghost"; yarn ch15
if (journey.includes(" host").toLowerCase()) {
// Host path being taken in error.
}

任何命令行输入或输出都应如下编写:

[0-0] ---> Clicking button[type="submit"] ...
[0-0] ---> button clicked.
[0-0] ---> pageSync() completed in 25 ms
[0-0] ---> Clicking button[type="bogus"] ...
[0-0] ---> button[type="submit"] was not clicked.
[0-0] Error: Can't call click on element with selector "button[type="bogus"]" because element wasn't found

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“在这个例子中,用户没有参加派对,而是点击了我害怕按钮。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上发现我们作品的任何形式的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com.

分享您的想法

一旦您阅读了《使用 WebdriverIO 增强测试自动化》,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?您的电子书购买是否与您选择的设备不兼容?

别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止这些,您还可以获得独家折扣、新闻通讯和每日免费内容的访问权限

按照以下简单步骤获取福利:

  1. 扫描下面的二维码或访问以下链接

packt.link/free-ebook/978-1-83763-018-9

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱

第一章:工具腰带——每个超级英雄 SDET 需要的工具

这不是您普通的枯燥乏味的科技手册。这本书旨在有趣。这就是为什么许多章节都采用了漫画书主题。但与一些超级英雄不同,您不需要被放射性蜘蛛咬伤才能获得这些力量。我们只需要一些工具,利用 TypeScript 中的 WebdriverIO 创建一个伟大的框架。

如果您是刚开始作为 软件测试开发工程师SDET)的旅程,您可能会想直接跳过,安装 TypeScript 编码环境,运行您的第一个测试,并希望看到 通过的结果。我自己也这样做过,但后来意识到有些工具我错过了,这些工具本可以帮助从开始就使旅程变得更轻松。这就是为什么第一章讲述了系统规格、工具和配置,这些将帮助我们从第一天开始编写更好的代码。

在此过程中,我将分享我作为 SDET 超过 20 年的经验和技巧。这里会有一些经验法则和高级技术。这些旨在帮助您编写更多测试,更有效地调试,并创建一个稳定、可扩展的测试框架,且代码维护量远低于其他框架。

本章涵盖的主要主题包括:

  • 纯净机器设置

  • 为您的操作系统安装 Visual Studio Code

  • 使用 Prettier、ESLint 和 GitLens 编写更好的代码

  • 安装 Chrome 扩展程序

  • 安装 WebdriverIO

纯净机器设置

在您可以在纯净机器上的测试自动化世界中做任何事情之前,您必须安装一些包,因此您需要机器的管理员权限。所以,在继续之前,请确保您已经全局安装了以下包及其最稳定的版本:

  • NodeJS

  • Yarn

  • Java JDK

  • 集成开发环境IDE)(IntelliJ、VSCode 等等)

  • Git

如果您正在使用 Windows 机器,以下是一些额外的步骤:

  • 为您的 node 设置 PATH 环境变量

  • 重启机器以使所有更改生效

在我们运行第一个测试之前,我们需要检查系统要求并获取我们的工具。在本章中,我们将介绍如何安装和配置使我们的工作更轻松的工具,如下所示:

  • 硬件规格

  • Node.js

  • GitHub 账户GitHub Desktop 用于代码变更管理

  • Microsoft Visual Studio Code

  • PrettierGitLensESLint 扩展

  • SelectorsHubEditThisCookie Chrome 扩展程序

注意,要安装这些工具,您需要本地管理员权限,或者认识您 IT 安全部门中拥有权限并能为您安装它们的人。没有本地管理员权限,您将无法取得进展。您应该拥有与产品开发团队相同的权限,您将测试他们的应用程序。

这就带我们来到了我们的第一个经验法则。

经验法则——硬件资源和访问权限必须与开发团队相匹配

在整本书中,我将提出一些我用来保持我们走在正确道路上的经验法则,并避开荆棘丛。

让我们谈谈为什么这很重要。一开始,你只需考虑你是否能安装 Chrome 扩展,就可以评估你的自动化项目是否会成功。如果你的企业 IT 安全部门阻止安装任何浏览器扩展,你的自动化进度将受到严重阻碍。我们都希望有一个成功的测试自动化项目。我们不想在起点就受到限制。测试自动化是代码开发;它需要开发工具,而你就是一个开发者。不要让任何人告诉你不同。

如果你的雇主或客户将你的项目视为仅仅是记录和回放,那么你的项目从一开始就注定要失败。这种情况的最大红旗是,你的计算机资源和访问权限与开发者的不匹配。

问题:我的 WebdriverIO 测试自动化系统的技术规格要求是什么?

答案很简单:不做

不要使用互联网上任何地方列出的最低要求。无论是什么,都太小了。

确实要匹配 CPU 速度、RAM 数量、驱动器空间和桌面上显示器的数量。

这包括匹配开发团队使用的 Mac 或 Windows 操作系统的版本。Windows 应该是 64 位的,可能是专业版。

这还包括你的应用程序开发者的本地管理员权限。这允许你安装将节省团队时间的浏览器扩展。这意味着你可能需要提出一个商业案例来满足这一要求。

真的很简单:没有这些工具,你将花费时间手动编写定位器,并采取额外步骤来清除 cookies。项目将进展缓慢,公司将在相同的时间内为更少的测试支付更多费用。在极端情况下,你可能不得不放弃一个项目,并寻找一个愿意认真对待 QA 测试的新雇主。唯一的例外是如果你的应用程序开发者正在使用 Eclipse,这并不推荐用于专业级别的代码开发。

话虽如此,让我们首先安装 WebdriverIO 的工具,以确保我们朝着正确的方向前进。

我们将首先模仿两个拥有无限财富和卓越智慧的英雄。为了有效地打击犯罪,一个拥有多个工具的腰带,另一个则有一个带有 AI 智能的金属战衣,以帮助更快地将恶棍——或者在我们的情况下,是虫子——绳之以法。

安装 Node.js 和 npm

Node.js 是一个开源的、跨平台的运行时环境,也是一个用于在客户端浏览器之外运行 Web 应用的异步库。该项目使用 Node 版本 16.13.0 创建,出于几个原因。早期版本仅需要支持同步模式,这在 WebDriverIO 7.0 中已被弃用,并在版本 8.0 中被移除。尽管截至本文撰写时 Node 的最新版本是 19.8.1,但建议使用 16.13.0,因为它与大多数其他模块和包的兼容性最好。

确保您有足够的硬盘空间用于安装。安装至少需要 3 GB。默认情况下,这些工具安装在 C: 驱动器上。如果您的驱动器接近容量,请考虑在更大的驱动器分区上安装。

让我们从安装 Node 和 npm 开始。以下截图显示了如何进行操作:

图 1.1 – 从 https://nodejs.org/en/download/ 下载 Node.js

图 1.1 – 从 https://nodejs.org/en/download/ 下载 Node.js

对于 Mac,安装最新的 .pkg 文件。

对于 Windows,下载 64 位版本。

要安装的 Node.js 版本将是 @wdio/sync 同步模式,它仅通过 Node.js 版本 12.0 得到支持且稳定。本书将使用 async()await() 命令进行异步命令执行。

安装过程中还将安装以下内容:

  • Chocolatey 用于 Windows 或 Brew 用于 Mac,是一个包安装工具

  • Python

  • Node.js

  • npm

  • 必需 系统更新

提示

所有这些都需要管理员权限才能成功完成。

完成后,检查 Node.js 和 Chocolatey 路径是否已添加到系统的 PATH 环境变量中,如下面的截图所示。如果没有,它们必须手动添加:

图 1.2 – Windows PATH 环境变量中的 Node.js 和 Chocolatey 路径

图 1.2 – Windows PATH 环境变量中的 Node.js 和 Chocolatey 路径

对于 Mac 和 Windows,我们将安装至少版本 18.0 的 Node。从命令行中,输入以下两个命令:

> nvm install 18
Downloading node.js version 18.17.1 (64-bit)...
> nvm use 18
Now using node v18.17.1 (64-bit)

虽然这完成了 Node 的安装,但我们还需要注意提供额外选项的替代包管理器。

替代节点包管理器 – Yarn 与 npm 的比较

虽然 npm 是默认的节点包管理器,但我们推荐使用 Yarn 来安装包和运行程序。Yarn 的一个主要优点是它并行安装包。这显著减少了初始化或刷新 package.json 文件时的构建时间。

再次从命令行输入以下内容:

> npm install --global yarn

安装 Yarn 后,您可以通过运行以下命令来验证安装:

> yarn --version
1.19.22

完成这些后,我们将决定项目将存放在哪里。

使用 GitHub Desktop 配置编码环境

在接下来的章节中,我们将更深入地探讨 Git 和 GitHub 用于代码版本控制。但制定计划总是好的,因此我们将为我们的项目创建一个 Git 文件夹结构。

我们的 wdio 项目工作区将位于 Git C: 驱动器中。原因是 Node.js 项目依赖于许多支持包。这些将在 node_modules 文件夹中占用大量的额外空间。在某个时候,驱动器将被填满,影响响应速度。

让我们从在驱动器根目录或 Mac 上的桌面上创建一个 \repos 文件夹开始,以存放我们的项目:

图 1.3 – 示例仓库和项目目录结构

图 1.3 – 示例仓库和项目目录结构

我们的项目将位于名为 \wdio 的本地仓库中。这是我们存储文件的地方。稍后,我们将使用代码仓库进行版本控制,例如GitHubGitLabBitbucket。对于这本书,我们将使用 GitHub,GitHub Desktop 将是我们的代码提交工具。

GitHub 和 GitHub Desktop 工具

所有开发者都需要的一个工具是代码版本控制。这将是任何你加入的团队的要求,并且他们期望你了解 Git 命令。学习语法和命令对新程序员来说可能是一个挑战。在命令提示符中输入的错误可能在任何时刻发生,而知道如何解决这些问题可能是一个更大的挑战。

然而,有一个更简单的方法可以使生活变得更美好。使用GitHub Desktop工具进行代码提交,可以直观地了解代码更改。你可以检查它使用的 Git 命令,以学习如何使用终端窗口并减少错误。

首先,我们需要一个 GitHub 账户。

获取 GitHub 账户

访问 www.github.com 并点击注册。输入电子邮件、密码和用户名,并验证您的账户。

在账户设置过程中,选择自动化和 CI/CD选项。这将配置项目,使其能够在一天或一周中的特定时间自动触发执行:

图 1.4 – 自动化和 CI/CD 配置

图 1.4 – 自动化和 CI/CD 配置

现在我们有一个免费的公共 GitHub 账户来练习提交和版本控制。现在,转到您的 GitHub 页面,创建一个带有 README 文件的 wdio 仓库。请注意,免费 GitHub 账户是公开的。在专业工作中,最好是你或你的雇主购买付费计划以使仓库私有:

图 1.5 – 使用 GitHub 的 README 文件初始化 wdio 项目仓库

图 1.5 – 使用 GitHub 的 README 文件初始化 wdio 项目仓库

我们现在有了 GitHub 账户仪表板。它提供了安装额外工具的建议:

图 1.6 – GitHub 中的链接以安装 GitHub Desktop 和 Visual Studio Code

图 1.6 – GitHub 中的链接以安装 GitHub Desktop 和 Visual Studio Code

从这里,我们将添加 GitHub Desktop 和 Visual Studio Code IDE。首先,我们将安装 GitHub Desktop。

安装 GitHub Desktop

[从 desktop.github.com/ 下载 GitHub Desktop](https://desktop.github.com/)

GitHub Desktop 的安装非常简单。只需下载适用于您操作系统的安装程序并启动它。一旦过程完成,GitHub Desktop 将启动:

图 1.7 – 为您的操作系统下载 GitHub Desktop

图 1.7 – 为您的操作系统下载 GitHub Desktop

如果你曾经看过 Git 的入门级命令行视频,学习所有这些复杂的命令可能会让你脊背发凉。这可能会激发出恐惧,如果在不先从其他团队成员那里拉取更改的情况下提交更改,团队项目可能会被破坏。这就是为什么对于初学者来说,GUI 比打印的 Git 技巧表更好——它可以在学习 Git 命令时防止错误。以下是一个例子:

图 1.8 – GitHub Desktop 显示在提交操作之前应该拉取的挂起更改

图 1.8 – GitHub Desktop 显示在提交操作之前应该拉取的挂起更改

在速度和准确性方面,图形界面工具比命令行界面更推荐。在先前的例子中,我们知道我们处于 webdriverio 仓库的 main 分支,并且项目已从其他团队成员那里提交了更改,一个大的蓝色 拉取来源 按钮提醒我们首先拉取其他团队成员已提交的更改。跳过此步骤可能会撤销代码更改,造成麻烦。

GitHub Desktop 的 历史 视图提供了最近提交的描述。它告诉我们哪些文件被更改了,以及旧代码行和新代码行之间的差异:

图 1.9 – GitHub Desktop 的历史视图显示旧的和新的代码更改

图 1.9 – GitHub Desktop 的历史视图显示旧的和新的代码更改

通过单击一个按钮执行代码提交比在终端窗口中键入要快。我们将很快链接我们的项目。接下来,我们将安装我们的编码环境工具。

选择你的 TypeScript 开发环境 – 微软 Visual Studio Code 与 JetBrains Aqua

在开发一个健壮框架的旅途中,我们需要做出许多决定,其中之一是选择哪个 IDE 来编写和运行我们的代码。我个人的偏好是使用 Visual Studio Code,因为它在多个视图中编码更容易。

然而,截至本文撰写时,JetBrains Aqua 仅支持在从配置启动脚本时设置断点。当从嵌入的终端窗口启动测试脚本时,它不会在断点处暂停。这就是我们将运行我们的 WebdriverIO 脚本的方式。由于 Visual Studio Code 是开源的,因此将是这些项目的首选工具,但我仍然建议您尝试 JetBrains Aqua,因为它拥有优越的代码界面设计。

Microsoft Visual Studio Code是本书的免费 IDE,并得到了强大的公司支持。然而,许多免费工具缺乏收入来源来支持开发团队或产品支持团队。免费工具在功能上可能比付费工具落后几年。以 Selenium 为例,它在 2019 年 5 月发布的 4.0 版本中引入了相对元素定位,而这一功能在大多数付费工具集中都已有,包括 Micro Focus Unified Functional Testing (UFT),它可以追溯到 2010 年。

为您的操作系统安装 Visual Studio Code

code.visualstudio.com/download 下载 Visual Studio Code。

按照您操作系统的安装过程进行安装。再次提醒,建议将这些工具安装在一个大于\repos目录的较大驱动器上。

现在 Visual Studio 已经安装,我们可以从嵌入的终端外壳窗口检查 Node 和 npm 是否已安装。

从主菜单,选择Terminal > New Terminal,然后在 Windows 上按Ctrl + Shift,在 Mac 上按^ + Shift + `

启动测试的终端外壳取决于您的个人选择。PowerShell推荐给 Windows 用户,ZSH推荐给 Mac 用户,Git Bash对于命令行 Git 用户来说是个不错的选择。但是,对于调试,两个平台都需要JavaScript Debug Terminal

从终端窗口,点击位于右下角+按钮旁边的v向下箭头,选择JavaScript Debug Terminal,并输入以下内容:

node –v
npm -v

系统将响应您已安装的 Node 和 npm 的版本:

图 1.10 – 从 Visual Studio Code 中嵌入的终端外壳检查您已安装的 Node 和 npm 版本

图 1.10 – 从 Visual Studio Code 中嵌入的终端外壳检查您已安装的 Node 和 npm 版本

注意,JavaScript Debug Terminal 的运行速度会比 PowerShell 或 Bash 外壳慢,因此最好只在需要停止在断点进行调试时使用它。接下来,我们将从 Visual Studio Code 初始化我们的项目。

初始化 Node 项目

现在我们已经安装了 Visual Studio Code 并创建了我们的项目工作区目录,我们可以检查 Node 是否已安装并初始化我们的项目。从终端导航到/repos/wdio文件夹,并输入以下命令:

npm init -y

这将创建一个新的 Node package.json 文件,并带有默认配置:

图 1.11 – 初始化 Node package.json 文件

图 1.11 – 初始化 Node package.json 文件

此文件跟踪 WebdriverIO 使用以构建和自动化测试的所有支持 Node 包。

现在我们已经有了第一个项目文件,接下来,我们将配置编辑器设置以使编码更少出错。

配置 Visual Studio Code

第一个变化是 Visual Studio Code 将保存文件。默认情况下,从代码窗口切换到终端时不会发生隐式保存。由于我们的测试将从终端启动,我们想确保执行的是最新版本的代码。以下是我们可以如何配置它:

图 1.12 – 将 Visual Studio Code 设置为在焦点切换到终端窗口时保存文件

图 1.12 – 将 Visual Studio Code 设置为在焦点切换到终端窗口时保存文件

前往 自动保存

将设置从 afterDelay 更改为 onWindowChange

这将确保在从代码控制台切换到终端控制台时保存代码。这避免了代码已更新,但显示的结果却是使用未更改的代码执行的一个常见问题。

X-ray vision – 使用 Prettier、ESLint 和 GitLens 编写更好的代码

开发者需要帮助他们的代码格式正确、遵循良好的编码实践,并知道团队成员何时进行代码更改的编码工具。这就是 Visual Studio Code 扩展如 Prettier、ESLint 和 GitLens 变得非常有价值的地方。让我们安装这些工具。

安装 Visual Studio Code 插件 – Prettier

扩展 图标添加 Prettier 代码 格式化器 扩展:

图 1.13 – 通过点击立方体图标可以访问扩展

图 1.13 – 通过点击立方体图标可以访问扩展

Prettier 将自动格式化代码,而无需手动输入额外的制表符。在这个例子中,左侧的代码未格式化。现在我们可以通过右键单击代码并选择 格式化文档 来调用 Prettier:

图 1.14 – Prettier 格式化前的代码示例

图 1.14 – Prettier 格式化前的代码示例

然后以结构化的方式重新格式化代码。请注意,方括号和大括号缩进,并且自动包含额外的行:

图 1.15 – Prettier 格式化的缩进代码

图 1.15 – Prettier 格式化的缩进代码

现在代码已格式化以供阅读。下一个扩展将帮助我们处理 GitHub 团队的工作。

安装 Visual Studio Code 插件 – GitLens

在许多其他功能中,GitLens 扩展将显示在我们 GitHub 仓库中最后进行代码更改的人。从 扩展 中添加 GitLens 扩展:

图 1.16 – GitLens 扩展

图 1.16 – GitLens 扩展

点击项目中任何跟踪文档的任何一行都会激活 GitLens:

图 1.17 – GitLens 以灰色显示最后提交代码更改的人

图 1.17 – GitLens 以灰色显示最后提交代码更改的人

在前面的示例中,我们可以看到第 31 行是在 2 个月前由 Christian Bromann 更新的。这使得了解代码行何时被更改、由谁更改以及多久以前变得很容易。

这个附加组件将帮助我们找到代码错误,以提高我们框架的可靠性。

安装 Visual Studio Code 附加组件 – ESLint

检查器是一个程序,它会在我们的代码中寻找潜在的问题,就像房间里角落里积累的灰尘。大多数编程语言都有检查器,ESLint 是一个 TypeScript 检查器。为什么是 ESLint 而不是 JSLint?ES 代表 ECMAScript,这是 JavaScript 的代码标准,旨在确保网页在不同浏览器之间的互操作性。截至本文撰写时,当前版本是 ES6。在搜索代码示例时,请注意代码片段是否为 ES5 或更早版本,因为近年来已经添加了新功能。

扩展 中添加 ESLint 扩展:

图 1.18 – ESLint ECMAScript 检查器扩展

图 1.18 – ESLint ECMAScript 检查器扩展

ESLint 在 TypeScript 项目中查找并报告代码模式。目标是使代码更加一致,并提前避免错误。您可以在以下屏幕截图中看到它的使用情况:

图 1.19 – ESLint 在 TypeScript 项目中报告 81 个潜在问题的示例

图 1.19 – ESLint 在 TypeScript 项目中报告 81 个潜在问题的示例

ESLint 提供了一个新的 问题 窗口,列出了改进我们代码库的问题和建议,随着我们增强框架,它还可以通过新的规则进行自定义。

ECMAScript、JavaScript 和 TypeScript 之间的区别是什么?

ECMAScript 是现代浏览器中发现的 JavaScript 语言定义,ES5 和 ES6 是最近的新描述。TypeScript 是 JavaScript 的超集,它向 JavaScript 添加了类型声明,就像 Java 一样。

现在我们已经配置了 Visual Studio Code,让我们为浏览器添加一些用于选择器和 cookie 的工具。

安装 Chrome 扩展

我们接下来的两个工具安装起来最为简单。SelectorsHub 允许我们在 Chrome、Edge 以及任何基于 Chromium 的浏览器中创建稳健的元素定位器,而 EditThisCookie 允许我们从浏览器前端清除 cookie 缓存。稍后,我们将确保框架会在每次新的测试执行前清除 cookies。

添加 SelectorsHub Chrome 扩展

在您的 Chrome 浏览器右上角,选择三个垂直省略号。然后,点击 更多工具 并然后 扩展程序。点击左上角的汉堡图标。最后,在左下角点击 打开 Chrome 网上应用店

图 1.20 – 从 Chrome 网上应用店添加 Chrome 扩展程序

图 1.20 – 从 Chrome 网上应用店添加 Chrome 扩展程序

在 Chrome 网上应用店中,搜索并安装 SelectorsHub 扩展程序:

图 1.21 – SelectorsHub Chrome 扩展程序

图 1.21 – SelectorsHub Chrome 扩展程序

一旦扩展程序安装完成,它应该被允许在 隐身 模式下交互。

扩展程序 页面,点击 细节 按钮,将 允许在隐身模式下使用 开关设置为开启:

图 1.22 – 允许 SelectorsHub Chrome 扩展程序在隐身模式下显示

图 1.22 – 允许 SelectorsHub Chrome 扩展程序在隐身模式下显示

同样,我们将添加一个扩展程序,使清除我们的浏览器 cookies 更加快速。

添加 EditThisCookie Chrome 扩展程序

与之前的扩展程序一样,从 Chrome 网上应用店搜索 EditThisCookie 扩展程序:

图 1.23 – EditThisCookie Chrome 扩展程序

图 1.23 – EditThisCookie Chrome 扩展程序

EditThisCookie 扩展程序将使清除浏览器中的 cookies 更加容易。只需点击两次即可清除所有 cookies,并且它可以清除特定应用程序的 cookies,例如我们正在测试的应用程序。

接下来,我们需要这些扩展程序在 Chrome 浏览器上可见,以便于访问。

将 Chrome 扩展程序固定到浏览器标题栏

按照以下步骤操作:

  1. 点击浏览器右上角的拼图扩展程序图标。

  2. 点击两个扩展程序旁边的 推针 图标。

  3. 这些图标现在将出现在浏览器右上角的 扩展程序 快捷方式区域,以便于访问:

图 1.24 – 将扩展程序固定到浏览器工具栏

图 1.24 – 将扩展程序固定到浏览器工具栏

我们的工具带现在已经完整。我们有了编码环境、源代码编辑器以及一些故障排除工具,帮助我们一跃而起,完成高难度的项目。接下来,我们将安装 WebdriverIO。

安装 WebdriverIO

有两种选项可以使用和安装 WebdriverIO

  • 使用 WDIO TestRunner 的异步模式

  • 使用 WedbriverIO 独立模式

在下一章以及本书的其余部分,我们将使用第一种选项。尽管如此,仔细查看这两种选项:

选项 1:使用 WebdriverIO 及其内置的 WDIO TestRunner 是默认模式,也是最常见的使用场景。测试运行器有效地解决了在利用基本自动化库时经常遇到的许多挑战。首先,它通过组织和分配测试规范来简化您的测试执行管理,以最大化并发测试。此外,它熟练地管理会话操作,并提供一系列旨在帮助您在测试套件中排除故障和识别错误的功能。

在您方便的时候查看 klassi-js 仓库(https://github.com/klassijs/klassi-js)。还有一个项目模板,您可以克隆它(github.com/klassijs/klassi-example-test-suite),这样您就能在几秒钟内拥有一个运行中的项目。

以下是一个作为测试规范编写的示例脚本,并由 WDIO 执行:

import { browser, $ } from '@wdio/globals'
describe('DuckDuckGo search', () => {
    it('Searches for WebdriverIO', async () => {
        await browser.url('https://duckduckgo.com/')
        await $('#search_form_input_homepage').setValue('WebdriverIO')
        await $('#search_button_homepage').click()
        const title = await browser.getTitle()
        expect(title).toBe('WebdriverIO at DuckDuckGo')
        // or just
        await expect(browser).toHaveTitle('WebdriverIO at DuckDuckGo')
    })
})

注意

所有 WebdriverIO 命令都是异步的,需要使用 async/await 正确处理。

摘要

在本章中,我们安装了许多开始编写健壮的测试自动化框架所需的工具。我们通过两个扩展增强了浏览器,以简化元素定位器的创建和处理 cookies。使用 npm 安装了 Node.js 环境,并创建了一个代码仓库文件夹。安装了 Visual Studio Code IDE,并提供了用于静态代码分析和代码格式的工具,我们还提供了有关代码何时被修改以及由谁修改的详细信息。

在下一章中,我们将安装 WebdriverIO 并开始探索 WebdriverIO TypeScript 框架项目的文件夹结构。

第二章:独立堡垒 – 配置 WebdriverIO

在本章中,我们将安装 WebdriverIO 及其依赖项。有两种方法,我们将讨论每种方法的优点。同时,保持依赖项的版本更新也很重要。为此,我们将使用 Yarn 来保持我们的 package.json 和 yarn.lock 文件更新。

WDIO 的设置说明可以在官方网站的 入门 部分找到(webdriver.io/docs/gettingstarted):

图 2.1 – 入门

图 2.1 – 入门

图 2.2 – 7.x 版本的当前文档指示器

图 2.2 – 7.x 版本的当前文档指示器

在本节中,我们将涵盖以下主要主题:

  • WebdriverIO 设置

  • 构建和安装项目依赖项

  • 进行第一次提交

小贴士

确保您正在查看 WDIO 8.0 的最新版本。在 Google 上搜索有关 WDIO 功能的问题可能会导致旧版本的支持页面。

WebdriverIO 设置

WDIO 团队努力使一切安装变得简单,如文档所述。WDIO 可以以两种方式设置:

  • 在回答一系列问题时进行自定义配置

  • 从 GitHub 上的现有项目克隆

对于此项目,我们将展示问题和所选答案。第二种方法,克隆样板项目方法,将在下一节中描述。

选项 1 – 开始安装 TypeScript 的 WebdriverIO 8.0 所需的步骤

\repos\wdio 文件夹导航。使用 Yarn 快速设置 WDIO 项目的最快方法是键入 yarn create wdio,以点(.)结束:

> yarn create wdio .

WDIO 机器人将出现,并显示一系列配置问题:

图 2.3 – 从代码 TERMINAL 窗口初始化 WDIO

图 2.3 – 从代码 TERMINAL 窗口初始化 WDIO

初始化将询问如何从头开始配置 WDIO。以下是 WebDriver 8.0 的设置列表。有几个选项,许多人会使用默认设置。带有星号(*)的每个项目都显示了在设置时选择的选项:

注意

WebdriverIO 一直在更新。这些问题本身对于 Mac 和 Windows 用户应该是相似的。然而,随着新功能的添加,顺序、措辞和选择细节会有所变化。

图 2.4 – 设置

图 2.4 – 设置

? 您想进行哪种类型的测试?(使用 箭头键)

? 您的自动化后端位于何处?(使用 箭头键)

  • (*) 在我的本地机器(默认)

  • ( ) 在云中使用 Experitest

  • ( ) 在云中使用 Sauce Labs

  • ( ) 在云中使用 Browserstack 或 Testingbot 或 LambdaTest 或其他服务

  • ( ) 我有自己的 Selenium 云

现在,有许多云选项,包括 ExperitestSauce LabsBrowserStackTestingbotLambdaTest。对于这本书,我们将在本地的 Mac 或 Windows 机器上安装自动化后端。

接下来是环境类型。为此目的,我们将使用 Web

? 您想自动化哪个环境?(使用 箭头键)

  • (*) Web - 浏览器中的网页应用

  • ( ) 移动设备 - 原生、混合和移动网页应用,在 Android 或 iOS 上

然后,选择我们将要使用的浏览器。选择默认的 Chrome。注意,我们稍后可以添加其他浏览器:

**? 我们应该从哪个浏览器开始?(按 选择,按 切换所有,按 反转选择,并按 **继续)

  • (*) Chrome

  • ( ) Firefox

  • ( ) Safari

  • ( ) Microsoft Edge

接下来是报告框架类型。对于这本书,我们将使用 Jasmine。然而,提供的许多代码将适用于所有列出的框架:

? 您想使用哪个框架?(使用 箭头键)

WebdriverIO 默认使用 Mocha。然而,它也支持 Jasmine,并且可以与 Chai 结合使用进行高级断言。Cucumber 是一个抽象层框架,它隐藏了核心代码。这使得使用 Feature 文件创建测试时需要的专业技术资源更少。Cucumber 不在本书的范围之内,但描述的技术可以在 Cucumber WDIO 项目中实现。接下来,我们将告诉 WDIO 这是一个 TypeScript 项目:

? 您想使用编译器吗?(使用 箭头键)

问题:什么是 Babel 以及是否需要它?

Babel (babeljs.io/) 是一个 JavaScript 编译器。由于 JavaScript 在不同的浏览器中实现方式不同,因此使用编译器将我们的代码转换为较旧的 JavaScript 版本。某些功能在某些浏览器中未实现,例如 async/await,这取决于我们测试的浏览器版本。因此,编译器允许我们的框架具有向后兼容性。尽管这是一个 TypeScript 项目,但我们不需要 TypeScript 编译器。

问题:如何知道不同浏览器和版本中可用的功能?

caniuse.com 网站提供了不同 ECMAScript 特性支持的描述性表格:

我们将使用 TypeScript 编写测试,它是 JavaScript 的超集。将使用 TypeScript 编译器。现在,为了快速启动示例脚本。

? 您想让 WebdriverIO 自动生成一些 测试文件吗?

(Y/n) 是

这将自动设置一个示例测试以运行,以确保 WebdriverIO 正在正常工作。这也是我们将构建框架单元测试以检查功能是否正常工作的地方。哦,是的,我们是开发者,我们的自动化项目有自己的单元和集成测试。

以下是为 TypeScript 示例测试用例提供的默认路径,不应更改:

? 您的 规格文件 应该位于何处?

./test/specs/**/*.ts

测试可以组织到 specs 文件夹下的功能子文件夹和冒烟测试中。请注意,因为我们之前选择了 TypeScript,所以测试扩展 (.js) 已替换为 .ts。

? 您想使用页面 对象(martinfowler.com/bliki/PageObject.html) 吗?

这为我们项目设置了一个页面对象模型文件夹结构。

? 您的页面对象 位于何处? ./test/pageobjects//*.ts**

现在,我们想要配置我们的报告器。

您想使用哪个报告器?

  • (*) spec

  • ( ) dot

  • ( ) junit

  • (*) Allure

  • ( ) 视频

  • ( ) mochawesome

  • ( ) Slack

WebdriverIO 支持广泛的报告器。在这个小型示例中,我们将从 spec 和 allure 报告器开始。请注意,WDIO 甚至支持 视频 选项。您可能会注意到 Slack 已包含在内。在本书的最后一章中,我们将使用 Jenkins 向 Slack 频道发送更新消息。

? 您想将插件添加到您的 测试设置中吗?

在我们的框架中,我们将采用高级方法等待页面同步。此选项将保持不变。

如果要测试的应用程序(AUT)是 Angular 项目,建议使用 Angular Component Harnesses 配置。

? 您想将服务添加到您的 测试设置中吗?

  • ( ) VS Code

  • ( ) eslinter-service

  • ( ) lambdatest

  • ( ) crossbrowsertesting

  • ( ) VS Code

  • ( ) docker

  • ( ) Slack

备注

34 个附加服务已集成到 WDIO 中,包括 Slack、跨浏览器测试(Selenium Standalone)和 ES-Linter。涵盖所有这些超出了本书的范围。

WebdriverIO Visual Studio CodeVS Code)服务允许我们在 VS Code 桌面 ID 中无缝测试从端到端的扩展。通过提供您的扩展路径,服务将完成其余工作,如下所示:

  • 🏗 安装 VS Code(可以是稳定版、内部版本或指定版本)。

  • ⬇ 下载与给定 VS Code 版本特定的 Chromedriver。

  • 🚀 允许您从测试中访问 VS Code API。

  • 🖥 使用自定义用户设置启动 VS Code(包括对 Ubuntu、macOS 和 Windows 上 VS Code 的支持)。

  • 🌐 从服务器提供 VS Code,以便任何浏览器都可以访问进行测试扩展。

  • 📔 使用与您的 VS Code 版本匹配的定位器启动页面对象。

下一个问题要求您输入测试应用的着陆页。为此,我们将使用默认提供的,因为示例测试使用它来导航到测试网站。

? 基础 URL 是什么?

http://localhost

这是我们的测试将启动的基本着陆页。

一个基本着陆页确保我们不会重复添加代码来导航到相同的着陆页。在本书的后面部分,我们将看到如何自定义此值。目前,我们将使用互联网沙盒进行测试。

最终的安装步骤是让 npm 下载并安装所有包。虽然这部分可以由安装程序执行,但我们需要进行一项修改。对于最终问题选择否。

? 你想要我运行 npm** **install (Y/n)

由于 Yarn 的速度更快,我们将使用 Yarn 而不是 npm 作为包管理器。这完成了从向导安装和配置 WebdriverIO 的设置。另一个选项是克隆现有项目,这将在下一部分介绍。如果您不打算从现有项目克隆,请跳转到 安装和配置 WebdriverIO 部分。

由于我们使用 Yarn 作为包管理器而不是 npm,我们需要删除 package-lock.json 文件并运行 yarn install 命令来构建等效的 yarn.lock 文件。

> yarn install

选项 1 – 从模板项目克隆 WebdriverIO

设置 WDIO 的另一种方法是使用 WDIO GitHub 仓库中的预配置 WDIO 模板项目。这意味着可能不需要进行太多的故障排除。我们可以从许多预配置的模板项目中选择,这些项目包含所有必要的组件。

对于这个项目,我们将从 GitHub 上的 Jasmine TypeScript Boilerplate 项目进行分支:

图 2.5 – GitHub 上的 Jasmine TypeScript 模板项目

图 2.5 – GitHub 上的 Jasmine TypeScript 模板项目

点击 jasmine-boilerplate 链接。这将允许我们通过 代码 按钮创建自己的版本:

图 2.6 – 从 GitHub 复制项目 URL

图 2.6 – 从 GitHub 复制项目 URL

点击 代码。将显示克隆项目的多个选择。选择 使用 GitHub Desktop 打开:

图 2.7 – 从源路径克隆到本地目标

图 2.7 – 从源路径克隆到本地目标

点击 repos 路径。

接下来,我们将更改 repo\wdio,并点击 克隆

图 2.8 – VS Code 中的项目资源管理器图标

图 2.8 – VS Code 中的项目资源管理器图标

点击 WDIO 文件夹。

然后,点击 repo\wdio 文件夹,并点击 打开

图 2.9 – 信任项目的作者

图 2.9 – 信任项目的作者

如果出现此对话框,请检查 信任父文件夹‘repos’中所有文件的作者 选项,然后点击 是,我信任 作者

这样,我们就涵盖了克隆安装方法。接下来,我们将安装所有内容。

构建和安装项目依赖项

如果你从一个现有项目中安装了 WebdriverIO,这就是我们继续的地方。在我们运行第一个测试之前,我们需要构建项目。在终端中,输入以下内容:

> yarn install

这将引入所有相关包以运行项目。在未来某个时候,可能会出现漏洞,我们不得不更新我们的包。我们可以使用 Yarn 检查哪些包是当前的,哪些是过时的:

> yarn outdated

输出可以在以下屏幕截图中看到:

图 2.10 – 显示过时的包

图 2.10 – 显示过时的包

如果我们盲目地升级所有包,可能会发生不兼容的情况。幸运的是,有 yarn upgrade 命令,允许单独升级包:

> yarn upgrade-interactive

我们将看到以下输出:

图 2.11 – 升级时的交互式包列表

图 2.11 – 升级时的交互式包列表

这使我们保持项目包更新时具有最大的灵活性。

快速提示

如果你想清除终端,在 Windows 中使用 cls 或者在 Mac 上使用 Ctrl + K 或 clear。

安装后,yarn.lock 文件将被更新,node_modules 文件夹将下载所有支持依赖项。这包含已包含以支持 package.json 中包的包的扩展列表。yarn.lock 文件永远不需要编辑。

在这一点上,我们应该指出,WebdriverIO 的设置假设新手用户可能不知道如何引入所有支持包:

图 2.12 – 成功安装 TypeScript 的 WebdriverIO

图 2.12 – 成功安装 TypeScript 的 WebdriverIO

最后,我们可以使用版本标志确认已安装的 WebdriverIO 版本。

对于 Windows 用户:

> npx wdio --version

对于 Mac 用户:

> wdio --version

我们做到了!所有支持的功能都已添加到 package.json 文件中。WDIO 甚至给我们一个提示来尝试我们的第一个测试 – npm run wdio

图 2.13 – WebdriverIO 给我们提示如何运行第一个测试

图 2.13 – WebdriverIO 给我们提示如何运行第一个测试

这已经设置了 WebdriverIO 并创建了一个可以执行以下 yarn 命令的示例测试:

> yarn wdio

这将产生以下输出:

图 2.14 – yard 命令的输出

测试也可以通过运行命令来执行。让我们看看 Windows 和 Mac 的选项:

对于 Windows 用户:

> npx wdio run test/wdio.conf.ts

对于 Mac 用户:

> wdio run test/wdio.conf.ts

所有测试示例都可以在这个书的 GitHub 仓库中找到:github.com/PacktPublishing/Enhanced-Test-Automation-with-WebdriverIO

这将在 spec Reporter 窗口中运行样本测试,并将基本输出详细信息输出到终端窗口:

图 2.15 – 从样本 WDIO 测试的 spec 报告中显示的通过结果

图 2.15 – 从样本 WDIO 测试的 spec 报告中显示的通过结果

现在我们已经设置了项目,无论是通过回答初始配置问题还是克隆现有项目,我们就可以查看我们新的 WDIO 自动化项目的配置和文件设置:

图 2.16 – 所有项目文件

图 2.16 – 所有项目文件

这将显示项目中的所有文件和文件夹。它们相当多,所以我们将在这里介绍重要的部分。首先打开 README.md 文件。

对于任何项目,README 文件是开始的最佳位置。它提供了关于项目配置、特性和,最重要的是,如何快速开始样本测试的关键信息。

接下来,打开 package.json 文件。

这就是大部分 Node.js 配置发生的地方:

图 2.17 – wdio 项目中的所有 devDependancies

图 2.17 – wdio 项目中的所有 devDependancies

yarn.lock 文件是什么?

yarn.lock 文件包含所需项目包的完整列表,包括在 package.json 中支持其他包的包。它很大,但不用担心——你永远不需要更改它。Yarn 包管理器处理所有这些。哇!

让我们使用 install 命令运行 Yarn 包管理器,以获取所有内容并保持最新:

> yarn install

这可以在以下屏幕截图中看到:

图 2.18 – 使用 Yarn 包管理器构建项目

图 2.18 – 使用 Yarn 包管理器构建项目

进行第一次提交

现在我们已经运行了第一个测试,是时候将它带到我们的孤独堡垒——通过将其提交到本地仓库,然后到 GitHub 仓库。

忽略 Git 仓库中的文件

在我们将第一个提交到 Git 仓库之前,我们需要忽略一些文件。一旦我们设置了 WDIO 项目,VS Code 可能会建议将 node_modules 文件夹包含在 gitignore 文件中:

图 2.19 – VS Code 检测到 node_modules 文件夹可以被忽略

图 2.19 – VS Code 检测到 node_modules 文件夹可以被忽略

我们绝不想将这个文件夹提交到我们的 Git 仓库,因为它会不断由 npm 更新。让 npm 在线创建包含最新版本的文件夹内容会更好:

图 2.20 – GitHub Desktop 显示有超过 12,000 个文件需要提交到新仓库

图 2.20 – GitHub Desktop 显示有超过 12,000 个文件需要提交到新仓库

这比我们需要的文件多得多。

要告诉 Git 忽略此项目文件夹,只需在项目根目录中创建一个 .gitignore 文件,并输入 node_modules 文件夹名称:

图 2.21 – .gitignore 文件包含不应提交的文件和文件夹

图 2.21 – .gitignore 文件包含不应提交的文件和文件夹

这同样适用于我们的 Allure 报告和结果文件夹。这些文件将在每次测试后反复重建,并且不需要置于版本控制之下。一旦这些测试从 Jenkins 运行,之前的运行可以暂时或永久地保存在那里。

通过简单地添加和保存 .gitignore 文件,文件列表会显著变化:

图 2.22 – 仓库现在只存储文件

图 2.22 – 仓库现在只存储文件

一旦保存此 .gitignore 文件,我们将在 GitHub Desktop 中看到反映出的变化,文件数量减少到仅八个文件。

小贴士

永远不要在仓库中存储密码。密码应由安全数据提供者服务(如 Vault 或 AWS Secrets)提供。如果没有此类选项,则可以在项目文件夹之上引用密码文件。否则,将此类凭证文件存储在项目中需要将其添加到 .gitignore 文件中以确保安全。

我在我的职业生涯中发现的第一个错误与密码有关。在这里,用户有选项使用随机字符序列重置他们的密码。这个页面偶尔会崩溃。原因是要求密码由所有 128 个 ASCII 字符生成。这包括 BELL、不可打印字符,以及难以在键盘上输入的字符。真正的问题是这个集合包括了角度括号(<>)。只有当新密码生成时包含这两个字符之一,页面才会崩溃,因为它们被解释为页面上的打开或关闭 HTML 标签。

IT 安全使用一些工具来检测仓库中的密码,但它们通常只检查 mainmaster 仓库,并忽略后续的 feature 分支。这也是一个安全风险。始终清理旧分支,因为这可能被视为 安全运营中心SOC)II 合规违规,即使密码已经很久以前就过期了。

我们现在可以添加总结描述和可选的详细信息。只需点击 提交到主分支 – 我们的所有新文件都将提交到我们的本地主分支:

图 2.23 – 向本地提交添加注释和详细信息

图 2.23 – 向本地提交添加注释和详细信息

然而,这只是在我们的本地 Git 仓库中进行了暂存。最后一步是点击推送到远程仓库,这将将其推送到 GitHub,以便我们的团队可以拉取:

图 2.24 – GitHub Desktop 显示所有更改都已提交,并建议推送任何新更改

图 2.24 – GitHub Desktop 显示所有更改都已提交,并建议推送任何新更改

恭喜!你已经向你的 Git 仓库提交了第一个更改。现在,你的团队成员可以拉取你的更改,以确保所有测试都能顺利运行。

但如果你需要添加需要几天时间才能完成的新功能呢?

分支

要成为自动化团队的一员,你可能会被要求添加新的和复杂的功能。如果这需要几天时间,我们可能会考虑特性分支。新分支将从 main 分支创建。你的更改将提交到你的分支,并且会定期引入 main 分支的更改:

图 2.25 – 在 GitHub Desktop 中从主分支添加新分支

图 2.25 – 在 GitHub Desktop 中从主分支添加新分支

当你的更改完成后,将创建一个拉取请求,以将你的更改拉入 main 分支,并且可能删除 feature 分支。

对于本书,框架的最终状态将在这个章节命名的分支中。main 分支将包含最终项目。

摘要

在本章中,我们在一系列配置问题中添加了几个选项来安装 WebdriverIO。我们还展示了 Yarn 如何保持依赖项最新。最后,我们展示了如何从 GitHub Desktop 向仓库提交更改。

如果你遇到问题,请查看本书末尾的附录。在那里,你可以找到一个详细的常见和复杂问题的列表,包括原因、解释和解决方案。它还包括许多这些初始过程的节点命令速查表。

在下一章中,我们将探索 wdio 配置文件中的文件和连接,并查看调试我们代码的不同方法。

第三章:控制论增强 – WebdriverIO 配置和调试技巧

在本章中,我们将介绍创建和调试自定义 WebdriverIO 框架的技术。这将带我们了解帮助项目保持更新的服务。在项目中,许多文件被使用并相互交互。我们将添加辅助工具和其他功能来增强框架,并使调试更容易。我们将涵盖框架的节点文件,并演示在 Mac 和 Windows 操作系统上启动测试之间的差异。我们还将为单显示器调试创建我们的第一个钩子自定义。最后,我们将编写我们的第一个日志包装器,以在控制台窗口中获取更多控制权,通过自定义日志来提高调试效率。

具体来说,这是我们本章的主要话题:

  • WebdriverIO 节点项目的主要文件

  • 让 Yarn 帮助保持文件更新

  • 动态配置

  • global.log() 方法

  • 强制执行编码标准的规则

WebdriverIO 节点项目的三个主要文件

在遵循 WDIO 配置的过程中,会添加很多文件。这是熟悉这些文件的功能和它们之间关系的好时机,从以下这三个开始:

  • package.json

  • yarn.lock

  • wdio.config.ts

让我们按执行顺序逐一查看。

package.json 文件

我们将要讨论的第一个配置文件是 package.json 文件。它有助于管理项目的依赖关系,并提供了一种运行脚本和访问项目其他信息的方式。此文件具有几个用途:

  • 它可以指定可以从命令行运行的脚本。例如,一个 WebdriverIO 项目可能包括一个启动 Webdriver 服务器的 wdio 脚本和一个专门配置为在 Docker 容器实例上运行的 wdio-docker 脚本。

  • 它指定了项目的依赖关系,即项目需要正常工作所需的包。例如,一个 Webdriver 项目将依赖于 expect-wdio 包进行验证。我们不必担心所有这些包的依赖冲突。当使用带有 --interactive 或 -i 标志的升级命令时,Yarn 提供了一个交互式升级模式。交互式升级模式允许您选择要升级的包。当运行 >yarn upgrade-interactive 时,Yarn 将显示一个过时包的列表,并提示选择要升级的包。Yarn 在确定要升级的版本时,会尊重您在 package.json 文件中指定的版本范围。

  • 它可以指定 devDependencies,这些是开发者需要的,但并非一定用于执行。例如,一个 WebdriverIO 项目将依赖于 @wdio/cli 包。同样,node-check-version 工具将保持版本同步。

  • 它可以包含有关项目的元数据,例如项目的名称、版本和作者:

      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "wdio": "wdio run test/wdio.conf.ts",
        "debug": "cross-env set DEBUG=true && wdio run test/wdio.conf.ts",
        "report": "cross-env DEBUG=false wdio run test/wdio.conf.ts && allure generate report allure-results --clean && allure open",
        "wdio-docker": "DEBUG=false wdio run test/wdio.conf.ts && allure generate report allure-results --clean"
      },
      "devDependencies": {
        "@types/jasmine": "⁴.3.0",
        "@wdio/allure-reporter": "⁷.26.0",
        "@wdio/cli": "⁷.27.0",
        "@wdio/jasmine-framework": "⁷.26.0",
        "@wdio/local-runner": "⁷.27.0",
        "@wdio/mocha-framework": "⁷.26.0",
        "@wdio/spec-reporter": "⁷.26.0",
        "ts-node": "¹⁰.9.1",
        "typescript": "⁴.9.3",
      },
      "dependencies": {
        "expect-webdriverio": "³.0.0"
      }
    }
    

package.json文件还包含"scripts"模式。这是我们创建自定义运行配置快捷方式的地方。例如,要在命令提示符下运行测试,我们可以使用npxwdio包一起提供 WebdriverIO 配置文件的路径:

>npx wdio run ./wdio.conf.js

在安装时,WebdriverIO 在scripts包模式中包含一个wdio run配置:

    "scripts": {
        "wdio": "wdio wdio.conf.ts"
    }

我们可以使用wdio快捷方式从包管理器隐式运行节点执行器:

yarn wdio

我们现在可以为从上一章运行Allure报告添加一个快捷方式:

    "scripts": {
        "wdio": "wdio wdio.conf.ts"
   "report": "allure generate --clean allure-results && allure open"
    }

运行测试和生成报告的命令行现在简化为以下内容:

yarn wdio
yarn report

第十三章中,当我们对多个浏览器进行测试时,这将会再次发挥作用。现在,让我们简要地看一下所有不同的包。

yarn.lock 文件

此文件跟踪由package.json文件引入的所有外部支持包,这些包存储在node_modules文件夹中。如果已经检测到相同版本的包已被下载,则节点将跳过它以提高效率。此文件内容丰富,并且由于每次执行yarn add时都会重建它,因此无需手动修改。

接下来,我们将介绍 WebdriverIO 的核心。

wdio.conf.ts 文件和 webhooks

此文件是配置所有 WDIO 包功能的地方。它包括 webhooks – 在框架的某些点上自动执行的代码。这使我们免于反复重写代码。此代码可以在每个会话、套件、测试、WebdriverIO 命令之前或之后注入,甚至可以在每个钩子之前或之后注入。所有默认功能都在每个钩子内部有文档说明,可供修改。以beforeTest代码为例:

  /**
   * Function to be executed before a test (in Mocha/Jasmine) starts.
   */
  // beforeTest: function (test, context) {
  // },

通过取消注释beforeTest钩子函数,我们可以自定义 WebdriverIO 的功能。这只是为了在本地机器上运行,那里在小显示器上有有限的空间。例如,我们可以在每次测试之前最大化浏览器全屏:

beforeTest: function (test, context) {
    browser.maximizeWindow();
},

经验法则 – 匹配你的开发者的硬件

这里有一个请求第二个显示器的好理由。我们刚刚在运行时将浏览器扩展到全屏。如果我们只有一个显示器,这将完全遮挡我们的Visual Studio CodeVS Code)窗口。为了提高效率,我们需要在主显示器上以全屏模式执行测试时,在外部显示器上看到 VS Code 中的终端窗口。

但如果你只有一个显示器,你可以在wdio.config.ts文件的 webhooks 中实施一个简单的技巧:将浏览器高度设置为显示分辨率的四分之三。首先,通过访问设置然后是显示来获取当前显示的高度和宽度(在 Windows 上):

图 3.1 – Windows 上的主显示分辨率

图 3.1 – Windows 上的主显示分辨率

在 Mac 上,转到苹果菜单并选择关于本机 > 显示

图 3.2 – Mac 的主要显示分辨率

图 3.2 – Mac 的主要显示分辨率

将显示高度乘以 0.75。在 wdio.conf.ts 文件中,取消注释 beforeTest() 方法。在 browser.setWindow 方法中输入宽度和减小后的高度值(在这个例子中,970),如下所示:

beforeTest: function (test, context) {
    // VS Code Terminal visible on the bottom of the screen
    browser.setWindowSize(1920, 970)
},

这样,你就可以在单显示器上同时获得两者的最佳效果,如下面的截图所示:

图 3.3 – 在单显示器上运行自定义尺寸的浏览器,并在下方终端日志中运行

图 3.3 – 在单显示器上运行自定义尺寸的浏览器,并在下方终端日志中运行

你可以在webdriver.io/docs/options/#hooks在线文档中了解更多关于 WebdriverIO 插件的信息。由于这是一个 TypeScript 项目,因此也需要一些配置。

tsconfig.json 文件配置了 TypeScript 编译器的节点选项。它包括将要使用的框架,并包含用于断言的 WebdriverIO expect 库。这就是我们可以更改 ECMAScript 目标版本以匹配节点版本的地方:

{
    "compilerOptions": {
        "moduleResolution": "node",
        "types": [
            "node",
            "webdriverio/async",
            "@wdio/jasmine-framework",
            "expect-webdriverio"
        ],
        "target": "es2022"
    }
}

es2022 目标是 ECMAScript 10 版本。ECMAScript 版本名称与功能的相关性可以在en.wikipedia.org/wiki/ECMAScript中找到,而 node 与 ECMAScript 版本的相关性可以在node.green/中找到。

test/spec 文件夹是执行测试脚本的存放位置。子文件夹可以帮助将测试分类。建议不要将这些文件夹结构做得太深,因为这会使相对路径难以跟踪。

test/pageObjects 文件夹包含用于查找和填充元素的页面对象模块。

最后,node_modules 文件夹包含了为支持节点项目而下载的所有支持包。

Chrome 浏览器总是在更新。接下来,我们需要确保项目资源保持最新。WebdriverIO 提供了一个服务来完成这项工作。

让 Yarn 帮助保持文件更新

正如 Yarn 升级交互式工具必须保持所有支持包的当前状态一样,WebdriverIO 提供了 ChromeDriver 服务以跟上 Chrome 的持续更新。我们可以通过在控制台中运行以下命令来安装此服务:

yarn add wdio-chromedriver-service

然后,它必须在 wdio.config.ts 文件中进行配置。为此,找到以下内容:

    services: ['chromedriver'],

用以下内容替换它:

    outputDir: 'all-logs',
    services: [
        ['chromedriver', {
            args: ['--silent']
        }]
    ],

最后,应将 all-logs 文件夹添加到 .gitignore 文件中。现在,让我们来讨论一些调试技巧。

使用 VS Code 配置调试

VS Code 提供了四个命令提示符外壳来启动脚本。你使用哪个取决于你的操作系统。对于 Windows,有 PowerShell、Git Bash、命令提示符和 JavaScript 调试终端。Mac 包括 ZSH 壳:

图 3.4 – Windows 上的 VS Code 调试控件和壳终端

图 3.4 – Windows 上的 VS Code 调试控件和 shell 终端

在 Mac 上看起来是这样的:

图 3.5 – Mac 上的 VS Code 调试控件和 shell 终端

图 3.5 – Mac 上的 VS Code 调试控件和 shell 终端

脚本执行停止时,在代码中输入断点(如第 9 行左侧的空白处单击),这将附加调试器和控制面板。从面板中,可以继续代码执行,跳过方法调用,进入方法,或从调用代码返回,或重新启动或断开调试会话。

JavaScript 调试终端始终附加调试器。调试模式会减慢执行速度。因此,对于 Mac,一个实际的选择是打开两个 shell:JavaScript 调试终端和 ZSH 以实现更快的非调试执行。任何 shell 都可以通过自动附加激活调试:

图 3.6 – 自动附加调试器的命令面板选项

图 3.6 – 自动附加调试器的命令面板选项

PowerShell、Git Bash、ZSH 和命令提示符必须通过将命令面板中的 自动附加 设置为 始终 来启用调试配置。它可以从 VS Code 的状态栏临时禁用:

图 3.7 – 可以从 VS Code 底部的状态栏临时禁用自动附加模式

图 3.7 – 可以从 VS Code 底部的状态栏临时禁用自动附加模式

在这一点上,我们可以通过传递 环境变量 来进一步自定义我们的框架。在 Mac 上,任何终端都很容易做到:

> DEBUG=true yarn wdio

Windows 使得这一点变得复杂。要添加一个如 DEBUG 这样的变量,每个 shell 都有用于多行执行的单独语法。以下是列表:

  • Git Bash:

    > set DEBUG=false && yarn wdio
    
  • PowerShell 和 JavaScript 调试终端:

    > set DEBUG=false; yarn wdio
    
  • 命令提示符和 ZSH(Mac):

    > DEBUG=true yarn wdio
    

此外,包快捷方式的语法在此处不同:

  • Mac:

    "debug": "DEBUG=true wdio run test/wdio.conf.ts",
    
  • Windows:

    "debug": "set DEBUG=true && wdio run test/wdio.conf.ts",
    

如果你有一个混合使用 Mac 和 Windows 的团队,情况可能看起来很糟糕。但超级英雄有助手,Node 也不例外。

经验法则 – cross-env 节点包

为了解决所有这些问题,我们将安装 cross-env 包:

yarn add cross-env

通过添加 cross-env 包,我们现在可以创建一个新的调试快捷方式,它在 Mac 和 Windows 上都使用相同的语法:

debug: cross-env DEBUG=true wdio wdio.conf.ts

browser.debug()

另一种调试我们代码的方法是添加 browser.debug() 语句:

图 3.8 – 使用 browser.debug() 在 VS Code 中暂停执行

图 3.8 – 使用 browser.debug() 在 VS Code 中暂停执行

默认情况下,WebdriverIO 会暂停执行,但它受限于我们框架的默认超时间隔。对于 Jasmine,默认值大约是 1 分钟。然而,当发生错误时,我们需要更多的时间进行调试。通过将defaultTimeoutInterval设置为 15,000,000 毫秒(大约 4 小时),脚本将会有更多的时间来调试任何问题:

jasmineOpts: {
  defaultTimeoutInterval: 15_000_000,

当然,我们不希望手动反复更改这个值,尤其是如果我们正在云环境中运行。这可以通过我们的下一个超级功能来处理。

动态配置

动态配置意味着我们可以通过分配系统变量并将它们传递给我们的框架来改变框架的行为方式。这些变量遵循常量的ALL_CAPS命名约定。让我们首先根据DEBUG环境变量的值分配一个超时。在config文件的顶部,我们将捕获DEBUG环境变量的值:

const DEBUG = (process.env.DEBUG === undefined) ? false : (process.env.DEBUG === `true`)

如果没有明确定义DEBUG,则默认设置为false。现在,当我们明确执行调试快捷方式时,我们可以有一个扩展框架超时的变量:

let timeout = DEBUG ? 10_000 : 16_000_000

经验法则

使用数字分隔符使你的代码更易读。TypeScript 支持在整数和浮点数中使用下划线代替逗号。这使得16_000_000成为一个有效的整数,同时使代码对人类更易读。

我们可以在jasmineOpts下找到超时。让我们引用这个新的超时变量。找到以下代码:

jasmineOpts: {
defaultTimeoutInterval: 10000,

改成以下内容:

jasmineOpts: {
defaultTimeoutInterval: timeout,

你可以考虑在快捷方式省略时将DEBUG默认设置为True,然后在从 Jenkins 等 CI/CD 环境(如 Docker)运行时明确将其关闭:

const DEBUG = (process.env.DEBUG === undefined) ? true : (process.env.DEBUG === `true`)

原因是我们大部分时间都花在编写和调试代码上,这意味着我们花费较少的时间重复输入来启动测试:

DEBUG=true yarn wdio

这只是在本地运行时扩展超时:

yarn wdio

然后,我们可以在快捷方式中隐式地设置debug开关,在 CI/CD 中明确设置:

     "scripts": {
        "wdio": "wdio wdio.conf.ts"
        "debug": "DEBUG=true wdio wdio.conf.ts"
        "wdio-docker": "DEBUG=false wdio wdio.conf.ts"
    }

在未来的章节中,我们将执行在 Docker 中的测试。在这种情况下,我们不希望我们的测试在调试错误时等待几个小时。在这个脚本中,我们将DEBUG改为false;测试将使用一个短超时,具体考虑这一点:

yarn wdio-docker

问题:自动化框架的客户是谁?你可能认为它是利益相关者,但实际上并非如此。利益相关者是那些拥有非常吸引人的图表的受益者。你的团队是每天与框架一起工作的团队。这意味着你是客户。优先考虑框架功能,以帮助你日常工作中更高效的标准,而不是你资助者的心血来潮。

你可以在这里了解更多关于动态配置的信息:webdriver.io/docs/debugging/#dynamic-configuration

当我们查看 Jasmine 选项时,我们可能会考虑在测试失败时自动将屏幕截图添加到结果中。这些可以添加到以下代码中:

expectationResultHandler
expectationResultHandler: function(passed, assertion) {
 /**
 * only take screenshot if assertion failed
 */
 if(passed) {
      return
 }
 browser.saveScreenshot(`assertionError_${assertion.error.message}.png`)
 }

这将在我们项目的根目录下创建一个屏幕截图。屏幕截图不需要占用 Git 仓库的空间。因此,我们将它们添加到.gitIgnore*.png文件中。

关于模板字符串的注意事项

你可能已经注意到,在这些代码示例中广泛使用了带重音符号的模板字符串 'strings'。虽然 TypeScript 支持字符串的单引号和双引号,但在测试自动化项目中,模板字符串更有意义。

假设,例如,我们希望将此字符串写入我们的控制台日志:

Meet Dwane "The Rock" Johnson at Moe's tavern

如果我们使用引号,我们需要在字符串中双重转义引号:

console.log("Meet ""Dwayne The Rock"" Johnson at Moe's  tavern")

如果我们使用单引号,则需要用反斜杠转义撇号:

console.log('Meet Dwyane "The Rock" Johnson at Moe\'s  tavern')

但使用带重音的模板字符串时,不需要转义:

console.log(`Meet Dwayne "The Rock" Johnson at Moe's tavern today`)

模板字符串还可以传递${variables},使报告更加灵活和描述性:

let guest = `Dwayne "The Rock" Johnson`
let location = `Moe's tavern`

现在,我们可以输出一个模板字符串到控制台,它比使用单引号或双引号的字符串更容易阅读:

console.log(`Meet ${guest} at ${location} today`)

自定义报告函数的大部分目的是在调试期间减少噪音。

降低信噪比

现在,我们需要进行一些负面测试。将ch3.ts脚本修改为通过在expect验证链中添加.not来生成错误:

await expect(SecurePage.flashAlert).not.toBeExisting();

现在,当我们运行代码时,我们会得到大量的消息细节:

[0-0] 2022-11-28T09:14:03.160Z INFO webdriver: COMMAND findElements("css selector", "#flash")
[0-0] 2022-11-28T09:14:03.160Z INFO webdriver: [POST] http://localhost:9515/session/2e35b72bb526b5f0e346ba1379e4f5d9/elements
[0-0] 2022-11-28T09:14:03.161Z INFO webdriver: DATA { using: 'css selector', value: '#flash' }
[0-0] 2022-11-28T09:14:03.174Z INFO webdriver: RESULT [
[0-0]   {
[0-0]     'element-6066-11e4-a52e-4f735466cecf': 'd6b5d426-fbbb-4871-b190-94de4ef331cd'
[0-0]   }

产生了大量并非所有都很有洞察力的信息。在wdio.config.ts文件中,我们可以通过logLevel设置来控制控制台显示的详细程度:

// Level of logging verbosity: trace | debug | info | warn | error | silent
    logLevel: 'info',

选项的顺序是按照详细程度列出的。默认的'info'级别可能会显得过于冗长。将其降低到'warn'级别更适合我们的需求。

我们最终的调试技术是使用包装器增强console.log()命令。

我们的第一个自定义包装方法 – global.log()

问题:什么是包装器?

包装器是一种定制方法或函数,其签名几乎与内置方法相同,但增加了额外的功能。在我们的第一个例子中,我们将创建一个全局包装器console.log()

虽然console.log()方法适用于将信息输出到控制台窗口,但它可以增强并缩短。让我们在wdio.config.ts文件的末尾构建我们的第一个log()包装器:

/**
 * log wrapper
 * @param text to be output to the console window
 */
global.log = async (text: any) =>  {
    console.log(`---> ${text}`)
}

这个global.log()包装器几乎与console.log相同,但它有一些突出显示的文本格式。让我们通过添加一些示例到测试中来看一下:

console.log (`Entering password`)
[0-0] Entering password
await global.log (`Entering password`)
[0-0] ---> Entering password

这样,我们可以将添加到框架中的自定义消息与 Jasmine 和 node 报告输出生成的消息区分开来。

经验法则

即使一个函数只有一行,也要使用花括号。有两个原因。首先,它使得逻辑分支问题更容易被发现。其次,当添加括号时,你很快就会添加更多代码行。

假设我们想忽略传递给日志的空字符串和 null。当写成单行代码时,意图不是很清楚:

global.log = async (text: any) => {
    if (text) console.log(`---> ${text}`)
}

但有了括号,逻辑看起来更清晰:

global.log = async (text: any) => {
    if (text) {
        console.log(`---> ${text}`)
    }
}

这遵循了我们之前在经验法则框中提到的第二个原因——你很快就会添加更多的代码行。如果我们知道未解决的承诺传递给日志的时间和出现问题的行,前面的代码会更好。因此,我们将为Promise对象添加一个异常,并显示控制台跟踪以显示该行:

global.log = async (text: any) => {
    if (text) //truthy value check
    {
        if (text===Promise){
            console.log(`--->     WARN: Log was passed a Promise object`)
            console.trace()
        }else{
            console.log(`---> ${text}`)
        }
    }
}

问题:为什么文本被分配为any类型而不是string类型?

在大多数情况下,我们将在 TypeScript 中声明参数的类型。这就是使用它的全部意义所在。但在这个案例中,我们希望我们的调试更加稳健。我们将在ch3.ts脚本中添加六个日志示例:

describe(' Ch3: Cybernetic Enhancements', () => {
    it(should give detailed report and resize browser', async () => {
        await LoginPage.open();
        console.log (`Entering password`) // Intrinsic Log
        await global.log (`Entering password`) // Custom
        await global.log (``) // Does not print
        await global.log (null) // Does not print
        await global.log (Promise) // Adds trace
        await LoginPage.login('tomsmith', 'SuperSecretPassword!');
        await expect(SecurePage.flashAlert).toBeExisting();
        await expect(SecurePage.flashAlert).toHaveTextContaining(
            'You logged into a secure area!');
    });
});

有了这个,我们添加了自己的详细程度,使其脱颖而出。它跳过了空字符串和 null 字符串。它还提供了问题起源的行号,例如未解决的Promise

[0-0] Entering password
[0-0] ---> Entering password
[0-0] --->     WARN: Log was passed a Promise object
[0-0] Trace
[0-0]     at global.log (D:\repos\Test-Automation-with-WebdriverIO\test\wdio.conf.ts:379:21)
[0-0]     at UserContext.<anonymous> (D:\repos\wdio\test\specs\ch3.ts:13:22)
...
[0-0] PASSED in chrome - D:\repos\wdio\test\specs\ch3.ts

运行此代码使我们能够更灵活地记录我们的框架正在做什么。我们还有一个最后的话题要讨论,以确保我们编写出良好的代码。

执行编码标准的规则

每个编码项目都应该有一个文档,说明在代码审查期间将执行哪些编码规则。被称为“linters”的工具擅长检测这些规则。它们需要在项目开始时激活,以确保每个人都处于同一页面上。TypeScript 项目中可以激活多个规则。第一个被称为严格模式

严格模式

JavaScript 有一个严格模式功能。将"use strict"作为 JavaScript 源文件的第一个行启用,可以启用额外的规则以确保遵循良好的编码实践,从而避免微小的代码错误。

这包括强制使用letvarconst关键字显式声明变量。TypeScript 有一个类似的严格模式,可以进一步强制所有变量都分配一个类型,例如stringnumberboolean。这是为了避免隐式地将变量分配给any类型,这可能导致类型强制问题:

图 3.9

图 3.9 – 一个严格模式警告,其中文本被隐式声明为any类型

在前面的例子中,text变量被隐式地假定为any类型,因为没有为text变量提供类型分配:

global.log = async (text) =>

启用严格模式后,text参数下面出现三个点。将鼠标悬停在这些点上会显示问题描述。它还包括 VS Code 建议快速修复的可能性,该修复将从使用中推断参数类型:

global.log = async (text: any) =>

在一个新的 TypeScript 项目中,应该从一开始就启用严格规则。这些规则通过在tsconfig.json文件的"compiler options"部分下添加它们来启用。此规则启用了列出的所有子集规则:

"compiler options": {
"strict": true,
...

然而,在现有项目中启用所有规则可能会创建大量的代码需要重构,这可能会导致测试创建的重大延迟。在这种情况下,可以逐步启用严格规则的子集并进行重构。

启用单个 TypeScript 子集规则检查

在严格模式编码标准之下,可以启用或禁用以下规则列表。

“noImplicitAny”: true

此规则会在具有隐含 any 类型的表达式和声明上引发错误。在以下示例中,xy 变量被隐式设置为 any 类型。因此,如果传递了一个字符串,代码将强制将数字转换为字符串并连接值,而不是相加:

function add(x, y) {
  return x + y;
}
const result = add(10, '20');
console.log(result); // '1020'

以下代码通过显式地将 xy 的类型赋值为 number 类型来解决这个问题。现在要传递的变量类型只能是数字,而不是字符串:

function add(x: number, y: number) {
  return x + y;
}
const result = add(10, 20);
console.log(result); // 30

“strictNullChecks”: true

此规则会在变量隐式赋值为 null 值时引发错误。当空字符串是有效变量但传递了 Null 值时,这可能会导致问题,抛出错误。

“strictFunctionTypes”: true

此规则将启用对函数参数类型的严格检查。以下代码是一个例子:

const multiply = function(x: number, y: number) { return x * y; };

为了修复这个错误,你需要对 multiply 函数的类型进行注解:

const multiply: (x: number, y: number) => number = function(x, y) {
  return x * y;
};

“strictBindCallApply”: true

此规则强制对函数上的 bindcallapply 方法进行严格检查。这超出了本书将要介绍的技术范围。

bind()

bind() 函数创建一个新的函数,并为其指定一个特定的 this 值。它将用作 this 的值作为第一个参数;任何额外的参数在调用原始函数时传递。以下是一个例子:

class MyClass {
  public myProperty: string = 'hello';
  public someMethod() {
    setTimeout(function() {
      console.log(this.myProperty); // 'this' is not properly bound to                                     // an object
    }, 1000);
  }
}

在前面的例子中,someMethod() 方法包含一个匿名函数,该函数使用 this 关键字访问当前对象上的属性。然而,this 关键字并没有正确地绑定到对象上:

class MyClass {
  public myProperty: string = 'hello';
  public someMethod() {
    setTimeout(function() {
      console.log(this.myProperty);
    }.bind(this), 1000);
  }
}

在前面的代码中,错误已经通过使用 bind() 函数将 this 关键字绑定到当前对象上得到解决。

call()

call() 函数与 bind() 类似,但它立即调用原始函数,而不是创建一个新的函数。它将用作 this 的值作为第一个参数;任何额外的参数在调用原始函数时传递:

class MyClass {
  public myProperty: string = 'hello';
  public someMethod() {
    const greeting = 'Hello';
    console.log(greeting.call(this, greeting)); // error: 'call' is                                                 // not a function
  }
}

在这个例子中,someMethod() 方法在字符串值(greeting)上调用 call() 函数。然而,call() 函数只能用于函数,所以当代码执行时这将会导致错误。

如果你启用了 strictBindCallApply 规则,ESLint 将捕获此错误并提醒你问题。为了修复错误,请在函数上调用 call() 函数,而不是字符串:

  public greet(greeting: string) {
    console.log(`${greeting}, ${this.name}`);
  }
  public someMethod() {
    const greeting = 'Hello';
    this.greet.call(this, greeting); // calls the greet() method with                                      // a specific value for 'this'
  }
}

apply()

apply() 函数与 call() 函数类似,但它将传递给原始函数的参数作为一个数组而不是单独的参数列表。它将用作 this 的值作为第一个参数,将参数数组作为第二个参数:

class MyClass {
  public myProperty: string = 'hello';
  public someMethod() {
    const greeting = 'Hello';
    console.log(greeting.apply(this, greeting)); // error: 'apply' is not a function
  }
}

在前面的例子中,someMethod() 方法在 (greeting) 字符串值上调用 apply() 函数。然而,apply() 函数只能用于函数,所以当代码执行时这将会导致错误:

class MyClass {
  public myProperty: string = 'hello';
  public greet(greeting: string) {
    console.log(`${greeting}, ${this.name}`);
  }
  public someMethod() {
    const greeting = 'Hello';
    this.greet.apply(this, [greeting]); // calls the greet() method with a specific value for 'this'
  }
}

前面的代码展示了如何通过在函数上调用 apply() 函数而不是字符串来修复错误:

"strictPropertyInitialization": true,

这个 ESLint 规则检查在类中声明但未在构造函数中初始化的属性。此规则可以用来强制在类使用之前正确初始化类中的所有属性:

class MyClass {
  public myProperty: string;
  constructor() {
    // myProperty is not initialized in the constructor
  }
  public someMethod() {
    console.log(this.myProperty.toUpperCase());
  }
}

当你尝试使用 ESLint 检查代码时,它会抛出一个错误,因为 myProperty 在构造函数中未初始化。为了解决这个问题,将 myProperty 赋值为一个字符串:

  public myProperty: string = "";

“noImplicitThis”: true

这会在具有隐含 any 类型的表达式中引发错误。在 TypeScript 中,this 关键字指的是类的当前实例,它通常在类方法内部用于访问当前对象的属性或方法:

class MyClass { public myProperty: string = 'hello';
  public someMethod() {
    setTimeout(function() {
      console.log(this.myProperty);
      // 'this' is not properly bound to an object
    }, 1000);
  }
}

ESLint 会抛出一个错误,因为匿名函数内的 this 关键字没有正确绑定到对象上。为了修复这个错误,使用箭头函数将函数绑定到对象上:

      setTimeout(() => { console.log(this.myProperty);
      // 'this' is now properly bound to the current object

“alwaysStrict”: true

这条最终规则确保 TypeScript 文件在第一行添加 use strict。实际上,这是指导编译器以严格模式创建 TypeScript,即使 TypeScript 文件中缺少该命令。

@ts-ignore 指令

最终,你可能需要告诉编译器忽略一个警告。例如,自定义的 log() 函数故意将消息分配给 any 类型而不是 string 类型。这是为了忽略空字符串并捕获传递的未包装的承诺,这些承诺可以追溯到特定的代码行:

// @ts-ignore
global.log = async (text: any) =>  {
    console.log(`---> ${text}`)
}

重要的是要注意,@ts-ignore 指令仅应作为临时措施使用,以帮助你在编写代码时绕过错误或警告。使用此指令广泛抑制错误或警告不是一个好主意,因为它可能导致不安全或不可靠的代码。

下一个问题是我们什么时候会有时间重构和记录我们的框架,当我们把所有时间都花在编写测试脚本上时?

小贴士 - “完成它”周五

最好的方式是将重构计划作为冲刺活动的一部分。敏捷项目有一个每日站立会议。一些团队选择取消周五的站立会议,并专门花额外的时间进行代码清理、重构和文档编写。我们的想法是,我们的周末应该属于我们自己和我们的家人,而不是专门用于工作。

利用人工智能

GitHub Copilot:由 GitHub 和 OpenAI 开发,它直接在编辑器中提供 AI 代码建议。它就像一个配对程序员,帮助你更快地编写代码,并在过程中学习新的 API 和语言。

Tabnine:一个与流行 IDE 一起工作的 AI 代码补全工具。它预测并建议你可能要编写的下一块代码,并支持多种编程语言。

CodeGPT:由 OpenAI 开发的一个强大且创新的 AI 驱动编码助手。它建立在 GPT-3 这一最先进语言模型的基础上,并专门针对帮助开发者和程序员在编码任务中提供支持。

CodeGPT

CodeGPT 是由 OpenAI 开发的一个革命性的 AI 代码生成工具。它利用 GPT-3.5 架构来帮助开发者在各种编程语言中创建代码片段、函数,甚至整个程序,包括 TypeScript。这项技术是开发者的游戏改变者,因为它可以加速编码过程,提高代码质量,并帮助调试和解决问题。有了 CodeGPT,你可以快速生成代码示例,编写单元测试,甚至获得如何实现特定功能的建议,使其成为任何开发者工具箱中的宝贵补充。

这里是一个 CodeGPT 如何帮助生成 TypeScript 代码的例子。假设你想创建一个计算数字阶乘的 TypeScript 函数。使用 CodeGPT,你可以请求如下代码片段:

```typescript

function factorial(n: number): number {

if (n <= 1) {

return 1;

}

return n * factorial(n - 1);

}

```js

CodeGPT 生成了计算阶乘的 TypeScript 代码,该函数接受一个输入数字n,并递归地计算其阶乘。这只是 CodeGPT 如何通过提供准确和高效的代码片段来简化编码任务的一个例子。

在编写这本书的过程中,我遇到了一个真实生活中的例子。我们要求一个高级函数,该函数可以从key=value格式的字符串中解析值,并将其转换为具有一些示例的字典对象。GitHub Copilot 已经知道 SwitchboardFactory SBF 对象,并提出了以下带有头部的函数:

/**
 * Parses a string of key-value pairs and updates the SwitchBoard state with those values.
 * Each pair within the testData string should be separated by spaces, and
 * keys/values should be separated by an '=' character.
 *
 * For example, a string "guests=2 zipcode=12345" will result in SBF having
 * "guests" set to 2 and "zipcode" set to 12345.
 * @param {string} testData - The string containing key-value pairs to be parsed.
 */
export function parseToSBF(testData: string) {
  let parts = testData.split(" ");
  parts.forEach(part => {
    if (part.includes('=')) {
      let [key, value] = part.split("=");
      SBF.set(key, parseInt(value));
    }
  });
}

人工智能是未来的道路。我们正处于一个时刻,类似于查尔斯·布拉迪·金发明冲击钻时,用大锤砸碎混凝土的时刻。这是我们的大锤!

摘要

在本章中,我们回顾了 Webdriver 节点框架的节点文件。我们向您展示了如何使测试启动在 Mac 和 Windows 团队成员之间变得通用。我们还向您展示了如何设置环境以启用 TypeScript 中的调试和编写更好的代码。最后,我们编写了我们第一个自定义日志包装器,它优化了输出到控制台窗口。

通过控制日志记录,我们可以通过决定其格式和发送到 Allure 报告的内容来使我们的调试过程更加高效。在接下来的章节中,我们甚至将为可见性添加颜色,并将这个相同的包装概念应用到最常用的 WebdriverIO 浏览器方法上,以创建超级强大的稳健测试。

但我们不想过于急躁,医生!接下来,我们将讨论时间旅行的影响,因为 TypeScript 有点像速度选手!

第四章:超级速度 – 时空悖论和未兑现的承诺

在本章中,我们将讨论我们如何处理在测试框架的事件循环中多线程执行时出现的问题。然后,我们将探讨当我们开始添加更复杂的功能时,如何保持框架中的开关在一致的位置。

JavaScript 是一种疯狂快速的编程语言。因为它的主要目标是尽可能快地构建网站页面,它使用多线程在事件循环中执行代码行。这在尽可能快地构建网页方面是一个优势,但在需要按特定顺序执行事件的测试自动化中可能会成为障碍。

事实上,这个速度极快的家伙甚至可以穿越时空。让我们在下一节中看看一个例子。

在我们这样做之前,这里是我们将在本章中涵盖的主题列表:

  • 时空困境

  • 薛定谔和测试自动化的量子力学

  • 回调、Promise 和 async/await

  • 纤维的死亡和同步模式

技术要求

所有测试示例都可以在这个 GitHub 仓库中找到:github.com/PacktPublishing/Enhanced-Test-Automation-with-WebdriverIO

时空困境

让我们从最基本的脚本开始 – 登录。在 \pageobjects 文件夹中打开 login.page.ts。注意 login() 函数中有一个 async 命令:

    public async login (username: string, password: string)
        await this.inputUsername.setValue(username);
        await this.inputPassword.setValue(password);
        await this.btnSubmit.click();
    }

async 关键字强制函数始终是异步的,返回一个表示函数完成或失败的 Promise 对象。在 .setValue.click 命令之前还有一个 await 关键字,它暂停函数直到 Promise 对象被解决或拒绝。如果移除 await 命令会发生什么?

    public async login (username: string, password: string) {
        //Removed await keyword
        this.inputUsername.setValue(username);
        this.inputPassword.setValue(password);
        this.btnSubmit.click();
    }

从 Visual Studio Code 命令行运行 wdio 测试:

 > yarn wdio

当测试执行时,它失败了!这表明在以下结果中提供的用户名无效,这让我们陷入了一个兔子洞,因为密码实际上是完全有效的:

» \test\specs\ch2.ts
 My Login application
    x should login with valid credentials
 1 failing (12.2s)
 1) My Login application should login with valid credentials
 Expect $(`#flash`) to have text containing
- Expected  - 1
+ Received  + 2
- You logged into a secure area!
+ Your username is invalid!

那么,发生了什么变化?代码的执行顺序!没有 await 关键字,Node.js 将会同时执行所有 JavaScript 命令。所以,“您已登录到安全区域!”失败了,因为“用户名”字段被完全填充。相反,它报告“您的用户名无效”,因为当提交按钮被点击时,用户名仍然是空的。

当然,最好的超级英雄侦探需要更多的证据。没有人想到在犯罪现场拍照。所以,让我们添加一些调试输出并再次尝试测试:

global.log (`Logging in with '${username}' and '${password}'`)
this.inputUsername.setValue(username);
global.log (`Entered '${username}'`)
this.inputPassword.setValue(password);
global.log (`Entered '${password}' and clicking Submit`)
this.btnSubmit.click();
global.log ("Submit clicked!")

这次,结果显示测试通过了。然而,我们现在遇到了一个过时的元素:

[0-0] ---> Logging in with 'tomsmith' and 'SuperSecretPassword!'
[0-0] ---> Entered 'tomsmith'
[0-0] ---> Entered 'SuperSecretPassword!' and clicking Submit
[0-0] ---> Submit clicked!
[0-0] 2022-12-03T18:07:52.839Z WARN webdriver: Request encountered a stale element - terminating request
[0-0] PASSED in chrome - D:\repos\wdio\test\specs\ch2.ts

哪个元素是过时的?为了找出答案,我们需要将 wdio.conf.ts 中的 logLevel 改回 info

    logLevel: 'info',

现在,当我们重新运行测试时,我们得到了一个完全不同的错误,隐藏在大量信息中:

[0-0]   error: 'no such element',
[0-0]   message: 'no such element: Unable to locate element: {"method":"css selector","selector":"#flash"}\n' +
[0-0]     '  (Session info: chrome=107.0.5304.122)',

看起来事情已经失控了。如果我们尝试添加更多的 debug 语句,我们可能会得到不同的结果,这些结果可能无法重复。你相信这个现象在比这本书更高级的书中被描述过吗?

谢林格和测试自动化的量子力学

这个问题类似于量子力学中众所周知的 测量问题。简单来说,在量子层面上测量事件的后果可能会改变事件的后果。想象一下用温度计测试冷水温度。随着时间的推移,测量设备会稍微加热冷水,而设备本身也会从冷水中冷却。因此,随着时间的推移,读数变得不确定。

在这种情况下,将详细信息发送到控制台窗口会给系统带来一点额外的开销。语句的执行速度略有变化,完成顺序也是如此,每次都会得到不同的结果。这与 JavaScript 事件循环中语句执行优先级的选择有很大关系,如下所示:

图 4.1 – JavaScript 事件循环中承诺和回调执行顺序的可视化

图 4.1 – JavaScript 事件循环中承诺和回调执行顺序的可视化

JavaScript 有一个包含主线程、宏任务和微任务的 Event Loop。后者,即承诺,在主线程语句之后执行。包括回调和超时语句的 MacroTasks 可以在主线程之前但承诺之后执行。当这些任务以意外的顺序完成时,你可能会在调试过程中浪费数小时的时间来隔离问题。

MacroTasks 通常与 I/O 操作或 UI 渲染相关。MacroTasks 的例子包括 setTimeoutsetIntervalsetImmediate 和 I/O 操作。这些任务由事件循环执行,并且可以在 MicroTasks 之前或之后运行。

MicroTasks 通常与承诺相关。它们还可以包括突变观察者。开发者会在各种场景中使用这些,例如跟踪属性的变化、检测子元素的添加或删除,或者甚至观察元素内字符数据的变化。MicroTasks 在主线程语句执行之后和下一个 macroTask 执行之前执行。它们用于处理回调和解决承诺。

当任务以意外的顺序完成时,可能会导致调试挑战。这是因为执行顺序会影响测试的整体行为。对于在多个线程上优化构建网页所需的时间来说,这是可以接受的,但对于试图按顺序运行脚本步骤的 SDETs 来说,这会造成混乱。

回调、承诺和 async/await

为了解决这些问题,我们需要强制 JavaScript 以线性顺序执行代码。JavaScript 提供了三种解决方案——回调、承诺和 async/await 关键字。JavaScript 承诺和回调是两种了解异步调用何时有结果的方式。回调允许你在收到响应后执行一个函数。承诺做同样的事情,并允许你为多个操作指定一个易于阅读的顺序,以及处理错误情况。然而,你知道还有一个处理承诺的更简单的方法,称为同步模式吗?

纤维和同步模式的消亡

为了使异步回调到函数的实现更容易,JavaScript 添加了承诺。函数被传递时不带括号,这使得它们在视觉上与变量和对象相似。然后,node-fibers 包项目在后台隐式地将语句包装为回调。

直到版本 7.0,WebdriverIO 利用 node-fibers 包作为 @wdio/sync 功能的一部分。这意味着所有浏览器方法都会以同步方式执行,而不需要回调、承诺或 await。这对 WebdriverIO 框架架构师来说是一个绝妙的权衡!它避免了时间旅行问题,同时使代码更简单。

不幸的是,node-fibers 项目在 2021 年被终止。WebdriverIO 被迫通知用户两个解决方案——他们可以将 Node 锁定到最后一个与 node-fibers 兼容的受支持版本,从而错过 JavaScript 通过不断发展的 ECMAScript 标准添加的新功能。或者,他们可以将代码库重构以在函数中包含 async 并在浏览器方法中使用 await。大多数人选择了后者,并面临了大量代码重构的工作。

图 4.2 – 寻找资金的包

图 4.2 – 寻找资金的包

话虽如此,让我们看看我们如何解决这个时间危机。在我们之前的章节中,我们通过传递一个 promise 对象来模拟错误:

await global.log (Promise) // Adds trace

如果我们移除 await 关键字会发生什么?

global.log (Promise) // Adds trace with WARN out of order
[0-0] Trace
[0-0]     at global.log (D:\repos\Test-Automation-with-WebdriverIO\test\wdio.conf.ts:379:21)
[0-0]     at UserContext.<anonymous> (D:\repos\wdio\test\specs\example.e2e.ts:13:22)
[0-0] --->     WARN: Log was passed a Promise object

注意,当代码中缺少 await 时,所有发生的事情只是在错误详情之后出现自定义的 WARN 消息,而不是之前。这与 JavaScript 在多个线程中执行有关。我们有事件执行顺序错误的证据。

使用 async 和 await 保持简单

本书中的每个自定义方法都将异步执行。从理论上讲,我们可能会认为同时填充字段和下拉列表以提高速度是聪明的。然而,通常从列表中选择一个值将启动一个 Ajax 方法,该方法将更新网页。然后,如果元素尚未存在,因为代码在页面重新构建时执行,这将导致字段生成错误。

摘要

在本章中,我们了解了同步代码执行对我们框架的影响。虽然回调和承诺是保持代码按顺序运行的方法,但最好使用 async 和 await 来保持一致性。我们还升级了全局值到一个单一的 switchboard 对象,该对象可以在调试会话中查看。

在下一章中,我们将把到目前为止的所有内容结合起来,以增强我们的第一个方法包装函数。

第五章:另一个自我 – 点击包装器

在本章和接下来的几章中,我们将介绍为.click().select().setValue()内置方法添加包装器的概念。这些包装器使我们能够向这些方法添加更多功能,从而使框架更加健壮,减少在测试过程中出现故障的可能性。

包装器是扩展测试套件功能的最简单方法,消除了重复添加代码(以多个测试脚本的形式)的需要。有时在测试时,页面加载缓慢,我们的元素需要更长的时间才能加载。有时,页面由于异步 JavaScript 和 XMLAJAX)而更新,我们找到的元素现在已过时,必须再次找到。我们在控制台窗口和 Allure 中显示的结果应该足够详细,以指示正在发生什么。包装器使我们能够高效地处理所有这些信息,包括滚动元素到视图中进行屏幕截图,寻找类似的替换对象以减少维护,以及在页面构建期间节省时间,以便元素有时间出现,而不必求助于较慢的硬编码等待方法。

我们将探索三种方法来为我们的框架注入超级力量:

  • 辅助命令

  • 浏览器命令

  • 元素命令

我们首先开始添加一个helpers文件及其先决条件。

添加一个辅助文件

我们首先准备我们的辅助命令,这些命令需要utility-types包来支持 TypeScript 中的可选参数。此包可以通过以下命令安装:

> yarn add utility-types

我们接下来将添加一个包含helpers.ts文件模块的\helpers文件夹。这个模块包含解决框架中问题的几个方法。这是我们存储框架大部分自定义支持代码的地方。

要在用 TypeScript 编写的 WebdriverIO 项目中创建一个辅助文件,我们需要执行以下操作:

  1. 在我们的项目中创建一个新的目录来存储我们的辅助函数。这个目录将被称为helpers,位于我们的src目录中。

  2. helpers目录中,为我们的辅助函数创建一个新的 TypeScript 文件。让我们称这个文件为helpers.ts

  3. helpers.ts文件的顶部,我们需要导入支持文件路径、全局对象和 Allure 报告的必要类型和函数。其中一些将在未来的章节中发挥作用:

    import * from as fs from "fs"import * as path from "path";
    import { ASB } from "./globalObjects.ts";
    import allure from "@wdio/allure-reporter";
    
  4. helpers.ts文件中,我们需要定义我们的辅助函数。文件的结构将包括公共方法包装器,如click()pageSync()assert()。它还将包含用于查找和替换数据标记的私有支持方法,以及getElementType()来确定如何通过类处理错误:

    export const clickElement = async (driver: WebDriver, element: WebDriver.Element) => {p  try {p
        await driver.click(element)p
      } catch (error) {p
        throw new WebDriverError(`Unable to click element: ${error.message}`)p
      }p
    }
    
  5. 在我们想要使用辅助函数的文件中,我们按照以下方式从helpers.ts文件中导入函数:

    import { clickAdv } from './helpers/helpers'pWe can then use the clickAdv function in our test code like this:
    await clickAdv(driver, element)
    

这只是我们如何在用 TypeScript 编写的 WebdriverIO 项目中创建和使用辅助函数的一个例子。我们可以在helpers.ts文件中定义我们需要的任何辅助函数,并在需要时将它们导入到测试代码中。

在我们到达click()方法之前,让我们从console.log的包装器开始,这样我们就可以自定义详细信息。如果我们碰巧用is blankemptynull变量等字符串调用它,它不需要打印任何内容。但如果传递了除字符串或数字之外的内容,例如一个承诺或一个对象,它应该输出一个警告而不会失败:

 p
/**
 * Console.log wrapper
 *    - Does not print if string is empty / null
 *    - Prints trace if not passed string or number
 * @param message
 */
export async function log(message: any): Promise<void> {
  try {
    if (typeof message === "string" || typeof message === "number") {
      if (message) {
        console.log(`---> ${message}`);
      }
    } else {
      console.log(`--->   helpers.console() received: ${message}`);
      console.trace();
    }
  } catch (error: any) {
    console.log(`--->   helpers.console(): ${error.message}`);
  }
}

注意当错误输出到控制台时有一些额外的间距。这是故意的。我们将在稍后通过间距和颜色使我们的输出更直观。

要利用辅助工具,我们在测试文件中添加以下导入行:

import * as helpers from '../../helpers/helpers.ts';

要调用该方法,我们使用以下语句:

helpers.log (`Hello, World!`)
> [0-0] ---> Hello, World!

如果方法被调用时传递了一个空字符串,它不会在控制台输出任何内容:

helpers.log (``)

如果传递的不是字符串或数字,那么会输出对象和跟踪信息,以下情况通常会发生,由于缺少await关键字,返回了一个承诺对象而不是字符串:

helpers.log (Promise)
> [0-0] --->   helpers.console() received: function Promise() { [native code] }
> [0-0] Trace
> [0-0]     at Module.log (file:///D:/repos/Test-Automation-with-WebdriverIO/helpers/helpers.ts:14:15)
> [0-0]     at UserContext.<anonymous> (file:///D:/repos/Test-Automation-with-WebdriverIO/test/specs/example.e2e.ts:10:17)

当一个有问题的元素传递给helper.log包装器时,第二行代码UserContext将确定实际错误发生的位置。我们的调试现在开始看起来好一些。

让我们接下来识别并解决login.page.ts文件中.click方法的一个潜在问题。

测试自动化的“Hello, World!”

测试自动化的Hello, World!通常是一个检查网页上简单元素存在性的测试,例如标题或一段文本。这可能涉及编写以下操作的测试:

  • 导航到一个网页

  • 使用 XPath 或 CSS 选择器方法在页面上定位一个元素

  • 验证元素是否在页面上存在并显示

在我们的第一个示例中,我们有以下五个简单的步骤来执行登录:

  1. 导航到一个模拟的登录界面。

  2. 输入用户名。

  3. 输入密码凭据。

  4. 点击登录按钮。

  5. 验证是否出现一条消息,表明登录成功。

一个例子可以在pageObjects\login.page.ts文件中找到:

await this.inputUsername.setValue(username);
await this.inputPassword.setValue(password);
await this.btnSubmit.click();

我们已经通过使用await语句解决了由于代码执行顺序错误可能出现的错误。然而,随着开发者更改我们正在自动化的页面,元素定位器可能会随着时间的推移而变得过时。例如,想象一下登录按钮的类最初是一个button类:

<button class="radius" type="submit" fdprocessedid="ra4xrd">p
    <i class="fa fa-2x fa-sign-in"> Login</i>p
</button>

但在下一个版本中,它被更改为一个锚链接类:

<a class="radius" type="submit" fdprocessedid="ra4xrd">p
    <i class="fa fa-2x fa-sign-in"> Login</i>p
</a>p

然后.click方法将抛出以下错误:

[0-0] Error: Can't call click on element with selector "button[type="submit"]" because element wasn't found

我们可以在点击周围使用 try/catch 方法来捕获异常,并获取更多关于确切错误原因的详细信息。在这个例子中,如果点击方法成功,我们输出成功的事件。然而,如果元素不存在,被另一个元素覆盖,或者以其他方式无法点击,我们输出 captured 错误细节并将其发送到我们的控制台:

    try{
        await this.btnSubmit.click();
        helpers.log(` Clicked button`);
    } catch (err)
        helpers.log(`    Click failed because\n${err)`);
  }

我们希望每个元素都有这样的功能,但又不希望在我们所有的测试脚本中重复添加此代码。幸运的是,有了包装器,有更好的方法。

ES6 辅助模块与覆盖内建方法

有多种方法可以实现这个目标。一种方法是在每次调用中完全覆盖 click() 方法的内建行为。另一种方法是创建我们自己的自定义方法,并使用其独特的命名约定。最后,我们可以创建一个自己的函数,该函数接受对象作为参数并执行额外的功能。以下是这三种方法可能的样子:

我们可以覆盖所有的内建 click() 方法:

btnLogin.click() // Customized with overWriteCommand

我们可以添加一个自定义方法:

btnLogin.clickAdv()

或者在我们的 Helpers 文件中创建一个自定义函数:

clickAdv(btnLogin)

你使用的方法由你决定。但关键是选择一个并始终如一地坚持,而不是使用不同方法的混合。每种方法都有其独特的优点值得考虑。让我们看看前两种方法的优缺点。

覆盖内建元素方法

我们可以增强 click() 方法的一种方式是完全覆盖来自 ts.config.json 文件的内建元素方法,如下所示:

browser.overwriteCommand('click', async(origClick, element)= {
    let success = true;
    try{ p           element = await getValidElement(element, "button");
       await element.click(); // Instrinsic click
       console.log(' Clicked ${element.selector}');
    } catch (err)
        success = false;
        console.log(`$   {element.selector}click failed\n ${err)`);
  }
    return success;
})

这个命令将封装诸如检查对象有效性、设置框架、将框架设置到某个位置以及当元素不存在时跳过额外方法等功能。我们通过健全性测试来完成这项工作。例如,我们将尝试点击一个不存在的按钮 btnBogus 并查看脚本的其他部分如何执行。以下是这个虚假按钮的对象描述:

await this.btnBogus.click();

使用这种方法,签名保持不变,并且我们所有的测试脚本都得到了增强:

await this.btnSubmit.click();

自定义的 click() 方法可以添加更多关于正在发生什么的细节:

[0-0] ---> Clicking button[type="submit"] ...
[0-0] --->   button clicked.

这种方法有效地为整个测试套件中每个元素的 click() 方法添加了增强功能。但这也是一个缺点——这个方法中的错误可能会在 click() 方法被使用的任何地方造成破坏。没有简单的方法可以将测试脚本中的特定行恢复到原始的内建方法,以查看错误是否是由覆盖的代码引起的。我们只能全部开启或全部关闭。

必须还有另一种方法!

添加自定义元素方法

我们可以向浏览器对象添加一个自定义元素命令。在这种情况下,让我们称它为 clickAdv()。它可以通过更改前面代码的第一行和最后一行来实现,如下所示:

browser.addCommand("clickAdv", async function (){
…
}, true);

方法的调用变为如下所示:

await this.btnSubmit.clickAdv();

自定义clickAdv()方法与覆盖版本具有相同的细节:

[0-0] ---> Clicking button[type="submit"]
[0-0] --->   button clicked.;

快速提示

避免使用“魔法”值。这些值只有开发者知道,可能在文档中难以找到。在上一个示例中,最后一行将隐式的false覆盖为true。这意味着自定义方法应该添加到元素中,而不是浏览器中。

现在我们有了clickAdv()自定义方法的灵活性,可以通过在原子级别移除Adv来将其还原为内置版本:

await this.btnAddToCart.clickAdv();

这是通过从方法中移除Adv来实现的:

await this.btnAddToCart.click();

这看起来很完美!我们现在只需要用无效元素进行一点负面测试,看看输出是否表明点击失败。

谁测试 SDET 的代码?自动化框架的健全性测试

单元测试是任何开发项目的重要组成部分。由于预期开发者将为应用程序的功能编写单元测试,因此 SDETs 为框架代码库本身编写单元测试也是合理的。然而,“单元测试”和“集成测试”这两个术语在应用于自动化框架本身时常常引起混淆。让我们将这个方面称为“自动化框架的健全性测试”,尽管它实际上是针对离散框架功能及其集成的单元测试。这些是故意测试框架中功能可行性的脚本。就像之前的“Hello, World”示例一样,测试自动化有一个健全性测试版本,它测试我们的每个高级方法。由于自动化是一个开发项目,我们应该有一个简短的测试,故意测试框架的能力。这可能包括查找过时的 XPath 或 CSS 定位器,动态更改嵌入的数据标签为当前日期,或从包装方法中编写详细的日志。它可能包括对一个不存在的元素的负面测试,故意失败作为其预期结果。

随着我们添加更多功能,我们也应该在脚本中添加健全性测试。我们将添加一个不存在的按钮btnBogus,这将迫使方法失败:

    public get btnBogus () {
        return $('button[type="bogus"]');
    }

为了进行健全性测试,我们将点击btnBogus按钮,然后点击login page类中的login fail方法中的submit按钮:

        await this.btnBogus.clickAdv();

而我们的结果并不是我们所预期的:

Error: Can't call clickAdv on element with selector "button[type="bogus"]" because element wasn't found

WebdriverIO 在执行自定义方法之前验证元素。如果方法被覆盖,也会发生相同的情况。这里的问题是它阻止我们在框架中实现自我修复对象或用可行的元素替换过时的元素。

必须有另一种方法。

通过自定义点击方法扩展我们的 ES 模块辅助文件

有两种方法可以编写无错误的程序;只有第三种方法有效。

–艾伦·J·佩里斯

我们的第三个选项是在helpers文件中创建一个click()方法。虽然它改变了我们的签名,但它使我们能够更好地控制从元素问题中恢复:

await helpers.clickAdv(this.btnSubmit);
await helpers.clickAdv(this.btnBogus);

输出现在看起来是这样的:

[0-0] ---> Clicking button[type="submit"] ...
[0-0] --->   button clicked.
[0-0] --->   pageSync() completed in 25 ms
[0-0] ---> Clicking button[type="bogus"] ...
[0-0] --->   button[type="submit"] was not clicked.
[0-0] Error: Can't call click on element with selector "button[type="bogus"]" because element wasn't found

快速提示

与许多编码约定不同,没有命名包装方法(wrapper methods)的通用方法。一些团队可能会使用尾随下划线(_),而其他团队可能会附加 Wrapper 这个词。这里的重要启示是,团队应该就一个可识别的命名约定达成一致,并在编码标准文档中记录,并在代码审查中遵循。

现在我们正在检查我们的按钮是否可以点击,让我们看看之后会发生什么。

为什么等待(waits)难以正确实现?

所有自动化工具都有确定当元素不存在时该做什么的方法。最常见的方法是等待对象存在。WebdriverIO 在 wdio.config.json 文件中有一个选项可以调整元素的超时时间:

// Default timeout for all waitFor* commands.
waitforTimeout: 10_000,

默认时间是 10 秒。多年来,最常见的做法是等待 30 秒。问题是,根据我们的工具,如果我们的脚本导航到了错误的页面,超时可能会发生在每个元素上。如果有很多元素不存在,那么等待脚本最终结束的时间就会很长。

回到 2000 年代,WorkSoft 的创始人琳达·海斯(Linda Hayes)指出,有一种方法可以为我们框架计算一个良好的等待超时时间。将我们最慢页面渲染的平均时间乘以三。这意味着我们将预测测试中的应用程序将增加负载,并且可以足够灵活地处理它。

不同的工具有许多等待元素出现的方法。例如,Selenium 这样的工具有三个:隐式等待(implicit waits)、显式等待(explicit waits)和流畅等待(fluent waits)。隐式等待告诉 Selenium 在找不到页面上的元素之前等待一定的时间,然后抛出异常。显式等待等待特定条件满足后再继续,例如 enabledclickable。流畅等待允许自定义最大等待时间、检查条件的频率以及应忽略的异常类型。

但是,一个元素不存在下一个最常见的原因是什么?那是因为页面仍在构建中。我们只需为元素出现添加一个硬编码的等待时间即可。

问题在于当这些等待混合在一起时,Selenium 脚本的实际等待时间可能会累计到几分钟,变得难以调试。

类似地,WebdriverIO 提供了多种等待类型来处理网页上的动态元素。这些包括 waitUntil()waitForExist()waitForDisplayed()waitForEnabled()waitForSelected() 等命令,这些命令允许在继续测试执行之前等待特定条件满足。

注意,waitForExist() 命令在测试自动化中很少使用。它仅仅意味着元素存在于 DOM 中,并不意味着它出现在页面上。waitForDisplayed() 提供了相同的检查,并使得元素更有可能被用户交互。

再次强调,琳达·海斯指出,为了使框架更健壮,每个元素都应该被检查以确保它存在并且已启用。这就像我们在夜间沿着蜿蜒的山路开车时,为我们的高速汽车提供了刹车。

如果我们不是等待每个元素都出现并启用,而是等待每次点击后页面构建完成,那会怎么样?这样,页面上每个元素更有可能是有效的。这将消除每次检查元素是否存在的需求。只有在元素未找到时,框架才能在抛出错误之前想出找到它的方法。

“我不总是使用 Pause(),但当我使用时,它的时间不到 1000 毫秒”

是的,有时我们需要执行等待操作,但我们希望做得更智能。例如,pageSync()包装方法需要一个四分之一秒的等待时间,因为它正在计算页面上的元素数量。这是自定义pause(ms)方法的代码,它将告诉我们当硬编码的半秒等待时间发生时:

/**
 * Wrapper for browser.pause
 * @param ms reports if wait is more than 1/2 second
 */
export async function pause(ms: number) {
  if (ms > 500){
  log(`  Waiting ${ms} ms...`); // Custom log
  }
  const start = Date.now();
  let now = start;
  while (now - start < ms) {
    now = Date.now();
  }
}

如果延迟时间超过半秒,该方法将写入控制台。这是为了提醒我们注意框架中的延迟量。wait就像给汤里加盐一样:一点是好的,但太多会毁掉所有人的汤。我们还可以计算总共浪费了多少时间。

突出显示元素

接下来,我们将添加一种突出显示元素的方法,以确保我们在调试时可以看到正在发生的情况。这些突出显示也是一个方便的检查元素是否可见的方法。此代码将以绿色突出显示一个元素,尽管我们可以覆盖该颜色。它还有一个检查,看我们正在突出显示的元素是否已经过时。如果是这种情况,该元素将被保存到自动化交换板,并且过时元素开关设置为true。这允许我们在调用例程中更新元素,以消除过时元素减慢测试执行速度的可能性。最后,该函数返回元素是否可见:

export async function highlightOn(
  element: WebdriverIO.Element,
  color: string = "green"
): Promise<boolean> {
  let elementSelector:any
  let visible: boolean = true;
  try {
      elementSelector = await element.selector;
      try {
        await browser.execute(`arguments[0].style.border = '5px solid ${color}';`, element);
        visible = await isElementVisible(element)
      } catch (error: any) {
        // Handle stale element
        const newElement = await browser.$(elementSelector)
        ASB.set("element", newElement)
        ASB.set("staleElement", true)
        await browser.execute(`arguments[0].style.border = '5px solid ${color}';`, newElement);
        //log (`  highlightOn ${elementSelector} refresh success`)
      }
  } catch (error) {
    // Element no longer exists
    visible = false
  }
  return visible;
}

由于我们已经启用了突出显示功能,我们应该添加一些可以关闭突出显示的东西,我们将使用旋转器检测方法:

export async function highlightOff(element: WebdriverIO.Element): Promise<boolean> {
  let visible: boolean = true;
  try {
      await browser.execute(`arguments[0].style.border = "0px";`, element);
  } catch (error) {
      // Element no longer exists
      visible = false;
  }
  return visible;
}

此方法简单地告诉我们移除突出显示时元素是否可见。

接下来,让我们实现一个旋转器检测方法,该方法等待直到旋转器不再出现在页面上:

图片

注意,这个元素定位器将因项目而异。在某些项目中,我们寻找旋转器和加载器:

export async function waitForSpinner(): Promise<boolean> {
  let spinnerDetected: boolean = false;
  // This spinner locator is unique to each project
  const spinnerLocator: string = `//img[contains(@src,'loader')]`;
  await pause(100); // Let browser begin building spinner on page
  let spinner = await browser.$(spinnerLocator);
  let found = await highlightOn(spinner);
  let timeout = ASB.get("spinnerTimeoutInSeconds")
  const start = Date.now();
  if (found) {
    const startTime = performance.now();
    spinnerDetected = true;
    try {
      while (found) {
        found = await highlightOn(spinner);
        if (!found) break;
        await pause(100);
        found = await highlightOff(spinner);
        if (!found) break;
        await pause(100);
        if  (Date.now() - start > timeout * 1000) {
          log (`ERROR: Spinner did not close after ${timeout}           seconds`)
          break;
        }
      }
    } catch (error) {
      // Spinner no longer exists
    }
    log(`  Spinner Elapsed time: ${Math.floor(performance.now() -     startTime)} ms`);
  }
  return spinnerDetected;
}

该方法利用highlightOn()highlightOff()方法在检测到旋转器或加载中…元素时闪烁它。如果旋转器在超时时间内没有消失,它将记录一条消息。最后,如果检测到旋转器,它将返回一个布尔值。这有助于我们优化框架,因为如果我们需要执行页面同步,旋转器就会出现。

现在,我们可以继续扩展click()包装器。它需要一个动态方法来等待页面构建。

扩展点击方法包装器

让我们扩展clickAdv()方法,使其更加健壮,减少失败的可能性。首先,我们将在辅助类中添加一个pageSync()函数。这是一个确定页面构建何时稳定下来的替代方法。在每次点击后,我们将执行以下操作:

  1. 计算页面上可见的/span元素的数量。

  2. 等待 1/4 秒。

  3. 重复以上步骤,直到以下任一情况发生:

    • /span元素的计数稳定了三次

    • 超时了

  4. 报告完成所需时间的服务级别协议指标。

  5. 添加自定义等待时间长度的选项。

页面同步方法将动态等待页面构建。它被优化为至少花费 0.75 秒来检测页面何时完成。这减少了如果我们的元素在页面构建过程中不存在时抛出错误的概率。但它也给了我们等待更长时间的灵活性,如果系统负载很大,导致一切变慢,页面可以等待长达 30 秒。

它相当大,所以让我们将其分解:

/**
 * pageSync - Dynamic wait for the page to stabilize.
 * Use after click
 * ms = default time wait between loops 125 = 1/8 sec
 *      Minimum 25 for speed / stability balance
 */
let LAST_URL: String = "";
export async function pageSync(
  ms: number = 25,
  waitOnSamePage: boolean = false
): Promise<boolean> {
  await waitForSpinner();

我们首先等待任何旋转器或加载…元素出现。然后我们通过仅在 URL 发生变化时执行来优化:

let result = false;
  let skipToEnd = false;
  let thisUrl = await browser.getUrl();
  if (waitOnSamePage === false) {
    if (thisUrl === LAST_URL) {
      //skip rest of function
      result = true;
      skipToEnd = true;
    }
  }

如果 URL 没有变化,那么更有可能是因为选择了列表选项,导致页面上的元素发生了变化,从而简单地出现了一个旋转器。如果 URL 是新的,那么我们首先获取元素的第一次计数。这是一个动态循环,至少执行三次:

  if (skipToEnd === false) {
    LAST_URL = thisUrl;
    const waitforTimeout = browser.options.waitforTimeout;
    let visibleSpans: String = `span:not([style*="visibility: hidden"])`;
    let elements: any = await $$(visibleSpans);
    let exit: boolean = false;
    let count: number = elements.length;
    let lastCount: number = 0;
    let retries: number = 3;
    let retry: number = retries;
    let timeout: number = 20; // 5 second timeout
    const startTime: number = Date.now();
    while (retry > 0) {
      if ((lastCount != count) || (count < 20)) {
        retry = retries; // Reset the count of attempts
      }
      // Exit after 3 stable element counts
      if (retry == 0) {
        break;
      }
      if (timeout-- === 0) {
        log("Page never settled");
        exit = true;
        break;
      }
      lastCount = count;

在每个元素计数之后,我们让页面短暂地继续构建。然后我们再次计数元素,直到连续三次尝试的计数相同。此外,我们持续检查页面计数是否大于 20 个元素。通常,这低于一个阈值,如果没有旋转器或加载…元素,页面就是空的。

需要注意的是,我们只计数Span元素。这可能是div元素,但我们不应该计数所有带有星号通配符的元素,原因在于,计数页面上的每个元素本身可能需要超过四分之一秒的时间:

      // wait 1/4 sec before next count check
      await pause(ms);
      try {
        elements = await $$(visibleSpans);
      } catch (error: any) {
        exit = true;
        switch (error.name) {
          case "TimeoutError":
            log(`ERROR: Timed out while trying to find visible             spans.`);
            break;
          case "NoSuchElementError":
            log(`ERROR: Could not find any visible spans.`);
            break;
          default:
            if (error.message === `Couldn't find page handle`) {
              log(`WARN: Browser closed. (Possibly missing await)`);
            }
        }
        // Error thrown: Exit loop
        break;
      }
      count = await elements.length;
      retry--;
    }

元素计数可能会抛出错误。函数可能会超时。页面可能没有 span 元素,或者调用可能没有使用await关键字。在任何情况下,函数都会退出:

    // Metric: Report if the page took more than 3 seconds to build
    const endTime = Date.now();
    const duration = endTime - startTime;
    if (duration > waitforTimeout) {
      log(`  WARN: pageSync() completed in ${duration / 1000}       sec  (${duration} ms) `);
    }
  }
  return result;
}

此方法接受两个可选参数。第一个是页面元素计数之间的等待时间(以毫秒为单位)。默认值可以低至 25 毫秒,但不应该超过 250 毫秒。这为我们提供了在点击后页面完成构建的最佳时间量。如果点击执行时页面没有变化,则为了速度,将跳过同步,并且如果没有 span 元素存在,它将提前退出。这通常发生在页面为空白的情况下。如果页面在几秒钟内没有稳定下来,它将退出并报告给控制台。

如果等待时间超过了框架的预期超时时间,它还会给我们一个警告。这意味着页面加载时间比用户愿意等待的时间更长。我们可以调用我们的 DBA 并检查具有过度记录检索时间的 SQL 查询。

为了提高效率,pageSync只有在页面 URL 发生变化时才会执行。这是一个速度优化。第二个参数是一个开关,用于强制进行页面同步检查,即使页面 URL 保持不变。

最后,它将指示浏览器是否不再存在。这可能发生在调用方法中缺少 await 语句,并且测试提前结束的情况下。

点击事件前的超级充电滚动

在执行点击之前检查元素是否在屏幕上是一个好的做法。此方法如果元素在视口中,则返回true

export async function isElementInViewport(element: WebdriverIO.Element): Promise<boolean> {
  let isInViewport = await element.isDisplayedInViewport();
  return isInViewport;
}

使用此方法,我们可以在执行点击事件之前仅当元素不在屏幕上时才滚动元素,从而优化我们的代码。然而,有一个注意事项:如果元素在 WebdriverIO 尝试点击它时正在移动,它可能会点击错误的元素!因此,我们需要另一个函数来告诉我们元素何时停止移动:

export async function waitForElementToStopMoving(element: WebdriverIO.Element, timeout: number = 1500): Promise<boolean> {
  let rect = await element.getRect();
  pause (100);
  let isMoving = (rect !== await element.getRect())
  let startTime = Date.now();
  // Keep checking the element's position until it stops moving or the timeout is reached
  while (isMoving) {
    // If the element's position hasn't changed, it is not moving
    if (rect === await element.getRect()) {
      // Element is static
      isMoving = false;
    }else{
      // Element is moving...
      pause (100)
    }
    // If the timeout has been reached, stop the loop
    if (Date.now() - startTime > timeout) {
      break;
    }
    // Wait for a short amount of time before checking the element's position again
    await pause(100);
  }
  return !isMoving;
}

此方法检查元素位置是否在水平或垂直方向上发生变化。为了优化速度,只有在检测到元素已离屏时才会调用此方法。

让我们现在将这些内容组合成一个clickAdv()方法,以降低其失败的可能性。

快速提示:准确性胜过速度

如果测试无法达到成功结论,那么加快测试速度并不会带来优势。在pageSync()中的元素计数之间的等待时间可以减少到 0 毫秒,但这会增加执行过早的风险。你可以调整这个值以获得最佳性能,但要注意框架不要运行得太快。

扩展点击方法包装器

现在,我们已经准备好添加我们的clickAdv()包装方法。每次点击后,我们将有一个pageSync()方法执行,以便在等待页面完成时内置灵活的时间:

export async function clickAdv(
  element: ChainablePromiseElement<WebdriverIO.Element>) {
  let success: boolean = false;
  const SELECTOR = await element.selector;
  log(`Clicking ${SELECTOR}`);
  try {
    //await element.waitForDisplayed();
    if (!await isElementInViewport(element)){
      await element.scrollIntoView({ block: "center", inline: "center" });
      await waitForElementToStopMoving(element)
    }
    await highlightOn(element);
    await element.click({ block: "center" });
    await pageSync();
    success = true;
  } catch (error: any) {
    log(`  ERROR: ${SELECTOR} was not clicked.\n           ${error.message}`);
    expect(`to be clickable`).toEqual(SELECTOR);
    // Throw the error to stop the test
    await element.click({ block: "center" });
  }
  return success;
}

在这段代码中,我们从元素中获取选择器的名称。我们在点击方法周围使用try/catch。然后,我们通过使用我们自己的自定义错误消息强制测试失败来结束测试,指出元素无法点击。在自动化交换机中,我们将已失败的值设置为 true,以确保由于点击失败而跳过框架中其他方法的执行。最后,我们返回truefalse布尔值以表示成功。

现在我们用错误的元素运行测试时,我们可以得到更多关于潜在问题的详细信息:

[0-0] ---> Clicking button[type="bogus"]
[0-0] --->   ERROR: button[type="bogus"] was not clicked. [0-0] Error: element ("button[type="bogus"]") still not displayed after 30000ms

注意,我们在 30 秒后得到了一个错误,说元素找不到。然而,我们的页面是在 28 秒前构建的。因此,让我们将我们的超时设置得更高效一些。这可以通过修改wdio.conf.ts文件中的waitforTimeout值来实现,如下所示:

waitforTimeout: 3000,

现在当测试运行时,测试失败之前不会超过三秒钟,但测试会等待更长的时间来构建页面:

public get btnBogus() {
  return $('//button[type="bogus"]');
}
await helpers.clickAdv(this.btnBogus);

再次强调,这个超时值会因项目而异。接下来,我们需要实现跟踪我们执行的一些信息的功能。

指标的重要性

这些方法报告了它们执行所需的时间。ClickAdv() 还会在构建页面所需的时间超过框架默认超时时间时报告一个警告。通过这种方式,我们可以开始了解我们的更改如何随着时间的推移影响应用程序的响应速度,以及框架本身如何影响执行速度。

例如,使用给定测试的基线执行时间,我们可以看到框架增强是否对总时间有积极影响,或者意外地减慢了执行速度。一个好的页面稳定化方法通常会早于硬编码的等待继续执行。通过跟踪Pause()方法添加的总时间来跟踪总执行时间。

自愈元素

通过自定义方法包装器,我们现在可以开始通过使用自愈元素来减少框架中所需的维护工作。这些是在用户界面中自动从问题中恢复而不需要更新页面对象模型的元素。这可以包括由于自上次发布以来类已更改而变得无效的链接元素。自愈元素旨在通过减少手动输入的需求并允许测试有更好的机会完成来改善测试体验。

例如,假设在最新版本中Login按钮是一个链接:

  public get lnkSubmit() {
    return $('//a[text()="submit"]');
  }

在此之后,我们调用不再有效的按钮:

    // Class switching
    await helpers.clickAdv(this.lnkSubmit);

通常,如果点击此元素,它会抛出错误。但如果我们注入一个基于定位器获取有效元素的函数,即使底层类已更改,我们也有机会通过这一步:

  element = await getValidElement(element);
  const SELECTOR = await element.selector;
  await log(`Clicking ${SELECTOR}`);

在以下代码中,我们将提取链接的类标签名和文本,并动态生成按钮元素定位器。虽然不太可能,但代码也涵盖了反向情况,即按钮类已变为链接:

export async function getValidElement(
  element: WebdriverIO.Element
): Promise<WebdriverIO.Element> {
  let selector: any = await element.selector;
  // Get a collection of matching elements
  let found: boolean = true;
  let newSelector: string = ""
  let newElement: any = element;
  let elements: WebdriverIO.Element[];
  let elementType:string = ""
  let elementText:string = ""
  try {
    elements = await $$(selector);
    if (elements.length === 0) {
      let index: number = selector.indexOf("[");
      elementType = selector.substring(0, index);

到目前为止,我们没有找到与定位器匹配的元素。因此,我们需要根据自上次发布以来丢失的元素进行创新。在以下代码中,我们将查看从链接锚点变为按钮或反之亦然的潜在类更改:

      switch (elementType) {
        case "//a":
          elementText = selector.match(/=".*"/)[0].slice(2, -1);
          newSelector = `//button[contains(@type,'${elementText}')]`
          break;
        case "//button":
          elementText = selector.match(/=".*"/)[0].slice(2, -1);
          newSelector =`//a[contains(text(),'${elementText}'])`
          break;

在以下代码中,我们将添加类似的类切换代码来处理列表和Input元素:

        default:
          found = false;
          newElement = element;
          break;
      }
      newElement = await $(newSelector);
      found = await isElementVisible (newElement)
    }
  } catch (error) {
    found = false;
  }
  // Successful class switch
  if (found) {
    await log(
      `  WARNING: Replaced ${selector}\n with ${newSelector}`
    );
  } else {
    await log(`  ERROR: Unable to find ${selector}`);
  }
  return newElement;
}

请记住,getValidElement()并不能解决我们所有的维护问题。目标是通过拥有足够智能的框架来从多个过时元素中恢复,并到达给定的端点,从而显著减少我们的维护工作。替换定位器的代码将针对每个项目是独特的,但可能会令人惊讶地发现你的测试将多么稳健。虽然有一些额外的代码和控制台日志的开销,但与将新版本推送到预发布环境时更新元素定位器所需的时间相比,这微不足道。

使用“alreadyFailed”开关板键来模拟方法

具有单一出口点的框架可以使用开关板来跟踪通过、失败和跳过的步骤数量。通过在 ASB 开关板中嵌入alreadyFailed开关,可以编写方法以模拟,仅报告动作本意要执行的操作:

   ASB.set(`alreadyFailed`, !found)
   if (ASB.get(`alreadyFailed`) === true)) {
      allure.addStep(`Click '${selector}'`, undefined, 'skipped');
      ASB.set(`skipped`, ASB.get(`skipped`)++)
      return;
    }

这样可以跳过所有页面同步时间和延迟,同时增加计数器以获取测试到达目的地的所需步骤数。

摘要

在本章中,我们添加了一个helpers库,其中包含几个方法来增强我们的click方法。然后我们解决了将屏幕外的元素拉到屏幕上,并使用页面同步等待页面构建的灵活时间,以确保我们的元素在测试时不会抛出错误的问题。我们还添加了高亮显示以及旋转检测器。我们引入了类切换的概念,以尝试找到,并包括了一些关于执行速度的细节。

现在我们已经为click()方法建立了大部分支持框架,让我们对setValue()做同样的事情。这包括验证我们的输入元素,并使用最快的方式通过剪贴板填充字段。接下来,我们将看到如何输入文本并替换动态数据。

第六章:setValue 包装器 – 输入文本和动态数据替换

在本章中,我们将适应之前章节中 click() 方法的功能,并将其扩展到 setValue() 方法。此外,包装器方法引入了在输入数据之前清除字段的多种方法。本章将向您展示如何实现动态数据标签替换作为增强功能。这是数据防止过时的焦点。例如,一个测试可能需要一个未来的或过去的日期。最后,我们将查看检测密码字段并使用 setPassword() 函数对其进行屏蔽。

在本章中,我们将涵盖以下主题:

  • 创建 setValue 包装器

  • 从普通英语规范化元素类描述

  • 清除字段并输入数据的替代方法

  • 动态替换 <Today> 标签为日期

  • 隐藏敏感凭证数据

首先,有一些家务要做。在上一个章节中,我们介绍了按钮的类切换。我们将对输入字段以及即将到来的列表和文本元素做同样的事情。虽然我们可以推断出传递给 getValidElement() 方法的元素类型,但我们也可以直接从包装器中传递类型:

inputField = await getValidElement(inputField, "field");

这意味着我们可以通过跳过提取 element 类的代码来优化代码的速度,通过明确地指定元素类型:

 // Extract the element type if not provided
 if (elementType === "") {
 let index: number = selector.indexOf("[");
elementType = selector.substring(0, index);
 }else{
 elementText = normalizeElementType(elementType);
}

然而,显式的 field 字符串将不会匹配 //input 的隐式字符串类型。为了解决这个问题,我们将添加一个方法,将像 fielditem 这样的通用描述符转换为合适的类字符串,例如 //input 和 “//li”。

规范化元素类型

这是我们将编写一个方法来规范化所有显式字符串的地方。在这里,link 变为 //abutton 变为 //button,依此类推。您的框架可以根据需要继续扩展许多其他元素类型。以下函数 normalizeElementType() 将将元素的普通英语描述转换为常见的 xPath 等效值。请注意,fieldinput 变为相同的类,而空的类描述成为所有元素的定位符:

function normalizeElementType(elementType: string)
{
  // Pessimistic: return all matches if the type is unknown
  let elementText = "//*"
  switch (elementType)
  {
    case "link":
      elementText = "//a";
      break;
    case "button":
      elementText = "//button";
      break;
    // Support different terms of the same field type
    case "field": // plain English reference to a type input field
    case "input": // type input
      elementText = "//input";
      break;
    case "list":
      elementText = "//select";
      break;
    case "text":
      elementText = "//p";
      break;
    default:
      log (`WARNING: Unable to normalize element type ${elementType}`)
  }
  return elementText;
}

在这个函数中,有几个值得注意的点。首先,该函数具有悲观性质,它假设传递的定位字符串为空或 nullelementText 被初始化为 //* 以返回第一行中的所有元素。这意味着我们假设在某个时刻可能会传递一个尚未实现的字符串,例如 list。为了文档记录,我们将未知字符串的名称作为警告输出到控制台日志。

其次,我们将元素更改为匹配所有值而不是停止测试。我们希望框架尽可能多地尝试到达终点,而不增加更多的维护时间。然而,它确实给出了一个警告,我们应该尽可能详细地描述。

由于我们现在支持未知元素类型,我们将向 getValidElement() 函数添加一个通用定位器:

case "//*":
elementText = selector.match(/=".*"/)[0].slice(2, -1);
newSelector = `//*[contains(text(), '${elementText}'])`;
found = await isElementVisible(await $(newSelector));
break;

当我们规范化文本时,我们将常见的英语描述符替换为 xpath 元素或 CSS 字符串定位符等价物。然而,这不仅仅适用于类。这个相同的概念也被用于许多属性中。在我们继续到输入字段之前,让我们花一点时间看看一个链接。

规范化文本可以在 XPath 定位器中看到,使我们能够找到包含嵌入的回车和多余的临时空白空间的元素。在这个例子中,“嵌入回车”网页元素有额外的空格和回车:

 <!DOCTYPE html>
<html>
<head>
    <title>Dynamic Loading Example</title>
    <script>
        function embeddedCarriageReturn() {
            var paragraph = document.getElementById("change-me");
            paragraph.innerHTML = "You clicked the Embedded Carriage             Return link!";
        }
    </script>
</head>
<body>
    <h1>Weblink Challenge!</h1>
    <p>Can you framework click the link below</p>
    <a href="#" onclick="embeddedCarriageReturn()">Embedded <br>        Carriage  Return</a>
    <p id="change-me"></p>
</body>
</html>

这意味着这个 xPath 无法识别链接:

  public get btnEmbeddedCarriageReturn() {
    return $("//a[text()='Embedded Carriage Return']");
  }

然而,我们可以使用 normalize-space() 节点来规范化文本,以删除回车 <br> 换行符以及多余的空白:

  public get btnEmbeddedCarriageReturn () {
    return $("//a[contains(normalize-space(),'Embedded Carriage     Return')] ");
  }

现在我们又有了工具箱中的另一个工具,我们可以编写可以找到包含额外空格和换行符的元素的定位器,如果这些元素被开发者清理,这将减少维护时间。让我们通过输入元素和 setValue 方法将这一概念提升到下一个层次。

添加 setValue() 方法包装器

我们首先在 helpers 文件中添加一个新的包装器,我们将扩展它以执行几个检查,然后再执行内建的 setValue() 方法:

export async function setValueAdv(
  inputField: ChainablePromiseElement<WebdriverIO.Element>,
  text: string) {
//Custom setValue wrapper code here
await element.setValue(newValue);
}

现在,我们准备开始增强我们框架的数据填充方面。

这趟旅行真的有必要吗?

首先要做的就是检查以下代码是否必须执行。如果没有文本要输入,我们没有理由找到并替换一个有效的状态元素。因此,我们将首先检查是否已传递任何数据以进行输入:

//Custom setValue wrapper code here
try{
    if (text.length === 0) {
        log (`      Warning: Attempted to enter "" into ${element.selector}`)
    return true;
}
}catch (error){
    log (`      Warning: Attempted to enter NULL into ${element.selector}`)
    log (`      Check if there was a query column to a missing column in a data file `)
    return false;
}

这个函数有三个动作:

  • 如果文本不是 null 且不为空,代码将继续执行方法的其余部分。

  • 如果文本为空字符串,则返回 true,意味着测试可以继续。这很有用,因为我们可能正在填充整个页面,但并非每个字段都需要值。我们可能通过姓氏、邮编和州的任意组合进行搜索。这允许我们设计一个页面方法,它具有所有输入字段,但仅与接收某些数据的元素交互。

  • 空值是一个特殊情况。它是一个表明有问题的线索。作为超级英雄,我们总是想收集线索来识别通常的嫌疑人,他们正在犯罪。如果线索是以谜语的形式出现的,我们可能会与当地的精神病院联系,看看是否还有囚犯被关在带有问号的牢房里。

  • 在这种情况下,NULL 值通常表明查询返回了空值。我们向控制台发送警告,并返回 false 作为状态。就像空值一样,它跳过函数的其余部分。

确认输入数据后,我们将采取第二步,并添加保持数据新鲜的能力。

煤炭变成钻石——替换动态数据标签

测试自动化中的一个非常常见的任务是使用当前日期填充字段。现在,我们不想每天手动更改日期,所以我们想要一个动态的功能来提供这种功能。如果我们足够聪明,这个功能可以返回当前、过去或未来的日期。甚至日期格式也可以修改。这就是嵌入式动态数据标签技术发挥作用的地方。

动态数据标签是一种保持定期变化的数据新鲜的方式。这可能是一周中的当前日期,一个由需要完成的批处理作业创建的唯一顺序号,或者一个排除周末和假期的未来业务日期。

每个项目都有许多独特应用。在这种情况下,我们将提供一个最常见的数据替换的简单示例——将标签名"Today is: <today>"替换为当前日期(即"Today is 6/21/2023")。

然而,我们不会止步于此。我们还将对未来的日期进行任意天数的日期偏移:

"Tomorrow is: <today+1>"
"Tomorrow is: 6/22/2023"

或者,我们也可以为过去日期进行操作:

"Last week was: <today-7>"
"Last week was: 6/14/2023"

最后,我们希望有改变格式的功能:

"Yesterday in European format: <today-1 dd/mm/yyyy>"
"Yesterday in European format: 20/6/2023"

这个<today>标签的基本格式将被替换为过去或未来的日期,使用自定义的replaceTags()函数。接下来,我们添加一个函数,该函数检测通过setValueAdv()传递的每个字符串中的这些标签。这将处理所有类型的标签:

function replaceTags(text: string) {
  //check if the passed tag is in the format of "<someTag>"
  let newText: string = text;
  // Capture anything that is not a space
  let match = newText.match(/\<(.*?)\>/);

我们使用一种名为正则表达式的暗黑魔法,它可以识别括号内的字符串并将其提取出来:

  • /:这是正则表达式的开始分隔符。

  • <:这匹配文本中的打开尖括号<

  • (.*?):这是一个捕获组,匹配任意字符(由点.表示)零次或多次(由*?表示),直到遇到正则表达式中的下一个字符(在这种情况下,关闭的尖括号>)。?使*量词懒惰,意味着它将尽可能少地匹配字符以满足正则表达式模式。

  • >:这匹配文本中的关闭尖括号>

  • /:这是正则表达式的结束分隔符。

可能需要在字符串中替换多个标签。因此,我们将遍历所有标签。标签识别不区分大小写,意味着<today><TODAY>是等效的:

  while (match) {
    let tag = match[0].toLowerCase();
    let tagType = match[1].toLowerCase();

这个switch语句匹配标签扩展的第一部分,与未来的多个标签相匹配。在这种情况下,我们的第一次匹配将是一个以<today开头的标签,并通过后面的值来偏移日期:

    switch (true) {
      case tag.includes("<today"):

我们有了tag字符串。现在,如果存在日期格式,我们将对其进行分割以转换函数末尾的日期:

        let format: string = tagType.split(" ")[1] ? tagType.split(" ")[1] : "";
        let days: number = 0;
        const match = tag.match(/+-/);

另一个正则表达式用于提取偏移日期的天数:

  • /:这是正则表达式的开始分隔符。

  • [+-]:这匹配文本中的+-字符。方括号表示字符类,这意味着正则表达式将匹配方括号内任意一个字符。

  • (\d+):这是一个捕获组,匹配文本中的一或多个数字(由\d表示)。括号围绕\d+将匹配的数字作为一个组捕获。+量词意味着正则表达式将匹配一个或多个数字。

  • /:这是正则表达式的结束分隔符。

下一个动作是确定是否存在对过去或未来日期的偏移天数:

        if (match) {
          const days = parseInt(match[0]);
        }

在这里,我们用函数替换字符串中的标签,该函数获取当前日期的偏移量和自定义格式:

        newText = newText.replace(tag, getToday(days, format));
        break;
      default:
        log(`ERROR: Unknown tag <${tag}>`);
        break;
    }
    match = newText.match(/\<(.*?)\>/);
  }

这个循环会一直持续到所有标签都被替换。如果发现并替换了任何标签,新的文本将被输出到控制台进行记录:

  if (newText !== text) {
    log(`    Replaced tags in '${text}' with '${newText}'`);
  }
  return newText;
}

现在我们有了提取动态日期标签的能力,我们需要使用getToday()函数来处理偏移日期和格式。默认情况下,即今天的日期为空,如果格式参数为空,则日期格式为MM-dd-yyyy

export function getToday(offset: number = 0, format: string = "MM-dd-yyyy") {
  const currentDate = new Date();
  currentDate.setDate(currentDate.getDate() + offset);

这里是我们的超级秘密配方。这段代码将根据传入的格式生成日期。为什么需要写大量的代码来支持所有日期格式,从两位数或四位数的年份和0前导日期到欧洲格式,当Date.toLocalDateString可以在这么少的代码行中为我们完成所有这些呢?

  return currentDate.toLocaleDateString(undefined, {
    year: format.includes("yyyy") ? "numeric" : undefined,
    month: format.includes("MM")
      ? "2-digit"
      : format.includes("M")
      ? "numeric"
      : undefined,
    day: format.includes("dd")
      ? "2-digit"
      : format.includes("d")
      ? "numeric"
      : undefined,
  });
}

我们的动态日期标签提取器和格式化器已经完成!下一个技巧是将它填充到字段中。正如你可能猜到的,有不止一种方法可以做到这一点——慢速和快速的方法。

向字段中注入文本与输入文本

我们可能想要覆盖内置的setValue()命令来填充字段:

await inputField.setValue(newValue);

原因是向元素注入值不一定能触发元素背后的任何附加 JavaScript 代码。这也可能跳过我们在注入数据时开发者添加的一些格式。或者,我们可以使用addValue()

await inputField.addValue(newValue);

现在,我们可能正在向已经包含文本的字段中追加文本。我们想要的函数是首先清除字段(如果已填充),然后像用户一样逐字输入,之后按Tab键移出字段。

这可以在我们的框架中以两种方式实现。

首先,我们将焦点设置在元素上,并通过browser.keys()方法发送按键。其次,我们直接使用元素的AddValue()方法发送按键。这将是一个备选方案,速度稍慢。无论使用什么工具,有时在高速输入时,元素可能无法正确接收输入的文本。因此,AddValue方法将作为备选方案,以确保字段能够准确填充。

让我们从第一种方法开始,使用browser.keys方法将文本发送到元素,并专注于速度。这是通过点击元素来设置焦点来实现的:

await highlightOn(inputField);
await inputField.click();

元素现在具有焦点,多亏了高亮显示,我们可以看到哪个元素将接收输入。我们应该检查该字段是否需要清除。

检查字段是否预填充以提高速度

接下来,如果字段有任何预存文本,我们将清除该字段。执行此操作的基本方法是使用clear()方法:

if (await inputField.getAttribute('value') !== '') {
await inputField.clear();
}

也可以通过发出Meta-a命令来选择所有文本来清除字段,从而提供另一种清除字段的方法。通过从浏览器发送回车 ASCII 键码来清除选定的文本:

await browser.keys(['Meta', 'a']);
await browser.keys(['\ue003']);

现在,我们将把传递给包装器的文本输入到浏览器中的字段:

await browser.keys(text);

更快并不总是更好。如果你发现 WebdriverIO 输入文本的速度导致问题,你可以使用以下替代代码来控制文本输入的速度:

// type text letter by letter
for (let letter = 0; letter < text.length; letter++){
await pause(10); // control the typing speed
await inputField.addValue(text[letter]);
}

一旦输入文本,可以通过按Tab键来激活字段:

await browser.keys(['tab']);

虽然Tab键可以用来激活元素,但有时需要使用Enter键:

await browser.keys(['\ue007']);

然而,我们是不是要发送我们的密码到控制台,让每个人都能看到?当超级反派能够驾驶英雄的超能犯罪战斗车辆进行欢乐驾驶时,这就是城市里糟糕的一天。让我们让这种情况发生的可能性降低。

面具背后 – SetValuePassword()方法以保持数据安全

超级英雄戴面具是为了保护家人和朋友。在测试自动化中,我们需要保护我们的敏感数据,如密码。在这个方法中,我们采取额外步骤确保我们的密码不会显示在控制台和报告输出中,通过将大多数字符串替换为星号(`Password" = "Pa****rd"))。然而,如果我们的问题的根本原因是密码已过期,我们可能希望有一个小线索。因此,我们需要屏蔽我们的凭证的一部分:

function maskString(str: string): string {
  let maskedStr = '';
  for (let charIndex = 0; charIndex < str.length; charIndex++) {
    if (charIndex > 1 && charIndex < str.length - 2) {
      maskedStr += '*';
    } else {
      maskedStr += str[charIndex];
    }
  }
  return maskedStr;
}

这里是原始密码和返回值的示例:

let originalString = "SuperSecretPassword!";
let maskedString = maskString(originalString);
console.log(originalString); // Output: 'SuperSecretPassword! '
console.log(maskedString); // Output: 'se**********ation'

在输出中检测和屏蔽密码

下一步是检测可能为密码的字段,然后清除传递给它的数据。我们将密码发送到字段,但将数据的一个清洗版本输出到我们的结果中。首先,让我们获取文本的清洗版本:

scrubbedtext = maskString (text)

接下来,我们将获取字段元素名称并检查它是否包含ssword字符串模式。这使我们很可能会清洗任何包含passwordPassword字符串的字段。这是由自定义的getFieldName()辅助方法提供的:

/**
* Returns the first non-null property from the prioritized list: 'name', 'id', 'type', and 'class'. Can be amended to add other attributes such as "aria-label"
* @param {WebdriverIO.Element} element - The WebdriverIO element to get the name of the field
* @returns {string | null} The field name, or null if no properties have a value
*/
async function getFieldName(element: WebdriverIO.Element) {
// Add any custom properties here, e.g.:
// const customPropertyName = await element.getAttribute("aria-label");
// if (customPropertyName) return custom;
// Get the 'name' property of the element
  const name = await element.getAttribute("name");
  if (name) return name;
  // Get the 'id' property of the element
  const id = await element.getAttribute("id");
  if (id) return id;
  // Get the 'type' property of the element
  const type = await element.getAttribute("type");
  if (type) return type;
  // Get the 'class' property of the element if others are null
  const className = await element.getAttribute("class");
  return className;
}

你可能想知道,为什么不创建一个名为getElementName()的通用方法来返回任何元素的名称?原因是属性和优先级可能因我们是在寻找输入字段、按钮、列表或其他元素而有所不同。这使我们能够根据元素类型优化代码执行。

将所有内容组合在一起

现在我们已经拥有了所有定制的部件,让我们组装我们的超级方法。这些方法将返回成功值truefalse。我们确保我们有一个来自前面章节的有效元素。我们将用未来的或过去的偏移量替换日期等标签。我们将检测字段是否是密码,并相应地屏蔽我们的值输出:

export async function setValueAdv(
  inputField: WebdriverIO.Element,
  text: string
) {
  let success: boolean = false;
  inputField = await getValidElement(inputField, "field");
  const SELECTOR = await inputField.selector;
  let newValue: string = replaceTags(text);
  let scrubbedValue: string = newValue
  let fieldName: string = await getFieldName(inputField)
  //Mask Passwords in output
  if (fieldName.includes("ssword") ){
    scrubbedValue = maskValue(scrubbedValue)
  }
  await log(`Entering '${scrubbedValue}' into ${SELECTOR}`);
  try {
    // await element.waitForDisplayed();
    if (!(await isElementInViewport(inputField))) {
      await scrollIntoView(inputField);
      await waitForElementToStopMoving(inputField);
    }
    await highlightOn(inputField);
    //Check if text was entered
    // Clear input field
    await inputField.click();
    // Do we need to clear the field?
    if (await inputField.getValue()) await inputField.setValue(newValue);
    // Send text to input field
    for (const letter of text) {
      await inputField.addValue(letter);
    }
    success = true;
  } catch (error: any) {
    await log(`  ERROR: ${SELECTOR} was not populated with ${scrubbedValue}.\n       ${error.message}`
    );
    expect(`to be editable`).toEqual(SELECTOR);
    // Throw the error to stop the test, still masking password
    await inputField.setValue(scrubbedValue);
  }
  return success;
}

以下是我们输出中屏蔽凭证的示例:

[0-0] ---> Logging in with user role 'tomsmith'
[0-0] ---> Entering 'tomsmith' into #username
[0-0] ---> Entering 'Su****************d!' into #password

我们可以实现许多其他功能来定制字段的输入数据。我们可以使用相同的技巧注入 SQL 语句,以确保始终检索有效的订单号进行搜索。可以填充随机的Corporate Lorem Ipsum填充词字符串来检查精确的字段长度边界。带有粗俗语言的文本可以用来测试它们是否会被标记,并发送通知电子邮件到测试账户。花几分钟时间思考一下所有可能通过自动化框架进行测试的动态和特殊数据类型。

摘要

在本章中,我们向我们的setValueAdv()方法添加了各种定制的设备。此方法提供一个结果,指示成功或失败,值为truefalse。我们进行了背景检查,以确保我们有一个有效的元素。我们的方法涉及使用时间旅行的力量,通过用现在、未来或过去的日期偏移量替换与日期相关的标签。我们还考虑了安全性,并确保在相关字段可能是凭证输入时屏蔽我们的输出值。

接下来,我们将使用列表和组合框来超级增强Select()方法。

第七章:选择包装器 – 在列表和组合框中选择值

在本章中,我们将通过一个名为 selectAdv() 的多功能方法扩展我们的工具函数集。这个函数旨在有效地处理列表元素,类似于我们现有的 clickAdv()setValue() 函数的操作。我们将包含一个验证检查以确保传递给函数的元素是有效的。此外,我们将实现一个重试机制,该机制尝试最多三次定位元素,每次必要时将其滚动到视图中。

然而,真正的挑战出现在处理组合框时。这些元素与交互复杂,尤其是在打开它们以显示可选择的项列表时。另一个关注点是,在选择新值之前清除组合框中残留的任何文本。我们将探讨三种不同的策略来实现这一点。

一旦克服了这些障碍,下一步就是从列表中识别所需的项目并选择它。在这里,另一个工具 SelectorsHub 作为救星出现,帮助我们精确地定位正确的项目。

我们在本章中将涵盖以下主要主题:

  • ClicksetValue 的基本功能添加

  • 从列表中选择项目

  • 使用 SelectorsHub 检查失去焦点时关闭的列表

我们将从之前讨论的方法中共同的代码开始。

clickAdv()setValueAdv() 的基本功能添加到 selectAdv()

就像之前的 clickAdv()setValueAdv() 方法一样,我们希望确保我们的元素是有效的并且滚动到视图中以便进行屏幕截图。如果测试本身已经失败,我们将不会执行任何进一步的操作,本质上就是模拟函数。方法的开头部分将与 clickAdv() 方法类似:

exports.selectAdv = async (selector, text){
  element = await getValidElement(element, "list");
      let listName : String = getListName{element}

如果列表元素不存在,我们将在 getValidElement() 函数中尝试找到三个类似节点。第一种方法是通过尝试使用 @id 属性找到列表:

case 'list'
  newSelector = `//select[contains(@id,
    '${selector.toLowerCase()}'}]`;
  length = await (this.countMatches(newSelector)));
  exists = length != 0
  element = await ${`${newSelector}`);

如果没有找到所有小写 ID 的元素,我们将尝试再次不区分大小写:

  if (length == 0){
    // Second chance List locator
    newSelector = `//select[contains(@id, '${selector}'}]`;
    length = await (this.countMatches(newSelector)));
    exists = length != 0
    element = await ${`${newSelector}`);
  }

我们最后的尝试将是寻找包含字符串中文本的任何元素的子 select 节点。这通常是一个 DivSpan 节点:

  if (length == 0){
    // Second chance List locator
    newSelector = `//*[contains(text(),       '${selector}')]/parent::*/select]`;
    length = await (this.countMatches(newSelector)));
    exists = length != 0
    element = await ${`${newSelector}`);
  }
    break;
}

现在我们有了列表元素,我们有三种方式从列表中选择项目。每种方式都有其优点和缺点。

从列表中选择项目

WebdriverIO 提供了三种从元素中选择项目的方法:

  • selectByVisibleText:根据其可见文本匹配选项

  • selectByIndex:根据其索引位置(基于 0)匹配选项

  • selectByAttribute:根据特定的属性及其值匹配选项

例如,如果我们想从一个月份列表中选择第三个月份,这些方法中的每一个都可能有效:

await lstMonth.selectByVisibleText ("March");
await lstMonth.selectByAttribute ("value", "March");
await lstMonth.selectByIndex(2); // 0 based index

通常,WebdriverIO 的selectByVisibleText方法作为包装器中的默认方法运行良好,但有时列表元素需要以其他方式与打开的列表进行交互。

在每种情况下,我们都应该验证是否已选择了正确的值:

await lstElement.selectByVisibleText (item);
let itemValue = await listElement.getText();
if (itemValue === item) return true

此外,如果抛出错误,我们应该尝试在列表中找到一个接近匹配。

一种方法是在列表中发送下箭头。然后,我们检查所选值是否包含预期的值:

await listElement.click({ block: 'center' }) // Set focus
await browser.keys(["\uE015"]}; // Send down arrow key to open the list 

然后,我们可以循环遍历并记录匹配项或打印不匹配值的列表:

let item : String ="";
let arrItems: string[] = [];
let found : boolean = false
let lastItem : string = await listElement.getText()
arrItems.push(lastItem);

这是一个有两个出口点的无限循环。要么找到了一个接近匹配,要么到达了列表的最后一个项目但没有匹配:

do {
  if (await listElement).getText().contains(item){
    found = true
    global.log ("Found a close match: " +
      listElement).getText()
    break;
  }
  await browser.keys(["\uE015"]}; // Send down arrow key
  item = listElement.getText()
  if (lastValue === item) {
    break;
 global.log (`'${item}' was not found in list: ${arrValues}); // Output the item and the list of values
}
   arrItems.push(item)
} while !(lastValue == listElement.getText()) // No match

如果这个循环从未找到匹配项,我们将输出我们寻求的项目和存储在arrItems[]中的可用元素列表:

if (found === false) {
  await this.log (`    Failed to select '${text}' from ${arrItems} in ${listName}
  return element;
}

这完成了最常见的列表元素列表。然而,我们也可以支持一种与组合框交互的路径,这可能是一种完全不同的类型。

从组合框中选择

另一个使用包装器的原因是能够识别和与不是真正下拉列表的元素交互。在这个例子中,我们有一个组合框。这既是输入字段,也是从潜在匹配项列表中进行选择。以国家列表为例。

图 7.1 – 从部分文本中选择项的组合框

图 7.1 – 从部分文本中选择项的组合框

在具有多个<option>子元素的<select>元素中。这里有一些方法。

使用 selectByVisibleText

此方法允许您通过其可见文本(用户显示的文本)选择一个选项:

const comboBoxSelector = 'select#yourComboBoxId';
$(comboBoxSelector).selectByVisibleText('Option Text');

使用 selectByAttribute

此方法允许您通过其value属性选择一个选项:

const comboBoxSelector = 'select#yourComboBoxId';
$(comboBoxSelector).selectByAttribute('value', 'option-value');

使用 selectByIndex

此方法允许您通过其索引(0为基础)选择一个选项:

const comboBoxSelector = 'select#yourComboBoxId';
$(comboBoxSelector).selectByIndex(1); // Index starts from 0

然后,我们点击字段并输入项目文本。如果我们找到一个包含该文本的元素,我们就点击它。然而,在自闭合列表中编写该元素定位器可能很棘手。这就是SelectorsHub派上用场的地方。

使用 SelectorsHub 检查失去焦点时关闭的列表

有时,很难获取列表项的定位器,因为列表只有在鼠标光标悬停在其上时才会打开。在这个例子中,我们可以暂停网页的执行,以便在列表展开时与元素交互。DevTools 中的暂停功能位于选项卡:

图 7.2 – 选择“源”选项卡以显示暂停按钮

图 7.2 – 选择“源”选项卡以显示暂停按钮

这对于捕获不会在屏幕上停留很长时间的元素很有用,例如旋转器和加载...机制。有时,我们可能不够快,无法到达暂停按钮,或者当我们移动鼠标离开它时,列表简单地关闭。这就是一个名为SelectorsHub的工具派上用场的地方。这个工具是 Chrome 浏览器的插件:

图 7.3 – SelectorsHub 显示在浏览器工具扩展菜单中

图 7.3 – SelectorsHub 显示在浏览器工具扩展菜单中

这个工具可以通过在 Chrome 浏览器扩展中搜索来安装。

安装后,该工具可以在 元素 选项卡中找到。通常,它是列出的第一个选项卡,有时是最后一个。这个工具的一个隐藏功能是 调试 模式。

图 7.4 – 从 SelectorsHub 激活暂停以冻结转轮并获取其定位器 ID

图 7.4 – 从 SelectorsHub 激活暂停以冻结转轮并获取其定位器 ID

点击 SelectorsHub 将在五秒后自动暂停网页。这使我们能够及时捕获难以捉摸的元素。尝试暂停网站 candymapperr2.com/launch-candymapper加载中... 转轮页面:

图 7.5 – 网页上的转轮示例

图 7.5 – 网页上的转轮示例

默认情况下,五秒后进入暂停模式对于大多数这些情况来说已经足够了。为了捕捉非常短暂的对象,设置 选项允许我们根据需要更改延迟时间,使其更短或更长。

图 7.6 – 设置调试器等待时间

图 7.6 – 设置调试器等待时间

这也适用于短暂显示的 加载中... 等待 机制。

编写位于列表中的定位器

现在,我们在组合框中有我们的列表,可以清除任何现有的元素文本。有几种方法可以做到这一点。我们可以设置值,我们可以双击以选择所有现有文本,最后,我们可以向字段发送 Ctrl + A 键盘快捷键:

ListElement.setValue("");

然而,这可能不会适用于所有输入字段元素。也许双击字段会起作用:

await listElement.doubleclick()

嗯,如果字段中已经有一个单词,比如 Denmark,那么它就会起作用,但如果它包含空格,比如 Trinidad and Tobago,则不会起作用。

令人惊讶的是,在字段上三击会选中所有内容。然而,在撰写本文时,作者知道没有自动化工具支持三击。此外,如果你想知道,这并不是三击:

await listElement.click(); await listElement.click(); await listElement.click()

这里有一种清除字段的方法。通过单次点击将焦点置于字段上。然后,发送一个 Home 键来将光标置于字段的起始位置。接下来,按住 ShiftEnd 键以突出显示字段中的所有文本。最后,向字段发送一个 Delete 键,然后是文本,如下所示:

await listElement.click()
await browser.keys(['Home']);
await browser.keys(['Shift','End']);
await browser.keys(['Delete']);
await browser.keys(`${item}`)

在这种情况下,组合框会显示所有可供选择的项目。在一个 tryCatch 块中,我们现在可以获取所有与预期值匹配的列表项集合:

        // Find the item in the list
        try {
          listItems = await browser.$$(`//li/*`)

我们搜索一个接近完美匹配的列表项。

如果找到,我们就会中断循环并执行点击操作,使用自定义的 clickAdv() 方法:

          for (const listItem of listItems) {
            if ((await listItem.getText()).includes(item))             // Found the element
            break;
          }

          clickAdv(listItem)
        } catch (error) {

如果这失败了,意味着没有可点击的项目。我们现在需要记录的最重要信息是列表中显示的可用项目:

          listItems = await browser.$$(`//li/*`)
          for (const listItem of listItems) {
             textContent += await listItem.getText() + " | ";              // Get the text content of the element

        }
          await log(`  ERROR: "${item}" was not found in combobox: \n ${textContent}`)
        }

现在,我们有一个健壮的自定义方法,它将提供可操作的详细信息,帮助我们维护测试失败时的测试。从这里,我们可以扩展此方法,以便在存在多个近似匹配时报告。

摘要

在本章中,我们编写了一个自定义包装器来从列表元素中选择一个项目。我们学习了如何使用单一方法处理两种不同的对象类型,从而减少测试用例代码。组合框路径使用 click() 方法打开并遍历列表,以及清除输入字段以过滤列表中的匹配项。我们嵌入报告,如果不存在精确匹配或近似匹配,使调试更容易。这是通过发送日志错误消息来完成的,指示所寻求的项目、所使用的列表元素以及不匹配的值列表。我们还展示了如何使用 SelectorsHub 在列表关闭时,当对象失去焦点时,验证手写的 XPath 选择器。

这完成了测试自动化中最常用的四种方法中的三种。在下一章中,我们将创建一个增强的 Assertion 方法,该方法验证对象的状态或文本,以及验证页面上的通用文本。

第八章:断言包装器 – 内嵌细节的重要性

在本章中,我们将编写我们的第一个断言包装器。断言允许我们通过或失败一个测试,并添加有关预期和实际结果的相关细节。WebdriverIO 至少有三种实现断言的方法,每种方法都有自己的风格。首先,有标准的 Jest expect-webdriverio,用于本书中的所有示例。然而,关于这些方法如何不同的背景知识应该被注意。

本章我们将涵盖以下主要内容:

  • expect, assert, and should

  • 超时

  • 硬和软 expect 断言

  • Allure 报告

expect, assert, and should –我们是如何到达这里的?

让我们简要回顾一下 JavaScript 断言库的历史,以了解为什么我们将在自定义assert()包装器中做出一些选择。

Jasmine 是什么?

Jasmine 首次发布于 2010 年。它旨在提供一种简单灵活的方式来添加断言。它提供了一套内置的断言方法。注意接口是expect,具有链式方法,如.toBe.toEqual.not。以下是 Jasmine 中的一个示例断言:

function addNumbers(arg0: number, arg1: number): number {
  return arg0 + arg1;
}
describe('My Math Library', () => {
  it('should add two numbers correctly', () => {
    const result = addNumbers(2, 3);
    expect(result).toEqual(5);
    expect(result).toEqual(6); //Intentional fail
  });
});

前面的测试调用了一个简单的函数,该函数返回传递给AddNumbers()函数的两个参数的和。

这是一个基本的算术断言。如果我们运行它,我们会注意到通过的结果没有报告任何内容。只有失败被报告。在通过或失败时,它实际上并没有提供很多细节:

图 8.1:AddNumbers()函数通过和故意失败的测试结果

图 8.1:AddNumbers()函数通过和故意失败的测试结果

在一个测试自动化项目中,我们需要提取对象属性或值,并验证它们与预期结果是否一致。详细的结果可能只报告预期的[true]和实际的[false]。要提供这种输出,需要包含大量的附加代码,如果我们在测试或功能文件级别执行它。

Jest 是什么?

在 2013 年,Jest 由 Facebook 发布,并被 React 社区广泛采用。它具有与 Jasmine 类似的断言语法,并增加了包括快照测试和代码覆盖率报告等额外功能。注意接口也是expect。以下是 Jest 中的相同断言:

describe('My Math Library', () => {
  test('should add two numbers correctly', () => {
    let expected = 5
    let actual = 5
    expect(actual).toBe(expected);
    actual = 4
    expect(actual).toBe(expected);
  });

然而,Jest 本身不支持任何消息来报告验证的详细信息。应包含jest-expect-message包以提供此功能,使用npmyarn

npm install jest-expect-message
yarn add jest-expect-message

现在我们已经为 Jest 添加了expect消息包,我们可以提供更详细的输出:

describe('My Math Library', () => {
  test('should add two numbers correctly', () => {
    Let expected = 5
    let actual = 5
    expect(actual,
`Expected: '${expected}' Actual: '${actual}'`).toBe(expected);
expected = 4
expect(actual, `Expected: '${expected}' Actual: '${actual}'`).toBe(expected);
  });
});

Jest 作为 WebdriverIO 包的一部分被包含,但 WDIO 有一个扩展的断言库。这允许我们直接传递元素进行断言,而不是编写自己的代码。

Chai 是什么?

Chai 是 JavaScript 中流行的断言库,它提供了三个接口来进行断言:

  • should (BDD)

  • expect (BDD)

  • assert (TDD)

这些接口各有优缺点,我们将在子节中探讨。

应该

should 接口扩展了所有对象,使其具有一个 should 属性,可以用来进行断言。以下是一个 Chai should 示例:

import 'chai/register-should'
describe('My App', () => {
  it('should have the correct title', () => {
    browser.url('https://example.com');
    browser.getTitle().should.be.equal('Example Domain');
  });
});

虽然这个接口允许编写可读性和表达性强的代码,但它可能会以修改对象行为的方式产生意外的副作用。因此,它将不会成为我们实现的一部分。

断言

assert 接口提供了一种更传统的断言方式,使用传统的如 assert.equal()assert.notEqual() 方法。这个接口对于已经熟悉其他测试框架或更喜欢更传统测试风格的开发者来说很有用。然而,它可能不如 shouldexpect 接口可读性和表达性强,尤其是在处理更复杂的断言时:

const assert = require('assert');
describe('My App', () => {
  it('should have the correct title', () => {
    browser.url('https://example.com');
    const actualTitle = browser.getTitle();
    const expectedTitle = 'Example Domain';
    assert(actualTitle === expectedTitle);
  });
});

期待

Chai expect 接口提供了一种更灵活和可链式的断言方式。这个接口旨在易于阅读和编写,并提供了一种流畅的语法,可以用来以清晰和简洁的方式做出复杂的断言。以下是一个 Chai should 示例:

import assert from 'chai';
describe('My App', () => {
  it('expect to have the correct title with chai', () => {
    browser.url('https://example.com');
    expect(browser.getTitle()).to.equal('Example Domain');
  });
});

使用 Chai 的 expect 接口是进行断言的首选方式。它提供了很多灵活性,而没有 should 的副作用,并且语法与 Jest 断言相似。但是有一个问题,我们得不到所有细节。考虑以下:

await LoginPage.open();
await expect(browser).toHaveUrlContaining('the-internet.herokuapp.com/login')
[chrome 110.0.0.0 win32 #0-0]    ✓ Chapter 8: expectAdv Wrapper should check if actual is equal to expected
0.0 win32 #0-0] 1 passing (844ms)

当然,测试通过了,但它到底做了什么?没有预期结果,实际结果,或者断言做了什么的任何细节。这就是为什么我们需要包装器来简化我们结果的报告。

让我们看看一个失败的断言:

await LoginPage.open();
await expect(browser).toHaveUrlContaining('the-internet.herokuapp.com/bogus')
[chrome 110.0.0.0 win32 #0-0] Error: Expect window to have url containing "the-internet.herokuapp.com/bogus"
Expected: "the-internet.herokuapp.com/bogus"
Received: "https://the-internet.herokuapp.com/login"
[chrome 110.0.0.0 win32 #0-0] error properties: Object({ matcherResult: Object({ pass: false, message: 'Expect window to have url containing
[chrome 110.0.0.0 win32 #0-0]
[chrome 110.0.0.0 win32 #0-0] Expected: "the-internet.herokuapp.com/bogus"
[chrome 110.0.0.0 win32 #0-0] Received: "https://the-internet.herokuapp.com/login"' }) })
[chrome 110.0.0.0 win32 #0-0] Error: Expect window to have url containing
[chrome 110.0.0.0 win32 #0-0] Expected: "the-internet.herokuapp.com/bogus"
[chrome 110.0.0.0 win32 #0-0] Received: https://the-internet.herokuapp.com/login

现在我们过度报告了,因为我们只有一个验证。错误被报告了三次到输出中,这是一个问题。

存在一个问题——所有这些断言包都是设计用来执行硬断言以结束测试执行,而不是软断言,后者将允许测试执行更多验证:

Spec Files:      0 passed, 1 failed, 1 total (100% completed) in 00:00:06

注意验证失败需要 6 秒钟。我们真的需要等待那么长时间吗?我们已经有 pageSync() 方法消耗了所有需要的时间。

超时——比赛延迟

WebdriverIO expect 匹配器的默认超时时间为 3 秒,间隔为 100 毫秒。这意味着在 3 秒内有 30 次检查,这比行业标准等待 30 秒要好得多。记住我们正在使用 pageSync() 方法来消耗页面构建所需的时间。我们的断言几乎立即可用是有意义的。要调整 expect-webdriverio 断言的超时时间和间隔,我们可以在 wdio.config.ts 文件的 WebdriverIO 钩子部分进行更改:

before: function (capabilities, specs){
require('expect-webdriverio').setOptions ({wait:5000, interval: 250});
}

此代码现在将把我们的expect断言执行次数改为 20 次。等待超时时间为 5 秒。检查将每 1/4 秒进行一次:

Spec 文件:0 通过,1 失败,1 总计(100% 完成) 耗时 00:00:05

结果时间现在减少到了最优量。这仅仅是一秒钟,但点点滴滴的节省可以节省分钟和小时。

什么是 expect-webdriverio?

为了这本书的目的,我们将使用expect-webdriverio

WebdriverIO 使用expect-webdriverio断言库,它是 Jest expect接口的扩展。它增加了浏览器和元素断言:

const expect = require('expect-webdriverio');
describe('My App', () => {
  it('should have the correct title', () => {
    browser.url('https://example.com');
    expect(browser).toHaveTitle('Example Domain');
  });
});

然而,所有这些库都缺少执行软断言的能力。为此,我们转向 Chai 和soft-assert包。

什么是硬断言和软断言?

默认情况下,所有的断言包都会执行硬断言,这更常被称为硬断言。这意味着当断言失败时,测试就会结束。哪种超级英雄会在第一拳之后离开战斗?这很成问题,因为我们可能在一个页面上有四到五个我们想要断言的值。在第一个断言失败后,留下接下来的四个不纳入结果有什么意义?我们希望即使在过程中受到打击,也能继续战斗的力量。

正因如此,我们努力将软期望(更常被称为软断言)的功能添加到框架中。这个特性内置在 Java 的 TestNG 中。它似乎有些遗憾,这个特性在所有流行的 JavaScript 断言库中都缺失了。如果存在用于导航的按钮,最好的测试框架将能够到达终点并执行所有验证,无论它们是成功还是失败。这就是我们的最终双重目标:报告中的结果更多,重复的零散运行更少。

将所有这些放在一起

现在,我们需要保护我们的身份;为了完成这个壮举,我们使用expect-webdriverio,它扩展了 Chai expect接口。

我们现在可以在我们的Bogus按钮上执行失败的软断言,同时仍然允许后续的断言执行:

const btnBogus = $('button[name="Bogus"]');
softexpect(btnBogus.isEnabled()).to.be.true;
const btnAddToCart = $('button[name="Add To Cart"]');
softExpect(btnAddToCart.isEnabled()).to.be.true;
expect(addToCartButton.isClickable()).to.be.clickable;

我们有了我们的力量戒指。当我们把它们砸在一起时,我们将采取多种形式,因为将使用辅助expectAdv()包装器来增加在一致格式中提供详细信息的灵活性。本节将使我们超越通用的失败消息,并详细说明通过最少重复代码的通过结果。

expect-webdriverio库支持 23 种不同的元素匹配断言。其中八个是其他完整字符串断言的子字符串匹配器。其他,如.toBePresent.toHaveChildren.toBeDisplayedInViewPort,在 80/20 的相关性中占较小的部分。

软断言是什么?为什么我们需要它们?

一个高效的测试能够在页面上执行多个验证,但如果三个断言中的第一个失败,测试将立即结束。可能只有第一个断言失败,或者可能所有三个都失败了。我们希望得到断言的完整计数,而不是最少的。否则,它就变成了零散的过程,并减慢了我们的速度。

注意,Expect.toBeExistExpect.toBePresentExpect.toBeExisting仅意味着元素在 DOM 中。它们并不明确表示元素对用户是可见的,因此它们在大多数情况下是不切实际的。

WebdriverIO 提供了对元素状态的积极和消极检查:

Expect.toBeDisplayed
Expect.toBeFocused
Expect.toBeEnabled
Expect.toBeDisabled
Expect.toBeClickable
Expect.toBeChecked
Expect.toBeSelected

它还提供了两种检查元素是否包含文本或值的方法:

Expect.toHaveText / Expect.toHaveTextContaining
Expect.toHaveValue / Expect.toHaveValueContaining

它还提供了对 ID、元素和属性的验证,这些验证可以是精确的或字符串子集:

Expect.toHaveElementProperty
Expect.toHaveAttribute
Expect.toHaveAttributeContaining
Expect.toHaveElementClass
Expect.toHaveElementClassContaining
Expect.toHaveId
Expect.toHaveLink / Expect.toHaveLinkContaining

软断言 - 允许在断言失败后继续测试

在我们的自定义expectAdv包装器中,我们将实现一些概念,使其可以像普通英语句子一样阅读。第一个参数actual有意分配了any类型。这是因为我们希望有灵活性来验证元素或字符串值:

function expectAdv(
actual: any,
assertionType: string,
expected?: any,
Description: string = 'A description of this assertion is recommended.')
}

在这里,assertionType是一个字符串,表示要执行的断言。元素可能存在;元素可能等于一个预期的字符串。

预期参数是可选的,因为如果元素“已启用”,则不需要它。

快速提示

描述是必需的。每次验证都应该有一些关于正在执行的操作的详细信息。因此,如果缺少描述,将提供一个有用的提示,以增加我们测试用例的透明度。

在软断言的情况下,该方法返回一个布尔值truefalse。这意味着我们的测试用例可以通过决策树进行优化。这个概念将在后续章节中讨论,届时我们将讨论如何使步骤在没有失败的情况下继续执行,即使元素不存在。

Allure 报告简介

Allure 是一个强大的报告框架,它提供了简洁且组织良好的报告。您可以通过安装@wdio/allure-reporterallure-commandline包来访问此报告模板:

> yarn add @wdio/allure-reporter

Allure 以称为 Allure 结果格式的标准化格式导出报告。要生成全面的报告,您可以通过命令行界面利用 Allure 框架:

"node_modules/.bin/allure generate --clean ./reports/allure-results && allure open -p 5050"

Allure 框架是一个多才多艺且轻量级的测试报告工具,支持多种编程语言。它以 HTML 格式简洁地展示测试结果,使开发过程中的所有利益相关者都能从常规测试执行中提取有价值的见解:

// Code example using expect-webdriverio
export async function expectAdv(actual, assertionType, expected) {
  const softAssert = expect;
  const getAssertionType = {
    equals: () => (softAssert(actual).toEqual(expected)),
    contains: () => (softAssert(actual).toContain(expected)),
    exist: () => (softAssert(actual).toBeExisting()),
    isEnabled: () => (softAssert(actual).toBeEnabled()),
    isDisabled: () => (softAssert(actual).toBeDisabled()),
    doesNotExist: () => (softAssert(actual).not.toBeExisting()),
    doesNotContain: () => (softAssert(actual).not.toContain(expected)),
    default: () => (console.info('Invalid assertion type:  ', assertionType)),
  };
  (getAssertionType[assertionType] || getAssertionType['default'])();
  if (!getAssertionType[assertionType]){
    allureReporter.addAttachment('Assertion Failure: ', `Invalid Assertion Type = ${assertionType}`, 'text/plain');
    allureReporter.addAttachment('Assertion Error: ', console.error, 'text/plain');
  } else {
    allureReporter.addAttachment('Assertion Passes: ', `Valid Assertion Type = ${assertionType}`, 'text/plain');
 }
  allureReporter.endStep();
}

通过将这些 Allure 语句添加到我们的框架中,我们可以以视觉信息丰富的方式向利益相关者提供更多细节。

图 8.2:Allure 中的测试结果样本及其历史趋势

图 8.2:Allure 中的测试结果样本及其历史趋势

Allure 报告可以将测试组织成子类别。这使得相关测试是否失败变得清晰。它还显示了运行随时间的变化情况。这可以显示测试用例覆盖率增加的趋势以及结果改善或最近恶化的趋势。

![图 8.3:带有登录页面屏幕捕获的逐步执行样本]

图片

图 8.3:逐步执行的示例,包含登录页面的屏幕截图

这些报告还提供了添加屏幕截图的选项,例如 X 射线视觉。这可以提供有关测试失败时发生情况的宝贵线索,尤其是如果我们正在云中运行而没有直接实时查看系统运行情况时。

摘要

在本节中,我们讨论了 assert、expect 和 should 断言的历史。我们介绍了硬断言和软断言的概念,为什么它们是重要的区分,以及何时应该实现它们。我们还介绍了 Allure 报告,以提供正在执行的所有事件的详细信息以及它们是否通过或失败的结果。Allure 报告将通过提供通过和失败的测试的历史视图来进一步增强我们的视图。在下一章中,我们将构建页面对象模型。

第九章:古代咒语书 - 构建页面对象模型

所有框架都有三到四个抽象层。这最好被想象为一本古代咒语书。最简单的咒语在开头。这些是构建中间更复杂和强大的魔法的基础。而最黑暗的秘密总是隐藏在神秘的咒语书末尾。同样,有一个测试层,它调用中间页面对象类层中引用对象的方法,而这个类层又利用底层核心层中的辅助包装和其他功能。在 Cucumber 框架中,从测试功能文件层到步骤定义、到粘合代码再到核心代码层,还有一个额外的抽象层。在本章中,我们专注于创建页面元素和用于在页面上执行操作的这些方法。

在此之前,以下是本章我们将涵盖的所有主要主题列表:

  • 理解页面对象模型是什么

  • 为测试创建页面类

  • 添加对象选择器

  • module.exports 语句

  • 通过常用对象和方法减少代码

  • 使用 Klassi-js 的 POM

技术要求

所有测试示例都可以在这个 GitHub 仓库中找到:github.com/PacktPublishing/Enhanced-Test-Automation-with-WebdriverIO

什么是页面对象模型?

页面对象模型POM)是一种在测试自动化中使用的模式,用于创建一个结构化和可维护的框架,用于 Web 应用程序测试。它促进了测试代码与网页实现细节的分离。

在 POM 中,每个网页都表示为一个单独的类,页面的属性和行为封装在这个类中。测试方法通过页面类提供的方法与网页交互,而不是直接访问 Web 元素或使用低级浏览器 API。

使用 WebdriverIO 或 Klassi-js 等框架,可以在 Node.js 中实现 POM

什么构成了一个好的页面对象模式?

在测试自动化中,一个好的页面对象模式是那种促进代码的可维护性、可重用性和可读性的模式。一个良好实现的页面对象模式的一些特征包括:

  • 遵循单一职责原则(SRP):每个页面类应该有一个单一职责,并代表应用程序的特定页面或组件。这确保了代码组织良好且易于维护。

  • 封装:页面类应该封装它所代表的网页或组件的细节和行为。它应该提供方法来与页面元素交互,而不暴露底层实现细节。这种抽象简化了测试代码,并使其更易于阅读。

  • 模块化和可重用:页面类应模块化,并在不同的测试和测试套件中可重用。它们应提供一致的接口来与页面元素交互,从而便于重用并减少代码重复。

  • 遵循关注点分离(SoC)原则:页面对象模式将测试逻辑与网页的实现细节分离。测试方法应利用页面类提供的方法,而不是直接与网页元素交互或使用低级浏览器 API。这种分离提高了代码的可维护性,并使得在应用程序更改时更新测试变得更加容易。

  • 独立于测试框架:页面类应独立于所使用的特定测试框架。它们不应依赖于测试框架,例如断言或测试执行逻辑。这确保了页面类可以轻松地与不同的测试框架或工具一起重用。请看以下示例:

    class LoginPage {
      get usernameField() { return $('#username'); }
      get passwordField() { return $('#password'); }
      get loginButton() { return $('#login'); }
      enterUsername(username) {
        this.usernameField.setValue(username);
      }
    
  • 清晰的命名约定:页面类、方法和变量应具有有意义的描述性名称,准确反映其目的和功能。这有助于提高代码的可读性和理解性:

    loadPage: Async (url, seconds) => {
    Await browser.url(url, seconds)
    }
    
  • 定期维护:页面类应随着应用程序的发展而定期维护和更新。它们应与应用程序 UI 和功能的变化保持同步。定期审查和更新页面类有助于确保其准确性和可靠性。

为测试创建页面类

我们已创建一个 LoginPage 类,代表网络应用程序的特定页面。网页元素选择器定义为使用 WebdriverIO 的 $ 函数作为获取器,这允许我们使用 CSS 选择器在页面上定位元素。

该类还包括页面方法,如 enterUsernameenterPasswordclickLoginButton。这些方法封装了可以在页面上执行的操作,例如在输入字段中输入文本和点击按钮。

Linux/Unix 中的 mkdir 命令允许用户创建或新建目录。mkdir 代表“创建目录”:

转到你的 mkdir 命令:

mkdir loginPage.ts
homePage.ts
testClass.ts

添加对象选择器

TestClass 测试类利用了页面类的导出实例。在测试用例中,我们通过页面对象中定义的方法与网页进行交互。

// LoginPage.ts

LoginPage:此类封装了相应网页的属性和行为:

class LoginPage {
  get usernameField() { return $('#username'); }
  get passwordField() { return $('#password'); }
  get loginButton() { return $('#login'); }
  enterUsername(username) {
    this.usernameField.setValue(username);
  }
  enterPassword(password) {
    this.passwordField.setValue(password);
  }
  clickLoginButton() {
    this.loginButton.click();
  }
}
module.exports = new LoginPage();

// HomePage.ts

HomePage:此类封装了相应网页的属性和行为:

class HomePage {
  get welcomeMessage() { return $('#welcome'); }
  get logoutButton() { return $('#logout'); }
  getWelcomeMessage() {
    return this.welcomeMessage.getText();
  }
  clickLogoutButton() {
    this.logoutButton.click();
  }
}

module.exports = new HomePage(); 调用测试中要使用的方法

使用 module.exports 语句导出每个页面类的实例作为一个模块:

module.exports = new HomePage();
module.exports = new loginPage();

// TestName.ts

TestName测试文件利用页面类的导出实例。在这个示例测试用例中,我们使用在相应页面类中定义的方法和对象与 LoginPage 和 HomePage 网页进行交互:

import LoginPage from('../PageObjects/LoginPage');
import HomePage from('../PageObjects/HomePage');
import assert from('assert');
describe('Test Name', () => {
  before(() => {
    // Set up WebDriverIO configuration
  });
  it('should perform login and logout', () => {
    LoginPage.enterUsername('username');
    LoginPage.enterPassword('password');
    LoginPage.clickLoginButton();
    const welcomeMessage = HomePage.getWelcomeMessage();
    assert.strictEqual(welcomeMessage, 'Welcome, User!');
    HomePage.clickLogoutButton();
  });
  after(() => {
    // Quit WebDriverIO instance
  });
});

使用常见对象和方法减少代码

通过利用页面对象模式中的常见对象和方法,可以减少代码重复并提高可维护性。以下是一些实现代码减少的策略:

  • 基础页面类: 创建一个包含多个页面共享的常见对象和方法的基页面类。这个基类可以封装多个页面共有的元素和行为,例如一个主页按钮、一个万圣节派对按钮,然后是一个寻找我的糖果!按钮,以减少重复:

图 9.1 – CandyMapper 派对页面网站头部,包含所有页面共有的链接

图 9.1 – CandyMapper 派对页面网站头部,包含所有页面共有的链接

这些元素出现在网站每个页面的头部。因此,在顶级页面类中声明它们并将其扩展到所有其他页面是有意义的。

图 9.2 – Candymapper 着陆页面头部,包含三个常见链接

图 9.2 – Candymapper 着陆页面头部,包含三个常见链接

如果选择器在每个页面类中,随着时间的推移,维护级别将会增加。因此,我们将创建选择器在公共页面类中,如下所示:

  get homeButton() {return $(`//a[text()='Home']`); }

图 9.3 – 链接选择器中突出显示的 HOME 链接

图 9.3 – 链接选择器中突出显示的 HOME 链接

让我们花点时间看看这三个例子,因为可能需要进行一些更改。这些选择器匹配页面头部和底部的按钮。我们可以锁定到第一个元素匹配,如下所示:

get homeButton() {return $(`(//a[text()='Home'])[1]`); }
get halloweenPartyButton() {return $(` (//a[contains(text(), 'Party')])[1]`); }

图 9.4 – 为第一个万圣节派对链接识别的定位器

图 9.4 – 为第一个万圣节派对链接识别的定位器

由于这些元素在所有我们的页面中都是通用的,因此创建一个通用的基Page类并将它们全部存储在那里是有意义的:

export default class Page {
  get homeButton() {return $(`//a[text()='Home']`); }
  get halloweenPartyButton() {return $(` (//a[contains(text(), 'Party')])[1]`); }
}

其他页面类现在可以继承这个基Page类及其所有常见对象和功能。我们可以使用extends关键字将这些对象添加到任何Page类中:

import * as helpers from "../../helpers/helpers";
import Page from "./page";
class CandymapperPage extends Page{
  await helpers.clickAdv(await    
    super.halloweenPartyButton);
}

最后,我们使用super关键字来引用公共父类中的对象和方法,以减少重复代码。

如果我们发现常见元素的文本在不同页面之间或在不同版本中频繁变化,我们可以使用以下方法来减少维护。考虑下面的‘FIND MY CANDY’链接元素:

 get findMyCandyButton() {return $(` (//a[contains(translate(normalize-space(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'my candy') and not(contains(@style, 'display: none')) and not(contains(@style, 'visibility: hidden'))])[1]`); }

图 9.5 – 对“寻找我的糖果!”链接的忽略大小写匹配定位器

图 9.5 – 对“寻找我的糖果!”链接的忽略大小写匹配定位器

这是通过元素的样式显示或可见性属性来完成的。有一个技巧可以找到仅可见的元素。最终,可以返回一个元素集合,并对每个元素进行立即可见性的检查。

其他页面类可以继承这个基类并继承其通用功能。

  • 页面组件:识别应用程序页面中重复出现在多个页面上的常见组件或部分。创建单独的页面组件类来表示这些可重用组件。然后,将这些组件包含在页面类中以重用通用功能并减少代码重复。

  • 辅助方法:识别在多个页面中执行的操作或动作,例如登录、在页面之间导航或处理弹出窗口。将这些操作提取到可以从不同的页面类中调用的辅助方法中。这集中了实现并避免了为这些常见动作重复编写代码。

  • 参数化:如果您有基于输入参数而变化的类似元素或动作,您可以在页面类中创建参数化方法。这些方法可以接受参数并根据提供的输入执行所需动作,从而减少为类似功能创建单独方法的需求。

  • 外部配置:将可配置的值,如 URL、超时或测试数据,移动到外部配置文件中。这允许您在多个测试和页面之间集中和重用配置,减少在单个页面类中硬编码值的需求。

  • 可维护的选择器:使用可靠且可维护的方式来定位页面上的元素,例如 CSS 选择器或 XPath 表达式。避免在测试方法中使用硬编码的选择器。相反,将选择器定义为页面类中的属性,这样在 UI 发生变化时更容易更新它们。

使用 Klassi-js 的 POM

Klassi-js 是一个强大且灵活的行为驱动开发BDD)JavaScript 测试自动化框架,它赋予开发人员和 QA 专业人员创建和执行针对 Web 和移动应用程序的全面测试的能力。在其核心,Klassi-js 利用了 WebdriverIO 的力量,这是一个 Node.js 的尖端自动化框架。这个基础使得 Klassi-js 能够无缝地与 Web 浏览器和移动设备交互,使其成为跨浏览器和跨平台测试的绝佳选择。

Klassi-js 的一个突出特点是它与 cucumber.js 的无缝集成,cucumber.js 是一个流行的 BDD 测试工具。这种集成允许创建人类可读的、富有表现力的测试场景,这有助于促进开发人员、测试人员和其他利益相关者之间的更好协作。它推广了一种共同的语言来讨论应用程序的行为,并有助于构建更可靠的测试。

Klassi-js 更进一步,通过提供集成视觉、可访问性和 API 测试功能,确保你的应用程序不仅功能正常,而且用户友好,符合可访问性标准,并能够提供预期的 API 响应。此外,Klassi-js 提供了在本地运行测试或利用基于云的测试平台(如 LambdaTest、BrowserStack 或 Sauce Labs)的灵活性,允许在各种环境中进行可扩展和高效的测试。

POM 是一种设计模式,用于组织你的 UI 自动化代码,使其更易于维护和阅读。当使用 Klassi-js 与 Cucumber 结合时,你可以按照以下方式实现 POM 设计模式。

项目结构

首先,组织你的项目结构以分离不同的关注点。一个常见的结构可能如下所示:

project-root
-- features
     -- login.feature
-- step-definitions
     -- login.steps.ts
-- pages
     -- login.page.ts
-- index.ts
-- runtime
-- world.ts
-- package.json

让我们逐一分析:

  • features:存储你的 Cucumber 功能文件

  • step-definitions:存储你的 Cucumber 步骤定义

  • pages:存储你的页面对象

Cucumber 功能文件

在你的项目的features目录中创建 Cucumber 功能文件。这些功能文件以纯文本形式描述了应用程序的行为。例如,你可以创建一个login.feature文件:

Feature: Login functionality
  Scenario: Successful login
    Given I am on the login page
    When I enter my username and password
    And I click the login button
    Then I should be logged in

页面对象

为你想要交互的每个网页或组件创建一个页面对象。以下是一个示例,login.page.ts:

class LoginPage {
    get usernameInput() { return $('#username'); }
    get passwordInput() { return $('#password'); }
    get loginButton() { return $('#login-button'); }
    get welcomeMessage() { return $('#welcome-message'); }
    open() {
        browser.url('/login'); // Adjust the URL as needed
    }
    login(username, password) {
        this.usernameInput.setValue(username);
        this.passwordInput.setValue(password);
        this.loginButton.click();
    }
}
module.exports = new LoginPage();

Cucumber 步骤定义

在你的步骤定义中,使用页面对象与网页元素进行交互。以下是一个示例,login.steps.ts:

const { Given, When, Then } = require('cucumber');
const LoginPage = require('../pageobjects/login.page');
Given('I am on the login page', () => { LoginPage.open(); });
When('I enter my username and password', () => {
    LoginPage.username.setValue('your_username');
    LoginPage.password.setValue('your_password');
});
When('I click the login button', () => {  LoginPage.loginButton.click(); });
Then('I should be logged in', () => {
    expect(LoginPage.welcomeMessage).toHaveText('Welcome, User');
});

运行测试

你可以使用 Klassi-js 像往常一样运行你的 Cucumber 测试。使用如下命令:

> node index.ts --tags @login

Klassi-js 将自动发现你的 Cucumber 功能文件并执行相应的步骤定义。

采用这种结构,你的 UI 自动化代码变得更加模块化,更容易维护。每个页面对象封装了特定页面或组件的功能和交互,使得更新和管理测试变得更加容易。

概述

遵循上述原则,你可以创建一个类似于超级英雄细致规划的页面对象模式,确保你的代码更加易于维护、可重用和可读。就像超级英雄调整他们的能力以更有效,通过遵循这些策略,你可以减少代码重复并提高页面对象模式的可维护性。跨页面重用常见对象、方法和组件就像利用超级英雄工具带的隔间,简化你的代码并确保修改易于实施和维护。

在下一章中,我们将通过利用系统变量和动态配置来进一步增强我们框架的能力,就像超级英雄适应不同的环境,无缝地在开发版和预发布版着陆页之间切换。

第十章:提高灵活性 - 编写健壮的选择器和减少维护

维护是测试自动化项目的永恒敌人。每个版本都包含更多的测试和可能过时的元素,导致测试失败。如果你是测试自动化领域的初学者,你可能没有意识到维护将如何越来越多地影响你的项目发布。我(保罗)想与你分享这个故事,它启发了几种独特的解决方案。

几年前,我的客户的开发团队决定改变测试应用所支持的整个底层架构。我们的自动化团队只有在发现我们近 100 个测试用例套件几乎全部失败时才意识到这个变化。事实上,唯一通过测试的是我们在项目第一天编写的 LogIn 测试用例。我们意识到数百个元素对象已经将它们的标签名称更改为不同类型并使用了不同的属性。我们面临着一项艰巨的任务,即逐个重写数百个元素选择器。我们估计页面对象的重新工作可能需要 2 天才能恢复到工作状态。

再加上客户已经习惯了在 2 小时内收到回归测试套件结果的详细总结,以及我们的烟雾测试在 15 分钟内检测到问题。我们面临解释发布结果将在另外 2 天或更长时间才可获得的任务。六个资源的手动团队可能需要那么长时间才能完成测试。

我的同事开始着手更新页面对象标签中的选择器。我有一个不同的想法。通过我的分析,我发现只有一个元素标签发生了变化。许多 <a> 链接锚点现在变成了 <button> 标签。幸运的是,用于定位元素的字符串没有变化,只是它们位于不同的对象属性中。我建议在我们的框架中添加一个薄层,以搜索不再找到的元素标签的替代标签。

代码更改在一小时内完成。执行测试用例的数量增加到 95%的完成度。两个测试用例在发布时失败,另外三个需要手动维护才能达到工作状态。这与之前发布迭代中的维护工作一致。我们的客户理解了架构变化带来的挑战,并且对我们能在 4 小时内提供可操作的结果感到非常高兴。

在类似的情况下,本章将涵盖以下主要主题:

  • 使用通用选择器减少页面对象维护

  • XPath 选择器的结构

  • 利用 data-qa可访问的富互联网应用 (ARIA) 属性

  • 编写包含文本子串的 XPath 元素

  • 第二次机会 - 从过时的选择器获取有效对象

技术要求

所有测试示例都可以在这个 GitHub 仓库中找到:github.com/PacktPublishing/Enhanced-Test-Automation-with-WebdriverIO

使用通用选择器减少页面对象维护

在我们深入探讨如何使我们的对象像塑料一样灵活的先进概念之前,让我们先看看几种我们可以编写更好的选择器的方法。一个健壮的选择器对于减少你的测试自动化框架的维护至关重要。我们将超越精确匹配,使用子串匹配以确保我们可以在元素略有变化的情况下找到它。

我们从一个简单的问题开始。XPath 和 CSS 哪个更好?有一种普遍的观点认为,CSS 是编写选择器的首选方法,因为它执行得更快。虽然这可能是对的,但今天的速度差异微乎其微。我宁愿花几毫秒找到元素,也不愿花几分钟重复更新对象选择器。此外,CSS 选择器的语法编写更困难。而且,当我们需要根据另一个元素找到某个元素时,CSS 选择器不够灵活——例如,定位许多通用 label 对象中的一个:

图 10.1 – 相对于 DOM 中名为“One”的 Label 元素的通用单选按钮

图 10.1 – 相对于 DOM 中名为“One”的 Label 元素的通用单选按钮

在前面的例子中,没有方法可以唯一地通过文本识别任何单选按钮。这是因为唯一可识别的文本包含在相对于 Span 单选按钮的 Label 元素中。

我们必须利用父元素和子元素的 XPath 轴,通过相对位置唯一地定位这些元素。我们为具有文本 One 的父对象编写一个选择器:

//label[text()='One']

然后我们跟随通用子单选按钮:

span[@class='radiobtn']

然后我们将它们结合起来:

//label[text()='One']/span[@class='radiobtn']

那是一个容易解决的问题。但如果我们有包含额外空格、强制换行符,甚至混合引号和单引号的文本怎么办?为了解决这类问题,我们将更深入地探讨更多我们可以定位和识别元素的方法:

图 10.2 – 额外的空格,混合单引号和双引号,以及嵌入的换行符

图 10.2 – 额外的空格,混合单引号和双引号,以及嵌入的换行符

到目前为止,我们已经看到了 XPath 和 CSS 选择器的示例。让我们花点时间进一步探索 WebdriverIO 中检索元素集合的组件。

XPath 选择器的结构

选择器由一个或多个节点标签类型组成,后面跟着方括号中的可选 [谓词]。谓词有运算符和函数来过滤特定的节点匹配。最后,它们包括路径分隔符,带有双冒号的通用 ,以进一步细化元素的路径。这使得无论元素在 DOM 中的位置如何,都更有可能找到该元素。

XPath 选择器被编写为绝对相对。这是一个指向Candymapper网站上Find My Candy按钮的绝对样式选择器的示例:

public get myElement() {
  const selector: string = "/html[1]/body[1]/div[1]/div[1]/div[1]/div[13]/div[1]/div[1]/div[1]/div[2]/div[2]/a[1]";
  return $(selector);
}

现在你已经看到了绝对选择器,只将其用作一个红旗。这个练习的目的是方括号内数字索引的流动性。确切的位置将随着版本的不同而动态变化,导致无尽的维护。如果你在代码库中看到很多这种格式的选择器,几乎可以肯定你的选择器不是健壮的。此外,对于其他开发者来说,这是一个难以解开的谜题,因为“Find My Candy”这个文本没有出现在选择器字符串中。让我们寻找更好的编写选择器的方法。

经验法则

总是花额外的时间将绝对选择器替换为相对选择器,并使用描述性的元素名称。前一个示例中的myElement名称没有帮助,应该重命名为findMyCandy或更好,btnFindMyCandy

相对选择器

大多数相对选择器以双斜杠(//)开头,后面跟单斜杠,表示路径中的下一个元素。让我们更详细地看看这个:

  • // //div选择文档中任何位置的<div>元素。

  • / /html/body/div选择所有是<html>根内部<body>元素的直接子元素的<div>元素。

  • * (星号):表示通配符匹配。

首先,我们将使用*通配符匹配器获取页面上所有节点的集合:

const allElementsByXPath: ElementArrayType = await browser.$$('//*');

节点测试函数 – text() 与 normalize-space()

这里显示的几个元素选择器标签,包括锚点、按钮和列表,可以使用text()节点测试函数进行精确匹配:

//a[text()=`FIND MY CANDY!`]

这是一个简单的示例,但如果文本中嵌入了一些奇怪的格式呢?

破碎的字符串

有时候多余的空格或换行符会使选择器匹配变得困难。在这种情况下,建议使用normalize-space()而不是text()

//a[normalize-space()=`FIND MY CANDY!`]

我们可以使用SelectorsHub Chrome 扩展程序来检查此选择器是否有效:

图 10.3 – SelectorsHub 显示多个元素将匹配该选择器

图 10.3 – SelectorsHub 显示多个元素将匹配该选择器

XPath 选择器是有效的,但它匹配屏幕上的四个额外元素。另一种方法是相对于容器页面获取按钮元素:

//*[contains(@class,"popup")]//following::a

此元素可以通过父类定位,并使用点(.)作为类名的快捷方式转换为 CSS 选择器:

.widget-popup a

类似地,我们可以通过类名上的popup进行近似匹配,以找到使用这种方式的 CSS 包含*=快捷方式的锚点链接子元素:

[class*="popup"] a

我们还可以将父元素缩小到特定的标签类型:

div[class*="popup"] a

我们将寻找五种常见的网页元素:链接、按钮、列表、字段和文本元素。

这里是使用精确字符串以及 XPath 中的子字符串匹配来查找这些元素的常见方法:

  • 链接:

    //a[normalize-space()='Link Text']
    //a[contains(normalize-space(),'Link')]
    //a[@href='https://example.com']
    
  • 按钮:

    //button[normalize-space()='Click Me']
    //button[contains(normalize-space(),'Click')]
    //button[@id='submit-button']
    
  • 列表(无序列表和有序列表):

    //ul/li[normalize-space()='Item']
    //ul/li[contains(normalize-space(),'Item')]
    //ol/li[position()=2]  // Not recommended
    
  • 字段和多行文本区域:

    //input[@type='text']
    //input[contains(@id,'input')]
    //textarea[@placeholder='Enter text']
    
  • 文本:

    //span[normalize-space() ='Some Text']
    //span[contains(normalize-space(),'Text')]
    //*[starts-with(normalize-space(),'Hello')]
    

利用 data-qa 和 ARIA 属性

在网站设计中,已经出现了两个新的发展,开发者可以帮助 SDETs 维护稳健且低维护的选取器。考虑以下网页元素片段:

<div data-qa="product-card" role="article" aria-label="Product Details">
      <a href="#" data-qa="add-to-cart">Add to Cart </a> </div>

这可以通过添加唯一的静态data-qa属性来实现。或者,如果开发团队遵循 ARIA 标准,许多文本元素可以通过aria-label属性来识别:

//div[contains(@aria-label, 'Product Details')]
//a[contains(@data-qa, 'Add to Cart')]

这里是一个利用data-qa和 ARIA 属性来确保您的 Web 应用程序元素可访问和可交互的例子:

describe("Accessibility Testing", function () {
  // Simulate loading a web page or application
  beforeAll(function () {
    // Load your web page or application
  });
 it("should have proper ARIA attributes", function () {
    // Find an element by its data-qa attribute
    const buttonWithQA = element(by.css('[data-qa="login-button"]'));
   // Verify that the ARIA role is set to "button"
    expect(buttonWithQA.getAttribute('role')).toEqual('button');
   // You can also check other ARIA attributes like "aria-label", "aria-describedby", etc.
    // Example: expect(buttonWithQA.getAttribute('aria-label')).toBe('Login Button');
  });
 it("should be keyboard accessible", function () {
    // Find an element by its ARIA label
    const buttonWithARIA = element(by.css('[aria-label="Login Button"]'));
   // Trigger a click event using Protractor
    buttonWithARIA.click();
   // Verify that the element is focused after the click
    expect(browser.driver.switchTo().activeElement().getAttribute('aria-label')).toEqual('Login Button');
  });
});

在这个例子中,我们有两个测试用例。第一个测试用例验证具有data-qa属性的元素是否具有正确的 ARIA 角色。第二个测试用例检查具有特定 ARIA 标签的元素的键盘可访问性。这只是一个基本示例,您可以根据您特定应用程序的需求进行修改,以确保您的元素正确可访问并且具有正确的属性。

规则要点 – CSS 选取器的替代方案

不幸的是,CSS 选取器没有提供直接根据文本内容过滤元素的方法,就像 XPath 中的text()normalize-space()函数一样。

因此,虽然 CSS 选取器被誉为更快,但在测试自动化中它们的功能可能有限。下一个例子利用 CSS 快速收集页面上的所有元素:

const allElementsByCss: ElementArrayType = await browser.$$('*');

对于获取特定类型的元素,可以结合使用 XPath 和 CSS。以下是一些补充的元素选取器类型:

  • 文本:

    //span[text()='Vital Signs']
    
  • 链接:

    a[href='https://example.com']
    //a[@href='https://example.com']
    
  • 按钮:

    button#submit-button
    //button[text()='Login']
    
  • 列表:

    ol li:nth-child(2)
    
  • 字段:

    input[type='text']
    input[id*='input']
    textarea[placeholder='Enter text']
    

仅通过文本查找元素

可以使用 XPath 选取器通过精确匹配字符串来找到元素。例如,一个精确匹配的Next按钮将格式化为如下:

//a[text()='Next >']

然而,尽管文本很可能是恒定的,但角度括号和间距可能会改变。我们可以通过具有子字符串的选取器来减少未来维护的机会。

编写包含文本子串的 XPath 元素

通过在选取器中添加contains(),只需文本的一小部分就可以找到对象:

//a[contains(text(),'Next']

这适用于许多元素,但复选框和单选按钮比较棘手。

相对于另一个元素查找元素

在下一个例子中,我们想要点击名字约翰·史密斯旁边的复选框。我们有几个复选框,但没有一个具有独特的名称标识符:

图 10.4 – 约翰·史密斯的名字及其关联的复选框是两个独立的元素

图 10.4 – 约翰·史密斯的名字及其关联的复选框是两个独立的元素

这个选取器将识别所有复选框:

//input[contains(@name,'chkSelect')]

要定位这个复选框元素,我们需要使用链接并找到它前面的输入复选框。以下是我们可以这样做的方法:

//a[normalize-space()='John.Smith']//preceding::input[@id='ohrmList_chkSelectRecord_2']

不区分大小写的部分匹配

如果我们了解到有时开发者会更改文本的大小写怎么办?解决这个问题的方法是为translate选项添加一个,并将文本转换为匹配大写或小写:

//a[translate(normalize-space(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='john.smith']//preceding::input[@id='ohrmList_chkSelectRecord_2']

现在的问题是,选择器返回的元素并不总是可见的。以下是我们的处理方法。

仅查找可见元素

自动化测试中最大的挑战之一是返回一个可见元素的集合。这让我们想起了迈克尔·基顿(Michael Keaton)的一个非常著名的引言:“想要疯狂?那就让我们疯狂吧!

这个广泛的选择器作为示例提供。它将以多种方式消除大多数不可见的对象。我们解决了不透明度为0、溢出或可见性被隐藏、显示为none以及宽度或高度设置为0的情况:

(//a | //input | //select | //textarea)[
not (
contains(@style,'opacity: 0;') or contains(@style,'visibility: hidden;') or contains(@style,'display: none;') or contains(@style,'overflow: hidden;') or contains(@style,'width: 0') or
contains(@style,'height: 0')) and
not(ancestor::*[contains(@style,'opacity: 0;') or
contains(@style,'visibility: hidden;') or
contains(@style,'display: none;') or
contains(@style,'overflow: hidden;') or
contains(@style,'width: 0') or
contains(@style,'height: 0')])]

此选择器还会消除任何具有隐藏祖先的元素。这是 80/20 规则适用的另一个地方。即使这个选择器只能消除大约 80%的非可见元素,我们仍然需要解析元素集合以找到第一个可见的元素。那么,为什么不让 XPath 或 CSS 处理超过一半的工作,以获取我们的可见元素呢?

我们想要这样做是为了给我们的方法第二次机会,尝试找到如果它已经从其类中更改的元素。找到元素的第二或第三次机会永远不会太晚。

第二次机会 – 从过时的选择器中获取有效对象

现在我们有了四个主要的方法包装器,让我们通过自我修复的代码使它们更加健壮。自动化最大的缺点是需要维护来修复页面对象模型POM)中的元素,当选择器变得过时时。在本节中,我们探讨自我修复技术以找到已更改其节点类型的元素。

自我修复技术

让我们在这个子节中回顾一些自我修复技术。

减少不区分大小写的匹配代码

所有这些函数都需要转换为不区分大小写的匹配。我们首先创建两个常量以减少大写和小写字母的重复使用:

const A_Z = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const a_z = 'abcdefghijklmnopqrstuvwxyz';

接下来,我们需要一个函数,该函数将从一个过时的定位器中提取唯一文本。

提取选择器文本

此函数将尝试返回任何唯一文本的第一个匹配项,该文本被双引号或单引号包围。这将注入到一个接近匹配定位器中:

function extractSelectorText(selector: string): string {
const singleQuoteCount = (selector.match(/'/g) || []).length;
  let newSelector = selector;

首先,我们检查选择器字符串中嵌入的单引号,例如所有格撇号。例如,"Moe's Bar"会导致单引号的数量为奇数:

if (singleQuoteCount === 1 || singleQuoteCount === 3){
  const parts = selector.split("'");
  if (singleQuoteCount === 1) {

处理只有一个单引号的情况,通过将其包裹在一个concat函数中:

      newSelector = `concat('${parts[0]}', "'", '${parts[1]}')`;

这将"Moe's bar"转换为"concat("Moe","'","'s bar")以支持单引号匹配。

    } else if (singleQuoteCount === 3) {

在任何其他语言中,都不可能在单个定位器中同时有一个单引号和引号字符串。但是,因为 JavaScript 允许使用反引号表示字面字符串,所以可能会有这样的字符串:

 `//*[text()=Meet Dwayne "The Rock" Johnson at Moe's Bar]`

因此,我们处理了有三个单引号的情况,只有第二个单引号应该被转义,如下所示:

      newSelector = `concat('${parts[0]}${parts[1]}', "'", '${parts[2]}')`;
    }
  }

这会提取为以下内容:

`concat(Meet Dwayne "The Rock" Johnson at Moe","'"'s Bar"`

按如下方式提取两个双引号或单引号之间的文本:

  let match = newSelector.match(/"([^"]+)"$/) || newSelector.match(/'([^']+)'$/);

如果没有找到匹配项,或者匹配的组无效,则返回原始选择器。实际上,传递的定位器字符串可能不够健壮,不足以给予第二次机会。我们返回一个清楚地标识问题的字符串,因为返回Null值会引发错误,而空字符串可能会匹配所有元素:

  if (!match || match.length < 2) {
    return "NO TEXT FOUND IN LOCATOR";
  }

否则,如果检测到单引号,则返回单引号或双引号之间的捕获组进行修改:

return match[1];
}

现在我们已经提取了定位器的文本,我们可以将其注入到每个元素类的一个类似定位器中。

从链接到按钮

Candymapper沙盒网站的主页上,恰好有一个<Button>元素。它是一个<A>锚链接,就像页面上的其他链接一样。并且它是全大写的。我后来修复了这个问题,但我需要花时间修复代码中的定位器吗?

图 10.5 – Candymapper 网站上的发送按钮元素

图 10.5 – Candymapper 网站上的发送按钮元素

这是以前曾经工作过的原始定位器:

//a[text()='SEND']

如果我们能够将文本提取出来并注入到像这样的按钮类中呢?

//button[normalize-space()='SEND']

如果它仍然不起作用,我们进行了第三次尝试,进行不区分大小写的匹配:

//button[translate (normalize-space(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') = 'send']

现在,我们有一个解决方案,允许我们找到我们的元素,无论大小写敏感与否。现在我们可以继续使用突变合并功能来优化它。

仅通过文本查找元素

这可以作为一个函数一起使用,由getValidElement()调用,以给我们的按钮第二次被识别的机会,而无需重构代码:

function transformLink(selector: string): string {
let extractedText = extractSelectorText(selector)
// Create the new selector string
const newSelector = `//button[contains(translate (normalize-space(),'${A_Z}','${a_z}'), '${extractedText.toLowerCase()}')]`;
return newSelector;
}

现在,即使发送按钮更改了大小写和类,也可以到达它。

public get sendLink () {
    return $(`//a[text()='Send']`);
}
await helpers.clickAdv(await this.sendLink)

字段和列表

字段可以从<input>变为<textarea>对象。要找到它们,可以使用@placeholder属性进行此更改:

function transformField(selector: string): string {
let extractedText = extractSelectorText(selector)
// Create the new selector string
const newSelector = `//textarea [contains (@placeholder, '${A_Z}','${a_z}'), '${extractedText.toLowerCase()}')]`;
return newSelector;
}

当然,<select>对象可能会变为<input>组合框:

function transformList(selector: string): string {
let extractedText = extractSelectorText(selector)
// Create the new selector string
const newSelector = `//input[contains (@placeholder, '${A_Z}','${a_z}'), '${extractedText.toLowerCase()}')]`;
return newSelector;
}

短字符串

在离开这个兔子洞之前,还有一个技巧。有时,小的文本变化仍然可以准确找到。"Select all active files"变为"Select all activated files"。将文本分为三部分。如果剩余长度大于五个字符,中间字符串"all activ"匹配的可能性很大,只要它是一个唯一的匹配:

function getMiddle(s: string): string {
  const len = s.length;
  // Return the string as it is if its length is less than or equal to 5
  if (len <= 5) {
    return s;
  }
  // Divide the string into three parts
  const oneThird = Math.floor(len / 3);
  const twoThirds = 2 * oneThird;
  // Extract the middle part
  return s.substring(oneThird, twoThirds);
}
console.log(getMiddle("Select all active files")); // Output: "all active fi"
console.log(getMiddle("small")); // Output: "small"

统计上,这大约 40%的时间会返回一个唯一的元素。

在薄冰上

滚动元素提出了一个特别困难的挑战。偶尔,它们会部分超出浏览器的视图区域。让我们看看这个例子:

图 10.6 – 位于浏览器视图区域上方的左上角文本区域对象 0,0;第二个文本区域对象的中心位于浏览器底部

图 10.6 – 位于浏览器视图区域上方的左上角 0,0 的文本区域对象;位于浏览器底部之外的第二个文本区域对象的中心

如果你的网站支持侧滑元素动画,又会怎样?如果你的框架激活了一个滑动菜单,然后立即尝试点击内部的元素,很可能会得到一些奇怪的结果:

图 10.7 – 点击滑动菜单项中心的示例

图 10.7 – 点击滑动菜单项中心的示例

更让人沮丧的是,如果出现这个问题,测试结束时的屏幕截图很可能会在滑动动画完成后出现。你可能会得到的唯一线索是一条消息,表明元素在点 2050, 250 时不可点击,这个点位于一个 1920 x 1080 像素分辨率的显示器的右边缘之外。

内置的 WebdriverIO .click() 方法在与屏幕外的元素交互时不应有任何问题。clickAdv() 包装函数滚动元素进入浏览器视图区域的主要原因是为了在发生错误时有更好的机会在屏幕截图中显示元素。

如果元素不在视口中,尝试使用花哨的 JavaScript 点击可能会抛出错误。这取决于点击是在对象的左上角执行还是在计算出的中心执行。以下是一个使用 browser.execute 方法执行 arguments[0].click(); 代码字符串的 JavaScript 点击调用示例:

async function jsClick(element: WebdriverIO.Element): Promise<void> {
await browser.execute("arguments[0].click();", element); }
// Usage example
  await jsClick('#some-button-id');

另一个很好的原因是这种做法可能会引起问题。在 GUI 自动化中,我们总是希望尽可能地模拟用户。如果我们在这里显示的模态弹出窗口覆盖了所需的元素,会发生什么呢?

图 10.8 – 调用 jsclick() 强制与模态弹出窗口下方的元素交互将会存在问题

图 10.8 – 调用 jsclick() 强制与模态弹出窗口下方的元素交互将会存在问题

如果我们正在寻找的元素是从屏幕外滑入控制,我们通常会得到一个错误,表明元素点击超出了范围。如果元素是一个仍在动画中的展开列表中的项,这种情况可能会发生。有时,当对象滚动了几像素出视口时,可能会发生错误。为了解决这个问题,我们需要知道错误抛出时元素是否在移动,以及移动何时停止。以下是我们如何做到这一点的方法:

async function scrollOneClickUp(): Promise<void> {
await browser.execute(() => { const event = new
WheelEvent("wheel", { deltaY: -50 });
document.dispatchEvent(event); });
}

滚动一个鼠标滚轮并点击,如下所示:

async function scrollOneClickDown(): Promise<void> {
await browser.execute(() => { const event = new
WheelEvent("wheel", { deltaY: 50 });
document.dispatchEvent(event); });
}

编写 isMoving() 方法

通过 xy 坐标点击元素的日子已经离我们远去。这并不意味着元素的坐标没有价值。令人惊讶的是,在某些情况下,点击操作有时是相对于元素的屏幕位置进行的。有了坐标,我们可以确定元素是否在移动,以确保我们的框架有更高的精度。考虑以下这段代码:

const currentLocation: WebdriverIO.LocationReturn = await element.getLocation();

这将返回一个包含元素当前 xy 屏幕坐标位置的对象。通过循环并暂停几毫秒,我们可以实现一个动态等待,确保我们的对象滚动动画已经完成:

export async function waitForElementToStopMoving(element: WebdriverIO.Element, timeout: number): Promise<void>
  const initialLocation = await element.getLocation();
  return new Promise((resolve, reject) => {
    let intervalId: NodeJS.Timeout;
    const checkMovement = () => {
      element.getLocation().then((currentLocation) => {
        if (
            currentLocation.x === initialLocation.x &&
            currentLocation.y === initialLocation.y
        ) {
          clearInterval(intervalId);
          resolve();
        }
      });
    };
    intervalId = setInterval(checkMovement, 100);
    setTimeout(() => {
      clearInterval(intervalId);
      reject(new Error(`Timeout: Element did not stop moving within ${timeout}ms`));
    }, timeout);
  });
}

最佳实践是在任何 browser.execute 滚动之后和任何类似基于点击的方法之前实现这一点。

摘要

在我们超级英雄编码传奇的这次激动人心的篇章中,我们穿越了元素定位的神秘世界,掌握了在网络的荒野中定位难以捉摸的 HTML 实体的艺术。我们的探索之旅使我们征服了 <a> 锚点的变化形态,将它们转化为强大的 <button> 守卫,并将简单的 <input> 字段进化成广阔的 <textarea> 元素。我们航行在变换的迷宫中,下拉菜单变成了组合框,我们运用我们的力量,忽略大小写匹配文本,甚至寻找字符串中的隐藏含义。

我们的工具包扩展了,我们拥抱了自我修复定位器的神秘艺术,编织咒语以应对数字风的变化。当元素在屏幕上翩翩起舞,快速穿梭时,我们坚定地站立,动画的幻影让一个低级的科技法师都感到困惑。

当我们站在发现的边缘时,我们提出了一个挑战我们工艺现实的问题:如果页面对象定位器的需求只是一个幻觉呢?如果在 UI 的阴影深处,我们只需对框架低语一声,就能召唤一个 发送 按钮?答案在召唤我们——我们敢跃入未知吗?下一章等待着,承诺着超越我们想象力的奇迹。

第十一章:回声定位 - 跳过页面对象模型

到目前为止,我们使用页面对象模型POM)来封装页面内的 UI 元素和交互。我们通常可以用 XPath 或 CSS 定位器清楚地看到我们的目标目标,但考虑一下那些在黑暗中盲目完成任务的超人。虽然 POM 有很多优点,但在某些情况下,仅根据一些线索在黑暗中通过文本找到对象可以提供优势:

  • 快速原型设计和简化测试创建:对于快速且简单的测试或原型设计,建立包含数千个对象的完整 POM 可能有些过度。在这种情况下,直接定位元素可以加快初始测试开发过程。

  • 处理动态内容的元素:在现代 Web 应用程序中,内容可能非常动态。元素可能没有固定的 ID、类或其他属性。文本内容在 DOM 背后的后期版本中通常更稳定。

  • 代码可读性:使用直接文本查询编写的测试有时可能更易读且更具自解释性。任何阅读测试的人都可以理解正在模拟的用户交互,而无需深入研究页面对象来了解每个方法的作用。

在本章中,我们将介绍以下主要内容:

  • 减少的代码库

  • 使用纯英文进行自动化

  • 通过名称获取可见的按钮、字段和列表

  • 从集合中获取可见元素

减少的代码库

跳过 POM 减少了需要维护的代码量。这在小型项目或概念验证实现中尤其有益,在这些项目中,快速开发比长期可维护性更重要。

虽然“基于文本”的方法有其优点,但重要的是要注意,这并不是一个一刀切解决方案。它的目的是保持高度可靠性,减少维护所需的数量。

在本章中,我们将通过仅传递文本来增强我们的元素定位。使用的方法将提供有关要考虑的节点类型的线索。如果我们只需将setValueAdv("First name", "Paul")点击到名字字段,或者甚至从客人数量列表中选择2,这意味着什么呢?

我们将增强我们的三个自定义函数,使它们能够仅基于字符串识别元素。除了传递一个对象外,一个简单的文本字符串还将传递给clickAdv()setValueAdv()selectAdv()方法。这样,我们可以完全消除一些页面对象。

本章将介绍以下主要内容:

  • 使用纯英文进行自动化

  • 点击一个命名的按钮或链接

  • 在一个命名的字段中输入文本

  • 从一个命名的列表中选择一个项目

  • 追踪超过三层深度的兔子洞

使用纯英文进行自动化

我们将继续进一步修改我们的自定义方法,允许传递两种不同类型的类。我们的方法仍然支持WebdriverIO WebElement,但现在,我们将通过字符串来增强它们。例如,假设我们想点击 CandyMapper 网站顶部的万圣节派对按钮。考虑以下代码:

图 11.1 – HALLOWEEN PARTY 链接的 DevTools 视图

图 11.1 – HALLOWEEN PARTY 链接的 DevTools 视图

这是 POM 方法来查找链接:

public get btnHalloweenParty () {
   return $(`#nav-55206 > li:nth-child(2) > a`);
}
Helpers.clickAdv (btnHalloweenParty);

通过使用 echo 位置超级能力,这一行代码可以足够智能,只需这一行就能找到正确的链接:

Helpers.clickAdv ("Halloween Party");

或者,你可以在字段中输入电子邮件:

Helpers.setValueAdv ("Email", "me@mydomain.com");

你还可以添加总共两名客人陪你参加派对:

Helpers.selectAdv ("Guests","2");

现在,我们可以增强该方法,使其沿着一个路径分割,该路径可以是对象或字符串;在这种情况下,我们可以使用我们的getValidObject()来返回包含该字符串的可见元素集合。此外,我们可以根据被调用的动作的动词推断要查找的元素类型。ClickAdv()将查找按钮、链接和类似元素。SetValue()将查找输入字段或textarea节点。SelectAdv()将与列表交互。

快速提示

虽然我们可以将此扩展到我们的assertAdv()函数,但这是不建议的。问题是带有字符串的assertAdv()函数需要更多的上下文。几乎不可能确定我们是在验证按钮状态、字段值、列表项或某些显示的文本。最好是保持简单,只确认我们寻求的文本在页面上是可见的,并突出所有潜在的匹配项。对于其他任何内容,只需传递WebElement类。

我们的第一步是扩展一个代码路径,该路径将在clickAdv()方法中与WebElement和字符串交互。这个过程将适用于getValidElement()函数,我们将在下一节中这样做。最后,SetValueAdv()Selectadv()函数将使用getValidElement()中的相关部分进行修改。

获取命名的按钮

在这三个自定义函数中的每一个,我们将扩展传递给元素的类型,包括这样的字符串:

export async function clickAdv(
element: WebdriverIO.Element | string,
text: string ) {

如果传递了一个字符串,我们将使用它来识别传递的类型的有效元素:

// If button element is a string, find the elements using the string
if (typeof element === 'string') {
element = await getValidElement(element, "button");
}

在这个例子中,我们提供了一个线索,这个元素将是一个按钮。接下来,我们将以相同的方式返回一个设置值的字段。

获取命名的输入字段

同样,我们将以类似的方式修改setValueAdv。然而,我们将指示getValidElement寻找输入或textarea字段类:

export async function setValueAdv(
inputField: WebdriverIO.Element | string,
text: string ) {
// If inputField is a string, find the elements using the string
if (typeof inputField === 'string') {
inputField = await getValidElement(element, "field");
}

与最后两个函数一样,我们将使用一个最终的线索字符串来扩展selectAdv()

获取命名的列表

最后,selectAdv()也将被修改。可能匹配的元素类型将被列出:

export async function selectAdv(
inputField: WebdriverIO.Element | string,
text: string ) {
// If inputField is a string, find the elements using the string
if (typeof inputField === 'string') {
inputField = await getValidElement(inputField, "list") as Element;
}

现在这些三种方法都已更新,我们需要增强getValidElement()方法,使其返回适合每种动词类型的元素。

通过名称获取可见按钮

getValidElement() 方法的第一项改进是允许传递一个字符串,就像前三个方法一样:

export async function getValidElement(
  element: WebdriverIO.Element | String,
  elementType: string
): Promise<WebdriverIO.Element> {

我们可以执行的第一项检查是查看是否有任何元素可能匹配我们想要寻找的内容。在这种情况下,我们可以再次利用 XPath 和 CSS 定位器。这个 XPath 定位器将寻找包含传递给方法的文本的任何节点:

if (typeof element == "string") {
    // Try finding "Halloween Party" element by xPath text
    elements = await browser.$$(`//*[contains(normalize-space(),'${eleText}')]`)

如果没有返回元素,将进行第二次尝试,使用 CSS 选择器并利用 href 属性。这个属性通常包含以连字符为导向的小写文本字符串:

    // No such elements by element
    if (elements.length == 0) {
      //Try finding CSS href contains "halloween-party"
     const elements = await browser.$$(`[href*='${eleText}.toLowerCase().replaceAll(" ", "-")}']`)
    }

现在,我们有三种类型的元素需要处理。让我们分别处理每一个,从点击按钮开始:

If (elements.length > 0 and elementType === "button"{
let buttonElements = await browser.$$(`(//a|//button)[contains(normalize-space(),'${element}')]`)
    }

如果这仍然没有返回匹配的元素,我们可以尝试这个不区分大小写的方案:

 if (elements.length === 0) {
let buttonElements = await browser.$$(` (//a|//button)[contains(translate(normalize-space(text()), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${element}')]
`)
}

现在,我们有很好的机会仅基于传递给函数的字符串找到按钮。让我们也以相同的方式处理输入字段和文本区域。

通过名称获取可见字段

接下来,我们必须收集一系列字段:

If (elements.length > 0 and elementType === "field"{
elements = await browser.$$(`//label[
normalize-space()='${element}']//preceding::input `)
    }

如果没有匹配项,我们将再次尝试找到与标签相关的文本区域:

If (fieldElements.length === 0 and elementType === "field"{
elements = await browser.$$(`//label[normalize-space()='${element}']//preceding::textarea`)
}

最后,我们将对一系列列表执行相同的操作。

通过名称获取可见列表

接下来,我们将尝试根据文本找到列表元素:

If (lelements.length > 0 and elementType === "list"{
elements = await browser.$$(`//select[@id='${element}'] `)
    }
If no Select element matches that we take a second chance by searching for by the name attribute.
If (elements.length === 0 and elementType === "list"{
elements = await browser.$$(`//select[@name='${element}'] `)
    }
///

如果没有 Select 元素与这些匹配,我们将通过搜索与标签相关的组合框进行最后的尝试:

If (elements.length === 0 and elementType === "list"{
listElements = await browser.$$(`//label[contains(@for,'#{element}')]/following::select`)
    }

我们还没有完成。必须对返回的元素集合进行过滤以确定其可见性。

从集合中获取可见元素

现在我们有一系列潜在元素,我们将解析它们以找到第一个可见的元素:

for (let element of elements) {
const tagName = await element.getTagName();
// const tagName = await element.getAttribute('class'); // Alternate class match
await element.waitForDisplayed({ timeout: 0 });
const isVisible = await element.isDisplayed();
// const isVisible = await highlight(element);
// Alternate visible validation.
If (isVisible)
//Found a matching button or an element with anchor class. Exiting loop
break;
}

到这一点,我们已经对被引用的元素做出了合理的猜测。我们只需要从调用方法中返回要交互的元素:

return element;
}

到这一点,你可能看到了进一步识别集合的方法,我们鼓励你修改两个或三个示例定位器以适应你特定的框架,但...

小心无底洞!

当我们接近本节的结尾时,你可能已经注意到我们没有给出超过三个示例来动态地通过文本定位多个节点。而且有很好的理由。你可能会花费数小时试图找到第五个或第六个模板,它将返回完美的元素集合。我们建议将搜索限制在三次尝试之内。深入这个兔子洞不值得,这会降低准确性并减慢结果搜索时间。

摘要

在本章中,我们展示了如何仅使用字符串动态定位元素,就像超级英雄利用敏锐的感官仅凭轮廓就能定位恶棍一样。我们利用每个动作的线索,缩小潜在元素的范围,只关注与请求的动作相关的元素,就像超级英雄锁定目标一样。最后,我们为识别有效元素尝试的次数设定了上限,这类似于超级英雄在改变策略之前可能会限制他们的搜索努力。

在下一章中,我们将探讨如何将我们的测试扩展到多个环境,类似于超级英雄如何适应大都会不同郊区的各种挑战。

第十二章:超级英雄着陆 – 设置灵活的导航选项

我们超级英雄框架着陆的城市总是在变化,他们在巡逻时往往不知道他们面临的是什么。我们的下一步是使着陆页 URL 更加灵活。我们需要能够从 QA 环境切换到预发布环境。同时,它们应该足够健壮,能够处理小的差异。在本章中,我们将探讨处理存在于一个发布或环境中的元素,但在另一个环境中不存在的元素。此外,我们还将增强日志包装器,以包含颜色。

我们将涵盖以下主要主题:

  • 使用系统变量

  • 添加数据配置文件

  • 配置 allure 报告

快速提示

避免在开发环境中进行测试,因为那里会不断发生变化。集中精力在 QA 和预发布环境中。承诺在开发环境中保持测试运行状态将产生更多的维护时间。更多的维护意味着更少的时间来创建新的测试和分析现有结果,这意味着更多的错误会滑入生产环境,增加了我们资金流失的风险。如果当权者坚持,请明确指出可以提供 4-10 个测试的小子集,仅为了给开发者一个关于其环境状态的“温暖舒适”感。我们确实希望左移,但过度分散我们的团队将适得其反。

技术要求

所有测试示例都可以在这个 GitHub 仓库中找到:github.com/PacktPublishing/Enhanced-Test-Automation-with-WebdriverIO

使用系统变量

当从命令行运行我们的测试时,我们可以轻松设置用户变量来指示要使用或运行的测试环境。这可以通过环境变量 {``process.env.ENV} 来完成:

> Env=dev

这个变量可以在我们的框架内部读取,并将我们的登录方法重定向到正确的环境,如下所示:

prod=www.candymapper.com
dev=www.candymapperr2.com

快速提示

在生产环境中测试时要格外警惕。与领导讨论它可能产生的影响。使用低效的 SQL 调用减慢生产数据库,返回一百万结果将会掩盖任何发现的错误。设置你的作业运行时,使用标记来指示你的生产环境的安全测试用例。

添加数据配置文件

传说中,数据文件是由一位聪明而神秘的科学家创建的,他的名字只有知道其存在的人才会低声耳语。据说它们包含着古老的知识、神圣的算法和隐藏的代码,可以解开被测试应用程序的奥秘。

测试使用的数据存储位置

使用 TypeScript 将数据文件添加到你的测试代码中,与 JavaScript 的做法完全一样,但使用 TypeScript,你可以利用 TypeScript 的静态类型和模块,这有助于你提前捕获类型相关的错误,使你的测试更加健壮和易于维护。

组织测试数据

首先,创建一个目录来存储您的测试数据文件。您可以将其命名为test-datashared-data。将您的数据文件(例如 JSON、CSV 等)放在此目录中。

设置 TypeScript 配置

确保您的 TypeScript 配置(tsconfig.json)包括测试文件和模块的适当设置。以下是一个示例:

   // Json file
   {
     "compilerOptions": {
       "target": "es6",
       "outDir": "./dist",
       "esModuleInterop": true
     },
     "include": ["src", "shared-data", "tests"]
   }

tsconfig文件的include部分包含test-data目录和tests目录。

从文件中读取数据

在这里,我们使用fs模块从文件中读取数据:

   import * as fs from 'fs';
   const jsonData: string = fs.readFileSync('./shared-data/data.json', 'utf-8');
   const parsedData: MyDataInterface = JSON.parse(jsonData);

一旦我们有了文件系统对象,我们就可以开始构建数据驱动的测试

在测试中使用测试数据

在您的测试文件中,您可以导入必要的数据并在测试用例中使用它,如下所示:

   import { expect } from 'expect-webdriverio';
   import { someFunction } from '../src/someModule';
   import testData from '../shared-data/data.json';
   describe('someFunction', () => {
     it('should return the correct value', () => {
       const result = someFunction(testData.input);
       expect(result).toEqual(testData.expectedOutput);
     });
   });

在前面的例子中,我们从共享数据目录中的data.json文件中拉取了一些数据。然后,将这些输入数据与实际结果进行比较,断言这些值是否匹配。

不仅仅是掩码 – 使机密数据不可见

如前所述,超级英雄们通常会采取各种措施来保护他们的身份,例如戴口罩或戴上一副眼镜。但如果是真的想保持低调,没有什么比一个秘密库更有效了。

使用数据文件来存储诸如用户名和访问密钥之类的机密信息在今天非常普遍。出于安全原因,这些信息决不应上传到您的代码仓库。一个优秀的 DevSecOps 团队会解析 GitHub 和 GitLab 仓库,寻找诸如“password”之类的术语,并在找到任何匹配项时标记您的团队,指出其不符合系统与组织控制 2SOC II)的要求。

在项目的基础目录中创建一个.env文件来存储所有您的机密数据,完成后将dotenv添加到您的依赖项中。这将使process.env能够访问.env文件中的所有数据:

// content of .env
# LambdaTest Credentials
 LT_USERNAME=LT_USERNAME
 LT_ACCESS_KEY=LT_ACCESS_KEY
 LT_HOST_URL=LT_HOST_URL

为了做到这一点,我们需要另一个名为dotenv的 node 包。这个包允许开发者将配置数据存储在名为.env的纯文本文件中。.env文件中的每一行通常代表一个形式为KEY=VALUE的环境变量,例如API_KEY=your_api_key_here。安装它很简单:

> yarn add dotenv

接下来,我们将此放在wdio.config文件中import语句的下方:

require('dotenv').config()
// usage in wdio config
module.exports = {
   // ….
   user: process.env.LT_USERNAME,
   key: process.env.LT_ACCESS_KEY,
   // ….
};

在这种情况下,我们创建一个系统变量来保存LT_USERNAMELT_ACCESS_KEY。这就是我们传递敏感数据而不在仓库中存储我们的凭证的方式。

Spec 和 Allure – 立方记者与明星记者

在许多漫画书中,有几位记者记录了城市中的重大事件和犯罪。新手记者为我们的超级英雄提供内部知识以拯救世界,而明星记者则提供引人注目的头版标题。Spec 和 Allure 报告器是 WebdriverIO 中的类似报告机制。它们具有不同的功能并提供不同级别的详细信息。Spec 报告器最适合 SDETs 在测试过程中即时调试失败的测试运行。它告诉你测试是否通过或失败,显示测试的名称,并报告运行时间。如果测试失败,Spec 报告器在控制台中提供错误消息和堆栈跟踪。这为你提供了对所发生情况的即时理解,但需要你自己提供关于测试运行的深入上下文数据。

Allure 提供了适合向项目经理和高级管理人员展示结果的炫目历史图表。它超越了基础功能,为你提供更全面的视角。它生成一份时尚且信息丰富的报告,包含大量附加信息,例如以下内容:

  • 测试和套件描述

  • 在失败时附加截图

  • 将文本/plain 上下文附加到测试报告中

  • 使用 BDD 标签和严重性标记你的测试

  • 对常见应用程序区域的测试进行测试用例分类

  • 趋势历史和故障分析

  • 环境信息

因此,Allure 报告器提供的报告比 Spec 报告器更丰富、更详细。它允许更好地理解测试过程中发生的事情,并提供对你测试套件的更全面视图。你可以将其视为简单标题(Spec 报告器)与包含照片、分析和背景的完整新闻文章(Allure 报告器)之间的区别。

第一步是将 Allure 添加到我们的项目中:

> yarn add @wdio/allure-reporter
> yarn add allure-commandline

然后,在wdio.conf.ts文件中,我们将添加以下配置:

    reporters: ["spec", ["allure",
    {
       outputDir: "./reports/allure-results",
       disableWebdriverStepsReporting: false,
       disableWebdriverScreenshotsReporting: false,
     }]],

本节指导了报告细节的存储位置,并包括两个可启用或禁用的选项。默认情况下(false),这两个选项都是启用的,允许 Allure 提供详细的逐步报告,并包含相关的截图,以增强测试结果的可视性和可理解性。禁用这些选项的唯一原因可能是为了节省磁盘空间,这并不推荐。从生成的报告中排除 Webdriver 步骤报告和截图报告只会使我们的分析工作更难。

配置 Allure 报告

如果你之前没有将Allure设置为报告器,可以手动完成。这是一个两步过程:Allure,跳转到步骤 2

  1. 要安装 Allure,请输入以下命令:

    > yarn add @wdio/allure-reporter
    

    这将安装 Allure 作为devDependancies。我们可以验证该包是否已添加到package.json文件中。

图 12.1 – Allure 报告器依赖项添加到 package.json

图 12.1 – Allure 报告器依赖项添加到 package.json

  1. Allure 包被添加到开发依赖中。接下来,必须在 wdio.config.ts 中配置 HTML 报告和屏幕截图的输出目录:

图 12.2 – 将 Allure 配置添加到 wdio.config.ts 文件中

图 12.2 – 将 Allure 配置添加到 wdio.config.ts 文件中

wdio.config.ts 文件中,outputDir 指定了 HTML 文件和屏幕截图的存储位置。让我们使用 allure-results。现在再次运行测试:

> yarn wdio

这将启动 example.e2e.ts 测试。它还会在 allure-results 文件夹中生成结果,供 Allure 构建仪表板。

图 12.3 – Allure 创建 HTML 报告页面的新支持文件

图 12.3 – Allure 创建 HTML 报告页面的新支持文件

  1. 要显示结果,请输入以下命令:

    > allure generate -–clean && allure open
    
  2. Bash 终端也可以执行这样的组合语句:

    > allure generate –clean; allure open
    

我们已经安装并配置了 WebdriverIO 和 Allure 仪表板服务,以便为我们的利益相关者展示美观的结果图表。但在测试自动化中,有一个恒定不变的因素,那就是变化。我们需要保持所有支持包的版本更新。如果有冲突,肯定会有麻烦。幸运的是,有一个简单的解决方案。

这些信息不需要存储在仓库中,所以我们将将其添加到我们的 .gitignore 文件中:

allure-report
allure-results
Screenshots

在每个测试的顶部,我们应该保持一致地使用 Allure 报告标签,以帮助在报告中组织和分类我们的测试用例。这包括测试所有者(作者)、功能、故事和描述的标签。高级报告可以包括使用 TMS 链接回 Jira 任务的链接。让我们从 Owner 标签开始:

AllureReporter.addOwner("Paul Grossman");

任何超级英雄最想回答的第一个问题是,“是谁干的?”在早期章节中,我们提到代码可以快速通过 GitLens 追踪到其所有者。由于测试的原作者最了解他们所编写的测试,因此团队成员应该养成在所编写的每个测试中添加他们名字的习惯。接下来,我们需要按功能组织我们的测试:

allureReporter.addFeature("Automation Hello World");

![图 12.4 – 在“Automation Hello World”功能下显示的一个通过测试的 Allure 报告]

图片

图 12.4 – 在“Automation Hello World”功能下显示的一个通过测试的 Allure 报告

一个 Feature 标签描述了应用程序的哪个区域正在被这个和其他测试所测试。测试用例可以被分组以更有效地执行。这可以是一个只与一个区域相关的测试的小子集。这将消除通过冒烟测试和回归测试套件分离测试的需要。测试还需要有关测试本身功能的一些细节。

这是向报告中测试添加描述性标签名称的命令:

AllureReporter.addDescription("Verify the user can login");

这个描述可以在以下屏幕截图中看到,用蓝色突出显示。

![图 12.5 – 描述表明测试将断言登录功能]

图片

图 12.5 – 描述表明测试将断言登录功能

Description标签是测试本身执行的验证的摘要。这通常是从问题跟踪工具中引用现有手动测试的条目标题逐字复制。它也可能是另一个自动化项目的条目标题,该条目链接到另一个项目中单独的手动测试。这些条目编号应与Story可追溯性标签匹配。

这就是我们将故事描述添加到测试报告中的方法:

allureReporter.addStory("TA-001");

然后将这些信息附加为测试的名称。

图 12.6 – 添加了 Jira 条目引用的“TA-001”故事

图 12.6 – 添加了 Jira 条目引用的“TA-001”故事

测试需要与个别故事详细信息进行可追溯性。在故事条目中重复文本几乎没有意义,因此只需提供条目编号就足够了。它可以被附加到浏览器中保存的 URL 上,以便快速查找。

添加自定义注释到 Allure 报告中

第八章中,我们讨论了为Expect创建包装器。我们可以使用addattachment()函数添加自定义报告:

allureReporter.addAttachment('Assertion Failure: ', `Invalid Assertion Type = ${assertionType}`, 'text/plain');

在这个例子中,我们故意使用一个无效的断言动词equa来失败。expectAdv向 Allure 报告详细错误,描述了原因。

图 12.7 – 报告错误的问题字符串“equa”

图 12.7 – 报告错误的问题字符串“equa”

最好的建议是尽可能高效。可追溯性可以与单个描述结合:

AllureReporter.addDescription("TA-001 : Verify the user can login");

Log包装器内部,我们可以向两个报告者提供详细信息。但不是所有内容。那会导致信号与噪声比很高。所以,我们只记录错误和警告:

let SEND_TO_ALURE = false

此外,Spec 报告者可以通过一些颜色变得更加引人注目。假设我们希望任何表示结果通过的文字在控制台中显示为绿色,而失败的测试显示为红色。Log包装器可以修改为监视PASS:FAIL:文本。这些字符串可以由带有 ANSI 颜色标记的行包围。

首先,让我们将Allure添加到我们的项目中:

const { addFeature, addDescription } = require('@wdio/allure-reporter').default;
describe('My feature', () => {
    it('should do some things', () => {
        browser.url('https://webdriver.io');
        // Add a step to the report
        addFeature('Navigate to WebdriverIO website');
        browser.url('https://webdriver.io');
        // Add a description to the report
        addDescription('This is a description of what the test should         do');
    });
});

接下来,让我们确定我们的辅助文件顶部的基本颜色,从绿色开始:

const ANSI_GREEN = `\x1b[38;2;140;225;50m` // PASS

颜色转义序列在此分解:

  • \x1b是转义字符,它启动了序列。

  • [控制序列引入符CSI),它告诉终端将以下字符解释为命令。

  • 38是自定义 ANSI 颜色的前景文本。48设置背景颜色。使用 30-37 设置颜色为八种默认前景颜色之一,使用 40-47 设置八种默认背景颜色之一。

  • 2指定颜色将使用 RGB 值设置为浅色。其他选项包括3用于斜体,56用于闪烁文本,7用于反色文本,以及9用于删除线文本。

  • 140;225;50 分别是红色、绿色和蓝色的值,用于设置颜色。在这种情况下,它们定义了一种绿色。

  • m 是最后一个字符,它标志着转义序列的结束。

如您所见,我们可以在文本的颜色和格式上非常具有创意。接下来,我们为失败的消息添加红色,为警告消息添加黄色:

const ANSI_RED= `\x1b[38;2;145;250;45m`    // FAIL
const ANSI_YELLOW = `\x1b[38;2;145;226;45m`  // WARNING

当我们输出定位器时,它们也应该有自己的颜色:

const ANSI_PURPLE= `\x1b[38;2;250;235;80m`  // Locator

任何用单引号括起来的文本也可以自动格式化为自己的颜色:

const ANSI_WHITE= `\x1b[97m`  // TEXT entered into a field

最后,我们希望将任何颜色设置重置为默认值,以便我们可以区分来自我们的框架和 WebdriverIO 的消息:

const ANSI_RESET= `\x1b[0m` //Reset

这些颜色可能并不适合每个人。您可以在以下位置找到一组 ANSI RGB 颜色组合,以便根据您的喜好进行自定义:github.com/hinell/palette-print.bash

现在,让我们增强日志包装器以获取一些颜色:

if (message.includes("Warning: ")) {
    message = ANSI_YELLOW + message + ANSI_RESET
    SEND_TO_ALLURE = true
else if (message.includes("Error: ") || message.includes(Promise"){
    message = ANSI_RED + message + ANSI_RESET
    SEND_TO_ALLURE = true
} else {
   message = ANSI_GREEN + message + ANSI_RESET
}

当我们使用重音符号输出字符串时,我们可以唯一地识别它们并将它们着色:

message  = message .replace(/`([^`]+)`/g, `${ANSI_WHITE}$1${ANSI_RESET}`);

我们还可以从日志方法中嵌入我们的 xPath 定位器的颜色:

message = message.replace(/\/{1,2}[\w\-\.\:]*\[[^\]]*\]/g, `${ANSI_PURPLE}$1${ANSI_RESET}`);

同样的,CSS 定位器也是如此:

message = message.replace(/[#.|]?[a-zA-Z]+\s?)+[{] /g, `${ANSI_PURPLE}$1${ANSI_RESET}`);

现在,当传递结果时,它可以根据内容在运行时以颜色显示:

global.log(`FAIL: Invalid Assertion Type = ${assertionType}`);

但从 ClickSelectEnterExpect 方法包装器中这样做会更可靠。

最后,我们可以将任何错误日志重定向到 Allure 报告,如下所示:

if (SEND_TO_ALURE){
addStep(str);
}

Webhooks 和屏幕截图

我们的最后一步是在测试用例的末尾添加屏幕截图。您可以选择是否只在失败的测试用例上捕获屏幕截图。然而,根据我们的经验,我们认为无论是否失败都捕获屏幕截图将给您提供机会,在查看 Jenkins 中保存的历史运行时,了解通过和失败之间的差异:

    /**
     * Function to be executed after a test (in Mocha/Jasmine only)
     * @param {object}  test             test object
     * @param {object}  context          scope object the test was executed with
     * @param {Error}   result.error     error object in case the test fails, otherwise `undefined`
     * @param {*}       result.result    return object of test function
     * @param {number}  result.duration  duration of test
     * @param {boolean} result.passed    true if test has passed, otherwise false
     * @param {object}  result.retries   information about spec related retries, e.g. `{ attempts: 0, limit: 0 }`
     */
    afterTest: async function (
        test,
        context,
        {error, result, duration, passed, retries}
    ) {
      if (!passed) {
        await browser.takeScreenshot();
      }
    },

这是通过将前面的代码行添加到 WDIO.config 文件的 afterTest 钩子中实现的。

注意

onPrepareonWorkerStartonWorkerEndonComplete 钩子在不同的进程中执行,因此不能与其他钩子共享任何全局数据,这些钩子位于工作进程中。

摘要

在本章中,我们踏上了类似穿越超级英雄多元宇宙动态领域的英勇之旅。我们掌握了将测试场景引导到各种操作域的艺术——无论是 QA、预发布,还是当情况需要时,开发甚至生产。与此同时,我们向控制台日志中注入了一道彩虹般的色彩,就像一位斗篷骑士的鲜艳服装。我们的 Allure 报告,就像一个精心组织的工具带,现在以精确和清晰的方式显示信息。我们还解锁了数据文件的力量,保护了我们数字城市的钥匙——敏感凭证——免受邪恶敌人的窥视。

在这些多样化的环境中导航,如同守护者穿越平行宇宙的复杂任务——每个环境在轮廓上都很熟悉,但在内容上却独一无二。随着我们准备进入下一章节,我们将用超级英雄盾牌的韧性加固我们的测试,确保它们能够经受住可能已消失在虚空的缺失元素的考验。此外,我们将拓宽我们的视野,进入广阔的跨浏览器测试领域,确保我们的数字努力像变形英雄的能力一样多变。

第十三章:多宇宙 - 跨浏览器测试和跨环境测试

在本章中,我们将开始向浏览器操作系统和其他平台添加水平扩展的突变力量。这与垂直扩展形成对比,垂直扩展涉及向我们的套件添加更多测试,例如向一个隐藏在普通视线中的超级英雄基地添加更多楼层。水平扩展就像在城市街区上下扩展更多建筑。我们的测试可以在多个浏览器、版本、操作系统和其他平台上运行。这意味着如果我们使用 Mac 而不是 Windows PC,那么我们将有信心我们的应用程序和测试在我们选择的浏览器上运行良好。Chrome 通常是目标浏览器,因为 Windows 和 Mac 上的用户数量都很多。但许多 Mac 用户更喜欢 Safari,而 Windows 用户更喜欢 Edge。那么,我们如何确保这些组合得到测试?

正是在这里,独立的 Selenium WebDriver 服务变得非常有用。此服务用于在各个浏览器和平台上自动化测试过程,有助于识别可能在特定环境中出现的问题。利用此服务可以是一个创造性的解决方案,以简化测试自动化框架,因为它允许以更少的手动工作实现更全面的测试覆盖。然而,它也可能很快变得令人不知所措。

将其视为多个超级英雄宇宙之间的交叉。我们将扩展测试,从 Windows 机器上的 Chrome 扩展到 Edge,以及从 Mac 上的 Chrome 扩展到 Safari。然后,我们将使用基于云的解决方案来处理各种组合。

本章的主要内容包括:

  • 水平扩展

  • 通过wdio配置文件使用内置功能

  • 使用 LambdaTest 在线自动化浏览器测试网格

  • 使用 Selenium Standalone 服务器在本地构建测试网格

  • 避免水平扩展的兔子洞

  • 处理特定环境的逻辑

水平扩展 - 跨浏览器测试

有三种方法可以进行项目跨浏览器测试:

  • 通过wdio配置文件使用内置功能

  • 使用 LambdaTest 在线自动化浏览器测试网格

  • 使用 Selenium Standalone 服务器在本地构建测试网格

虽然我们将在本书中讨论所有三种方法,但我们的示例将使用wdio配置文件提供的内置功能来完成。

通过 wdio 配置文件使用内置功能

跨浏览器测试涉及设置测试环境,使用 TypeScript 中的 Jasmine 语法编写测试,并在不同的浏览器上运行测试。这是在 WebdriverIO 的配置文件中的能力部分完成的。我们将从 Chrome 扩展到 Edge 的能力部分。这也控制了将使用maxInstances参数并行启动多少个并发浏览器。

扩展 wdio 配置文件以支持多个浏览器

设置wdio.conf.ts以确保它定义了您的测试设置和浏览器功能:

   // wdio.conf.ts
   exports.config = {
     specs: ['./tests/**/*.spec.ts'],
maxInstances: 2,
capabilities: [
       {
         browserName: 'chrome',
       },
       {
         browserName: 'safari',
       },
       {
         browserName: 'edge',
       },
     ],
     framework: 'jasmine',
     jasmineOpts: {
       defaultTimeoutInterval: 60000,
     },
     Services:[
"chromedriver",
"safaridriver",
"edgedriver"
     ]
   };

在“服务”部分,我们必须提供与浏览器交互的驱动程序。chromedriver运行 Chrome 浏览器,这是我们一直在使用的。要驱动 Safari,将使用safaridriver。请注意,可以使用的并发浏览器数量受本地机器可用资源的限制。

以下是一个可以运行的测试类型示例:

   // test/example.spec.ts
   import { browser } from '@wdio/globals';
   describe('Example Test', () => {
     it('should open a website', async () => {
       await browser.url('https://example.com');
       const title = await browser.getTitle();
       expect(title).toContain('Example Domain');
     });
   });
Yarn

最后,我们必须通过运行以下命令在多个浏览器中执行测试:

yarn wdio wdio.conf.ts --spec ./test/example.spec.ts

这将在wdio.conf文件的能力部分配置的所有浏览器上执行前面的示例测试,即 Chrome、Safari 和 Edge。

处理浏览器特定问题

如果您的应用程序有浏览器特定的代码或问题,您可以使用条件检查或功能检测来优雅地处理它们。

测试响应性

除了功能测试外,确保您的应用程序在不同屏幕尺寸和设备上具有响应性并且运行良好。这需要一些高级平台支持。像 LambdaTest、Browser Stack 和 Sauce Labs 这样的公司提供定制环境配置,以确保我们的应用程序在不同的架构下正确运行。这包括 iOS 和 Android 移动设备、平板电脑和不同屏幕尺寸的笔记本电脑。在这里,尝试维护所有这些物理设备并保持最新更新可能变得不可行。

使用 LambdaTest 在线自动化浏览器测试网格

使用 LambdaTest 进行跨浏览器测试允许您在广泛的浏览器和操作系统上测试您的 Web 应用程序或网站。LambdaTest 是一个基于云的平台,它提供在虚拟机上运行的实时浏览器,使您能够在不设置本地物理设备或虚拟机的情况下进行全面的测试。

要使用 LambdaTest 进行跨浏览器测试,请按照以下步骤操作:

  1. 首先,您需要注册一个 LambdaTest 账户。一旦注册成功,您就可以访问 LambdaTest 仪表板:

图 13.1 – LambdaTest 仪表板

图 13.1 – LambdaTest 仪表板

  1. 在 LambdaTest 仪表板上,您可以选择要测试网站上的浏览器和操作系统。提供大量浏览器和版本,包括 Windows 和 macOS 等不同操作系统上的 Chrome、Safari 和 Edge,以及 iOS 和 Android 移动设备:

图 13.2 – LambdaTest 浏览器、操作系统和屏幕分辨率选择

图 13.2 – LambdaTest 浏览器、操作系统和屏幕分辨率选择

  1. 您可以选择在实时交互测试环境或自动化截图测试环境中运行测试。

实时交互测试

在此模式下,您可以实时与浏览器交互,就像使用物理设备一样:

图 13.3 – LambdaTest 为手动测试人员提供的实时交互式测试

图 13.3 – LambdaTest 为手动测试人员提供的实时交互式测试

您可以浏览您的网站,执行操作,并手动检查多个问题。交互式实时测试是现代测试自动化框架中的一个关键特性,与在执行过程中检查测试的关注点相吻合。

LambdaTest 提供的实时交互式测试功能允许测试人员在实时环境中与网站或网络应用程序进行交互。这反映了用户在物理设备上可能拥有的体验。

自动截图测试

在此模式下,LambdaTest 会自动在不同浏览器和操作系统上对您的网站进行截图。这对于快速检查以及查看您的网站在各种配置下的外观非常有用:

图 13.4 – 自动截图测试

图 13.4 – 自动截图测试

一旦您选择了浏览器和测试模式,您可以在 LambdaTest 中输入您的网站 URL 并开始测试过程。平台将打开带有所选浏览器的虚拟机,并加载您的网站进行测试。

在测试过程中,您可以检查元素,使用开发者工具,并调试您遇到的任何问题。您还可以截图并保存以供进一步分析和报告。

LambdaTest 提供详细的测试报告,包括截图和日志,这可以帮助您识别跨浏览器和操作系统配置中的任何差异。您可以将这些报告与您的团队分享,以便讨论和解决在跨浏览器测试过程中发现的问题。

他们还提供与各种测试和协作工具的集成,使将跨浏览器测试无缝集成到现有的开发工作流程中变得更加容易。通过使用 LambdaTest 进行跨浏览器测试,您可以确保您的网络应用程序在不同浏览器和操作系统上表现一致且优化。

使用 Selenium 独立服务器在本地构建测试网格

使用 Selenium 独立服务器进行跨浏览器测试允许您使用 Selenium WebDriver API 在多个浏览器和操作系统上测试网络应用程序或网站。独立服务器充当枢纽,连接到不同的浏览器并在其上执行测试脚本。

要使用 Selenium 独立服务器执行跨浏览器测试,请按照以下步骤操作:

  1. 从官方 Selenium 网站下载 Selenium 独立服务器 JAR 文件,并在您的机器或专用服务器上运行它。此服务器充当中央枢纽,管理浏览器会话并从您的测试脚本接收测试命令。

  2. 在 Selenium Standalone 服务器运行的机器上安装您想要测试的浏览器。确保您为每个浏览器安装了必要的浏览器驱动程序(例如,ChromeDriver 用于 Chrome,GeckoDriver 用于 Edge),并且它们已被添加到您的系统 PATH 中。

  3. 使用您首选的编程语言和 Selenium WebDriver 绑定(例如,JavaScript、Python、C#等)开发您的测试脚本。在您的测试脚本中,设置所需的配置能力以指定您想要测试的浏览器和操作系统配置。所需的配置能力定义了 Selenium Standalone 服务器应使用哪个浏览器、浏览器版本和操作系统进行测试。使用 Selenium WebDriver API 从 Selenium Standalone 服务器请求新的浏览器会话,指定所需的配置能力。然后,服务器将在配置的机器上启动指定的浏览器。

一旦建立了浏览器会话,您的测试脚本可以通过使用 WebDriver 命令与 Web 元素交互。您可以导航页面、点击按钮、填写表单以及执行其他操作来测试 Web 应用程序的功能和用户界面。在测试执行过程中,服务器将收集测试结果、日志以及在跨浏览器测试过程中遇到的任何错误。

使用共享配置文件的跨环境测试

跨环境测试涉及配置 WebdriverIO 在不同的环境中运行测试,例如测试和预发布环境。偶尔,这还可能包括开发和生产环境。这种方法允许您确保不同环境之间的兼容性和功能,帮助您在开发过程中早期发现潜在问题:

图 13.5 – 三个 wdio conf 文件共享一个公共配置文件

图 13.5 – 三个 wdio conf 文件共享一个公共配置文件

但我们不想在多个文件中重复所有设置。幸运的是,WebdriverIO 允许我们在所有环境中共享值。我们创建了一个shared.conf文件,其中包含所有跨所有环境共享的设置。如果需要更改任何设置,我们可以在单个位置进行必要的更改。

实现这一点的做法是为每个操作系统和环境创建单独的文件,例如windows.confmac.conf。我们将在云环境中使用lambdatest.conf进行此操作。

wdio.shared.conf.ts配置文件中,定义多个环境(例如,开发、测试和生产)并为每个环境设置适当的配置。以下是一个示例:

  // wdio.shared.conf.ts
/**
*  The baseUrl will only be used if you don't specify a url in your script
*  loadPage('/')
*  if you specify on then its ignored
*  loadPage('https://candymapper.com/')
*/
let baseUrl: string
let env = process.env.Env
let urls = {
    uat: 'https://the-internet.herokuapp.com',
    dev: 'https://candymapperr2.com/',
    prod: 'https://candymapper.com/'
}
baseUrl = urls[env]
   exports.config = {
     // ... other configurations ...
     baseUrl: baseUrl,
     // ... other configurations ...
   };

不论操作系统如何,每个浏览器都将导航到相同的 URL,而无需多次复制信息。

对于在本地机器上具有不同资源和配置的项目来说,这可能相当复杂。因此,下一步是利用云资源以确保所有测试配置的一致性,例如在 LambdaTest 上。这就是shared.conf文件在windows.confmac.conf以及像lambdatest.conf这样的基于云的服务中使用的方式。

以下是一个使用shared.conf.ts文件创建的windows.conf.tsmac.conf.ts文件的示例:

import { config as sharedConfig } from './wdio.shared.conf'
export const config: WebdriverIO.Config = {
    ...sharedConfig,
    ...{
        capabilities: [
            {
                browserName: 'chrome',
                'goog:chromeOptions': {
                    args: ['--disable-gpu']
 },
      acceptInsecureCerts: true,
      },
      {
       browserName: 'safari'
      }
      ]
    }
}

然而,LambdaTest.conf.ts或其他基于云的服务(SauceLabs、BrowserStack 等)将需要不同的配置集。

以下是一个使用shared.conf文件创建的基于云的服务示例:

import { config as sharedConfig } from './wdio.shared.conf';
export const config = {
    ...sharedConfig,
    ...{
        services: [
            ["lambdatest",
                {
                    tunnel: false,
                    lambdatestOpts: {
                        logFile: "tunnel.log"
                    }
                }
            ]
        ],
        user: process.env.LT_USERNAME,
        key: process.env.LT_ACCESS_KEY,
        capabilities: [
            {
                "LT:Options": {
                    browserName: "Edge",
                    version: "latest",
                    name: "Test WebdriverIO Single",
                    build: "WebDriver Selenium Sample"
                }
            },
        ],
        logLevel: "info",
        coloredLogs: true,
        screenshotPath: "./errorShots/",
        waitforTimeout: 100000,
        connectionRetryTimeout: 90000,
        connectionRetryCount: 1,
        path: "/wd/hub",
        hostname: process.env.LT_HOST_URL,
        port: 80
    }
}

在这个例子中,我们使用baseUrl变量根据在运行测试时设置的"Env=uat"环境变量来选择适当的环境:

使用配置中的baseUrl导航到每个环境的不同 URL:

   // tests/ch13.spec.ts
   describe('Cross-Environment Test', () => {
     it('should open the website', () => {
       browser.url('/');
       const title = browser.getTitle();
       expect(title).toContain('Example Domain');
     });
   });

在命令行中,我们可以更改测试运行的环境。在这个例子中,我们正在针对uat运行,它是the-internet,以及dev,在 Windows 上的 Chrome 和 Edge 浏览器中对应的是candymapperr2.com。最后,prod示例在 Mac 上的 Chrome 和 Safari 中针对candymapper.com

   Env=uat wdio wdio.conf.ts --spec ./test/specs/ch13.ts
   Env=dev wdio wdio.dev.conf.ts
   Env=prod wdio wdio.prod.conf.ts
   Env=uat wdio wdio.lambdatest.conf.ts --spec ./test/specs/ch13.ts

从这里,我们可以看到我们可能开始达到一个点,即我们试图支持大量操作系统、浏览器甚至旧版本的组合。仅此级别的架构支持本身将不可持续,因此下一步合乎逻辑的步骤是将测试迁移到云端。这为我们带来了一些独特的优势。当在云端环境中运行时,测试的控制台输出仍然可用:

图 13.6 – LambdaTest 终端窗口中的结果

图 13.6 – LambdaTest 终端窗口中的结果

在云端,测试用例可以被分配到在多个浏览器、版本和操作系统上运行,但无需配置和支持底层架构:

图 13.7 – 云端多个操作系统和浏览器中的测试用例结果

图 13.7 – 云端多个操作系统和浏览器中的测试用例结果

以下示例显示了我们可以针对的多个浏览器和操作系统。现在,如果我们点击一个单独的项目,我们可以深入了解特定系统的详细信息并运行结果:

图 13.8 – 在云端 Mac Monteray 上运行 Safari V.15 的测试

图 13.8 – 在云端 Mac Monteray 上运行 Safari V.15 的测试

虽然屏幕截图很棒,但观看整个录制视频更好。这可以清楚地了解测试运行的交互:

图 13.9 – LambdaTest 中运行的测试用例的视频截图

图 13.9 – LambdaTest 中运行的测试用例的视频截图

再次,视频存储空间和清理工作不太耗时。成本可以与有一个或两个团队成员专门负责开发、增强和维护在现场生成的大型文件相比,当这些团队成员本可以花更多时间编写更多测试用例、分析结果和编写缺陷时,这种成本变得难以承受。

避免陷入水平扩展的兔子洞

需要牢记 80/20 法则和三法则。当我们的客户只使用 20%的浏览器和操作系统组合时,我们不想尝试支持 80%的流行组合。当我们的客户只在 Windows 上使用 Chrome 时,尝试支持 Mac 上的 Safari 可能听起来很积极主动。在每一个环境中尝试在新的浏览器上进行回归测试,这变得对数级地不可能。你可能没有时间在所有浏览器和所有环境中执行所有测试用例。我们只想在更多用户使用的浏览器上进行测试,这可能是一个最多三组合:两个操作系统中的一个浏览器或一个操作系统中的两个浏览器。此外,如果我们试图确定为什么某个测试在一个浏览器或操作系统中运行而另一个失败的根本原因,那么时间可能会被用于创建新的测试。

处理特定环境的逻辑

如果你的应用程序有特定环境的代码或问题,请使用条件检查或功能检测来优雅地处理它们:

If (process.platform === 'mac'){
  // do something specific thats mac only
} else {
 // contine as usual
}

经验法则

尝试不要陷入在每一个浏览器和操作系统中都达到全通过的困境。扩展到额外的一个浏览器,然后是额外的一个操作系统。最好只对边缘配置进行烟雾测试。这很容易消耗你的时间,支持对数级地。

如果我们在测试环境中添加了一个新字段,但在生产环境中不存在,我们该怎么办?我们能构建一个支持两者的测试吗?在这个时候,我们可以引入一组新的IfExist()自定义命令。每个基本方法,包括click()setValue()select(),都将有一个相应的函数:clickIfExist()setValueIfExist()selectIfExist(),分别。我们还可以添加一个verifyIfExist()方法。目标是,而不是为每个环境有单独的测试版本,我们有一套测试,它高度可能达到旅程的终点,即使沿途有细微的差异。

多宇宙 – 一个测试,两个环境

优点是这些 IfExist() 方法如果对象不存在,则不会停止测试。我们的测试现在可以在存在新功能的新测试环境中执行,以及在功能尚未推送的生产环境中执行。例如,一个页面可能要求从长调查导航路径上的列表中选择一个月。在预发布环境中,这需要明确点击下一步按钮才能转到页面。然而,在质量保证(QA)中,下一步按钮被移除,一旦用户从列表中选择一个项目,页面就会隐式地继续:

Helpers.clickIfExists(await this.btnNext);

此实现有两种方法。首先,我们可以通过一个可选属性来增强 clickadv() 方法:

export async function clickAdv(element: WebdriverIO.Element, ifExists: boolean = false) {
// isExist code branch here ...
}

然而,这会导致代码在意图上不够清晰,可能会使用一个魔法布尔参数:

await Helpers.clickAdv(this.btnNext, true); // may not exist

相反,让我们创建一个带有附加 ifExists 的替代函数。此函数使用自动化交换机板告诉初始包装器,如果元素不存在,则采取不同的行动:

const IF_EXISTS = "IF_EXISTS";
export async function clickAdvIfExists(element: WebdriverIO.Element) {
ABS(IF_EXISTS) = true;
let result = await this.clickAdv(element);
ASB(IF_EXISTS) = false;
return result;
}

第二种方法是在检查元素是否有效时存储元素的状态。如果元素尚未在 beforeCommand 钩子中保存,我们还将保存元素的定位器:

export async function getValidElement(
  element: WebdriverIO.Element,
  elementType: string
): Promise<WebdriverIO.Element> {
...
  if (!found) {
    ABS.set ("ELEMENT_SELECTOR") = element.selector)
    await log(`  ERROR: Unable to find ${selector}`);
  }
  ASB.set ("ELEMENT_EXISTS") = found;
  return newElement;
}

最后,我们从 clickAdv() 方法立即返回:

if (ASB.get("ELEMENT_EXISTS") == false){
await log(`  IfExist: Skipping clicking
${ASB.get("ELEMENT_SELETOR")}`);
return true;
}

现在,我们只需添加 IfExists 就可以添加这个功能:

await Helpers.clickAdvIfExists(this.btnNext); // may not exist

我们可以用同样的方法来增强 setValueAdv() 方法:

export async function setValueAdvIfExists(
element: WebdriverIO.Element),
text: string
)
ABS(IF_EXISTS) = true;
let result = await this.setValueAdv(element, text);
c;
return result;
}
export async function setValueAdv(
  inputField: WebdriverIO.Element,
  text: string
) {
If (ABS(IF_EXISTS) == true)
return true;
}

我们必须做同样的事情来创建 selectValueAdvIfExists

export async function selectAdvIfExists(
element: WebdriverIO.Element),
text: string
)
ABS(IF_EXISTS) = true;
let result = await this.clickAdv(element);
ASB(IF_EXISTS) = false; // Reset for next element
return result;
}

现在,我们可以有足够健壮的测试,在略微不同的测试环境中运行,并且仍然到达端到端测试的结论。

例如,在下面的图中,我们有两个网站:

图 13.10 – 移除按钮元素的生产环境与预生产环境

图 13.10 – 移除按钮元素的生产环境与预生产环境

左侧的生产网站有一个联系我们按钮,该按钮将页面滚动到客户详情输入支持页面。

右侧是网站的新版本。请注意,此网站不包括进入 联系 我们按钮。

如果按钮存在,则可以点击按钮而不使测试失败,我们可以开始拥有在略微不同的环境中更加灵活的测试。如果按钮只存在于一个环境中,测试可以继续执行而不会在两个环境中失败。这改变了我们的关注点,从维护测试案例到提高达到最终路径的机会。最后,即使方法因为定位器不同而失败,接下来的几个步骤将在错误的页面上执行,但仍然会停止测试以进行维护。

摘要

在我们史诗般的旅程中,通过整合自动化控制台,我们为脚本解锁了新的超级能力。这种新发现的能力确保了我们的脚本能够像不断进化的超级英雄世界一样灵活适应。现在,它们可以在各种浏览器和操作系统上无缝运行,变得像超级英雄的工具箱一样多才多艺。

当我们翻到下一章激动人心的篇章时,准备好见证我们的网络英雄 WebdriverIO 飞向云端,进入基于云的测试自动化和排程的领域。就像超级英雄在天空中翱翔一样,我们将深入探索在云端执行测试的非凡领域。这一章节承诺将是一场扣人心弦的冒险,展示我们超级英雄脚本在征服测试世界的新的高度和挑战时所展现出的惊人潜力。

第十四章:时间旅行者的困境——基于状态的端到端用户旅程

本章讨论了不同但常见的一种测试自动化类型——逐页导航通过调查,以达到一个共同终点。这里的挑战是,用户在途中做出的决定可能会改变下游显示的页面顺序——如果它们甚至出现的话。在这种情况下,我们需要一个灵活的用户旅程,可以走许多分支到达一个共同终点,并且能够报告是否有路径以错误页面结束。尝试为端到端自动化创建一个不断扩展的页面路径是不高效的。if/then分支或select/case选项的数量将是无限的且复杂。为了解决这个问题,我们将探讨通过 URL 识别每一页的方法,并查看解耦页面路径的方法,允许测试以任何顺序处理顺序页面,同时保持最小维护。想象这种方法就像是一个球在大型弹珠游戏中的多个柱子之间随机弹跳。

本章涵盖了以下主要主题:

  • 按章节划分

  • 快乐路径

  • 使用getPageName()从当前 URL 中提取页面名称

  • 页面处理循环

  • 常见退出点

技术要求

所有测试示例都可以在这个 GitHub 仓库中找到:github.com/PacktPublishing/Enhanced-Test-Automation-with-WebdriverIO

分而治之!

在这种方法中,我们将考虑一种非确定性的导航方式,从起点到终点。我们不会基于一个页面跟随另一个页面的传统假设。相反,我们的脚本将反复通过每个潜在的页面,并且只有当当前页面匹配页面类之一时才会进行交互。然后,根据高级流程要求,我们可以做出不同的选择,甚至输入不同的和独特的数据。我们甚至可能会在途中发现一个错误!

在这个小型示例中,我们将自动化 Candymapper 网站的万圣节派对功能。这为顾客提供了一组有限的选项来计划主题派对。派对客人可以使用相同的网站参加或避免这些令人毛骨悚然的活动。以下路径包括客户和客人:

  • 客户可以选择举办僵尸主题派对

  • 客户可以选择举办幽灵主题派对

  • 客人可以选择参加 Zombieton 派对

  • 参加幽灵镇派对

  • 惊恐的客人可以退出到主持人或参加者选择页面

至少这些路径中的一个会在www.Candymapper.com生产网站上出错。这次旅程在新网站的发布中得到了修正,即www.CandymapperR2.com,以及页面顺序和为参加派对的客人提供的新的带朋友选项。

在这四条路径中的前四条中,我们将结束在共同的派对倒计时计时器页面。在最后一条路径中,我们将回到万圣节派对举办者或参加者页面。我们将遇到的页面包括以下操作:

  • 主页:清除弹出窗口,并在页面标题中点击万圣节派对

  • 万圣节派对页面:选择举办或参加派对

  • 举办派对页面:地点地址和“了解更多”按钮(仅限宾客路径)

  • 派对地点:一个可以选择 Zombieton 或 Ghostville 的页面

  • 参加派对页面:有两个地点选择和一个害怕返回按钮

  • 派对地点选择页面:Zombieton 或 Ghostville:

    • (仅限开发者)宾客名单

    • 一个带有“提醒我”按钮的电子邮件字段(仅限开发者)

  • 派对倒计时页面:最终目的地

此测试的核心是PartyPath()驱动程序,它接受一个可以解析以在多个里程碑处进行决策点更改的单个字符串。这个函数是一个巨大的循环,反复遍历每个已知的页面。对于每个页面,我们将创建一个对象模型和一个build()方法。此方法仅在当前页面与类页面类型匹配时执行。如果所有操作都成功,则返回true。如果当前页面不是此页面,则是一个null函数并返回false。此方法将引用一个预先配置了快乐路径数据的测试数据模型,可以从Switchboard对象中覆盖。这将允许从开始到结束修改页面流程中的选择。

简化动态旅程的复杂性

在现实世界的应用中,我们可能会在多个具有不同数据要求的求职申请页面中导航。一些决策点可能包括添加当前地址、军事状态、前雇主联系信息和个人推荐人。申请流程可能包括为 21 岁以下的人或需要证明专业驾驶执照以驾驶卡车的驾驶员提供额外的页面。对于单身、已婚、离婚或丧偶且有子女的人,多个路径可能会要求提供越来越多的信息。

挑战在于没有人能预测用户旅程中的下一页,最终将引导到最终成功的页面目的地。然而,每个里程碑都可以用高级术语来描述,这些术语表明在每个决策点应该做什么。

如果我们有一个术语字典,可以按几乎任何顺序传递给测试,描述申请人在现实世界工作中申请的类型?

  • 快乐路径:单身、平民、21 岁以上、无子女、无工作历史或推荐人

  • 已婚子女 cdl:此路径包括配偶、子女和 CDL 驾驶执照信息

  • 军事未成年 cu dd:使用公司已知的缩写词信用合作社直接存款

对于万圣节派对的例子,我们将遵循以下路径:

  • 快乐路径:举办一个以僵尸为主题的派对,倒计时计时器作为最终目的地

  • Attend/ ""Attend zombie"": 以僵尸主题的决策点参加派对,而不是默认的幽灵主题

  • Scared: 开始参加派对的路径,但返回到主选择“主机”或“参加”选择页面

这种方法的秘密是一个路径生成器,partyPath()。这是一个循环,它反复执行页面对象模型中每个页面的 build() 方法,直到达到最终页面。每个页面都会确定当前页面是否是处理页面。然后,使用测试数据与该页面的值进行交互。每个字段和列表填充方法将在它们各自的对象上执行。如果没有为特定输入字段提供数据,则执行交互。

退出无限循环将有四个原因:

  • 达到了最终的派对倒计时页面(快乐路径

  • 遇到了一个未知页面

  • 在两次循环尝试后,页面保持不变

  • 恐惧之旅会遇到“主机”或“参加”页面

最终,build() 方法将始终尝试显式地移动到下一页。在大多数情况下,这将是一个 Page 类,所有其他页面都从中扩展。在其他情况下,移动到下一页将是通过简单地输入最终所需的字段来实现,其中不会涉及前面章节中提到的任何 clickIfExists() 功能。目标始终是尝试到达过程的末尾,而不是寻找错误。

在某些情况下,页面可能不会继续前进,期望在做出选择后从用户那里获取更多信息。虽然这可能在同一路径内处理,但它可能是第二次遍历同一页面上检测到的路径。因此,如果在两次循环尝试后导航没有前进,测试将以用户路径不完整退出,并在 Allure 报告中详细说明问题。

我们需要添加的第一个组件是确定我们当前所在的页面。虽然这因项目而异,但我们经常发现可以在其 URL 中找到一个唯一的标识符。让我们从提取页面 URL 的最后一段中的唯一标识符开始:

要捕获框架导航到的 URL,getPageName() 函数将给出 URL 末尾的描述。以下函数将分别从上述 URL 返回 halloween-partyparty-location-1party-time

/**
 * Gets last segment of current URL after splitting by "/".
 * @returns {Promise<string>} The last URL segment.
 */
export async function getPageName(): Promise<string> {
  const currentURL = await browser.getUrl();
  const urlSegments = currentURL.split('/');
  return urlSegments[urlSegments.length - 1];
}

这将在主 partyPath() 循环中执行,并存储在自动化交换板对象的 page 键中。请注意,我们只使用 URL 的结尾部分,因为这允许我们在不同的环境中具有类似的功能:

每个页面类都包含可能出现在页面上的 pages 对象模型:

import Page from './page.tjs';
import * as helpers from "../../helpers/helpers.tjs";
/**
 * sub page with selectors for a specific page
 */
class HalloweenPartyPage extends Page {
    /**
     * define selectors using getter methods
     */
    public get hostParty () {
        return $(`//a[contains(normalize-space(),'ost')]`);
    }
    public get attendParty () {
        return $(`//a[contains(normalize-space(),'ttend')]`);
    }

注意,通用的定位器使用 normalize-space,并且如果首字母大小写发生变化,还会跳过第一个字母。这使得它们更加健壮,不太可能因为文本从发布到发布的变化而变得过时。

接下来,我们有一个对所有页面都通用的 build() 方法。这个方法首先会检查 URL 字符串中的标识符是否与页面匹配,如果不匹配则立即返回 false

public async build () {
   // Is this the page to process?
   if (await ASB.get("page") !== "attend-a-party") {
      return false // Not the right page
   }
   if (ASB.get("hostorattend") === `attend`){
     return await helpers.clickAdv(
         await this.attendParty);
   }
   return helpers.clickAdv(await this.hostParty);
}
export default new HalloweenPartyPage();

此路径示例默认将举办派对。在 switchboard 中的 HostOrAttend 里程碑可以被切换,使其成为一个参加派对的用户旅程。点击后,出现的下一页将根据我们点击的按钮以及运行此测试的环境而有所不同。因此,我们在哪里解耦用户旅程中预期的页面至关重要,因为这可能并不总是相同的。然而,我们需要从某个地方开始,那就是 Happy Path。

我们将要引入的最后一个特性是一个 beforeEach() 函数,这将在编写测试时显著减少我们的代码。由于我们有多个测试用例,并且每次都编写 jsloginPage.open(``)。这可以通过将函数放在 beforeEach() 函数中来实现。这个重构执行的是完全相同的过程,但代码行数更少:

describe("Ch14: State-drive Automation - Host a Party (Default in Ghostville)",  () => {
  it("should loop around until the final page is found", async () => {
    await LoginPage.open(``);
    await stateDrivenUtils.partyPath("Host");
  });
});
describe("Ch14: State-drive Automation - Host a Party (Default in Ghostville)",  () => {
  it("should loop around until the final page is found", async () => {
    await LoginPage.open(``);
   await stateDrivenUtils.partyPath("Host");
  });
});

在这个重构版本中,我们将 await ```jsLoginPage.open(``)```` 移入 beforeEach(async ()` 函数中。现在,这段代码将在每个测试用例之前执行。

beforeEach(async () => {
  await LoginPage.open(``);
});
describe("Ch14: State-drive Automation - Host a Party (Default in Ghostville)",  () => {
  it("should loop around until the final page is found", async () => {
    await stateDrivenUtils.partyPath("Host");
  });
});
describe("Ch14: State-drive Automation - Host a Party (Default in Ghostville)",  () => {
  it("should loop around until the final page is found", async () => {
    await stateDrivenUtils.partyPath("Host");
  });
});

请记住,这是因为我们不是在构建一个从一个页面跳转到另一个特定页面的测试。我们只是在填充当前活动的页面,尝试在循环中移动到下一个页面。让我们看看我们的状态驱动流程驱动程序 pathyPath(testData) – 它将导航到我们网站的里程碑决策点。

Happy Path

如果 testData 参数为空,则将执行默认的 Happy Path。这配置在 shared-data 文件夹中的 userData.json 文件中。这两个里程碑包括 HostOrAttendlocation

}
  "journeyData": {
    "_hostOrAttend_comment": "'host' (default Happy Path), 'attend' or 'scared' ",
    "hostOrAttend: "host",
    "_location_comment": "'zombieton' (default Happy Path) or 'ghostville' ",
    "location": "zombieton",
  }
}

经验法则 - 记录 JSON 文件

任何值得尊敬的开发者都不会在没有一些手头文档的情况下设计数据文件。然而,JSON 文件不允许我们像在 JavaScript 文件中使用双斜杠(//)那样添加注释。解决方案是添加一个以下划线开头并以单词“comment”结尾的匹配键。现在,每个键都可以被记录下来,以确保团队中的其他人可以理解设计背后的意图。

此文件首先被提取到 Switchboard 对象中。然后,任何覆盖的值都会从 testData 参数或 JOURNEY 系统变量中解析出来:

  public parseTestData(testData: string = '') {
    if (process.env.JOURNEY !== undefined) {
      testData = process.env.JOURNEY;
    }

在这一点上,我们必须为未来做计划。在我们的最后一章中,我们将使用JOURNEY系统变量从 Jenkins 驱动这些测试用例。如果这个变量被填充,它将覆盖testData参数。

当我们构建这个驱动程序时,我们应该有一个已知公司术语的列表,这些术语可以用来调整里程碑决策点。

在我们的示例中,我们有hostattend作为路径动词,以及zombieghostscared作为决策点:

  if (testData != "") {
      testData = " " + testData.toLowerCase(); // Add space to make sure we match whole words and convert to lowercase once
// Overriding default values
      if (testData.includes(" host")) {
        ASB.set("hostOrAttend", "host");
      }
      if (testData.includes(" attend")) {
        ASB.set("hostOrAttend", "attend");
      }
      if (testData.includes(" zombie")) {
        ASB.set("location", "zombieton");
      }
      if (testData.includes(" ghost")) {
        ASB.set("location", "ghostville");
      }
      if (testData.includes(" scared")) {
        ASB.set("location", "scared");
      }

接下来,默认数据值会被解析到 ASB 交换板中:

    parseToASB("path/to/userdata.json")

然后,testData字符串中的任何覆盖key:value数据都会被解析到 ASB 对象中:

    parseToASB(testData)

在测试数据字符串内部,我们将允许测试为我们的交换板中的键分配特定的值。例如,我们可能想要将邮编设置为12345。在这种情况下,我们需要一个正则表达式来解析与等号相连的值。带有和不带有空格的字符串可以是be address=""123 main""以及zip=12345

export function parseToASB(testData: string) {
  const regex = /(\w+)=("([^"]*)"|\b\w+\b)/g;
  let match;
  while ((match = regex.exec(testData)) !== null) {
    let key = match[1];
    let value = match[2];
    // Remove quotes if present
    if (value.startsWith('"') && value.endsWith('"')) {
      value = value.slice(1, -1);
    }
    let keyLower = key.toLowerCase();
    let oldValue = ASB.get(keyLower);
    // Always save value as a string
    ASB.set(keyLower, value);
    if (oldValue !== undefined) {
      console.log(`ASB(«${keyLower}") updated from "${oldValue}" to "${ASB.get(keyLower)}"`);
    } else {
      console.log(`ASB(«${keyLower}") set to "${ASB.get(keyLower)}"`);
    }
  }
}
import fs from 'fs';
import xml2js from 'xml2js';
export async function parseXMLFileToASB(filePath: string) {
  const data = fs.readFileSync(filePath);
  const result = await xml2js.parseStringPromise(data);
  for (let key in result) {
    let newValue = result[key];
    let oldValue = ASB.get(key.toLowerCase());
    // Always save value as a string
    ASB.set(key.toLowerCase(), newValue);
    if (oldValue !== newValue) {
      console.log(`ASB(«${key.toLowerCase()}") updated from "${oldValue}" to "${newValue}"`);
    }
  }
}

虽然testData字符串参数告诉我们每个里程碑点将发生什么变化,但这个信息首先被转移到 ASB 交换板对象中,所有页面在执行时都会引用这个对象。数据文件将包含填充到交换板中的默认值,然后testData被解析以自定义这些值。为了准备我们的最后一章,这个函数还会从JOURNEY系统变量中获取数据,该数据将由 Jenkins 发送:

import { ASB } from "../../helpers/globalObjects";
import candymapperPage from "../pageObjects/candymapper.page";
import halloweenAttendPartyPage from "../pageObjects/halloweenAttendParty.page";
import halloweenPartyPage from "../pageObjects/halloweenParty.page";
import halloweenPartyLocationPage from "../pageObjects/halloweenPartyLocation.page";
import halloweenPartyThemePage from "../pageObjects/halloweenPartyTheme.page";
import halloweenPartyTimerPage from "../pageObjects/halloweenPartyTimer.page";
import * as helpers from "../../helpers/helpers";
import AllureReporter from "@wdio/allure-reporter";
import Page from "../pageObjects/page";
class StateDrivenUtils extends Page {

主要驱动循环

partyPath()方法实际上是一个实用工具,而不是一个测试,因此我们将它存储在项目根目录下的Utilities文件夹中:

export async function partyPath (testData) {
    let complete: Boolean = false;
    let lastPage: string = ""
    let retry = 2
   this.parseTestData(testData);  // Parse the known milestone verbs                                   // to the switchboard
    helpers.parseToASB(testData);   // Parse the key=value data to set                                     // the switchboard

下一步是导航万圣节派对的起始路径。这需要处理每个页面的is a循环,重复寻找四个出口点之一:

    while (complete === false) { // Loop until final page
      //Get Page Name
        let pageName = await browser.getUrl();
        pageName = extractPathFromUrl(pageName)
        ASB.set("page", pageName);

这将当前页面的名称放入交换板中。这个对象将在每个页面的build()方法中被读取。接下来,我们将使用逻辑OR调用每个已知的页面,以确定是否有任何页面被成功识别:

   // Pass through every known page
knownPage =
        await halloweenLocationPage.build() ||
        await halloweenAttendPartyPage.build() ||
   await halloweenHostPartyPage.build() ||
   await halloweenPartyPage.build() ||
// Add new pages along the journey here.

注意,这些页面不需要按照任何特定的顺序排列。实际上,我们的设计将第一个处理页面列表放在最后,其他页面则大致按照相反的顺序排列。这样做通常可以确保每次循环只解析一个页面。每次循环执行多个页面也是可以接受的。

经验法则——重构

在这个小型示例中,我们只使用了六个页面。现实世界的项目可能有 50 到 100 个网页需要导航。始终建议将代码缩减为更小、更易于管理的单元。在这种情况下,页面列表越长,就越应该将其重构为它们自己的函数。

现在我们添加四个出口点中的第一个。注意,在前面的页面列表中,有一个缺失——最后的倒计时页面。这是我们的旅程结束的成功页面。此时,其他任何东西都不再重要:

// Exit Point #1: Success reached the timer page
   if (await halloweenPartyTimerPage.build()) {
      knownPage = true; // Skip Exit point 2
      console.log("Success: Reached the timer page")
      complete = true; // Exit the loop
 break;
   } // End of all paths except unknown page

如果这个页面被处理,我们报告旅程成功的结束。它还设置了knownPage标志为true,以便在函数结束时进行额外的报告或清理。然后,我们退出。

我们在这里的工作已经完成,除非有什么未知的东西潜伏在那里?

// Exit Point #2: Unknown page encountered
   if (knownPage === false) {
     //None of the build methods returned true
     AllureReporter.addAttachment(`Unknown Page detected: ${pageName}`, "", "text/plain");
    console.log(`Unknown Page detected: ${pageName} - Exiting Journey`);
    expect(pageName).toBe("a known page");
    break;
}

在这种情况下,没有页面返回true。我们输出未知页面的名称。我们还使用一个巧妙的expect将测试设置为失败状态,同时进一步记录手头的问题,即新页面的 URL。在我们的例子中,当访客因为害怕参加派对而期望返回时,生产网站Candymapper.com将生成一个Error 404页面。这个页面从未被期望过,但如果我们能够截屏它。

接下来,我们需要处理一种情况,即页面并没有从上一个页面前进:

// Exit Point #3: Page did not change
if (lastPage === pageName) {
  retry--; // Give two additional attempts
  if (retry === 0) {
    console.log(`Page did not change: ${lastPage} - Exiting Journey`);
    expect("Page did not move on from").toBe(lastPage);
  } else {
     // Page moved on, reset retry for next page
     retry = 2;
  }
}

作为超级英雄,我们总是对第二次机会持开放态度。在这种情况下,一个页面本身可能有两种或三种状态。一个销售点页面在购物车为空时可能添加一个产品,而在购物车有一个项目时添加不同的商品,然后继续前进。可能需要调用这样的方法两次,以提供灵活性。

最后,可能有一个期望以替代页面作为其最终目的地的旅程。这种情况很少见,但作为一个选项提供。这条路径是我们返回到过程中的一个先前步骤。这之所以有效,是因为页面在循环的第一步中被识别和处理,在到达这个点之前移动到下一个页面:

// Exit Point #4: We were scared and went back - Halloween Party Home page reached - only works in dev, prod has an intentional Error 404 issue.
if (ASB.get("page") === "halloween-party") {
   console.log("Halloween Party Home page reached")
   complete = true;
}

这就是所有的退出点吗?我们确信还有其他方式旅程必须提前结束。我们可以想到另一个例子——一个无限循环的旅程。这需要跟踪所有访问过的 URL 数组。当页面第三次被访问时,它会被触发。我们已经给了你所有实现这一点的技巧和窍门。现在,是时候自己解决这个问题了。

在再次循环之前,我们的最后一步是将当前页面名称设置为最后一个页面名称:

      lastPage = ASB.get("page"); // Save the last page name
    }

这确保了在确定我们是否卡在同一个页面上时,退出点#3 能够正确工作。

接下来,使用测试数据文件对象来确定哪些字段被填充。如果测试数据对象不包含特定字段的资料,我们会在结果中添加一个警告,但测试会继续。

最后,执行前进到下一个页面的方法。请注意,我们将使用ClickIfExits()方法来确保我们的引擎即使在未来的测试环境中元素不存在的情况下也能保持稳健。

在循环中,我们检查页面是否通过递增计数器(从 2 开始)前进。如果页面在第一次循环中没有前进,我们减少循环计数器。如果循环计数器达到 0,我们未能成功前进,并退出。如果连续的循环导致新的页面 URL,计数器将重置以执行两个额外的循环。

一些页面可能有代码可以解决由不完整数据生成的问题。这可能在页面build方法的第一次执行中未被检测到;因此,在放弃测试用户旅程之前,必须至少执行build方法两次。

完成每个页面的这个过程后,我们可以开始添加里程碑,基于从JOURNEY用户变量传递的值。在这个例子中,我们将有一个默认计划僵尸派对的路径,但将通过在JOURNEY字符串中传递attend来切换到用户参加派对。这将反过来注入到测试数据对象中,并由适当的页面读取。

如果我们遇到一个新页面,测试将停止。我们将向 Allure 报告页面的 URL,并提供结果的屏幕截图。我们的报告自动告诉我们导致那个新端点的路径。

一切都在细节中

同样,我们可以在我们的页面报告中通过屏幕截图检测 URL 中的字符串error,或者我们可以这样捕获屏幕上的所有文本 – const allText = await $('body').getText();

捕获屏幕文本的另一种方式是将Ctrl-A / Ctrl-V发送到浏览器,并将剪贴板发送到 Allure 报告:

import { clipboard } from 'electron';
const operatingSystem = process.platform;
let selectAllKeys: string[];
let copyKeys: string[];
if (operatingSystem === 'darwin') {
  selectAllKeys = ['Command', 'a'];
  copyKeys = ['Command', 'c'];
} else {
  selectAllKeys = ['Control', 'a'];
  copyKeys = ['Control', 'c'];
}
await browser.keys(selectAllKeys);
await browser.keys(copyKeys);
const allText = clipboard.readText();

这使得我们只捕获浏览器可见文本的可能性更大。

这个循环还提供了与任何已知页面交互的机会,无论它在路径中的顺序如何。考虑在 candymapperR2 网站上举办派对的这个路径:

  1. 举办派对

  2. 派对场地地址确认

  3. 派对主题

  4. 派对倒计时

考虑参加派对的路径:

  1. 参加派对

  2. 选择派对场地或返回

  3. 派对场地地址

  4. 派对倒计时

注意,这两条路径遇到了相同的派对场地地址页面,但来自不同的页面。此外,这两条路径与生产 Candymapper 网站不同。因此,我们可以在两个具有不同路径但目标相同的环境中运行相同的测试用例。

我们最终的路径是设置我们的最终预期页面。在这个例子中,路径返回到PlanAttend页面,指示用户旅程;尽管它没有结束在共同端点,但仍然是一个成功。

改变决策点

我们通过单个环境变量传递自定义路径的字符串,如下所示:

Set JOURNEY=""; yarn ch15

这样,我们可以创建多个测试路径。在这个例子中,用户没有参加派对,而是点击了我害怕按钮:

Set JOURNEY="attend scared"; yarn ch15

在这个例子中,用户选择了以僵尸为主题的举办派对的路径:

Set JOURNEY="Host ZOMBIE"; yarn ch15

单个环境值可以修改 Happy Path 基线中的多个决策点。虽然默认情况下空字符串将创建 Happy Path,但最佳实践是分配字符串,以便路径在结果中显示,解析不区分大小写。

let journey: string = " " + (process.env.JOURNEY || "Host").toLowerCase();
if (journey===" ")) {
    journey = " host"; //Default Happy Path
}

注意,在旅程变量之前有一个前置空格。这样做的原因是为了降低与路径中类似字符串匹配的可能性。我们有一个host命令和一个ghost派对。编写这一行代码可能会从ghost字符串中获取host命令:

Set JOURNEY="Attend Ghost"; yarn ch15
if (journey.includes(" host").toLowerCase()) {
// Host path being taken in error.
}

这已经解决了,因为每个被执行的命令总是有一个前置空格。现在,不正确路径匹配的机会更少了。以我们的示例为例,我们将更进一步,将Host作为默认的 Happy Path,将Attend作为路径的偏差:

if (journey.includes(" attend").toLowerCase()) {
// Attend a party path, case insensitively
Helpers.click(attend)
} else {
    // The JOURNEY variable contains "host" Happy Path
Helpers.click(host)
}

这就完成了在第一页上选择决策点的第一种方法。函数退出并循环。下一页可能是下一个,但不必按顺序排列。它可能位于循环列表的更下方。所有后续的页面都不会匹配 URL,并且build()方法将简单地立即返回一个空函数。下一页可能在循环的更早位置。它会在这一页之前作为空函数执行,现在随着循环从顶部再次开始执行。

洗,冲,重复

同样的原则适用于修改页面类中的数据类型。例如,自定义的date令牌可以像这样传递:

Set JOURNEY="host <today+7>"; yarn ch15
const match = journey.match(/(<.+>)/);
const dateToken = match ? match[0] : "";
Helpers.setValueIfExists(dateField, dateToken);

现在,我们已经从旅程值中提取了一个日期令牌。如果存在,令牌将传递到dateField,并设置为下周的日期。如果没有令牌,日期将设置为空字符串,方法将立即返回,因为没有要做的事情。

到目前为止,我们已经涵盖了非确定性引擎的所有过程。新页面将在结果中报告,并且必须添加以扩展路径覆盖率。如果一个页面在特定路径中从未遇到,它不会停止测试。如果遇到错误,它将被报告。如果路径卡住,它也将被报告。数据和路径可以自定义。这些构建方法可以从其他测试用例中调用。

为什么不用 API 调用生成这些工件呢?

使用 API 调用进行任务确实意味着更快、更稳定的测试,因为它可以直接与应用程序通信。它可以在开发过程的早期实现。这可能导致问题的早期发现,使开发过程更加灵活和敏捷。

相反,使用自动化的 GUI 可以提供对用户体验的更深入理解,因为它可以模拟用户可能采取的确切路径,包括与 API 测试可能忽略的视觉元素的交互。这种方法可能更直观,并且可以涵盖更广泛的分析,包括外观和布局,这对于用户满意度至关重要。

实际上,没有理由支持一种方法而不是另一种方法。GUI 方法确认系统对于用户来说是正确工作的。API 方法以更快的速度生成工件。我们可以通过在旅程解析代码中添加另一个关键字值来实现方法之间的差异:

Set JOURNEY="military references api"; yarn job-app-engine
if (journey.toLowerCase().includes(" api)) {
// API path
} else {
// GUI path
}

在这个示例中,默认的 GUI 方法在相同的build()方法中被 API 路径覆盖。

摘要

在本章中,我们深入探讨了一种复杂的自动化测试方法,这种方法让人联想到一个超级英雄在为动态且复杂的任务制定策略,这些任务基于所做选择有多种可能的结局。我们的方法涉及一个循环,它不断地导航到每一页,仅在识别到已知资源时才进行交互。旅程是非确定性的,由高级目标塑造,偶尔会遇到意外的障碍,如死胡同或逻辑循环。

这种方法很灵活,可以根据各种变量调整用户路径,并且可以适应不同的环境,突显其鲁棒性和多功能性。它还有助于为手动测试人员生成数据,与 Jenkins 等工具配合使用时,可以显著减少创建测试数据记录所需的时间和精力,从而提高整个测试过程的效率。

在最后一章中,这种方法将具有额外的优势,使手动测试人员更加高效。最糟糕的想法是假设自动化取代了“昂贵”的手动测试人员。我们应该始终努力增强他们的工作。当手动测试人员只需要验证结果时,他们可以减少创建复杂测试工件所需的大量设置时间。但我们真的希望他们安装编码工具并教他们如何运行这样的脚本吗?这就是我们使用我们最后的神奇物品,让 CI/CD 工具提供一种轻松完成繁重工作的方法的地方。也许像一件神秘的感知悬浮斗篷?

第十五章:感知斗篷——使用 Jenkins 和 LambdaTest 在 CI/CD 管道中运行测试

你是否曾好奇一些超级英雄是如何白天成为法庭律师,晚上成为犯罪斗士的?他们何时才能小憩片刻?

在本章的最后,我们将通过安排测试执行来使我们的 WebdriverIO 脚本得以执行。这可以通过在持续执行环境中使用执行管道来运行我们的作业来实现,这些作业在虚拟化的云环境中运行,而不是在我们的本地操作系统OS)中运行。想象一下,这是一个似乎有自己的思想的配件——一个永远警觉的沉默助手,其唯一目的是通过从你的工作中移除耗时任务来帮助你,通常在你睡觉的时候。这就是我们将介绍 Jenkins 和 LambdaTest 持续集成CI)以及跨操作系统使用的地方。

在我们开始之前,让我们回顾一下在 第一章 中提到的内容——自动化需要比平均计算资源更多。虚拟化需要更多。我们在这本书中使用了两个机器来编写代码:一个 Windows 11 系统——即一个微星国际MSI)雷神 GE76 12UE 游戏机,专门配备 2 TB 的 SSD 硬盘空间和 64 GB 的 RAM。它配备了 12 代英特尔 i9-12900H 核心 CPU,基速为 2.90 GHz。CPU 配备了 14 个核心,支持 20 个线程。我们还使用了两个配备 M1 芯片的 Apple MacBook Pro,使用 Parallels 虚拟化了一个 Windows 操作系统,硬盘容量为 1TB。

快速提示——如何对你的 PC 进行剖析

没有一个工具能在一个方便的位置给你系统洞察力,你不可能成为一个好的侦探。我们在 Windows 上使用了超过 15 年的剖析器是 Belarc Advisor 个人版。

我们这样做只是为了确保资源耗尽的可能性很小,从而避免随机瞬态问题,这些问题可能会导致假阳性。如果你的 RAM 可用性低于 15%,你很可能会遇到问题。如果你的 Windows 机器有一个表示需要升级的红点,请给它应有的优先级。

本章将涵盖以下主要主题:

  • 什么是 Jenkins 和 Slack

  • 安装 Jenkins

  • 使用 Jenkins 创建 WebdriverIO 项目

  • 从 Jenkins 报告 Slack

  • 按需和计划套件运行

  • Jenkins 上的调试运行类型

  • CI/CD 管道

技术要求

为了完成本章,你必须满足以下技术要求:

  • 安装和配置 Jenkins

  • 配置 Slack 以接收消息

  • 修改 wdio.config.ts 文件

  • 创建一个 Jenkins 作业运行单个测试并向 Slack 报告

  • 将测试组织到类别中

  • 创建一个 Jenkins 作业来运行每个类别

  • 将测试组织到 Sanity、Smoke 和 Regression 套件中

  • 创建一个 Jenkins 作业来运行每个套件

  • 设置和配置作业以每晚运行

  • 手动测试人员可以按需运行参数化作业

所有测试示例都可以在本书的 GitHub 仓库中找到,地址为 github.com/PacktPublishing/Enhanced-Test-Automation-with-WebdriverIO

什么是 Jenkins 和 Slack?

在测试自动化的领域中,尤其是在使用 WebdriverIO 等框架时,Jenkins 和 Slack 由于其功能而占据重要位置,这些功能简化并增强了测试部署过程。它们可以被配置为在测试运行完成后向 Slack 频道发送更新消息。Jenkins 可以安排测试套件的运行,或者任何团队成员都可以按需启动。

我们将以此章的最终部分开始,为 Jenkins 安装做准备。

为 Jenkins 安装 OpenJDK

在安装 Jenkins 之前,我们需要确保我们拥有正确的 Java 开发工具包版本。应使用 OpenJDK 版本 17,并建议使用最新版本。Java 17 SDK 可以从 www.oracle.com/java/technologies/downloads/#java17 下载。

在 Windows 上安装 Java 17 最简单的方法是下载并执行 MSI 文件,并将其保存到 Program Files 文件夹下的默认路径。复制路径并将其添加到 JAVA_HOME 环境系统变量路径中:

图 15.1 – 在 Windows 系统变量中添加路径

图 15.1 – 在 Windows 系统变量中添加路径

然后,将 %JAVA_HOME%/bin 添加到 Path 变量中:

图 15.2 – 将 JAVA_HOME\bin 添加到 Windows 路径变量

图 15.2 – 将 JAVA_HOME\bin 添加到 Windows 路径变量

接下来,我们必须确认 Java 已安装。

从命令提示符,输入以下内容:

> java -version

您将看到以下输出:

图 15.3 – 验证已安装 Java 版本 17

图 15.3 – 验证已安装 Java 版本 17

现在,我们可以开始安装 Jenkins。

将 Jenkins 作为独立应用程序安装

重要提示:Jenkins 将为管理员账户提供一个临时密码。在安装完成后,我们需要保存此信息。

安装 Jenkins

安装 Jenkins 相对直接。从 www.jenkins.io/download/ 下载适用于您的 Mac、Windows 或 Linux 操作系统的最新版本的 Jenkins,并使用默认路径进行安装。

下载完成后,将 jenkins.war 文件拖到桌面。从命令提示符,导航到桌面并输入以下内容:

> java –jar jenkins.war

您将看到以下输出:

图 15.4 – 在此示例中选择 LocalSystem。在现实世界中,IT DevOps 团队会为安全起见安装管理员域用户账户

图 15.4 – 在此示例中选择 LocalSystem。在现实世界中,IT DevOps 团队会为安全起见安装管理员域用户账户

当服务以 LocalSystem 身份安装时,建议更改为本地或域用户凭据。接下来,我们必须设置端口号。使用默认端口号8080并测试我们是否得到分配端口号的绿色勾选标记:

图 15.5 – 使用默认端口号 8080 并测试我们是否得到绿色勾选标记

图 15.5 – 使用默认端口号 8080 并测试我们是否得到绿色勾选标记

如果端口被阻止,则需要将其打开;否则,必须分配另一个开放的端口号。

下一页指定自定义设置功能。保持原样并点击我们在 Windows 中之前设置的JAVA_HOME环境变量。否则,设置 JRE 安装的路径:

图 15.6 – 点击安装,Jenkins 将准备就绪

图 15.6 – 点击安装,Jenkins 将准备就绪

我们的服务器 Jenkins 现在可通过https://localhost:8080/访问,并需要我们之前提供的临时管理员密码进行输入,然后为了安全起见进行更改。生成的凭据所在的日志文件路径列在网页上。

Jenkins 将询问您是否希望安装建议的插件:

图 15.7 – 安装建议的插件

图 15.7 – 安装建议的插件

继续安装建议的插件:

图 15.8 – 建议的插件

图 15.8 – 建议的插件

最后,我们必须创建第一个管理员用户的账户凭据:

图 15.9 – 创建第一个管理员用户

图 15.9 – 创建第一个管理员用户

点击保存并继续。Jenkins 将再次提供其 URL。最后一次点击保存并继续。点击开始使用 Jenkins将被重定向到主页面。我们建议您将此链接保存到浏览器书签栏。我们忠诚的 Sentient Cape 现在准备好执行我们的命令:

图 15.10 – Jenkins 主页面

图 15.10 – Jenkins 主页面

在这里,我们可以创建新的项目和作业,并按需或按预定的时间表运行它们。有了这个,现在是时候运行我们的第一个测试作业了。

使用 Jenkins 创建 WebdriverIO 项目

从主仪表板视图,点击+新建项目按钮。Jenkins 为我们提供了几个项目选项和组织工具。Freestyle 项目是一种简化的构建作业方法,而Pipeline则允许使用 Groovy 编程语言进行更细致的定制:

图 15.11 – 创建 Freestyle 项目

图 15.11 – 创建 Freestyle 项目

对于我们的目的,Freestyle 项目就足够了。将显示几个选项。我们只需要几个选项就能从第二章将我们的 GitHub源代码管理器SCM)中的内容检出并运行:

图 15.12 – 添加构建步骤

图 15.12 – 添加构建步骤

构建步骤部分,选择添加构建步骤以执行终端命令。对于 Mac,选择执行 shell,对于 Windows,选择执行 Windows 批处理命令选项。使用命令提示符,我们将导航到工作区路径。

接下来,添加一个运行 npm 命令构建步骤。当我们运行 LambdaTest 中的测试时,这是必需的。在本节中,我们将执行package.json文件中列出的命令。在此示例中,我们将使用npm ch2运行第二章中的测试。

但在那之前,我们需要添加一个最后的附加组件来支持工作通知。

安装 LambdaTest 的 Jenkins 插件

要安装 LambdaTest 的 Jenkins 插件,请按照以下步骤操作:

  1. 点击管理 Jenkins,然后管理插件

  2. 点击可用选项卡。

  3. LambdaTest

  4. 您将看到一个插件列表;选择LambdaTest

  5. 要安装LambdaTest Jenkins 插件,您必须选中LambdaTest前面的复选框。一旦插件安装完成,并且 Jenkins 已重新启动,您将在已安装插件下找到 LambdaTest Jenkins 插件:

图 15.13 – 搜索 LambdaTest Jenkins 插件

图 15.13 – 搜索 LambdaTest Jenkins 插件

使用 Jenkins 配置 LambdaTest

按照以下步骤配置 LambdaTest 与 Jenkins:

  1. 在 Jenkins 主页上,点击凭据

  2. 凭据下,点击系统

  3. 系统页面上,点击全局凭据(不受限制)域。将打开全局凭据页面。

  4. 点击添加凭据。将打开添加凭据页面。

  5. 在字段中输入相关数据,然后点击验证凭据。验证后,点击确定按钮。Jenkins 将生成一个 ID,该 ID 在凭据页面上可见。

  6. 保存您的更改:

图 15.14 – 向 LambdaTest 插件添加必要的凭据

图 15.14 – 向 LambdaTest 插件添加必要的凭据

一旦添加了您的凭据,Jenkins 将生成一个 ID。要为 LambdaTest 凭据检索此 ID,您必须转到 Jenkins 主页并点击左侧导航菜单中的凭据

从 Jenkins 主页,点击左侧菜单中的凭据。您可以复制 LambdaTest 凭据的 ID。

创建一个自由式项目和作业

从 Jenkins 仪表板,点击使用 WebdriverIO 增强测试自动化作为名称,并选择自由式项目

这将显示几个选项卡,包括常规源代码管理构建触发器构建环境构建构建后操作。我们将选择使用本地机器上的代码进行运行。高级用户可以使用基于云的企业 Jenkins 从私有仓库检出代码:

图 15.15 – 为手动测试人员设置基于状态的测试运行

图 15.15 – 为手动测试人员设置基于状态的定制测试运行

要运行构建,我们需要测试名称和项目目录的路径。

参数化

在作业配置中,我们将添加一个参数,我们将从 Jenkins 传递到我们的自动化框架中,命名为 JOURNEY。这将成为一个我们的框架期望并解析以确定托管或参加万圣节派对路径的系统变量。此外,我们还有一个选择参数,允许我们将 devprod 传递到 Env 系统变量中。这将改变此测试的着陆页 URL:

图 15.16 – 为手动测试人员设置基于状态的定制旅程测试

图 15.16 – 为手动测试人员设置基于状态的定制旅程测试

接下来,我们将拥有一个精简版的状态驱动自动化测试,这是我们之前章节中构建的:

import LoginPage from "../pageObjects/login.page";
// Host or Attend a party in Ghostville or Zombieton
import stateDrivenUtils from "../utilities/stateDriven.utils";
describe("Ch15: State-drive Automation from Jenkins", () => {
    it("should loop around until the final or first page is found", async () => {
        // Get the test data from the JOURNEY environment variable
        let env = process.env.ENV || "prod";
        await LoginPage.open(env);
        let testData = process.env.JOURNEY || ""; // Get test data from JOURNEY environment variable set by Jenkins
        stateDrivenUtils.partyPath(testData); // Attend path through the party
    });
});

注意,即使测试人员将旅程清空到空字段,测试仍然会默认使用在 Zombieton 举办派对的快乐路径运行:

图 15.17 – 使用旅程决策点建议构建带有参数的测试

图 15.17 – 使用旅程决策点建议构建带有参数的测试

我们的手动测试人员现在可以打开 Jenkins 并使用参数启动此测试。他们可以选择他们想要走的路径类型,以及从 Env 下拉菜单中更改要运行的测试环境。

一旦此测试用例完成,我们将在 LambdaTest 仪表板上看到结果:

图 15.18 – LambdaTest 结果屏幕

图 15.18 – LambdaTest 结果屏幕

通过利用基于云的自动化构建平台,我们获得了许多优势。首先,我们不需要管理底层 OS 配置。其次,我们不需要跟踪浏览器的版本。最后,我们得到了一个测试执行的精彩视频记录,这比单个屏幕截图要好得多。

所有这些神秘的魔法都是通过我们在第十三章中描述的 LambdaTest 配置文件实现的。

在此处添加 LambdaTest 凭据:

图 15.19 – 添加 LambdaTest 凭据

图 15.19 – 添加 LambdaTest 凭据

现在,让我们看看 Slack 中的报告是如何工作的。

从 Jenkins 在 Slack 中报告

成为超级英雄的最终问题之一是保持超级警觉。在不间断运行大量测试时的问题是我们必须记得偶尔检查它们是否已完成。设置提醒可能会打断我们对其他工作的注意力。我们可能被其他任务完全占据,以至于我们可能要在任务完成后几小时后才能回到结果。为了提高效率,我们可以将警报消息发送到我们的电子邮件收件箱,但如果收件箱被大量 incoming 消息轰炸,它们可能会被忽略。更好的解决方案是将更新发送到团队消息平台,如 Slack。这是一个三步过程——也就是说,添加 Jenkins 插件,添加 Slack 应用,以及添加 Jenkins 构建后步骤。第一步是在 Slack 和 Jenkins 中安装必要的附加组件,以指示消息将显示的 Slack 频道。

将 Slack 通知插件添加到 Jenkins

导航到管理 Jenkins | 插件 | 可用。然后,搜索并安装Slack 通知插件:

图 15.20 – 搜索 Slack 通知插件

图 15.20 – 搜索 Slack 通知插件

一旦插件安装完成,重启 Jenkins 并登录。现在Slack 通知插件将出现在已安装插件标签页中。我们的下一步是设置 Slack 集成。

将 Jenkins CI 应用添加到 Slack

因此,我们必须添加 Slack Jenkins CI 应用。浏览应用并搜索并添加Jenkins CI

图 15.21 – Slack 的 Jenkins CI 插件,将接收来自 Jenkins 的消息

图 15.21 – Slack 的 Jenkins CI 插件,将接收来自 Jenkins 的消息

此应用将要求您创建一个 Slack 频道或选择一个现有的频道。在这个例子中,我们将创建一个jenkins-jobs频道,以便通知显示。

我们的最后一步是获取令牌,以便这两个产品能够通信。导航到 Slack 应用目录并添加 Jenkins CI 集成:

图 15.22 – Slack 频道中确认集成已完成的通知

图 15.22 – Slack 频道中的通知,确认集成已完成

集成添加后,点击消息中的Jenkins链接以获取令牌。

此令牌可在 Jenkins 仪表板中的管理 Jenkins | 管理插件下找到,位于可用标签页:

图 15.23 – Slack 设置

图 15.23 – Slack 设置

将令牌和工作区复制到 Jenkins 应用的设置中,然后点击保存设置

接下来,返回 Slack。在仪表板 | 系统页面进入工作区。

然后,添加凭据和一些秘密文本。输入秘密。ID 是可选的。最后,点击测试连接

图 15.24 – Slack 中出现的示例消息

图 15.24 – 示例消息将出现在 Slack 中

我们现在已连接完成并进行了测试。我们的最后一步是让 Jenkins 向 Slack 发送信息。

添加构建后 Slack 操作

在我们的工作构建后操作区域中选择Slack 通知

图 15.25 – 选择 Slack 通知

图 15.25 – 选择 Slack 通知

选择通知成功每次失败时通知。然后,点击保存

图 15.26 – 在 Jenkins 中报告给 Slack 的完成作业的通过和失败状态

图 15.26 – 在 Jenkins 中报告给 Slack 的完成作业的通过和失败状态

一旦构建完成,更新将自动发送到指定的 Slack 频道。这最小化了作业完成和结果分析开始之间的延迟。反过来,这种效率优化了所有自动化团队成员的时间使用。

按需和计划套件运行

我们尽量让测试尽可能频繁地运行。有时,这可能会影响我们的环境,从而阻止手动测试团队进行工作。

有时,这个过程很安静,没有发生任何事情。曾经有一个项目经理问我们是否可以比在冲刺发布周期结束时更频繁地运行自动化测试套件。我高兴地展示了我们的 Allure 报告的直方图,并确保每个人都知道测试是每晚运行的。我们每天早上审查这些结果,以寻找意外的失败和状态变化。当我们编写新的测试并维护其他测试时,我们还注意到几乎每个测试每天都会执行多次,以确保它们相互影响。

使用 Jenkins 的调试运行类型

我们需要一个测试来检查我们在这本书中编写的自动化框架功能的可靠性。我过去把它称为框架功能性的单元测试。然而,这与应用开发者团队产生了混淆。他们听到“单元”这个词,认为我是在提议承担他们代码中的单元测试,这是一个他们始终负责的工作。因此,为了避免进一步的混淆,我们将一个测试称为框架的 Sanity 测试。

最常见的作业类型是SanitySmokeRegression

  • 通过将来自密钥存储的凭据输入到输入字段元素中,使用SetValueAdv(),并通过点击pageSync()并从列表中选择SelectAdv()来执行ClickAdv()。它必须至少包含一个使用AssertAdv()的验证。

    这不应该是一个漫长的端到端测试,但它可以是其中一个的初始步骤。虽然这个工作可以在每次新的框架提交后运行,但测试本身也应该包含在每个套件中。如果它失败了,这将表明有某些基本的东西已经损坏,需要立即进行调查。

  • 烟雾测试包括大约覆盖整个套件 10%的测试集合。

  • 回归 是套件中剩余的 90% 测试,不包括烟雾测试。

然而,还有其他类别的测试,我们通常设置为每晚或每周运行。

额外的套件类别

让我们看看额外的套件类别:

  • 构建验证套件:过去针对它们编写了缺陷的测试子集。通常,这是烟雾套件的十分之一。

  • API 套件:一种快速验证 API 调用(带有有效载荷)、验证响应和响应代码的方法。同样,这是一组小型快速测试,没有 GUI 交互。

  • 长测试套件:有些测试需要较长时间才能完成。让他们自己玩得开心,以便尽早完成烟雾和回归测试。

  • 单个测试套件:这允许用户请求所有测试组合的定制子集。这可能包括产品退货、搜索和保存购物车。这允许测试团队只关注一个区域,而无需在多个 BVS、烟雾和回归运行中搜索。如果它包含 Jira 编号,它还可以按测试用例名称运行测试。

  • 失败:一个标记为最近失败的测试列表,并从其他套件中移除。这个套件可以快速查看单个修复是否解决了多个失败的测试。

将 Allure 报告链接到 Jenkins 运行

在你的 Jenkins 作业配置中,在 构建后操作 下,添加一个 发布 Allure 报告 操作。

适当地配置 报告版本报告目录 字段。报告目录 应指向生成 Allure 报告文件的目录。

CI/CD 管道

CI/CD 代表 持续集成和持续部署(或 持续交付)。它是一组用于软件开发的实践和工具,用于自动化构建、测试和部署软件更改的过程。CI 是开发者频繁将代码合并到中央仓库,而不是等待长时间后再合并代码。这种方法有助于他们尽早识别集成问题,并确保代码库始终处于功能状态。

持续部署(或持续交付)通过自动化将软件更改部署到生产环境的概念扩展了 CI。目标是拥有一个可靠且自动化的管道,该管道从仓库中提取代码更改,构建它,测试它,并将其部署到生产环境,而无需人工干预。

CI/CD 流程通常涉及以下步骤:

  1. 代码集成:开发者频繁地将更改提交到版本控制系统(如 Git)。

  2. 构建和测试:CI/CD 系统自动构建应用程序并运行各种自动化测试,以确保更改按预期工作。

  3. 自动化部署:一旦构建和测试成功通过,CI/CD 系统会自动将其部署到预发布或生产环境(即云平台、容器化环境等)。

  4. 持续监控:一旦部署,CI/CD 系统可以监控应用程序的性能并记录任何错误或问题。

什么是持续测试?

持续测试是在整个软件开发生命周期中执行自动化测试的实践,以提供对代码质量的快速和频繁的反馈。

传统上,测试通常被认为是一个独立的阶段,发生在开发周期的末尾。然而,随着敏捷和 DevOps 实践的采用,测试已经转向整合到开发过程的每个阶段。

持续测试涉及以下五个关键方面:

  • 自动化测试:持续测试依赖于自动化测试,包括单元测试、集成测试和功能测试。这些测试是脚本化的,并且每当代码库发生变化时都会自动执行,目的是确保软件功能按预期工作并帮助早期捕捉到错误。

  • 早期和频繁的测试:持续测试强调我们早期和频繁地进行测试。一旦代码更改合并到仓库中,就会触发测试以验证更改的完整性。

  • 测试环境:持续测试涉及多个测试环境,包括本地开发、测试和预发布环境。这些环境用于在尽可能接近生产环境的设置中运行测试。

  • 测试数据管理:这全部关于有效地管理测试数据。这些数据应该能够覆盖广泛的情况和用户场景。测试数据还应该易于提供和重置,以确保可靠的和可重复的测试执行。

  • 持续反馈:这全部关于提供对代码更改质量的即时反馈。测试结果会自动生成,突出显示任何失败或问题。这种反馈帮助开发者快速处理失败/问题,确保代码库保持稳定和可靠。

现在我们已经了解了什么是持续集成以及如何进行它,我们需要检查我们的管道以执行我们的测试套件。

CI/CD 管道看起来是什么样子?

CI/CD 管道是一组自动化步骤和工具,它使 CI、测试和软件更改的部署成为可能。虽然 CI/CD 管道的具体实现可能因项目和组织的不同而有所差异,但以下小节提供了一个 CI/CD 管道可能看起来的一般概述。

代码仓库

管道从代码仓库开始,通常使用 Git 等版本控制系统。自动化工程师将他们的代码更改提交到 GitHub 或 GitLab 仓库。这可能包括添加或维护现有测试用例或增强自动化框架功能。

CI

CI 是一种软件开发实践,开发者定期将他们的代码更改合并到中央仓库,最好是每天多次。合并后,自动构建和测试运行以尽早发现错误并确保新更改与现有代码库兼容。CI 的主要目标是提高代码质量、尽早发现问题并促进快速、可靠的发布。CI 通常与其他 DevOps 实践和工具(如 CD)集成,以简化从编码到部署的开发生命周期。Jenkins 等工具通常用于编排 CI 过程:

  • 代码编译:当提交更改时,CI 系统从仓库拉取最新代码并将其编译成可执行代码。

  • 自动化测试:CI 系统运行一系列自动化测试,包括单元测试、集成测试和其他类型的测试,以验证代码更改的正确性和功能。

  • 代码质量检查:CI 系统可能执行代码质量检查,如代码检查或静态代码分析,以确保遵守编码标准和最佳实践。

  • 测试报告和通知:CI 系统生成测试报告并向开发团队发送通知。

艺术品生成

艺术品生成涉及通过自动化或计算机辅助过程创建数字或物理对象、数据或内容。如果代码通过了测试和质量检查,CI 系统会生成构建工件,如编译的二进制文件、库或容器镜像。这些工件是成功编译和测试过程的结果,包括 Allure 报告和屏幕截图。

部署

如果构建和测试成功通过,CI/CD 系统会自动将更改部署到预发布或生产环境。这一步骤可能涉及部署到云平台、容器化环境或其他基础设施配置。

  • 预发布环境:工件被部署到与生产环境相似的预发布环境。

  • 额外测试:在预发布环境中可能进行更全面的测试,例如性能测试、安全测试或用户验收测试。

  • 审批流程:根据组织的政策,在部署到生产环境之前,可能会有一个审批流程或手动审查。

CD

CD 通过自动化将软件更改部署到生产环境的过程扩展了 CI 的概念。使用 CD,目标是拥有一个可靠且自动化的管道,该管道从代码库中提取代码更改,构建它们,运行测试,并将它们部署到生产环境,无需人工干预:

  • 生产环境:如果预演测试和批准成功,则工件将自动或手动部署到生产环境,具体取决于组织的部署策略。

  • 部署后测试:在生产环境中可能进行额外的监控和测试(业务验证),以确保新的更改按预期工作。

  • 持续监控:CI/CD 管道通常包括监控工具,这些工具跟踪应用程序的性能、日志和指标。

持续反馈

持续测试对代码更改的质量提供即时反馈。测试结果自动生成,突出显示任何失败或问题。这个反馈循环帮助开发者快速识别和修复问题,确保代码库保持稳定和可靠:

  • 通知和报告:在整个管道中,会生成通知、报告和日志,并可供开发团队和利益相关者查看,提供对进度、测试结果和部署状态的可见性。

  • 迭代开发:CI/CD 管道促进了迭代开发过程,开发者可以快速收到对代码更改的反馈,并做出必要的调整,确保持续改进。

Jenkins 用于 CI/CD

Jenkins 是设置 CI/CD 管道的优秀工具,它允许您自动化测试生命周期的各个阶段。在动态测试环境中,尤其是在使用 WebdriverIO 的情况下,一个配置良好的 CI/CD 管道可以显著提高测试过程的效率。

Jenkins 可以配置为在代码推送后自动触发测试套件,这确保了测试套件始终与最新的代码库保持同步,减少了潜在问题滑过并进入生产的风险。

Jenkins 允许并行执行测试,这显著减少了测试执行时间。这对于任何旨在在当今快节奏的开发环境中保持敏捷性和速度的测试框架来说是一个必备功能。

Docker 有助于创建一个标准化的测试环境,这对于确保测试结果的可信度至关重要。拥有一致的环境意味着您的测试将更加可靠,并且不太可能受到环境不一致性引起的错误的影响。

Docker 容器为您的测试提供了一个隔离的环境,这是避免不同依赖项和系统配置之间冲突的创造性解决方案。它绕过了臭名昭著的“在我的机器上它工作”问题,确保测试自动化框架强大且可靠。

摘要

通过实施 CI/CD,开发团队能够简化软件开发流程,减少人为错误,并确保更快、更可靠地交付新功能和错误修复。它促进了团队成员之间的协作,提高了代码质量,并使快速和频繁的发布成为可能。

通过将持续测试纳入开发过程,团队能够及早识别和解决问题,降低缺陷到达生产的风险,并确保软件符合质量标准。它促进了整个开发团队的质量文化,并支持更快、更可靠的软件交付。

需要注意的是,CI/CD 管道中涉及的具体工具、技术和步骤可能因项目需求、技术堆栈和组织偏好而异。管道可以根据需要定制和扩展,包括额外的阶段,如安全扫描、性能优化,甚至在出现问题时自动回滚。

总体而言,Jenkins 和 LambdaTest 简化了设置和管理 CI/CD 管道的过程,使团队能够更快、更可靠、更有信心地交付软件,并确保代码更改的质量。

附录

TypeScript 错误消息、原因和解决方案的终极指南

本附录涵盖了作者在撰写本书时遇到的所有问题,包括(但不限于)安装、配置和运行时错误。每个问题后面都跟着一个或多个原因,每个原因都有一个解决方案。具有相同解决方案的问题被分组在一起。使用本附录的好方法是搜索从终端窗口复制的错误详情的小片段,例如检查 端口

问题:通过传递“--yes”参数安装默认 WDIO 设置仍会询问配置问题

命令提示符中的--参数:

> npm init wdio . --yes

将参数传递给wdio--

> npm init wdio . -- --yes

问题:缺少脚本“wdio”

scripts:部分或Package.json中缺少wdio脚本。

Package.json文件中的scripts:部分添加wdio

"scripts": {
    "wdio": "wdio run test/wdio.conf.ts"
},

问题:“node”不是 cmdlet、函数、脚本文件或可操作的程序的名称。检查名称的拼写,如果包含路径,请验证路径是否正确,然后重试。

原因:Node 尚未安装。

解决方案:安装 Node。

问题:“wdio”不是内部或外部命令”

原因 #1:控制台不在正确的文件路径文件夹中。

原因 #2:支持包尚未安装。

npm i

> npm i packageName
> yarn add packageName

问题:

  • 浏览器启动后立即关闭

  • “此版本的 ChromeDriver 尚未与 Chrome 版本进行测试”

  • “必须使用 import 来加载 ES 模块”或“不支持 ES 模块的 require()”(项目中没有 require 代码)

chromedriver.exe版本过时。

解决方案:安装缺失的包。

yarn add packageName

问题:在新的 WDIO 项目文件夹中安装时,没有创建\node_modules 文件夹。

问题:新项目安装失败,目录为空。

问题:测试突然失败,包括\specs\test.e2e.ts 的示例测试。

\node_modules文件夹、一个package.json和一个wdio.conf.ts文件。当在子文件夹中执行子安装时,它将检查父文件夹中预先存在的共享资源。这将跳过创建\node_modules文件夹,并可能覆盖父package.json和配置文件。示例\specs\test.e2e.js可能最初在新子文件夹中运行。然而,二级子项目安装可以进一步覆盖父\node_modules文件夹和配置文件,可能破坏所有引用共享资源的项目,以及安装过程无法完成。

从父文件夹将\node_modules文件夹、package.jsonwdio.conf.ts文件和\test文件夹移动到新的子项目文件夹中。从 GitHub 恢复父文件夹中最后已知可工作的package.jsonwdio.conf.ts文件到测试不再运行的子文件夹中。使用 yarn install 重建以创建缺失的\node_modules文件夹。现在测试应该可以运行。在子子文件夹中的新项目安装也应成功完成。

问题:“WARN webdriver: 请求遇到一个过期的元素 - 终止请求”

问题:“TypeError: elem[prop]不是一个函数”

问题:语句执行顺序错误

await命令:

setValueAdv(this.fldUsername, "username")

解决方案:添加 await 命令

await setValueAdv(await this.fldUsername, "username")

问题:“ERROR @wdio/runner: 错误:describe 期望一个函数参数;收到[object AsyncFunction]”

async位于describe()块中:

describe("async here causes troubles", async () => {

asyncdescribe()块中:

describe("no more troubles", () => {

问题:“不支持的引擎”

图 A.1 – npm 包管理器显示所需的和当前的 node/npm 版本

图 A.2 – yarn 包管理器显示预期的和过时的当前 node/npm 版本

图 A.2 – yarn 包管理器显示预期的和过时的当前 node/npm 版本

原因:当前版本的 Node 与所需版本不匹配。

解决方案:将 Node 更新到正确版本。在此示例中,Node 版本 14 不受 WDIO 包支持,必须更新到 16.13、18.x 或更高版本:

> nvm use 18

问题:JavaScript 调试终端跳过断点

import语句路径

图 A.3 – 移除未使用的辅助导入项阻止了断点的跳过

解决方案 #1:删除未使用的导入。

原因 #2:导入的资源中缺少 async 和 await 语句

解决方案 #2:添加 async 和 await 语句

原因 #3:缓存的导入路径从“pageobjects”更改为“pageObjects”

解决方案 #3:重启 VSCode

问题:浏览器启动后锁定

原因:与 Node 版本 16.0.0 不兼容。

解决方案:升级到 Node 版本 18:

> nvm install 18

问题:“SevereServiceError:无法启动 Chromedriver:超时。请检查端口号 [<端口号>] 是否被占用”

chromedriver 失败已锁定端口。

chromedriver 会话。

这是使用 Windows CMD 命令提示符的操作方法:

> Taskkill /IM chromedriver.exe /F

这是使用 macOS 的操作方法:lsof -i :<端口号> : kill -9 <进程 ID>

问题:MODULE_NOT_FOUND

Path 环境变量。

解决方案:将节点路径添加到系统变量中。

原因 #2:使用命令提示符安装且没有本地管理员权限的包

解决方案:使用管理员权限从 PowerShell 安装(仅限 Windows):

图 A.4 –VS Code 终端窗口中的不同类型壳

获取节点路径类型,如下代码和后续图所示:

npm config get prefix

图 A.5 –如何获取节点程序管理器的路径

图 A.5 –如何获取节点程序管理器的路径

将路径添加到 path 系统变量中(仅限 Windows):

图 A.6 –检查 npm 路径是否在 Windows 环境变量中

图 A.6 –检查 npm 路径是否在 Windows 环境变量中

不要忘记重新启动 Visual Studio Code!

问题:错误:由于缺少配置,无法执行“运行”,找不到文件“C:\repos\wdio\test\wdio.conf.ts”!您希望创建一个吗?

此错误在此处显示:

图 A.7 –TypeScript 和 JavaScript 项目在不同的文件夹中存储 wdio.config 文件

package.json 文件中的 scripts 下的 wdio.conf.js 路径不正确。

解决方案:检查测试是否使用错误的路径或扩展名启动:

npx wdio run test/wdio.conf.ts
npx wdio run wdio.conf.ts

package.json 文件。在此示例中,分号将生成错误,但双 ampersands 不会:

"allure":
 "wdio run test/wdio.conf.ts; allure generate report –clean; allure open"

解决方案:将分号替换为双 ampersands

"allure":
 "wdio run test/wdio.conf.ts && allure generate report --clean && allure open"

问题:运行 Allure 报告时出现“report does not exist”

allure-results 路径。

解决方案:添加结果路径。

> allure generate report allure-results

package.json 和/或 wdio.conf.ts 文件。

packagewdio.conf.ts

将报告快捷方式添加到 package.json 文件中:

"report": "wdio run test/wdio.conf.ts && allure generate report allure-results --clean && allure open"

将 Allure 配置为 reporters

reporters: ['spec',['allure', {
        outputDir: 'allure-results',
        disableWebdriverStepsReporting: true,
        disableWebdriverScreenshotsReporting: true,
}]],

问题:“[P]lugin “allure” reporter,既不是 wdio 作用域包 “@wdio/allure-reporter” 也不是社区包 “wdio-allure-reporter”。请确保您已安装!”

原因:未先添加 Allure 插件而进行的 Allure 配置。

解决方案:安装 Allure:

npm install @wdio/allure-reporter --save-dev
yarn add @wdio/allure-reporter

问题:TypeError:无法读取未定义的属性(读取“open”)

原因:TypeScript 安装失败。这发生在您尝试访问存储未定义值的变量的属性或方法时。

解决方案:添加缺失的包

yarn add package-name

问题:Cannot read properties of undefined (reading ‘setWindowSize’)

在从 CommonJS 迁移到 ESNext 时,webdriverio 中的 browser 引用更改为 Browser

wdio.conf.ts,替换以下内容:

import { browser } from "webdriverio";

问题:在‘onPrepare’钩子中服务失败 SevereServiceError:无法启动 Chromedriver:超时。请检查端口号 9515 是否被占用!

原因:JavaScript 调试 Shell 在断点处停止,阻止另一个 shell 使用该端口。

解决方案:使用 Ctrl+C 停止正在运行的调试 Shell。

问题:“找不到名称‘describe’”并以下划线标红

问题:“找不到名称‘it’”

问题:“找不到名称‘expect’。您需要为测试运行器安装类型定义吗?”但测试仍然运行。

原因:Jasmine 类型定义缺失或不正确。

tsconfig.json文件中,将jasmine改为jasmine-framework并重新启动 IDE:

"compilerOptions": {
        "types": [
            "@wdio/jasmine-framework"

jasmine到编译器选项|类型在tsconfig.json文件中:

> yarn add @types/jasmine jasmine

原因 #2:ESLint 或 TypeScript 调试器过时或已禁用。

解决方案:激活或更新扩展

原因 #3:框架未正确安装。

解决方案:重新安装 Jasmine、Mocha 或 Jest。

问题:“当启用allowImportingTsExtensions时,导入路径只能以.ts扩展名结束.ts(5097)”

tsconfig.json文件必须指示允许导入具有.ts扩展名的文件。

tsconfig.json文件:

{"compilerOptions":{
"allowImportingTsExtensions": true
"noEmit": true,
...
}}

问题:browser.debug()生成“无法从节点连接读取描述符:连接到系统的设备未正常工作。”

原因:良性问题。

logLevelinfo更改为warn

问题:元素隐式具有‘any’类型,因为类型‘typeof globalThis’没有索引签名

any类型

:any

问题:找不到‘jasmine’的类型定义文件

File 类型缺失

解决方案 #1:在终端中运行以下命令安装 Node 类型:

> yarn add @types/node
> yarn add @types/jasmine

删除node-modules文件夹和yarn.lock,然后按以下方式重新运行:

> yarn install

问题:“执行 0 个工作者”没有测试被执行。

capabilities:wdio.config.ts中。

解决方案:确保能力设置正确:

    capabilities: [{
        maxInstances: 5

问题:找不到名称‘browser’和找不到名称‘$’

$快捷键未添加到类中。

解决方案:将浏览器和$对象添加到

import {browser, $} from wdio/globals

问题:属性‘toBeExisting’在类型中不存在

@wdio/globals缺失或在tsconfig.json中未列为第一项(WDIO 版本 8+):

"types": [
"@wdio/globals",
"jasmine",
           "node",
           "@wdio/jasmine-framework",
           "expect-webdriverio"
        ],

@wdio/sync@wdio/types

原因 #2:缺少括号:

const ASB = switchboardFactory // No Parenthesis
ASB.set("DEBUG", true) // Throws error

解决方案 #2:添加括号:

const ASB = switchboardFactory() // No error
ASB.set("DEBUG", true) // No error

问题:ERR![错误:EACCES:权限被拒绝(Mac OSX)

npm没有访问权限。

解决方案:使用以下命令给予用户访问权限:

> sudo chown –R $USER /usr/local

问题:在wdio/selenium-standalone-service中错误:未找到:java

selenium-standalone-service但 Java SDK 未安装。

解决方案:安装 Java SDK 的最新版本并重新启动 shell。

问题:“ECONNREFUSED 127.0.0.1:9515”在‘onPrepare’钩子中服务失败 tcp-port-used

原因 #1:当连接到未运行的 localhost 时可能会发生这种情况。

解决方案 #1:关闭 Bash shell 并重新启动。

解决方案 #2:尝试使用 ZSH shell。

可能原因 #2:安全客户端连接已断开。

解决方案:重新连接到客户端应用程序。

可能原因 #3:依赖项过时。

解决方案:更新依赖项:

> npm update
> yarn upgrade

问题:错误:找不到模块‘C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js’。

import 语句缺少文件扩展名:

解决方案:添加导入语句。

import loginPage from '../pageobjects/login.page'

问题:“注意:package.json 必须是实际的 JSON,而不仅仅是 JavaScript”。

在项目列表末尾添加 ``,`)。

解决方案:删除列表末尾的额外逗号。

问题:协议错误(Runtime.callFunctionOn)目标已关闭。

await。在元素交互期间浏览器关闭。

使用 await 调用 async 方法。

问题:在 tsconfig.json 中出现“意外的标记”。

原因:括号不匹配。整个文件可能在 VS Code(macOS)中被标记为无效。

解决方案:修复不匹配的括号并保存文件。

问题:“TypeError:elem[prop] 不是一个函数”。

原因:像这个例子中一样,输入了自定义方法。

browser .addCommand("clickAdv", async function () {...}
await this.Submit.clickadv(); //Lowercase 'a' in clickAdv

解决方案:修复输入错误的方法名:

await this.Submit.clickAdv();

问题:“在“onPrepare”中发生 ServerServiceError:找不到包‘chromedriver’”。

chromedriverwdio-chromedriver-service 已安装。

package.json 文件中获取 chromedriver 服务。

"chromedriver": "^x.x.x",以及 wdio.conf.ts 文件:

"services": ["chromedriver"]

问题:属性‘{functionName}’在类型‘({functionType<{argName>}) => void’上不存在。

这里有一个例子:

Property 'toBeExisting' does not exist on type 'FunctionMatchers<any>'.ts

expect 库未从源导入。

解决方案:添加导入语句。

import {expect} from 'expect-webdriverio';

问题:“属性‘addCommand’在类型‘Browser’上不存在。”(macOS)。

原因:良性的 VS Code 错误。代码可能仍然可以工作。

// @ts-ignore 指令。

问题:ConfigParser:模式 ./test/specs/**/*.ts 未匹配任何文件。

tsconfigwdio.config 文件存在于 /test 文件夹中。

tsconfigwdio.config 文件添加到项目根工作目录,其中包含 package.json 文件。

如果从 npm 调用 wdio,模式相对于 package.json 所在的目录。

确保 tsNodeOpts 具有正确的project路径:

tsNodeOpts: {
            transpileOnly: true,
            project: './specs/**/*.ts'
        }

问题:错误:超时 - 异步函数未在 10000ms 内完成(由 jasmine.DEFAULT_TIMEOUT_INTERVAL 设置)。

wdio.conf.ts 包含 jasmineNodeOpts

jasmineOpts

问题:错误:找不到页面句柄。

原因:Jasmine 超时了测试。

解决方案:将 Jasmine 默认超时间隔设置为高于 10,000:

defaultTimeoutInterval: 9_999_999,

问题:此表达式不可调用。类型‘void’没有调用签名.ts 你是否遗漏了分号?

原因:未知。

解决方案:添加分号:

console.log(`timeout = ${Math.ceil(timeout / 60_000)} min.`);

问题:“文件不是模块”。

模块文件中的 export 关键字:

function log(message: any) {…}

导出 关键字:

export function log(message: any) {…}

问题:“找不到页面句柄”。

原因:浏览器关闭。

在调用 async() 方法时使用 await

问题:“错误:{pageName} 未定义”。

classexport default new 不匹配。

错误:“SecurePage 未定义”。

class TyposPage extends Page{
        public get typoText () {
        return $(`//p[contains(text(),'random')]`);
    }
export default new SecurePage();

解决方案:将类和导出名称更改为匹配。

class SecurePage extends Page{
        public get typoText () {
        return $(`//p[contains(text(),'random')]`);
    }
export default new SecurePage();

问题:类型为‘ChainablePromiseElement’的参数不能分配给类型为‘Element’的参数。

await

await helpers.clickAdv (this.btnTypos);

await 用于 Element

await helpers.clickAdv (await this.btnTypos);

问题:无法删除或修改 WebdriverIO 项目的文件或文件夹

原因:项目是用管理员权限创建的,而用户账户没有获得权限。

图 A.8 – 仓库权限

图 A.8 – 仓库权限

解决方案:给用户账户完全控制文件和文件夹的权限(仅限 Windows)。

问题:在解析 JSON 时遇到意外的令牌 EJSONPARSE

package.json 文件。

json 文件用于良好的格式化。

问题:在 Windows 的 JavaScript 调试终端中运行时,wdio.config.ts 文件中添加了一长串乱码垃圾字符

图 A.9 – 乱码字符字符串

图 A.9 – 乱码字符字符串

原因:在 wdio.config.ts 文件中使用后声明的全局日志函数。

解决方案:在调用任何初始调用之前,将任何全局函数声明移动到 wdio.config.ts 文件的顶部。

Windows 警告:注意长文件路径的限制:

图 A.10 – 警告

图 A.10 – 警告

原因:项目深度路径:

C:\users\darkartswizard\git\github\repos\webdriverio-book-project\test\wdio.conf.ts

解决方案:遵循 KISS 示例并 保持 简单

C:\repos\wdio\test\wdio.conf.ts

Yarn 和 Node 包管理器 (npm)、Node 版本管理器 (nvm) 和 Node 包执行器 (npx) Shell 命令速查表

这是如何安装 yarn 和 Node.js 的:

> brew install yarn

Node.js 的路径是什么?

> npm config get prefix
> yarn global dir

这是如何安装新的 Node 版本的:

> nvm install node version.number

这是如何切换到不同的 Node 版本的:

> nvm use version.number

安装了哪些版本的 Node?

> nvm list

当前激活的 Node.js、npm 和 nvm 版本是什么?

> node -v
> npm –v
> nvm version

可用的 Node.js 版本有哪些?npm 有一个未记录的 show 命令:

> npm show node versions

可用的 WebdriverIO 包版本有哪些?

> npm show webdriverio versions

可用的 Jasmine 包版本有哪些?

> npm show jasmine versions

已安装哪些 Node 包以及哪些是多余的或无效的?

> cd path-to-project
> npm list

这是如何删除多余的 node 包的:

> npm prune
> yarn install

在全局级别安装了哪些 node 包?

> npm list -g --depth=0

这是如何初始化 WebdriverIO 的:

> cd path/to/project
> npm wdio .
> yarn wdio init

这是如何显式执行所有 wdio 测试的:

> npx wdio run test/wdio.conf.ts
> yarn wdio wdio.conf.ts

这是如何从 package.json 文件中隐式执行脚本的:

> npm run wdio
> yarn wdio

这是如何显式执行一个 wdio TypeScript 测试的:

> npx wdio run ./wdio.conf.js --spec example.e2e.ts
> yarn wdio wdio.conf.ts --spec example.e2e.ts

参考链接

这本书的成功开发在很大程度上归功于几个关键资源的宝贵贡献。这些提供了构建全面且有洞察力的作品所需的基础知识、见解和关键数据,该作品是该领域的宝贵补充。他们在本书创作中的作用确实是关键的,反映了将此项目付诸实践的合作努力:

结语

作者向您,亲爱的读者,致以最诚挚的祝福,愿您在从凡人成长为超级自动化工程师的道路上取得突破。我们相信,您不仅从这本书中学到了技术知识,而且也享受了探索自动化工程充满活力的世界的乐趣。

这里许多想法的哲学基础可以追溯到阿尔伯特·爱因斯坦的智慧。他的智慧始终是我们的灵感之源,激励我们拥抱实验精神。爱因斯坦曾著名地建议,一个人不应害怕失败,因为每一次失败的实验都是向成功迈进的一步。他的理念贯穿了本书的篇章,鼓励您在工作中不断测试、适应和创新。

随着您在职业道路上的前进,愿您拥抱“玩耍和发现”的乐趣——也就是说,即修补、实验,有时甚至失败——以发现那些通向真正精通的意外发现。我们希望您在这持续的探索中找到极大的乐趣和成就感,从而不断丰富您的职业和个人发展。

祝愿您在自动化工程这个不断发展的领域中不仅成为精通者,而且真正出类拔萃。干杯!

“去创造生产力!”——保罗·M·格罗斯曼

“适应并繁荣,因为变化是生活的真正常数”——拉里·C·戈达德

问题:“[P]lugin “allure” reporter,既不是 wdio 作用域包“@wdio/allure-reporter”,也不是社区包“wdio-allure-reporter”。请确保您已安装它!”

问题:“只有当启用“allowImportingTsExtensions”时,导入路径才能以‘.ts’扩展名结束.ts(5097)”

问题:在 Windows 的 JavaScript 调试终端中运行时,wdio.config.ts 文件中会添加一长串乱七八糟的垃圾字符

Yarn 和 Node 包管理器 (npm)、Node 版本管理器 (nvm) 以及 Node 包执行器 (npx) Shell 命令速查表

posted @ 2025-10-25 10:36  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报