编写强大的前端测试元素定位器

Avatar of Mark Noonan
Mark Noonan

DigitalOcean 为您的旅程的每个阶段提供云产品。 立即开始使用 $200 免费信用额度!

自动化的前端测试非常棒。 我们可以用代码编写测试以访问页面 - 或 只加载单个组件 - 并且让测试代码像用户一样点击事物或输入文本,然后对应用程序交互后的状态进行断言。 这使我们能够确认测试中描述的所有内容在应用程序中按预期工作。

由于这篇文章是关于任何自动化的 UI 测试的构建块之一,因此我并没有假设太多先验知识。 如果您已经熟悉基础知识,可以随意跳过前几节。

前端测试的结构

在编写测试时,有一种经典模式非常有用:安排操作断言。 在前端测试中,这转化为一个测试文件,该文件执行以下操作

  1. 安排:为测试做好准备。 访问某个页面,或用正确的道具挂载某个组件,模拟一些状态,等等。
  2. 操作:对应用程序执行一些操作。 点击按钮,填写表单,等等。 或者不,对于简单的状态检查,我们可以跳过此步骤。
  3. 断言:检查一些内容。 提交表单是否显示了感谢消息? 它是否将正确的数据与 POST 一起发送到后端?

在指定与什么交互以及之后在页面上检查什么时,我们可以使用各种元素定位器来定位我们需要使用的 DOM 部分。

一个 定位器 可以是像元素的 ID、元素的文本内容或 CSS 选择器,例如 .blog-post 甚至 article > div.container > div > div > p:nth-child(12)。 任何可以将元素识别给您的测试运行器的元素信息都可以用作定位器。 正如您可能已经从最后一个 CSS 选择器中猜到的那样,定位器有很多种类。

我们通常根据脆弱性稳定性来评估定位器。 通常,我们希望使用尽可能稳定的元素定位器,这样我们的测试就可以始终找到它需要的元素,即使元素周围的代码随着时间的推移而发生变化。 也就是说,以任何代价最大化稳定性会导致防御性测试编写,实际上会削弱测试。 我们通过结合与我们希望测试关注的内容相一致的脆弱性和稳定性来获得最大的价值。

这样,元素定位器就像胶带一样。 它们应该在一个方向上非常牢固,而在另一个方向上很容易撕裂。 当对应用程序进行不重要的更改时,我们的测试应该保持在一起并继续通过,但是当发生与我们在测试中指定的内容相矛盾的重要更改时,它们应该很容易失败。

前端测试中元素定位器的入门指南

首先,让我们假装我们正在为一个实际的人编写工作说明。 一位新的门检察员刚刚被 Gate Inspectors, Inc. 聘用。 您是他们的老板,在每个人都介绍完后,您应该向他们提供检查第一个门的说明。 如果您想让他们成功,您可能不会为他们写这样的一张纸条

经过那栋黄房子,一直走,直到你碰到迈克母亲的朋友的羊在那里失踪的那块地,然后左转,告诉我你对面那栋房子的门是否打开。

这些指示有点像使用很长的 CSS 选择器或 XPath 作为定位器。 它很脆弱——而且是“不好的脆”。 如果黄房子被重新粉刷,你重复这些步骤,你就再也找不到门了,可能会放弃(或者在这种情况下,测试失败)。

同样,如果您不知道迈克母亲朋友的羊的情况,您就无法停在正确的参考点来确定要检查哪个门。 这正是“不好的脆”不好的原因——测试可能会因为各种原因而中断,而这些原因与门的可用性无关。

所以让我们进行一个不同的前端测试,一个更稳定的测试。 毕竟,在这个地区,法律规定所有道路上的门都应该有制造商提供的独特的序列号

去序列号为 1234 的门,检查它是否打开。

这更像是通过 ID 定位元素。 它更稳定,而且只有一步。 上一个测试的所有故障点都已消除。 此测试只会因为具有该 ID 的门未按预期打开而失败。

现在,事实证明,尽管没有两扇门应该在同一条路上有相同的 ID,但这实际上没有在任何地方执行。 有一天,道路上的另一扇门最终具有相同的 ID。

因此,下次新聘用的门检察员去测试“门 1234”时,他们会先找到另一扇门,现在他们正在访问错误的房子并检查错误的东西。 测试可能会失败,或者更糟糕的是:如果该门按预期工作,测试仍然通过,但它没有测试预期的主题。 它提供错误的信心。 即使我们最初的目标门在半夜被门窃贼偷走,它也会继续通过。

