使用 Cypress 测试 Vue 组件

Avatar of Mark Noonan
Mark Noonan

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

Cypress 是一个用于基于浏览器的应用程序和页面的自动化测试运行器。多年来我一直使用它为 Web 项目编写端到端测试,并且最近很高兴地看到 Cypress 中出现了组件测试功能。我正在开发一个大型企业级 Vue 应用程序,并且我们已经使用 Cypress 进行端到端测试。我们的大多数单元测试和组件测试都是使用 JestVue Test Utils 编写的。

当 Cypress 中出现组件测试时,我的团队都支持升级和尝试它。您可以直接从 Cypress 文档 中了解有关组件测试工作原理的很多信息,因此我将跳过一些设置步骤,重点介绍使用组件测试是什么感觉 - 它们看起来像什么,我们如何使用它们,以及我们发现的一些 Vue 特定的陷阱和帮助程序。

声明!在我写这篇文章的第一稿时,我是一家大型车队管理公司的前端团队负责人,我们使用 Cypress 进行测试。从撰写本文到现在,我已经开始在 Cypress 工作,在那里我可以为开源测试运行器做出贡献。

The Cypress component test runner is open, using Chrome to test a “Privacy Policy” component. Three columns are visible in the browser. The first contains a searchable list of component test spec files; the second shows the tests for the currently-spec; the last shows the component itself mounted in the browser. The middle column shows that two tests are passing.

此处提到的所有示例在使用 Cypress 8 编写时均有效。这是一个仍在 Alpha 阶段的新功能,如果将来更新中这些细节发生变化,我不会感到意外。

如果您已经具备测试和组件测试的背景,您可以 直接跳到我们团队的经验

组件测试文件的外观

为了简化示例,我创建了一个项目,其中包含一个“隐私政策”组件。它包含标题、正文和确认按钮。

The Privacy Policy component has three areas. The title reads “Privacy Policy”; the body text reads “Information about privacy that you should read in detail,” and a blue button at the bottom reads “OK, I read it, sheesh.”

单击按钮时,会发出一个事件,让父组件知道已确认。以下是它在 Netlify 上的部署

现在,以下是在 Cypress 中使用我们即将讨论的一些功能的组件测试的总体形状

import { mount } from '@cypress/vue'; // import the vue-test-utils mount function
import PrivacyPolicyNotice from './PrivacyPolicyNotice.vue'; // import the component to test

describe('PrivacyPolicyNotice', () => {
 
 it('renders the title', () => {
    // mount the component by itself in the browser 🏗
    mount(PrivacyPolicyNotice); 
    
    // assert some text is present in the correct heading level 🕵️ 
    cy.contains('h1', 'Privacy Policy').should('be.visible'); 
  });

  it('emits a "confirm" event once when confirm button is clicked', () => {
    // mount the component by itself in the browser 🏗
    mount(PrivacyPolicyNotice);

    // this time let's chain some commands together
    cy.contains('button', '/^OK/') // find a button element starting with text 'OK' 🕵️
    .click() // click the button 🤞
    .vue() // use a custom command to go get the vue-test-utils wrapper 🧐
    .then((wrapper) => {
      // verify the component emitted a confirm event after the click 🤯
      expect(wrapper.emitted('confirm')).to.have.length(1) 
      // `emitted` is a helper from vue-test-utils to simplify accessing
      // events that have been emitted
    });
  });

});

该测试对用户界面和开发者界面做了一些断言(感谢 Alex Reviere 以对我来说有意义的方式表达了这种划分)。对于 UI,我们使用其预期文本内容定位特定元素。对于开发人员,我们正在测试发射了哪些事件。我们还隐式测试该组件是否是一个格式正确的 Vue 组件;否则它将无法成功挂载,所有其他步骤都将失败。并且通过断言特定类型的元素用于特定目的,我们正在测试组件的可访问性 - 如果该可访问的按钮变成了不可聚焦的div,我们就会知道。

以下是将按钮替换为div后的测试代码。这有助于我们通过让我们知道是否意外地进行了替换,来维护按钮元素提供的预期键盘行为和辅助技术提示

The Cypress component test runner shows that one test is passing and one is failing. The failure warning is titled 'Assertion Error' and reads 'Timed out retrying after 4000ms: Expected to find content: '/^OK/' within the selector: 'button' but never did.'

一些准备工作

