自动化的前端测试非常棒。我们可以用代码编写一个测试来访问一个页面--或者只加载一个组件--并让测试代码像用户一样点击东西或输入文本,然后对交互后的应用程序的状态进行断言。这让我们确认测试中所描述的一切在应用程序中按预期工作。
由于这篇文章是关于任何自动化UI测试的构建模块之一,我不认为有太多的预先知识。如果你已经熟悉了基础知识,请随意跳过前几节。
前端测试的结构
在编写测试时,有一个经典的模式是很有用的。安排,行动,断言。在前端测试中,这意味着一个测试文件要做以下事情。
- 安排。为测试做好准备。访问某个页面,或用正确的道具安装某个组件,模拟一些状态,等等。
- 行动。对应用程序做一些事情。点击一个按钮,填写一个表格,等等。或者不做,对于简单的状态检查,我们可以跳过这个。
- 断言。检查一些东西。提交表单时是否显示了感谢信息?它是否用POST向后端发送了正确的数据?
在指定与什么交互,然后在页面 上 检查什么时,我们可以使用各种元素定位器来定位我们需要使用的DOM部分。
一个定位器可以是一个元素的ID,一个元素的文本内容,或者一个CSS选择器,比如.blog-post
,甚至article > div.container > div > div > p:nth-child(12)
。关于一个元素的任何东西,只要能让你的测试运行程序识别出这个元素,都可以成为一个定位器。正如你可能已经从最后一个CSS选择器中看出,定位器有很多种类。
我们经常从脆性或稳定性的角度来评估定位器*。*一般来说,我们希望有一个最稳定的元素定位器,这样我们的测试总是可以找到它所需要的元素,即使该元素周围的代码随着时间的推移而变化。也就是说,不惜一切代价最大限度地提高稳定性会导致防御性的测试编写,实际上削弱了测试。我们通过结合脆性和稳定性来获得最大的价值,这与我们希望我们的测试关注的内容相一致。
这样一来,元素定位器就像胶带。它们应该在一个方向上非常坚固,而在另一个方向上容易撕裂。当应用程序发生不重要的变化时,我们的测试应该保持一致并不断通过,但当发生与我们在测试中指定的内容相矛盾的重要变化时,他们应该很容易失败。
前端测试中元素定位器的初级指南
首先,让我们假设我们是在为一个实际的人写指示,以完成他们的工作。一个新的大门检查员刚刚在大门检查员公司被雇用。你是他们的老板,在每个人都被介绍之后,你应该给他们指示如何检查他们的第一个门。如果你希望他们能够成功,你可能不会给他们写这样的说明。
经过黄色的房子,一直走,直到你碰到迈克母亲的朋友的山羊那次失踪的地方,然后左转,告诉我你对面的房子前面的门是否打开。
这些指示有点像用一个长的CSS选择器或XPath作为定位器。它很脆--而且是 "坏的那种脆"。如果黄房子被重新粉刷,你重复这些步骤,你就再也找不到门了,可能会决定放弃(或者在这种情况下,测试失败)。
同样,如果你不知道迈克的母亲的朋友的山羊的情况,你就不能在正确的参考点停下来,知道要检查什么门。这正是 "糟糕的那种脆性 "的糟糕之处--测试可能因为各种原因而中断,而这些原因都与门的可用性无关。
所以让我们做一个不同的前端测试,一个更稳定的测试。毕竟,在这个地区的法律上,某条道路上的所有闸门都应该有制造商提供的唯一序列号。
到序列号为1234的门前,检查它是否打开。
这更像是通过其ID来定位一个元素。它更稳定,而且只有一个步骤。上一次测试的所有故障点都已经被移除。这个测试只有在具有该ID的门没有按预期打开时才会失败。
现在,事实证明,虽然在同一条道路上没有两个闸门应该有相同的ID,但实际上这一点在任何地方都没有被执行。有一天,道路上的另一个闸门最终有了相同的ID。
因此,下一次新雇用的大门检查员去测试 "1234号门 "时,他们先找到了另一个门,现在他们去错了房子,检查了错误的东西。测试可能会失败,或者更糟的是:如果那个门像预期的那样工作,测试仍然通过,但它没有测试到预期的对象。它提供了虚假的信心。即使我们原来的目标大门在半夜被偷了,它也会一直通过,被偷门的人偷了。
在发生这样的事件后,很明显,ID并不像我们想象的那样稳定。因此,我们做了一些下一步的思考,决定在大门的内部,我们希望有一个特殊的ID,只是为了测试。我们将派出一名技术人员,只在这扇门上安装特殊的ID。新的测试指令是这样的。
到带有测试ID "我最喜欢的门 "的门前,检查它是否打开。
这个就像使用流行的data-testid
属性。像这样的属性很好,因为在代码中很明显,它们是为自动测试使用的,不应该被改变或删除。只要门有这个属性,你就能一直找到这个门。就像ID一样,唯一性仍然没有被强制执行,但它的可能性更大一点。
这是离脆性最远的一次,它证实了 gate 的功能。除了我们为测试而特意添加的属性,我们不依赖于任何东西。但这里隐藏着一点问题...
这是一个大门的用户界面测试,但定位器是没有用户会用来寻找大门的东西。
这是一个错失的机会,因为在这个假想的县里,原来大门是需要印上门牌号的,这样人们才能看到地址。所以,所有的大门都应该有一个独特的面向人的标签,如果没有,这本身就是一个问题。
在用测试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中。如果该元素在一定时间后不存在,测试就会失败,而像type
和click
这样的动作,则是为了验证元素的可见性、启用性,以及在采取动作前没有被其他东西阻挡。
因此,即使在这样一个简单的测试中,我们也得到了很多 "免费 "的东西,但是我们也引入了一些我们(和我们的用户)并不真正关心的依赖性。我们正在检查的特定ID和类似乎足够稳定,特别是与div.main > p:nth-child(3) > span.is-a-button
或其他选择器相比。那些长选择器是如此具体,以至于DOM的一个小变化都可能导致测试失败,因为它找不到这个元素,而不是因为功能被破坏。
但即使是我们的短选择符,如#name
,也有三个问题。
- ID可能在代码中被改变或删除,导致元素被忽略,特别是当表单在一个页面上可能出现不止一次时。可能需要为每个实例生成一个唯一的ID,而这不是我们可以轻易预先填入测试的东西。
- 如果页面上有一个以上的表单实例,并且它们有相同的ID,我们需要决定填写哪个表单。
- 从用户的角度来看,我们实际上并不关心ID是什么,所以所有内置的断言都有点......没有完全利用?
对于问题一和问题二,推荐的解决方案通常是在我们的HTML中使用专门为测试而添加的专用数据属性。这样做更好,因为我们的测试不再依赖于DOM结构,当开发者修改组件周围的代码时,测试将继续通过而不需要更新,只要他们保持data-test="name-field"
连接到正确的input
元素。
但这并没有解决第三个问题--我们仍然有一个前端交互测试依赖于对用户毫无意义的东西。
互动元素的有意义的定位器
当元素定位器依赖于我们真正想要依赖的东西时,它们就是有意义的,因为关于定位器的某些东西对用户体验很重要。在互动元素的情况下,我认为最好的选择器是元素的可访问名称。像这样的东西是理想的。
// 👍 Recommended
cy.getByLabelText('Name').type('Mark')
这个例子使用了Cypress测试库的byLabelText帮助器。(事实上,如果你正在使用任何形式的测试库,它可能已经在帮助你编写像这样的可访问定位器了)。
这很有用,因为现在内置的检查(我们通过cy.type()
命令免费获得)包括表单字段的可访问性。所有的交互式元素都应该有一个可访问的名称,这个名称可以暴露给辅助技术。例如,这就是屏幕阅读器用户知道他们正在输入的表单字段被称为什么,以便输入所需的信息。
为表单字段提供这种无障碍名称的方法通常是通过一个ID与字段相关的label
元素。来自Cypress测试库的getByLabelText
命令可以验证该字段的标签是否恰当,同时也可以验证该字段本身是一个允许有标签的元素。因此,例如,在尝试使用type()
命令之前,下面的HTML会正确地失败,因为即使有一个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-cy
或data-test
属性之前,让我们先做一下判断,如果DOM根本不重要,这些属性就会一直在那里为我们服务。
在我们使用通用定位器之前,让我们记住:DOM的状态是我们作为网络开发者的全部事情(至少,我认为是)。而DOM对于那些没有在视觉上体验页面的人来说,是驱动用户体验的。因此,很多时候,可能会有一些有意义的元素,我们可以或应该在代码中使用,我们可以在测试定位器中包含。
如果没有,因为。比如说,应用程序的代码都是像div
和span
这样的通用容器,我们应该考虑先修复应用程序的代码,同时添加测试。否则,就有可能使我们的测试实际上指定了通用容器是预期的和需要的,从而使某人更难修改该组件以使其更容易访问。
这个话题开启了一罐关于可及性在组织中如何运作的麻烦事。通常,如果没有人谈论它,它也不是我们公司实践的一部分,我们作为前端开发者就不会认真对待可及性。但在一天结束时,我们应该是什么是设计的 "正确标记 "的专家,以及在决定时应该考虑什么。我在Connect.Tech 2021的演讲中讨论了更多这方面的问题,演讲的题目是"研究和编写可访问的Vue...东西"。
正如我们在上面看到的,对于我们传统上认为是交互式的元素*,*有一个非常好的经验法则,可以很容易地建立在我们的前端测试中:**交互式元素应该有可感知的标签与该元素正确关联。**因此,任何交互式的东西,当我们测试它时,应该使用所需的标签从DOM中选择。
对于那些我们不认为是交互式的元素--比如大多数显示文本片段的内容渲染元素,除了一些基本的地标,如main
,如果我们把大部分非交互式内容放到通用的div
或span
容器中,就不会引发任何Lighthouse的审核失败。但是,这种标记不会有很好的信息量,也不会对辅助技术有帮助,因为它没有向看不见的人描述内容的性质和结构。(要想知道这一点的极端情况,请看Manuel Matuzovic的优秀博文:"以完美的Lighthouse评分建立最难访问的网站")。
我们呈现的HTML是我们向任何不以视觉方式感知内容的人传达重要的背景信息的地方。HTML被用来构建DOM,DOM被用来创建浏览器的可访问性树,而可访问性树是各种辅助技术可以用来向使用我们软件的残疾人表达内容和可以采取的行动的API。读屏器往往是我们首先想到的例子,但可及性树也可以被其他技术使用,比如把网页变成盲文的显示器等等。
自动的可及性检查不会告诉我们,我们是否真的为内容创建了正确的HTML。HTML的 "正确性 "是我们开发人员对我们认为需要在可及性树中传达的信息所做的判断。
一旦我们做了这个判断,我们就可以决定有多少信息适合植入自动化的前端测试。
比方说,我们已经决定,一个带有status
ARIArole
的容器将容纳一个联系表格的 "谢谢 "和错误信息。这可能很好,这样表格的成功或失败的反馈就可以由读屏器宣布。.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)可能会更有用。
这有几个原因。
- 我曾经和那些想从内容管理系统中驱动一切的客户一起工作。内容,甚至是表单标签和状态信息,如果不在内容管理系统中存在,都是要避免的。客户希望完全控制,这样内容的改变就不需要编辑代码和重新部署网站。这是有道理的;代码和内容是不同的概念。
- 我曾在许多多语言代码库中工作过,所有的文字都需要通过一些国际化的框架拉进来,就像这样。
<label for="name">
<!-- prints "Name" in English but something else in a different language -->
{{content[currentLanguage].contactForm.name}}
</label>
- 就前端测试而言,如果我们不检查特定的 "谢谢 "信息,而是做这样的事情,我们的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结构刚好是什么,然后退回到 "第三个div
中的第二个列表项,类别为card
"的测试风格要好得多。
也有一些时候,当内容被期望是动态的,并且没有办法轻易地从一些共同的真实来源中抓取字符串来用于我们的测试。在这些情况下,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
。