在发生这样的事件后,很明显,ID 并不像我们想象的那么稳定。 因此,我们进行了一些更高级的思考,并决定在门的内部,我们想要一个专门用于测试的特殊 ID。 我们会派技术人员去这扇门上贴上特殊的 ID。 新的测试说明如下

去带有测试 ID“my-favorite-gate”的门,检查它是否打开。

这个更像是使用流行的 data-testid 属性。 这样的属性很棒,因为代码中很明显它们是专门用于自动测试,不应更改或删除。 只要门具有该属性,您就可以始终找到门。 就像 ID 一样,唯一性仍然没有强制执行,但可能性更大。

这与脆弱性相去甚远,并且确认了门的正常工作。 我们不依赖任何东西,只依赖我们专门为测试添加的属性。 但是这里隐藏着一个问题……

这是对门的用户界面测试,但定位器是任何用户都不会用来寻找门的東西。

这是一个错失的机会,因为在这个假想的县里,事实证明,门需要印上房号以便人们可以看到地址。 因此,所有门都应该有一个独特的面向用户的标签,如果没有,本身就是一个问题。

当用测试 ID 定位门时,如果事实证明门被重新粉刷,房号被遮盖了,我们的测试仍然会通过。 但门的全部意义是让人们进入房子。 换句话说,一个用户找不到的门应该仍然是一个测试失败,我们想要一个能够揭示这个问题的定位器。

这是对门检察员第一天进行的测试说明的另一次尝试

去 40 号房子的门,检查它是否打开。

这个使用了一个增加测试价值的定位器:它依赖于用户也依赖的东西,即门的标签。 它为测试添加了一个潜在的失败原因,然后才达到我们真正想要测试的交互,这乍一看似乎很糟糕。 但在这种情况下,由于定位器从用户的角度是有意义的,因此我们不应该将其视为“脆弱”。 如果门无法通过其标签找到,那么它是否打开并不重要——这是“好的脆弱”。

DOM 很重要

许多前端测试建议告诉我们避免编写依赖于 DOM 结构的定位器。 这意味着开发人员可以随着时间的推移重构组件和页面,并让测试确认面向用户的流程没有中断,而无需更新测试来追赶新的结构。 这个原则很有用,但我会稍作调整,说我们应该避免在前端测试中编写依赖于无关 DOM 结构的定位器。

为了使应用程序正常运行,DOM 应该反映屏幕上显示内容的性质和结构。 这样做的一个原因是可访问性。 从这个意义上说,正确的 DOM 对于辅助技术来说更容易正确解析和描述给看不到浏览器呈现的内容的用户。 DOM 结构和普通的 HTML 对依赖辅助技术的用户的独立性有很大的影响。

让我们启动一个前端测试,将一些内容提交到我们应用程序的联系表单。 我们将为此使用 Cypress,但战略性地选择定位器的原则适用于所有使用 DOM 定位元素的前端测试框架。 这里我们找到元素,输入一些测试,提交表单,并验证是否达到了“感谢”状态

// 👎 Not recommended
cy.get('#name').type('Mark')
cy.get('#comment').type('test comment')
cy.get('.submit-btn').click()
cy.get('.thank-you').should('be.visible')

在这四行代码中,发生了各种隐式断言。 cy.get() 正在检查元素是否存在于 DOM 中。 如果元素在一段时间后不存在,测试将失败,而像 typeclick 这样的操作会在采取操作之前验证元素是否可见、已启用且未被其他元素遮挡。

因此,即使在像这样的简单测试中,我们也能“免费”获得很多东西,但我们也对我们(和我们的用户)并不真正关心的东西引入了一些依赖。 我们正在检查的特定 ID 和类似乎足够稳定,特别是与 div.main > p:nth-child(3) > span.is-a-button 或其他类似的選擇器相比。 这些长選擇器非常具体,因此对 DOM 的微小更改会导致测试失败,因为它找不到元素,而不是因为功能已损坏。