既然我们已经了解了组件测试的外观,让我们后退一步,谈谈它如何在我们的整体测试策略中发挥作用。这些事物的定义有很多,所以简而言之,在我的代码库中

  • 单元测试确认单个函数在开发人员使用时按预期执行。
  • 组件测试以隔离的方式挂载单个 UI 组件,并确认它们在最终用户和开发人员使用时按预期执行。
  • 端到端 测试访问应用程序并执行操作,并确认整个应用程序在最终用户使用时按预期执行。

最后,集成测试对我来说是一个比较模糊的术语,可以在任何级别进行 - 导入其他函数的单元、导入其他组件的组件,甚至模拟 API 响应而不是到达数据库的“端到端”测试,都可能被视为集成测试。它们测试了应用程序中多个部分协同工作的情况,但不是整个应用程序。我不确定它作为类别的实际用处,因为它似乎非常广泛,但是不同的人和组织以其他方式使用这些术语,因此我想提一下。

有关不同类型的测试以及它们与前端工作关系的更详细概述,您可以查看 Evgeny Klimenchenko 的 “前端测试适合所有人”

组件测试

在上面的定义中,不同的测试层由使用代码的人员以及与该人员的契约来定义。因此作为开发人员,用于格式化时间的函数在提供给它一个有效的 Date 对象时应该始终返回正确的结果,如果提供给它的是其他东西,也应该抛出明确的错误。这些是我们通过单独调用函数并验证它对各种条件的响应是否正确来测试的,独立于任何 UI。“开发人员接口”(或 API)完全是关于代码与其他代码的交互。

现在,让我们放大组件测试。“契约”组件实际上是两个契约

  • 对于使用组件的开发人员来说,如果根据用户输入或其他活动发出预期的事件,那么该组件的行为是正确的。将属性类型和验证规则包含在“正确面向开发人员的行为”的概念中也是合理的,尽管这些东西也可以在单元级别进行测试。作为开发人员,我真正希望从组件测试中得到的是知道它已经挂载,并且根据交互发送了它应该发送的信号。
  • 对于与组件交互的用户来说,如果UI 始终反映组件的状态,那么它的行为是正确的。这不仅包括视觉方面。组件生成的 HTML 是其 可访问性树 的基础,可访问性树为屏幕阅读器等工具提供了 API 以正确地宣布内容,因此对我来说,如果组件没有为内容呈现正确的 HTML,则它就没有“按预期执行”。

至此,很明显组件测试需要两种断言 - 有时我们检查 Vue 特定的事情,比如“特定类型发出多少个事件?”,有时我们检查面向用户的事情,比如“可见的成功消息是否真的出现在屏幕上?”

组件级测试也感觉是一个强大的文档工具。测试应该断言组件的所有关键功能 - 依赖的定义行为 - 并忽略不重要的细节。这意味着我们可以查看测试来了解(或者在六个月或一年后记住!)组件的预期行为是什么。而且,如果一切顺利,我们可以更改测试中未明确断言的任何功能,而无需重写测试。设计更改、动画更改、改进 DOM,所有这些都应该是可能的,如果测试失败了,那是因为一个你关心的原因,而不是因为一个元素从屏幕的一部分移动到了另一部分。

最后这一点在设计测试时需要格外注意,尤其是在选择元素交互的选择器时,因此我们将在后面回到这个话题。

Vue 组件测试如何在有和没有 Cypress 的情况下工作

从高层次来看,Jest 和 Vue Test Utils 库的组合已经成为我所见过的运行组件测试的标准方法。

Vue Test Utils 为我们提供了挂载组件、提供其选项以及模拟组件可能正常运行所依赖的各种事物的帮助程序。它还在挂载的组件周围提供了一个wrapper对象,使对组件正在发生的事情进行断言变得更加容易。

Jest 是一个很棒的测试运行器,它将使用jsdom模拟浏览器环境来设置挂载的组件。

Cypress 的组件测试运行器本身使用 Vue Test Utils 来挂载 Vue 组件,因此这两种方法之间的主要区别在于上下文。Cypress 已经在浏览器中运行端到端测试,组件测试的工作方式相同。这意味着我们可以看到我们的测试运行、在测试中途暂停它们、与应用程序交互或检查运行过程中发生的事件,并且知道应用程序依赖的浏览器 API 是真实的浏览器行为,而不是这些相同功能的jsdom模拟版本。

组件挂载后,我们之前在端到端测试中使用 Cypress 的所有常用操作都适用,并且解决了一些有关元素选择方面的痛点。主要的是,Cypress 将负责模拟所有用户交互,并对应用程序对这些交互的响应进行断言。这完全涵盖了组件契约中面向用户的部分,但面向开发人员的部分,例如事件、道具和其他一切呢?这就是 Vue Test Utils 回归的地方。在 Cypress 中,我们可以访问 Vue Test Utils 在挂载组件周围创建的包装器,并对其进行断言。

