用Cypress测试Vue组件的方法

1,192 阅读22分钟

Cypress是一个基于浏览器的应用程序和页面的自动化测试运行器。我使用它为网络项目编写端到端测试已经很多年了,最近我很高兴看到单个组件的测试也来到了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用我喜欢的方式来表达这种划分)。对于用户界面,我们针对特定的元素和它们预期的文本内容。对于开发者来说,我们正在测试哪些事件被发射出来。我们也在隐含地测试该组件是一个正确形成的Vue组件;否则它将无法成功安装,所有其他步骤都将失败。通过断言特定种类的元素用于特定目的,我们正在测试组件的可访问性--如果那个可访问的button ,变成了一个不可聚焦的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的"Front-End Testing is For Everyone"

组件测试

在上面的定义中,不同的测试层是由谁将使用一段代码以及与该人的合同是什么来定义的。因此,作为一个开发者,当我提供给它一个有效的Date对象时,一个格式化时间的函数应该总是返回正确的结果,如果我提供给它不同的东西,也应该抛出明确的错误。这些都是我们可以通过独立调用函数并验证其对各种条件的正确响应来进行测试的,与任何UI无关。一个函数的 "开发者接口"(或API)都是关于代码与其他代码的对话。

现在,让我们放大组件的测试。一个组件的 "合同 "实际上是两个合同。

  • 对于使用组件的开发者来说,如果预期的事件是基于用户输入或其他活动发出的,那么组件的行为就是正确的。在我们的 "面向开发者的正确行为 "的概念中包括道具类型和验证规则也是公平的,尽管这些东西也可以在单元级别上测试。作为一个开发者,我真正想从一个组件的测试中得到的是知道它安装了,并根据交互发出了它应该发出的信号。
  • 对于与组件交互的用户来说,如果用户界面在任何时候都能反映出该组件的状态,那么它的行为就是正确的。这不仅仅包括视觉方面。组件生成的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

我们在组件测试方面的经验

组件测试的优势对我们来说听起来很好,但当然,在一个大型项目中,很少有东西能做到开箱即用,当我们开始测试时,我们遇到了一些问题。我们使用Vue 2和Vuetify组件库构建了一个大型企业SPA。我们的大部分工作都大量使用Vuetify的内置组件和样式。因此,虽然 "自行测试组件 "的方法听起来不错,但我们学到的一个重要教训是,我们需要为我们的组件设置一些上下文,让它们被安装在其中,我们还需要让Vuetify和一些全局样式发生,否则任何东西都不会起作用。

赛普拉斯有一个Discord,人们可以在那里寻求帮助,当我遇到困难时,我就在那里问问题。来自社区的人们--以及Cypress团队成员--友好地将我引向实例库、代码片断和解决我们问题的想法。这里列出了我们需要了解的一些小事,以便让我们的组件正确安装,我们遇到的错误,以及其他有趣或有用的东西。

导入Vuetify

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

这里是实现这一目标所需的所有代码,假设我们在commands.js 中做了所有事情,并且没有从plugins 文件夹中导入任何东西。我们用一个自定义的命令来做这件事,这意味着我们不是在测试中直接调用Vue Test Utilsmount 函数,而是实际调用我们自己的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');

这清除了偶尔出现的布局问题,然后我们就真正完成了对安装组件的周边环境的调整。

把规格文件放在我们想要的地方

外面的很多例子都显示了一个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在她的例子 repo中添加了一个简短的命令来做到这一点。因此,再次在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测试的大纲,也许还有一些断言。感觉我们从测试过程中得到了更多的价值,所以它不像一个票据末尾的栓塞任务。

这个过程对我们来说并不完全是测试驱动的开发,尽管我们可以偏向于此,但它往往是 "演示驱动 "的,因为我们想展示新的用户界面的状态,而Cypress是一个相当好的方法来做到这一点,使用cy.pause() ,在特定的交互后冻结运行中的测试,并谈论组件的状态。在开发时考虑到这一点,知道我们将在演示中使用测试来完成组件的功能,有助于以有意义的方式组织测试,并鼓励我们在开发时而不是开发后覆盖所有我们能想到的场景。

结论

当我第一次了解到Cypress时,对它的具体作用的心理模型对我来说是很棘手的,因为它包含了测试生态系统中的许多其他开源工具。你可以使用Cypress快速启动和运行,而不需要深入了解引擎盖下有哪些其他工具被利用。

这意味着,当事情出错时,我记得我不确定我应该考虑哪一层--某些东西不工作是因为Mocha的问题?一个Chai问题?我的测试代码中一个糟糕的jQuery选择器?对Sinon间谍的使用不正确?在某种程度上,我需要退后一步,了解这些独立的拼图,以及它们在我的测试中所扮演的确切角色。

组件测试仍然是这种情况,现在还有一个额外的层次:框架特定的库来装载和测试组件。在某些方面,这是更多的开销和更多的学习。另一方面,赛普拉斯以一种连贯的方式整合了这些工具,并管理它们的设置,因此我们可以避免仅仅为了组件测试而进行整个不相关的测试设置。对我们来说,我们已经想要独立地安装组件,以便用Jest进行测试,并在Storybook中使用,所以我们提前想好了很多必要的嘲弄想法,并倾向于支持具有简单的props/events接口的分离的组件。

总的来说,我们喜欢使用测试运行器,而且我觉得我看到更多的测试(和更多可读的测试代码!)出现在我审查的拉动请求中,所以对我来说,这是一个迹象,表明我们已经朝着一个好的方向发展。