但即使我们简短的选择器,比如 #name,也存在三个问题。

  1. ID 可能会在代码中被更改或删除,导致元素被忽略,尤其是在表单可能在页面上多次出现的情况下。可能需要为每个实例生成一个唯一的 ID,而这并非我们可以在测试中轻松预先填充的内容。
  2. 如果页面上存在多个表单实例,并且它们具有相同的 ID,则需要决定要填充哪个表单。
  3. 从用户的角度来看,我们实际上并不关心 ID 是什么,因此所有内置断言都有些… 未充分利用?

对于问题一和二,建议的解决方案通常是在 HTML 中使用专门的 data 属性,这些属性专门用于测试。这样做更好,因为我们的测试不再依赖于 DOM 结构,并且当开发人员修改组件周围的代码时,只要他们将 data-test="name-field" 附加到正确的 input 元素上,测试将继续通过,而无需更新。

但这并不能解决问题三 - 我们仍然有一个依赖于对用户来说毫无意义的东西的前端交互测试。

交互式元素的语义定位符

当定位符依赖于我们真正想要依赖的东西时,元素定位符才是有意义的,因为定位符的某些方面对于用户体验很重要。在交互式元素的情况下,我认为最好的选择器是元素的可访问名称。类似这样的示例是理想的

// 👍 Recommended
cy.getByLabelText('Name').type('Mark')

此示例使用来自Cypress Testing LibrarybyLabelText 帮助程序。(实际上,如果您以任何形式使用 Testing Library,它可能已经在帮助您编写类似这样的可访问定位符。)

这很有用,因为现在内置检查(我们通过 cy.type() 命令免费获得)包括表单字段的可访问性。所有交互式元素都应具有可访问名称,该名称会公开给辅助技术。例如,这就是屏幕阅读器用户了解他们正在输入的表单字段的名称的方式,以便输入所需的信息。

为表单字段提供此可访问名称的方法通常是通过一个 label 元素,该元素通过 ID 与该字段相关联。来自 Cypress Testing Library 的 getByLabelText 命令验证了该字段是否已正确标记,但也验证了该字段本身是否允许具有标记的元素。因此,例如,以下 HTML 将在尝试 type() 命令之前正确失败,因为即使存在 label,标记 div 也是无效的 HTML

<!-- 👎 Not recommended  -->
<label for="my-custom-input">Editable DIV element:</label>
<div id="my-custom-input" contenteditable="true" />

由于这是无效的 HTML,因此屏幕阅读器软件永远无法将此标签正确地与该字段相关联。为了解决这个问题,我们将更新标记以使用真实的 input 元素

<!-- 👍 Recommended -->
<label for="my-real-input">Real input:</label>
<input id="my-real-input" type="text" />

这很棒。现在,如果测试在对 DOM 进行编辑后在此时失败,则不是因为元素排列方式的无关结构更改,而是因为我们的编辑做了一些事情来破坏 DOM 的一部分,我们的前端测试明确关心这一点,这实际上对用户很重要。

非交互式元素的语义定位符

对于非交互式元素,我们应该戴上思考帽。在退回到始终存在的 data-cydata-test 属性之前,让我们稍微判断一下,如果 DOM 根本不重要,这些属性始终存在。

在我们深入通用定位符之前,让我们记住:DOM 的状态是我们的 Web 开发者 Whole Thing™(至少,我认为它是)。DOM 为所有非视觉体验页面的人驱动 UX。因此,很多时候,可能存在某些有意义的元素,我们可以或应该在代码中使用,我们可以将其包含在测试定位符中。

如果没有,因为,说,应用程序代码都是通用的容器,比如 divspan,我们应该考虑首先修复应用程序代码,同时添加测试。否则,有可能让我们的测试实际指定期望和所需的通用容器,这使得有人修改该组件以使其更具可访问性变得更加困难。

这个主题打开了一个关于可访问性在组织中如何运作的“潘多拉盒子”。通常,如果没有人谈论它,并且它不是我们公司实践的一部分,那么我们不会认真对待可访问性,作为前端开发人员。但归根结底,我们应该是设计“正确标记”的专家,以及在决定这一点时需要考虑什么。我在 Connect.Tech 2021 的演讲中更多地讨论了这方面的内容,名为“研究和编写可访问的 Vue… 东西”

正如我们在上面看到的,对于我们传统上认为是交互式的元素,有一个相当不错的经验法则,很容易将其构建到我们的前端测试中:交互式元素应具有与该元素正确关联的可感知标签。因此,任何交互式元素,当我们测试它时,都应该使用该必需标签从 DOM 中选择。