我喜欢这种方法是因为 Cypress 和 Vue Test Utils 都被用于它们最擅长的领域。我们可以完全不用框架特定的代码来测试组件的行为,就像用户一样,并且只在需要时使用 Vue Test Utils 来挂载组件并检查特定的框架行为。我们永远不必在执行完一些 Vue 特定的操作来更新组件状态后再去 await Vue 特定的 $nextTick。对于团队中没有 Vue 经验的新开发人员来说,这始终是最难解释的事情——在编写 Vue 组件测试时,他们何时以及为什么需要 await 某些东西。

我们组件测试的经验

组件测试的优势听起来很棒,但当然,在一个大型项目中,很少有事情可以做到开箱即用,而且当我们开始进行测试时,我们遇到了几个问题。我们运行着一个大型的企业级 SPA,该 SPA 使用 Vue 2 和 Vuetify 组件库 构建。我们的大部分工作都大量使用了 Vuetify 的内置组件和样式。因此,虽然“单独测试组件”的方法听起来不错,但我们学到的一大教训是,我们需要为组件提供一些上下文才能进行挂载,并且还需要让 Vuetify 和一些全局样式生效,否则什么都无法工作。

Cypress 有一个 Discord,人们可以在那里寻求帮助。当我遇到困难时,我在那里提问。社区成员——以及 Cypress 团队成员——热心地为我提供示例仓库、代码片段和解决问题的想法。以下列出了我们为了让组件正确挂载而需要理解的一些小事,遇到的错误以及其他任何有趣或有帮助的信息。

导入 Vuetify

通过潜伏在 Cypress Discord 中,我看到了 Bart Ledoux 的这个 示例组件测试 Vuetify 仓库,所以那是我的起点。该仓库将代码组织成一个相当常见的模式,其中包含一个 plugins 文件夹,该文件夹中的插件导出一个 Veutify 实例。该实例被应用程序本身导入,但也可以被我们的测试设置导入,并在挂载被测试的组件时使用。在该仓库中,一个命令被添加到 Cypress 中,它将替换默认的 mount 函数,使其使用 Vuetify 挂载组件。

以下是使这一切发生所需的所有代码,假设我们在 commands.js 中完成了所有操作,并且没有从 plugins 文件夹中导入任何内容。我们正在使用一个 自定义命令 来做到这一点,这意味着在我们的测试中,我们不会直接调用 Vue Test Utils 的 mount 函数,而是实际调用我们自己的 cy.mount 命令。

// the Cypress mount function, which wraps the vue-test-utils mount function
import { mount } from "@cypress/vue"; 
import Vue from 'vue';
import Vuetify from 'vuetify/lib/framework';

Vue.use(Vuetify);

// add a new command with the name "mount" to run the Vue Test Utils 
// mount and add Vuetify
Cypress.Commands.add("mount", (MountedComponent, options) => {
  return mount(MountedComponent, {
    vuetify: new Vuetify({});, // the new Vuetify instance
    ...options, // To override/add Vue options for specific tests
  });
});

现在,我们始终会在挂载组件时使用 Vuetify 以及我们的组件,并且我们仍然可以传入为该组件本身所需的所有其他选项。但我们不需要每次都手动添加 Veutify。

添加 Vuetify 需要的属性

上面新的 mount 命令唯一的问题是,为了正常工作,Vuetify 组件需要在特定的 DOM 上下文中渲染。使用 Vuetify 的应用程序将所有内容都包装在一个 <v-app> 组件中,该组件代表应用程序的根元素。有几种方法可以处理这个问题,但最简单的方法是在挂载组件之前,向命令本身添加一些设置。

Cypress.Commands.add("mount", (MountedComponent, options) => {
  // get the element that our mounted component will be injected into
  const root = document.getElementById("__cy_root");

  // add the v-application class that allows Vuetify styles to work
  if (!root.classList.contains("v-application")) {
    root.classList.add("v-application");
  }

  // add the data-attribute — Vuetify selector used for popup elements to attach to the DOM
  root.setAttribute('data-app', 'true');  

return mount(MountedComponent, {
    vuetify: new Vuetify({}), 
    ...options,
  });
});

这利用了 Cypress 本身必须创建一些根元素才能实际挂载组件的事实。该根元素是组件的父元素,并且具有 ID __cy_root。这给了我们一个方便的地方来添加 Vuetify 期望找到的正确类和属性。现在,使用 Vuetify 组件的组件将看起来和表现正确。

在进行了一些测试后,我们发现的另一件事是,必需的类 v-application 具有 display 属性,其值为 flex。这在使用 Vuetify 的容器系统的完整应用程序上下文中是有意义的,但当挂载单个组件时,这对我们来说有一些不必要的视觉副作用——因此,我们在挂载组件之前添加了一行代码来覆盖该样式。

root.setAttribute('style', 'display: block');

这消除了偶尔的布局问题,然后我们就真正完成了调整挂载组件周围的上下文。

将 spec 文件放在我们想要的地方

许多示例都展示了这样的 cypress.json 配置文件,用于组件测试

{
  "fixturesFolder": false,
  "componentFolder": "src/components",
  "testFiles": "**/*.spec.js"
}

这实际上非常接近我们想要的,因为 testFiles 属性接受一个 glob 模式。这个模式表示:“在任何文件夹中查找以 .spec.js 结尾的文件。” 在我们的例子中,以及可能还有许多其他情况,项目的 node_modules 文件夹包含了一些不相关的 spec.js 文件,我们通过添加前缀 !(node_modules) 来排除它们,如下所示

"testFiles": "!(node_modules)**/*.spec.js"

在确定这个解决方案之前,我们在进行实验时,将其设置为一个特定的文件夹,其中包含组件测试,而不是一个 glob 模式,该模式可以在任何地方匹配它们。我们的测试与我们的组件并排放置,因此这本来是可以的,但实际上我们有两个独立的 components 文件夹,因为我们打包并发布了我们应用程序的一小部分,供公司其他项目使用。在早期进行更改后,我承认我确实忘记了它最初是一个 glob,并且在进入 Discord 之前开始偏离正轨,在那里我得到了提醒并想通了。拥有一个地方可以快速检查某个方法是否正确,这在很多时候都很有帮助。

命令文件冲突

按照上面概述的模式来让 Vuetify 与我们的组件测试一起工作,产生了一个问题。我们把所有这些东西都堆积在同一个 commands.js 文件中,该文件用于常规的端到端测试。因此,虽然我们运行了几个组件测试,但我们的端到端测试甚至没有启动。在组件测试中只需要的导入之一,导致了早期错误。

我被推荐了几种解决方案,但那天我选择将挂载命令及其依赖项提取到自己的文件中,并在组件测试中仅在需要的地方导入它。由于这是运行两组测试时出现任何问题的唯一来源,因此这是一种将问题从端到端上下文中的删除的干净方法,并且它作为一个独立的函数工作良好。如果我们遇到其他问题,或者下次我们进行清理时,我们可能会按照给出的主要建议,使用两个单独的命令文件,并在它们之间共享通用部分。

访问 Vue Test Utils 包装器

在组件测试的上下文中,Vue Test Utils 包装器在 Cypress.vueWrapper 下可用。当访问它进行断言时,使用 cy.wrap 来使结果可链式调用,就像通过 cy 访问的其他命令一样。 Jessica Sachs 在她的 示例仓库 中添加了一个简短的命令来做到这一点。因此,再次在 commands.js 中,我添加了以下内容

Cypress.Commands.add('vue', () => {
  return cy.wrap(Cypress.vueWrapper);
});

这可以在测试中使用,如下所示

mount(SomeComponent)
  .contains('button', 'Do the thing once')
  .click()
  .should('be.disabled')
  .vue()
  .then((wrapper) => {
    // the Vue Test Utils `wrapper` has an API specifically setup for testing: 
    // https://vue-test-utils.vuejs.org/api/wrapper/#properties
    expect(wrapper.emitted('the-thing')).to.have.length(1);
  });

对我来说,这开始读起来很自然,并且清楚地划分了我们何时与 UI 交互,以及何时检查通过 Vue Test Utils 包装器揭示的详细信息。它还强调了,就像很多 Cypress 一样,要充分利用它,重要的是要了解它所利用的工具,而不仅仅是 Cypress 本身。Cypress 包含 Mocha、Chai 和各种其他库。在这种情况下,了解 Vue Test Utils 是一个独立的开源解决方案,它拥有自己的完整文档集,并且在上面的 then 回调中,我们处于 Vue Test Utils 的世界——而不是 Cypress 的世界——因此我们可以去正确的地方寻找帮助和文档。

挑战

由于这只是一个最近的探索,我们还没有将 Cypress 组件测试添加到我们的 CI/CD 管道中。失败不会阻止拉取请求,我们还没有考虑添加这些测试的报告。我不认为会有任何意外,但值得一提的是,我们还没有完成将这些集成到我们的整个工作流程中。我不能具体说明它。