对于我们不认为是交互式的元素 - 例如,大多数显示文本片段的文本渲染元素,除了某些基本的界标,比如 main - 如果我们将大部分非交互式内容放入通用的 divspan 容器中,我们将不会触发任何 Lighthouse 审核失败。但是,标记对于辅助技术来说将不是很有用,因为它不会向无法看到它的人描述内容的性质结构。(要查看此内容的极端情况,请查看 Manuel Matuzovic 优秀的博文,“构建具有完美 Lighthouse 得分的尽可能最不可访问的网站。”

我们渲染的 HTML 是我们向任何非视觉感知内容的人传达重要上下文信息的地方。HTML 用于构建 DOM,DOM 用于创建浏览器的可访问性树,而可访问性树是所有类型的辅助技术可以用来向使用我们软件的残疾人表达内容和可执行操作的 API。屏幕阅读器通常是我们想到的第一个例子,但可访问性树也可以被其他技术使用,例如将网页转换为盲文的显示器,以及其他技术。

自动可访问性检查不会告诉我们是否真正为内容创建了正确的 HTML。“正确性”是关于我们认为需要在可访问性树中传达哪些信息的开发人员的判断。

一旦我们做出了这个决定,我们就可以决定其中有多少适合烘焙到自动化的前端测试中。

假设我们已经决定,一个带有 status ARIA role 的容器将保存联系表单的“谢谢”和错误消息。这样一来,屏幕阅读器可以宣布表单成功或失败的反馈。可以应用 .thank-you.error 的 CSS 类来控制视觉状态。

如果我们添加此元素并希望为其编写 UI 测试,我们可能会在测试填写表单并提交表单后编写类似这样的断言

// 👎 Not recommended
cy.get('.thank-you').should('be.visible')

或者甚至使用非脆弱但仍然无意义的选择器的测试,如下所示

// 👎 Not recommended
cy.get('[data-testid="thank-you-message"]').should('be.visible')

两者都可以使用 cy.contains() 重写

// 👍 Recommended
cy.contains('[role="status"]', 'Thank you, we have received your message')
  .should('be.visible')

这将确认预期文本出现并位于正确的容器类型中。与之前的测试相比,这在验证实际功能方面更有价值。如果此测试的任何部分失败,我们会想知道,因为消息和元素选择器对我们都很重要,不应该随意更改。

我们确实在这里获得了一些覆盖范围,而无需编写太多额外的代码,但我们也引入了另一种脆弱性。我们在测试中包含了纯英文字符串,这意味着如果“谢谢”消息更改为类似“感谢您的联系!”,此测试突然失败。其他所有测试也是如此。对标签的编写方式进行的细微更改将需要更新任何使用该标签定位元素的测试。

我们可以通过在前端测试中使用与在代码中相同的真理来源来改进这一点。如果我们目前在组件的 HTML 中直接嵌入人类可读的句子… 那么现在我们有理由将这些东西从那里拉出来。

人类可读的字符串可能是 UI 代码的魔术数字

一个魔术数字(或者不太令人兴奋地,“未命名的数值常量”)是您有时在代码中看到的超级特定值,它对计算的最终结果很重要,比如一个老式的 1.023033 或类似的东西。但是,由于该数字没有标记,因此它的意义不清楚,因此它在做什么也不清楚。也许它应用了税收。也许它弥补了我们不知道的某些错误。谁知道呢?

无论哪种方式,前端测试都是如此,一般的建议是避免魔术数字,因为它们缺乏清晰度。代码审查通常会发现它们并询问该数字的作用。我们可以将其移到常量中吗?如果要在一个以上的地方重复使用一个值,我们也会做同样的事情。与其在所有地方重复该值,不如将其存储在变量中,并在需要时使用该变量。

多年来,我在编写用户界面时,越来越意识到 HTML 或模板文件中的文本内容与其他环境中的魔数非常相似。我们在代码中到处放置绝对值,但实际上,将这些值存储在其他地方并在构建时(甚至根据情况通过 API)引入它们可能更有用。

原因如下:

  1. 我曾经与客户合作,他们希望从内容管理系统(CMS)中驱动所有内容。即使是表单标签和状态消息,只要不在 CMS 中的内容都要避免。客户希望完全控制,以便内容更改无需编辑代码并重新部署网站。这很有道理,代码和内容是不同的概念。
  2. 我在许多多语言代码库中工作过,所有文本都需要通过一些国际化框架引入,例如:
<label for="name">
  <!-- prints "Name" in English but something else in a different language -->
  {{content[currentLanguage].contactForm.name}}
</label>
  1. 就前端测试而言,如果我们不检查硬编码到测试中的特定“谢谢”消息,而是执行类似的操作,我们的 UI 测试将更加健壮:
const text = content.en.contactFrom // we would do this once and all tests in the file can read from it

cy.contains(text.nameLabel, '[role="status"]').should('be.visible')

每种情况都不一样,但是拥有某种字符串常量系统在编写健壮的 UI 测试时是一个巨大的优势,我建议您使用它。然后,如果我们的情况需要翻译或动态内容,我们将为此做好更好的准备。

我也听说过关于在测试中导入文本字符串的反对意见。例如,有些人发现,如果测试本身独立于内容源指定预期内容,测试将更易读且总体上更好。

这是一个公平的观点。我不太认同这一点,因为我认为内容应该通过更像编辑审查/发布模型来控制,我希望测试检查源中的预期内容是否已渲染,而不是测试编写时正确的某些特定字符串。但许多人不同意我的观点,我说,只要团队内理解了这种权衡,两种选择都是可以接受的。

也就是说,更一般地说,将代码与前端内容隔离仍然是一个好主意。有时甚至可能需要混合搭配,例如在组件测试中导入字符串,而在端到端测试中不导入字符串。这样,我们就可以减少一些重复,并增加对组件显示正确内容的信心,同时仍然拥有独立断言预期文本(以编辑、面向用户的意义)的前端测试。

何时使用 data-test 定位器

[data-test="success-message"] 这样的 CSS 选择器仍然有用,并且在以一种有意的方式使用时可以非常有用,而不是一直使用它们。如果我们的判断是,没有有意义的、可访问的方式来定位元素,那么 data-test 属性仍然是最佳选择。它们比依赖于巧合(例如您编写测试时的 DOM 结构恰好是什么样)要好得多,并且比回退到“第三个包含 card 类别的 div 中的第二个列表项”风格的测试要好得多。

也有一些情况,内容预计是动态的,并且没有简单的方法从某个通用的真相来源获取字符串以在测试中使用。在这种情况下,data-test 属性可以帮助我们定位到我们关心的特定元素。它仍然可以与无障碍友好的断言相结合,例如:

cy.get('h2[data-test="intro-subheading"]')

这里,我们想要找到具有 data-test 属性为 intro-subheading 的元素,但仍然允许我们的测试断言它应该是一个 h2 元素(如果这是我们期望的)。data-test 属性用于确保我们获得了感兴趣的特定 h2,而不是页面上可能存在的其他 h2(如果由于某种原因,该 h2内容在测试时无法知道)。

即使在知道内容的情况下,我们也可能仍然使用数据属性来确保应用程序在正确的位置渲染该内容:

cy.contains('h2[data-test="intro-subheading"]', 'Welcome to Testing!')

data-test 选择器也可以是一种方便的方法,可以定位到页面的某个特定部分,然后在该部分内进行断言。这可能看起来像这样:

cy.get('article[data-test="ablum-card-blur-great-escape"]').within(() => {
  cy.contains('h2', 'The Great Escape').should('be.visible')
  cy.contains('p', '1995 Album by Blur').should('be.visible')
  cy.get('[data-test="stars"]').should('have.length', 5)
})

在这一点上,我们遇到了细微差别,因为可能还有其他很好的方法来定位此内容,这只是一个例子。但归根结底,如果我们担心这样的细节,那就太好了,因为至少我们对测试的 HTML 中嵌入的无障碍功能有一些了解,并且我们希望将这些功能包含在我们的测试中。

当 DOM 很重要时,测试它

如果我们仔细考虑如何告诉测试与哪些元素交互以及预期哪些内容,前端测试将为我们带来更多价值。我们应该首选可访问的名称来定位交互式组件,并且应该包含特定元素名称、ARIA 角色等信息,用于非交互式内容(如果这些内容与功能相关)。这些定位器(在可行的情况下)创建了强度和易碎性的正确组合。

当然,对于所有其他内容,我们还有 data-test