组件测试运行程序也处于早期阶段,还有一些小问题。起初,似乎每隔一次测试运行都会显示一个 linter 错误,需要手动刷新。我没有查明原因,然后它自己修复了(或被更新的 Cypress 版本修复了)。我希望新工具会出现这样的潜在问题。

另一个关于组件测试的一般障碍是,根据您的组件工作方式,在没有大量工作模拟系统其他部分的情况下,可能很难挂载它。如果组件与多个 Vuex 模块交互或使用 API 调用来获取自己的数据,则在挂载组件时需要模拟所有这些。虽然端到端测试几乎可以毫不费力地在任何在浏览器中运行的项目上运行,但现有组件的组件测试对您的组件设计更敏感。

这对于任何单独挂载组件的东西都是一样的,比如 Storybook 和 Jest,我们也使用过它们。通常当您尝试单独挂载组件时,您会意识到您的组件实际上有多少依赖项,而且似乎需要花费很多努力才能提供正确的上下文来挂载它们。从长远来看,这促使我们朝着更好的组件设计方向发展,使组件更容易测试,同时触及更少的代码库部分。

因此,如果您还没有组件测试,并且不确定要模拟什么才能挂载您的组件,请仔细选择第一个组件测试,以限制您必须在测试运行程序中看到组件之前需要正确处理的因素数量。选择一个小的、演示性的组件,该组件呈现通过道具或插槽提供的內容,以查看组件测试在实际操作中的效果,然后再深入依赖项的细节。

好处

组件测试运行程序对我们团队来说很有效。我们已经在 Cypress 中进行了广泛的端到端测试,因此团队熟悉如何启动新的测试和编写用户交互。我们也一直在使用 Vue Test Utils 进行单个组件测试。所以这里实际上没有太多新东西需要学习。最初的设置问题可能会令人沮丧,但那里有很多友好的热心人士可以帮助解决问题,所以我很高兴我使用了“寻求帮助”的超能力。

我会说我们发现了两个主要的好处。一个是测试代码本身在不同测试级别之间的一致方法。这很有帮助,因为不再需要进行思维转换来考虑 Jest 和 Cypress 交互之间的细微差异,浏览器 DOM 与 jsdom 以及类似问题。

另一个是能够独立地开发组件并在开发过程中获得视觉反馈。通过为开发目的设置组件的所有变体,我们得到了 UI 测试的概要,也许还有一些断言。感觉我们从一开始就从测试过程中获得了更多价值,所以它不像是在票证结束时附加的任务。

这个过程对我们来说还不算完全是测试驱动开发,虽然我们可以慢慢转向这种方式,但它通常是“演示驱动”的,因为我们想要展示新 UI 部件的状态,而 Cypress 是一个非常好的方法,使用 cy.pause() 在特定交互后冻结运行的测试并讨论组件的状态。考虑到这一点进行开发,知道我们将使用这些测试在演示中逐步介绍组件的功能,这有助于以有意义的方式组织测试,并鼓励我们在开发时间而不是之后涵盖所有我们能想到的场景。

结论

当我第一次了解 Cypress 的整体功能时,我很难理解它的具体工作原理,因为它将测试生态系统中的许多其他开源工具包装在一起。您可以快速使用 Cypress,而无需深入了解引擎盖下使用了哪些其他工具。

这意味着,当出现问题时,我记得不确定应该考虑哪个层级——是 Mocha 的问题吗?Chai 的问题吗?测试代码中的 jQuery 选择器错误?Sinon 间谍的错误使用?在某个时刻,我需要退一步,了解这些单独的拼图碎片,以及它们在我的测试中扮演着什么样的确切角色。

组件测试仍然是这种情况,现在又多了一层:框架特定的库来挂载和测试组件。从某种程度上来说,这需要更多开销,需要学习更多东西。另一方面,Cypress 以一种连贯的方式集成了这些工具,并管理它们的设置,因此我们可以避免仅仅为了组件测试而进行完全无关的测试设置。对我们来说,我们已经想要独立地挂载组件以使用 Jest 进行测试,以及在 Storybook 中使用,所以我们提前想出了很多必要的模拟想法,并且倾向于由于这个原因而偏爱具有简单道具/事件基于接口的良好分离的组件。

总的来说,我们喜欢使用测试运行程序,而且我感觉我在我审查的拉取请求中看到了更多测试(以及更易读的测试代码!),所以对我来说,这是一个表明我们朝好的方向迈进的信号。