💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
[TOC] # 测试什么 你是否曾在应用中部署了一个新功能,并且希望这不会意外地产生一个新的 bug?那么通过测试应用程序就可以大大减少此类担忧,并增强你的 Vue 应用程序。 一个经过彻底测试的应用程序通常由多种测试的良好实现组合,包括端到端 (E2E) 测试、有时是集成测试、快照测试和单元测试。本课程专门作为 Vue 中单元测试的初学者指南。正如你将在整个课程中看到的,单元测试是测试良好的应用程序的基础。 在本课程中,我们将使用流行的 Jest JavaScript 测试库来运行我们的测试,还有[Vue Test Utils](https://vue-test-utils.vuejs.org/),它是 Vue 的官方单元测试实用程序库。 ## 编写测试的目标 首先,需要清楚地了解测试应用程序的好处。通过编写更多的代码来测试代码,我们能从这些额外的工作中获得什么呢? ### 提升信心 除了在新功能部署后晚上睡得更好之外,测试还可以帮助团队中的每个人达成共识。如果你是一个代码库(repository)的新手,看到一套测试就像有一个经验丰富的开发人员坐在你旁边,看着你的代码,确保你正处于代码应该做的事情的正确轨道上。有了这些测试,您可以确信在添加新功能或更改现有代码时不会破坏任何东西。 ### 质量代码 当您在编写组件时考虑到测试时,您最终将创建独立的、更可重用的组件。如果您开始为组件编写测试,并且注意到它们不容易测试,那么这是一个明确的信号,表明您可以重构组件,从而最终改进它们。 ### 更好的文档 正如在第一点中提到的,测试的另一个结果是,它可以最终为您的开发团队生成良好的文档。当一个人刚刚接触一个代码库时,他们可以从测试中寻找指导,这些测试可以为组件应该如何工作提供洞察力,并为可能需要测试的边缘案例提供线索。 ## 确定要测试的内容 测试是有价值的,但是应该在应用程序中测试什么呢?很容易走极端,测试一些不必要的东西,这不必要地减慢了开发时间。那么,我们在一个 Vue.js 应用程序中测试什么呢?答案其实很简单:组件。由于 Vue 应用程序只是一个由相互关联的组件组成的拼图,我们需要测试它们各自的行为,以确保它们能够正常工作。 ### 组件契约 当我第一次了解 Vue 单元测试的时候,我发现当 Ed Yerburgh (他写了一本关于[测试 vuejs 应用程序](https://www.oreilly.com/library/view/testing-vuejs-applications/9781617295249/)的书) 谈到组件契约(*The Component Contract*)的思考时,这个单元测试很有帮助。通过这种方式,我们指的是组件和应用程序的其余部分之间的协议。 例如,假设一个组件接收了一个`min`和`max` prop,并在这些 prop 值之间生成一个随机数字,然后将这个数字渲染给 DOM。组件契约上说:我将接受两个 prop,并用它们产生一个随机数。本契约内在的是**输入**和**输出**的概念。组件同意接收`min`和`max` prop作为输入,并输出一个随机数。因此,我们可以通过考虑组件契约,并确定输入和输出,开始挑选我们应该测试的内容。 在高级别,常见的输入(inputs)和输出(Outputs)如下: **输入** * 组件 Data * 组件 Props * 用户交互 * Ex: user clicks a button用户点击一个按钮 * 生命周期方法 * `mounted()`,`created()`, etc.等等 * Vuex Store * 路由参数 **输出** * 呈现给 DOM 的内容 * External function calls外部函数调用 * Events emitted by the component组件发出的事件 * 改变路由 * 更新 Vuex Store * 与子组件之间的联系 * i.e. 子组件的变化 通过专注于这些方面,您可以避免专注于内部业务逻辑。 单元测试的目标纯粹是为了确保您的组件产生预期的结果。我们在这里不关心它是如何达到这个结果的。 我们甚至可能会改变我们以后在逻辑上达到那个结果的方式,所以我们不希望我们的测试对应该如何实现这个结果做出不必要的规定。 这由您的团队来确定实现这个结果的最有效途径。这不是测试的工作。就单元测试而言,如果它可行,那么就可以运行。 既然已经知道了要测试什么,那么来看几个基本示例,并确定可以在每个例子中测试什么。 ## 示例:**AppHeader 组件** 在本例中,如果 `loggetin` 属性为 `true`,则有一个组件将显示一个`Logout`按钮。 `AppHeader.vue`: ``` <template> <div> <button v-show="loggedIn">Logout</button> </div> </template> <script> export default { data() { return { loggedIn: false } } } </script> ``` 为了弄清楚我们应该测试这个组件的哪个部分,我们的第一步是确定组件的输入和输出。 **输入** * Data(`loggedIn`) * 这个数据属性决定按钮是否显示,因此我们应该测试这个输入 **输出** * 渲染输出(`button`) * 基于输入(`loggedIn`),按钮是否在应该显示的时候显示在 DOM 中? 对于更复杂的组件,将有更多的方面需要测试,但是同样的一般方法也适用。虽然对应该测试的内容有所了解当然是有帮助的,但是知道不应该测试的内容也是有帮助的。现在让我们来解开这个谜团。 ## 哪些是不用测试的 了解什么不需要测试是测试故事中很重要的一部分,许多开发人员没有考虑到这一点,反过来这又花费了他们大量的时间,而这些时间本可以花在其他地方。 让我们再看一下前面的例子,我们有一个组件,它接受一个 `min` 和 `max` props,并输出一个在这个范围内的随机数。我们已经知道我们**应该**测试呈现在 DOM 的输出,为此我们需要在测试中考虑 `min` 和 `max` prop。但是生成随机数的实际方法是什么呢?我们需要测试一下吗? 答案是否定的。为什么?因为我们**不需要迷失在实现细节中**。 ![](https://img.kancloud.cn/45/15/4515f2f73b1d544627262bb40a922dd5_1306x744.png) ### 不要测试实现细节 当进行单元测试时,我们不需要为某些事情*如何*工作而大惊小怪,只需要知道它们*确实*工作。 不需要设置一个测试来调用生成随机数的函数,确保它以某种方式运行。我们不关心这里的内部结构。 我们只关心组件是否产生了我们所期望的输出。通过这种方式,我们总是可以稍后再回来并替换实现逻辑 (例如,使用第三方库来生成随机数)。 ### 不要测试框架本身 开发人员经常尝试过多的测试,包括框架本身的内部工作。但是框架的作者已经建立了测试来做到这一点。例如,如果我们为我们的 `min` 和 `max` props 设置一些 prop 验证,指定它们需要是一个**数字**,我们可以相信,如果我们尝试传入一个字符串,Vue 将抛出一个错误。 我们不需要浪费时间做 Evan You 的工作和测试 Vue.js 框架。这也包括不在 Vue 路由器和 Vuex 上做不必要的测试。 ### 不要测试第三方库 如果您正在使用的第三方库是高质量的,那么它们已经有了自己的测试。我们不需要测试它们的内部结构。例如,我们不需要测试 Axios 是否按照应有的方式工作。Axios 团队为我们做到了。 如果我们不必要地担心这些事情,它们就会让我们陷入困境。如果你觉得你不能信任你正在使用的已经经过良好测试的库,也许这是您可能想避免使用它的一个迹象。 ### 小结 在本课中,在编写有效的单元测试之前,我们迈出了重要的第一步:确定在组件中应该和不应该测试什么。使用这种方法,我们可以明智地将时间集中在测试需要测试的部分上。在下一课中,我们将利用这些知识编写我们的第一个单元测试。 # 用 Jest 编写单元测试 在本课中,我们将使用 Jest 和 Vue Test Utils 编写第一个单元测试。您可以从这个页面的课程资源中可用的起始代码开始,或者您可以跟随并使用 Vue CLI 从头创建项目。 ## 创建我们的项目 使用 Vue CLI 创建一个新项目: ```shell npx @vue/cli create unit-testing-vue ``` 我们将选择 “Manually select features” ,然后单击 **enter 键**,这样我们就可以指定希望在新项目中包含哪些库。因为我们将学习如何使用 Vue 路由器和 Vuex 进行测试,选择这两个,当然还需要选择单元测试。 在前一步中,**Linter/Formatter** 是默认选择的,下一步允许我们定制该特性。对于这些,我选择了 **ESLint + Prettier** 和 保存时 lint 。这个配置完全取决于您的这个项目。 因为我们选择了**单元测试**作为项目中的一个特性,所以下一步将询问我们希望使用哪个库进行单元测试。我们将使用 **Jest**。 我们将把我们所有的配置放在它们自己的专用文件(dedicated config files)中,这样就可以在这里保留默认设置并按 **enter 键**。 你可以保存为一个预设置,如果不,输入 **n**,然后按 **enter 键**。然后,项目建造好了。 ## 熟悉项目结构 首先查看 package.json,在这里我们将看到为我们安装了 Jest 和 vue-test-utils。 ``` "devDependencies": { "@vue/cli-plugin-unit-jest": "^3.11.0", "@vue/test-utils": "1.0.0-beta.29" } ``` 这些库又是做什么的?提醒一下: [Jest](https://jestjs.io/) 是一个 JavaScript 测试框架,致力于简化单元测试。Jest 将为我们运行单元测试,并在测试通过或失败时向我们报告。虽然 Jest 是一个非常大的框架 (有很多关于这个主题的书) ,但是您只需要理解几个片段就可以编写一些基本的测试。 [Vue Test Utils](https://vue-test-utils.vuejs.org/) 是 Vue.js 的官方单元测试工具库。它使我们能够在测试中渲染组件,然后在这些渲染的组件上执行各种操作。这对于确定组件行为的实际结果至关重要。 我们已经安装了适当的测试工具。如何让它们发挥作用呢?注意 **package.json** 中的脚本命令: **package.json** ``` "scripts": { ... "test:unit": "vue-cli-service test:unit" }, ``` 这个命令实际上查看名为 `tests/unit` 的目录,并运行我们在 `ComponentName.spec.js` 文件中设置的测试。 如果我们查看`tests/unit`目录,就会发现已经创建了 `Example.spec.js` 文件。这是用于测试`src/components`目录中的 `HelloWorld.vue`组件的虚拟测试文件。现在,忽略`Example.spec.js`文件中写的内容,直接进入终端并输入`npm run test: unit`。 然后,将看到 `Example.spec.js` 中的测试正在运行,并且通过了。 ![](https://img.kancloud.cn/43/66/4366157d87b55b43be87a0f17ebcbe62_832x380.png) 我们将创建一个新组件,为其设置一些测试,并使用 `test:unit` 命令运行这些测试。 ## 新的组件和测试文件 在编写任何测试之前,我们需要一个组件来进行测试。因此,我们将删除 `src/components`中的 `HelloWorld.vue` 组件,然后创建一个名为 `AppHeader.vue` 的新文件,如下所示: ``` <template> <div> <button v-show="loggedIn">Logout</button> </div> </template> <script> export default { data() { return { loggedIn: false } } } </script> ``` 这个组件是一个简单的 App Header,当用户是 `loggedIn` 时,它会显示一个注销按钮。 现在有了要测试的组件,进入 `test/unit` 目录,删除示例测试文件并创建一个名为 `AppHeader.spec.js` 的新文件。正如你在 测试命名原则 里看到的,我们正在使用组件名称来测试 **AppHeader + spec.js**。Spec 代表 specification(规范),因为在这个文件中,我们实际上是*指定* AppHeader 组件应该如何运行,并测试该行为。 请注意,这些文件名**必须**包含`spec.js` ー 如果没有它,那么当我们使用 `npm run test: unit` 脚本时,它们将不会运行。 ## 确定要测试的内容 在为 `AppHeader.vue` 组件编写测试之前,需要先确定它的输入和输出。幸运的是,我们在上一课中已经讲过了。 **输入** * 数据(Data):`loggedIn` - 此数据属性确定按钮是否显示 **输出** * 渲染输出:`<button>` - 根据`loggedIn`输入,判断我们的按钮是否显示在 DOM 中。 我们知道,当`loggetin`等于 false(默认值)时,注销按钮不会显示在 DOM 中。当`loggetin`等于 true 时,将显示注销按钮。 所以我们对这个部分的测试是: 1. 如果用户没有登录,请不要显示退出按钮 2. 如果用户登录,显示退出按钮 ## 构建我们的第一个单元测试 现在我们已经知道要测试什么了,可以进入 AppHeader.spec.js 文件并开始编写测试了。首先,我们需要导入正在测试的组件。 AppHeader.spec.js: ~~~ import AppHeader from '@/components/AppHeader' ~~~ 现在,我们可以使用 Jest `describe()` 函数创建第一个测试块。 AppHeader.spec.js: ~~~ describe('AppHeader', () => { }) ~~~ `describe`块允许我们对相关的测试进行分组。当我们运行测试时,我们将看到控制台中输出的`describe`块的名称。 `describe() `接受一个字符串作为组件的名称,并接受一个函数作为测试的参数。其实,如果我们只有一个测试,我们不需要将它包装在一个`describe`块中。但是当我们有多个测试时,用这种方式组织它们是有帮助的。 现在已经有了测试的分组,可以开始编写这些单独的测试(individual tests)了。我们使用 Jest 的`test()`方法来实现这一点。 [`test()`](https://jestjs.io/docs/en/api#testname-fn-timeout)方法采用一个字符串来定义测试,并采用一个函数来定义实际的测试逻辑。 AppHeader.spec.js: ~~~ test('a simple string that defines your test', () => { // testing logic } ~~~ > 提示:您可能还会看到使用`it()`的测试块,同样可以运行,因为它是`test()`的别名。 > it <=> individual test 所以我们的两个测试开始时是这样的: AppHeader.spec.js: ~~~ test('if user is not logged in, do not show logout button', () => { // test body }) test('if a user is logged in, show logout button', () => { // test body }) ~~~ 目前我们已经建立了测试,但它们还没有执行任何逻辑。还需要在它们的主体中添加一些逻辑,以确定实际结果是否与预期的结果相匹配。 ### 断言的期望 在 Jest 中,我们使用断言来确定我们期望测试返回的内容是否与实际返回的内容相匹配。具体来说,我们使用 Jest 的 `expect()` 方法来实现这一点,该方法使我们能够访问许多 “匹配器” ,帮助我们将实际结果与预期结果进行匹配。 断言的语法基本上是这样的: ``` expect(theResult).toBe(true) ``` 在`expect()`方法内部,我们将要测试的结果本身放入。然后,我们使用**匹配器(matcher)**来确定结果是否是我们预期的那样。因此,在这里,我们使用通用的 Jest 匹配器`toBe()` 来说明:我们期望结果为真。 在编写测试时,首先编写一个您知道肯定会通过(或肯定会失败)的测试是有帮助的。例如,如果我们说: `expect(true).toBe(true)` 我们知道这一定会通过。传递给`expect()`的结果是`true`,我们说我们期望这个结果是`toBe` `true` 。所以如果我们运行这些测试,我们知道它们一定会通过,因为 `true` == `true`。 AppHeader.spec.js: ``` describe('AppHeader', () => { test('if a user is not logged in, do not show the logout button', () => { expect(true).toBe(true) }) test('if a user is logged in, show the logout button', () => { expect(true).toBe(true) }) }) ``` 如果这些测试没有通过,那么我们就知道在代码中的设置有错误。因此,编写类似这种的测试对其实是一种完备性测试,从而避免了原本可以通过的测试没有通过,反而浪费时间来调试测试代码。 理解如何编写测试,其实就是需要理解哪些匹配器是符合自己需要的,所以花一些时间来理解 [Jest 匹配器 API](https://jestjs.io/docs/en/expect)。 ## Vue Test Utils 的强大 现在我们已经完成了完备性测试,两个测试都通过了,接下来执行真正逻辑测试: 1. 如果用户没有登录,请不要显示退出(退出)按钮 2. 如果用户登录,显示退出按钮 为此,我们需要挂载 `AppHeader` 组件(以检查按钮在 DOM 中是否可见)。单独执行所有这些操作将是一个相当复杂的过程,但幸运的是,在 Vue Test Utils 的帮助下,这个库非常简单,因为该库与`mount`打包在一起。 将`mount`导入到我们的测试文件中,看看: AppHeader.spec.js: ``` import { mount } from '@vue/test-utils' import AppHeader from '@/components/AppHeader' describe('AppHeader', () => { test('if user is not logged in, do not show logout button', () => { const wrapper = mount(AppHeader) // mounting the component expect(true).toBe(true) }) test('if user is logged in, show logout button', () => { const wrapper = mount(AppHeader) // mounting the component expect(true).toBe(true) }) }) ``` 上面,在我们的每个测试中,`mount(AppHeader)` 方法创建了一个`wrapper` 常量,在其中。之所以将其称为包装器,是因为除了挂载组件外,此方法还创建了一个[包装器](https://vue-test-utils.vuejs.org/api/wrapper/),其中包含测试组件的方法。当然,了解包装器上的不同属性和方法是有帮助的,所以还是需要花一些时间研究[文档](https://vue-test-utils.vuejs.org/api/wrapper/)。 > **旁注:**在 Vue Test Utils 中,还有`shallowMount()`方法。如果您的组件具有子组件,则`shallowMount()`将返回该组件的简单实现,而不是完全呈现的版本。这很重要,因为单元测试的重点是隔离的组件,而不是该组件的子组件。 现在仍然没有执行实际的测试,现在有挂载了 AppHeader 组件的包装器,我们可以用它来写出完整的测试。 ### 测试按钮的可见性 在我们的第一个测试用例中,我们知道默认情况下用户没有登录(我们的输入是`loggedIn: false`),所以我们想检查并确保退出按钮不可见。 要对退出按钮的状态做出断言,我们需要获得对模板中定义的按钮元素的引用。为了实现这一点,我们将依赖于新**wrapper** 上提供的两种方法: :`find()`和`isVisible()`。`find()`方法将在我们的模板中搜索匹配选择器,以便找到我们的按钮,而 `isVisible()`将返回一个布尔值,告诉我们该按钮在组件中是否可见。 所以第一个测试看起来像这样: AppHeader.spec.js: ``` test('if user is not logged in, do not show logout button', () => { const wrapper = mount(AppHeader) expect(wrapper.find('button').isVisible()).toBe(false) }) ``` 对于第二个测试,以同样的方式找到按钮,但是这次希望它是可见的,因此将断言: `toBe (true)`。 *AppHeader.spec.js*: ~~~ test("if logged in, show logout button", () => { const wrapper = mount(AppHeader) expect(wrapper.find('button').isVisible()).toBe(true) }) ~~~ 因为有用户登陆时测试组件的行为 (当 `loggedIn` 为`true`时) ,所以需要更新这个值,否则这个测试就会失败。我们该怎么做?我们的测试使用的救援! ``` test("if logged in, show logout button", () => { const wrapper = mount(AppHeader) wrapper.setData({ loggedIn: true }) // setting our data value expect(wrapper.find('button').isVisible()).toBe(true) }) ``` 在这里,我们使用`wrapper`的内置 `setData()` 方法来设置数据,以适应我们正在测试的正确场景。现在,使用 `npm run test:unit` 运行测试时,它们应该都通过了!(Xee:哭了,我反正通不过,提示:`TypeError: wrapper.setData is not a function`,可能现在和 vue3 还有问题) ## 小结 刚刚介绍了很多步骤,这里重述一下我们刚刚做的: ![](https://img.kancloud.cn/a4/9c/a49cae3e6dc9332db0c065d6a2114982_754x481.png) 显然,测试的每个组件都会不一样,因此这些步骤可能会有所不同,特别是图中的步骤4。例如,我们可能需要设置 Props 或模拟用户交互,而不是设置数据(setting the data)。在以后的课程中,我们还会介绍更多的测试案例。 # 测试 Props 和用户交互 在上一课中,我们学习了如何编写和运行我们的第一个单元测试。在本课中,我们将继续为需要用户交互并接受一些props 的组件编写简单的测试。 ## 随机数组件 首先确定了一个组件的输入和输出,该组件在其`min`和`max` props 的范围内生成一个随机数。 下面是该组件的代码: *src/components/RandomNumber.vue*: ``` <template> <div> <span>{{ randomNumber }}</span> <button @click="getRandomNumber">Generate Random Number</button> </div> </template> <script> export default { props: { min: { type: Number, default: 1 }, max: { type: Number, default: 10 } }, data() { return { randomNumber: 0 } }, methods: { getRandomNumber() { this.randomNumber = Math.floor(Math.random() * (this.max - this.min + 1) ) + this.min; } } } </script> ``` ### 我们应该编写哪些测试? 就这个组件的输入而言,很明显 props 是输入,因为 props 实际上是输入到组件中的。另一个输入是用户的交互,不管用户是否单击了按钮,它都会运行生成随机数的方法。输出是显示`randomNumber` 的渲染 HTML。 **输入** Props: * `min`&`max` 用户交互: * 点击生成随机数按钮 **输出** 渲染输出(DOM) * 屏幕上显示的数字介于`min`和`max`之间吗? 我们可以利用这些知识来决定在这个组件中测试什么: 1. 默认情况下,`randomNumber`数据值应为`0` 2. I如果我们点击生成按钮,`randomNumber` 应该介于`1`(min)及`10`(max)之间 3. 如果我们改变`min`和`max` props为`200`及`300` 然后点击按钮,`randomNumber` 应该介于`200`(min)及`300`(max)之间 ## 随机数测试 为了测试这个组件,我们将创建一个新的测试文件:`/tests/unit/RandomNumber. spec.js` 现在,我们将简单地构建测试并编写我们知道将会失败的缺省断言。在上一课中,我们使用一个我们知道会通过的断言来构建测试。通过使用一个我们知道肯定会失败的断言,这可以达到一个类似的目的,即确保我们的组件一开始的工作正如我们所期望的那样。然后,我们将努力使测试通过。 */tests/unit/RandomNumber. spec.js:* ``` import { mount } from '@vue/test-util' import RandomNumber from '@/components/RandomNumber' describe('RandomNumber', () => { test('By default, randomNumber data value should be 0', () => { expect(true).toBe(false); }) test('If button is clicked, randomNumber should be between 1 and 10', () => { expect(true).toBe(false); }) test('If button is clicked, randomNumber should be between 200 and 300', () => { expect(true).toBe(false); }) }) ``` 运行 `npm run test:unit`,将看到这个测试有 3 个失败。 ## 检查默认随机数 看看这个组件,我们知道`randomNumber` 的默认值是 0,那么为什么还要测试它呢?如果我们团队中的其他人改变了默认的`randomNumber`呢?测试它可以使我们放心,在首次加载组件时将始终显示 0。 测试这的第一步是`mount`我们正在测试的组件(RandomNumber.vue),它提供了一个包装器,允许我们深入到组件中并测试需要的内容。这个测试看起来是这样的: */tests/unit/RandomNumber. spec.js* ~~~ test('By default, randomNumber data value should be 0', () => { const wrapper = mount(RandomNumber) expect(wrapper.html()).toContain('<span>0</span>') }) ~~~ 在这里,我们使用包装器来获取 `RandomNumber` 组件的 `HTML`,并断言我们期望 HTML 的内部 HTML `toContain`一个 0 的 `span`。 如果我们通过在终端中输入 `npm run test:unit` 来运行这个测试,我们将看到现在只有两个测试失败,并且我们已经通过了第一个测试。现在可以进行下一个测试,这需要一些用户交互。 ## 模拟用户交互 我们需要验证,当我们点击生成随机数按钮,我们在`min`和`max` props 之间获得了一个随机数。它们默认值分别为 1 和 10,因此随机数应该在这个范围内。 正如我们以前所做的,我们需要`mount` `RandomNumber`组件。这里的新概念是,我们需要触发点击生成随机数按钮 (使用`min`和`max` props 生成新的随机数的方法)。 我们将使用`find()`方法获得对 button 元素的引用,然后使用[`trigger()`](https://vue-test-utils.vuejs.org/api/wrapper/#trigger)方法触发 wrapper DOM 节点上的事件。`trigger`方法的第一个参数是一个字符串,用于指定要触发的事件类型。 在这种情况下,我们希望触发按钮上的 “单击” 事件。 */tests/unit/RandomNumber. spec.js*: ``` test('If button is clicked, randomNumber should be between 1 and 10', () => { const wrapper = mount(RandomNumber) wrapper.find('button').trigger('click') }) ``` 现在这个按钮被点击了,它会产生了一个随机数。需要在渲染的 html 中访问这个数字,我们可以写: ~~~ const randomNumber = parseInt(wrapper.find('span').element.textContent) ~~~ 这样可以有效地找到 span,并访问该元素的文本内容。但是因为我们需要内容是整数,所以我们使用了`parseInt`。 最后,可以使用其他的 Jest 断言来确保该随机数是落在 min prop 1 和 max prop 10 之间。 */tests/unit/RandomNumber. spec.js*: ~~~ test('If button is clicked, randomNumber should be between 1 and 10', () => { const wrapper = mount(RandomNumber) wrapper.find('button').trigger('click') const randomNumber = parseInt(wrapper.find('span').element.textContent) expect(randomNumber).toBeGreaterThanOrEqual(1) expect(randomNumber).toBeLessThanOrEqual(10) }) ~~~ 现在运行测试,可以看到 2 通过。现在我们可以进行最终的测试。 ## 设置不同的 prop 值 因为这个组件可以通过最小值和最大值来改变最小值和最大值的范围,我们需要对此进行测试。为此,我们将使用 `mount()` 方法,它可以传入可选的第二个参数,包括`propsData`。在这个例子中,可以用来重新设置`min`和`max`值,分别为 200 和 300。 */tests/unit/RandomNumber. spec.js*: ``` test('If button is clicked, randomNumber should be between 1 and 10', () => { const wrapper = mount(RandomNumber, { propsData: { min: 200, max: 300 } }) }) ``` 有了新的 `min` 和 `max`,这个测试看起来和我们上面写的非常相似。 */tests/unit/RandomNumber. spec.js*: ``` test('If button is clicked, randomNumber should be between 200 and 300', () => { const wrapper = mount(RandomNumber, { propsData: { min: 200, max: 300 } }) wrapper.find('button').trigger('click') const randomNumber = parseInt(wrapper.find('span').element.textContent) expect(randomNumber).toBeGreaterThanOrEqual(200) expect(randomNumber).toBeLessThanOrEqual(300) }) ``` 现在运行该测试了,所有的测试都应该通过了! ## 小结 并不是所有的组件都是一样的,所以测试它们通常意味着我们必须考虑模拟按钮点击和测试 props 。我们将继续学习有关测试 Vue 组件的常见方面的知识。 # 测试抛出的事件(Emitted Events) 在前面,我们考虑了测试一个组件,该组件包含一些 props,还可以单击按钮生成一个数字。这需要我们在测试中模拟(`trigger`)按钮的单击。这个 “ click” 属于原生 DOM 事件的范畴,但是在 Vue 组件中,我们通常需要我们的组件抛出它们自己的自定义事件,接下来,来测试这些事件。 ## 什么是自定义事件? 简而言之:有时候一个子组件需要让应用程序中的另一个组件知道内部发生了什么事情。它可以通过发出一个自定义事件(例如`formSubmitted`)来广播发生的事件,以让其父组件知道事件发生了。父级可以等待,监听事件的发生,然后在事件发生时做出相应的响应。 ## 启动代码 幸运的是,Vue Test Utils 为我们提供了一个[emitted API](https://vue-test-utils.vuejs.org/api/wrapper/emitted.html),可以使用它在组件中测试这些类型的自定义事件。稍后将探讨如何使用这个 API,首先看一下将要测试的组件。 *LoginForm.vue*: ``` <template> <form @submit.prevent="onSubmit"> <input type="text" v-model="name" /> <button type="submit">Submit</button> </form> </template> <script> export default { data() { return { name: '' } }, methods: { onSubmit() { this.$emit('formSubmitted', { name: this.name }) } } } </script> ``` 正如你所看到的,我们有一个非常简单的登录表单。注意我们如何使用 `onSubmit()`方法,它用于 `$emit`发出一个名为 `formSubmitted` 的自定义事件,该事件发送一个包含 name 数据的有效负载,该有效负载绑定到`input`元素。我们想要测试的是,当表单提交时,它确实会发出一个包含`name`的有效负载的事件。 ## 搭建测试文件 在编写测试时,我们以模仿实际终端用户与组件交互的方式来编写测试,会很有帮助。那么用户将如何使用这个组件呢?嗯,他们会找到文本输入字段,然后他们会添加自己的名字,然后提交表单。因此,我们将在测试中尽可能地复制这一过程,如下所示: *LoginForm.spec.js*: ``` import LoginForm from '@/components/LoginForm.vue' import { mount } from '@vue/test-utils' describe('LoginForm', () => { it('emits an event with a user data payload', () => { const wrapper = mount(LoginForm) // 1. Find text input // 2. Set value for text input // 3. Simulate form submission // 4. Assert event has been emitted // 5. Assert payload is correct }) }) ``` 上面,你可以看到我们已经导入了 LoginFrom 组件和 Vue Test Utils 的 `mount`,我们需要完成以下步骤: 1. 查找文本输入 2. 设置文本输入的值 3. 模拟表单提交 4. 已发出 Assert 事件 5. 断言有效负载是正确的 让我们实现这些步骤。 ## 设置文本输入值 首先,就像终端用户一样,找到文本输入并设置它的值。 *LoginForm.spec.js* ``` describe('LoginForm', () => { it('emits an event with user data payload', () => { const wrapper = mount(LoginForm) const input = wrapper.find('input[type="text"]') // Find text input input.setValue('Adam Jahr') // Set value for text input // 3. Simulate form submission // 4. Assert event has been emitted // 5. Assert payload is correct }) }) ``` ### 关于定位输入的注意事项 这对我们的特定需求非常有用,但是在这里值得一提的是,在生产测试中,您可以考虑在元素上使用特定于测试的属性,如下所示: ~~~ <input data-testid="name-input" type="text" v-model="name" /> ~~~ 然后,在您的测试文件中,您将找到使用该属性的`input`。 ~~~ const input = wrapper.find('[data-testid="name-input"]') ~~~ 这是有好处的,有几个原因。首先,如果我们有多个输入,我们可以使用这些 id 专门针对它们,也许更重要的是,这样可以将 DOM 从测试中分离出来。例如,如果您最终将原生输入替换为来自组件库的输入,那么测试仍然遵循相同的接口,并且不需要更改。它还解决了设计器更改元素的类或 id 名称导致测试失败的问题。特定于测试的属性(Test-specific attributes)是使测试具有未来可靠性的一种方法。 ## 模拟表单提交 一旦我们的终端用户填写好了我们的表单,下一步将提交表单。前面,我们讨论了如何使用 `trigger` 方法来模拟 DOM 元素上的事件。 虽然您可能想在表单的按钮上使用 `trigger` 来模拟表单提交,但是这样做可能会有一个问题。如果我们最终从这个组件中删除按钮,而是依赖输入的 `keyup.enter` 事件来提交表单,会怎样?我们将不得不重构我们的测试。换句话说:在这种情况下,我们的测试会与组件表单的实现细节紧密耦合(tightly coupled)。因此,更具前瞻性(future-proofed)的解决方案将是即使在表单本身上也可以强制提交,而无需依靠按钮作为中间人。 *LoginForm.spec.js*: ~~~ describe('LoginForm', () => { it('emits an event with user data payload', () => { const wrapper = mount(LoginForm) const input = wrapper.find('input[type="text"]') // Find text input input.setValue('Adam Jahr') // Set value for text input wrapper.trigger('submit') // Simulate form submission // 4. Assert event has been emitted // 5. Assert payload is correct }) }) ~~~ 现在,通过使用 `wrapper.trigger('submit')` ,实现了一个更具可伸缩性、解耦的解决方案,以模拟用户提交表单的过程。 ## 测试我们的期望 现在输入字段的值已经设置好了,表单也已经提交了,我们可以继续测试期望发生的事情是否真的发生了: * 事件已经发出 * 有效载荷是正确的 为了测试这个事件是否已经发出,我们将写入: *LoginForm.spec.js*: ``` describe('LoginForm', () => { it('emits an event with user data payload', () => { const wrapper = mount(LoginForm) const input = wrapper.find('input[type="text"]') // Find text input input.setValue('Adam Jahr') // Set value for text input wrapper.trigger('submit') // Simulate form submission // Assert event has been emitted const formSubmittedCalls = wrapper.emitted('formSubmitted') expect(formSubmittedCalls).toHaveLength(1) }) }) ``` 在这里,我们使用 Vue Test Utils 的 [emit API](https://vue-test-utils.vuejs.org/api/wrapper/emitted.html) 将 `formSubmitted` 事件的任何调用存储在一个常量中,并断言我们`expect`该数组的长度为`1`。换句话说:检查事件是否确实发出了。 现在,我们只需要确认发出的事件具有适当的有效负载(组件的`name`数据值)。我们将为此再次使用已发出的 API。 如果我们打开打印`wrapper.emitted('formSubmitted')`,我们会看到这样的结果: ~~~ [[], [{ 'name': 'Adam Jahr' }]] ~~~ 因此,为了瞄准有效负载本身,语法如下: ~~~ wrapper.emitted('formSubmitted')[0][0]) ~~~ 然后将其与预期的有效负载进行匹配,出于组织的目的,我们将其存储在`const expectedPayload = { name: 'Adam Jahr' }` 现在我们可以检查 `expectedPayload` 对象是否与 `formSubmitted` 事件一起发射的有效负载相匹配。 ~~~ const expectedPayload = { name: 'Adam Jahr' } expect(wrapper.emitted('formSubmitted')[0][0]).toMatchObject(expectedPayload) ~~~ > **旁注:** 我们也可以将预期的有效负载硬编码到匹配器:`.toEqual({ name: 'Adam Jahr' })`中。但是将它存储在一个常量中可以让我们更清楚地知道是什么对应什么。 我们的完整测试文件现在是这样的: *LoginForm.spec.js*: ``` import LoginForm from '@/components/LoginForm.vue' import { mount } from '@vue/test-utils' describe('LoginForm', () => { it('emits an event with user data payload', () => { const wrapper = mount(LoginForm) const input = wrapper.find('input[type="text"]') // Find text input input.setValue('Adam Jahr') // Set value for text input wrapper.trigger('submit') // Simulate form submission // Assert event has been emitted const formSubmittedCalls = wrapper.emitted('formSubmitted') expect(formSubmittedCalls).toHaveLength(1) // Assert payload is correct const expectedPayload = { name: 'Adam Jahr' } expect(wrapper.emitted('formSubmitted')[0][0]).toMatchObject(expectedPayload) }) }) ``` 终端命令:`npm run test:unit`,我们将看到新测试通过! ## 小结 我们已经学习了在演示用户如何与组件交互的同时编写测试,以便测试自定义事件是否以正确的有效负载发出。 # 测试 API 调用 除非您使用的是一个简单的静态网站,否则您的 Vue 应用很可能会从某些组件中进行 API 调用。我们将看看如何测试这些类型的数据获取组件。 关于测试进行 API 调用的组件,首先要了解的是,我们不希望对后端进行真正的调用。这样做将把我们的单元测试与后端结合起来。当我们希望在[持续集成](https://en.wikipedia.org/wiki/Continuous_integration)中执行单元测试时,这就成了一个问题。真正的后端也可能是不可靠的,我们需要我们的测试表现可预测的。 我们希望我们的测试是快速和可靠的,并且我们可以通过模拟我们的 API 调用来实现这一点,并且只关注我们正在测试的组件的输入和输出。在本课中,我们将使用 [axios](https://github.com/axios/axios) (流行的基于 promise 的 HTTP 客户端) 来进行调用。这意味着我们需要模仿 axios 的行为。但是首先让我们看一下起始代码。 ## 启动代码 为了简单起见,我们没有插入完整的后端,而是使用 [json-server](https://github.com/typicode/json-server),它提供了一个假的 REST API。你需要知道的是:`db.json` 文件是我们的数据库,json-server 可以从中获取数据。 我们的简单 db 有一个端点(endpoint): “消息” ,这就是我们要获取的数据。 *db.json*: ``` { "message": { "text": "Hello from the db!" } } ``` 在我们的项目中,我还添加了一个 API 服务层,它将处理实际的 API 调用。 *services/axios.js*: ~~~ import axios from 'axios' export function getMessage() { return axios.get('http://localhost:3000/message').then(response => { return response.data }) } ~~~ 正如您所看到的,我们已经导入了 axios,并导出了`getMessage()`函数,该函数向我们的端点发出 `get` 请求: `http://localhost:3000/message`,然后我们从响应返回数据。 查看触发这个 API 调用的组件,并显示返回的数据。 *MessageDisplay.vue*: ``` <template> <p v-if="error" data-testid="message-error">{{ error }}</p> <p v-else data-testid="message">{{ message.text }}</p> </template> <script> import { getMessage } from '@/services/axios.js' export default { data() { return { message: {}, error: null } }, async created() { try { this.message = await getMessage() } catch (err) { this.error = 'Oops! Something went wrong.' } } } </script> ``` 先从 axios.js 文件中导入了 getMessage 函数,在创建组件时,它使用 `async/await` 调用 `getMessage`,因为 **axios** 是异步的,我们需要等待它返回的承诺来解析。解析时,我们将组件的本地`message`数据设置为已解析值,该值将显示在模板中。 我们还将 `getMessage` 调用包装为 `try...catch`,来捕捉可能发生的错误,如果确实发生了错误,将相应地显示该错误。 ## 输入与输出 查看 **MessageDisplay.vue** 组件,在编写测试时需要考虑哪些输入和输出? 我们知道来自 `getMessage` 调用的响应是我们的输入,我们有两个可能的输出: 1. 调用成功进行,并显示消息 2. 调用失败,并显示错误 所以在我们的测试文件中,我们需要: 1. 模仿一个成功的`getMessage`调用,检查`message`显示 2. 模拟一个失败的`getMessage`调用,,检查`error`显示 ## 模拟 Axios 让我们构建测试块,导入我们正在测试的组件,挂载它,并使用注释来分离测试需要执行的操作。 *MessageDisplay.spec.js*: ``` import MessageDisplay from '@/components/MessageDisplay' import { mount } from '@vue/test-utils' describe('MessageDisplay', () => { it('Calls getMessage and displays message', async () => { // mock the API call const wrapper = mount(MessageDisplay) // wait for promise to resolve // check that call happened once // check that component displays message }) it('Displays an error when getMessage call fails', async () => { // mock the failed API call const wrapper = mount(MessageDisplay) // wait for promise to resolve // check that call happened once // check that component displays error }) }) ``` 让我们一个一个地填写这些测试。查看其中 “调用 `getMessage` 并显示消息” 的测试,我们的第一步是模拟 axios。同样,在测试进行 API 调用的组件时,我们不希望对数据库进行实际调用。我们可以使用 Jest 的 [mock](https://jestjs.io/docs/en/mock-functions.html) 函数简单地通过模拟该行为来调用。 为了模仿我们的 API 调用,我们首先从 **axios.js** 文件中导入 `getMessage` 函数。然后,我们可以通过向`jest.mock()`函数传递该请求函数所在文件位置的路径,来将该函数提供给它。 *MessageDisplay.spec.js*: ~~~ import MessageDisplay from '@/components/MessageDisplay' import { mount } from '@vue/test-utils' import { getMessage } from '@/services/axios' jest.mock('@/services/axios') ... ~~~ 您可以将 `jest.mock` 想象为:“我将获取您的 `getMessage` 函数,作为回报,我将给您一个模拟的 `getMessage` 函数。” ,当我们在测试中调用 `getMessage` 时,实际上我们调用的是这个函数的模拟版本,而不是实际版本。 让我们在测试中调用模拟的 `getMessage` 函数。 *MessageDisplay.spec.js*: ``` import MessageDisplay from '@/components/MessageDisplay' import { mount } from '@vue/test-utils' import { getMessage } from '@/services/axios' jest.mock('@/services/axios') describe('MessageDisplay', () => { it('Calls getMessage and displays message', async () => { const mockMessage = 'Hello from the db' getMessage.mockResolvedValueOnce({ text: mockMessage }) // calling our mocked get request const wrapper = mount(MessageDisplay) // wait for promise to resolve // check that call happened once // check that component displays message }) }) ``` 通过使用 jest 的 [`mockResolvedValueOnce()`]( https://jestjs.io/docs/en/mock-function-API.html#mockfnmockresolvedvalueoncevalue ) 方法,我们所做的正是按照方法名称的意思进行操作:模拟的调用 API 并返回一个模拟的值,以便调用用于解析。作为它的参数,这个方法接受我们希望这个模拟的函数用来 resolve 的值。换句话说,这就是放置模拟的请求应该返回的假数据的地方。因此,我们将传入`{ text: mockMessage }`来复制服务器将响应的内容。 正如您可以看到的,我们正在使用`async`,就像我们在以前的测试中一样,因为 axios(和我们的模拟 axios 调用)是异步的。因为在我们编写任何断言之前,我们需要确保我们的调用返回的 Promise 得到解决。否则,测试将在 Promise 解析之前运行,然后导致失败。 ## 等待 Promises 在确定在测试中`await`的位置时,需要想想在我们测试的组件中是如何调用 `getMessage`的。记住,它是在组件`created`生命周期挂钩上调用的吗? *MessageDisplay.vue*: ``` async created() { try { this.message = await getMessage() } catch (err) { this.error = 'Oops! Something went wrong.' } } ``` 由于 vue-test-utils 无法访问由`created`生命周期挂钩 请求的 pomise 的内部,因此我们实际上不能挖掘任何可以`await`这个 pomise 的内容。因此,这里的解决方案是使用一个名为 [flush-promises](https://www.npmjs.com/package/flush-promises) 的第三方库,它允许我们很好地兑现(flush)pomise,确保它们在运行我们的断言之前都得到了解决。 用 `npm i flush-promises -- save-dev` 安装了这个库,将它导入到我们的测试文件中,等待pomise的兑现(flush)。 *MessageDisplay.spec.js*: ~~~ import MessageDisplay from '@/components/MessageDisplay' import { mount } from '@vue/test-utils' import { getMessage } from '@/services/axios' import flushPromises from 'flush-promises' jest.mock('@/services/axios') describe('MessageDisplay', () => { it('Calls getMessage once and displays message', async () => { const mockMessage = 'Hello from the db' getMessage.mockResolvedValueOnce({ text: mockMessage }) const wrapper = mount(MessageDisplay) await flushPromises() // check that call happened once // check that component displays message }) }) ~~~ 既然我们已经确保在运行断言之前解决了 promises,那么可以编写这些断言了。 ## 我们的断言 首先,确保 API 调用只发生一次。 *MessageDisplay.spec.js*: ``` it('Calls getMessage once and displays message', async () => { const mockMessage = 'Hello from the db' getMessage.mockResolvedValueOnce(mockMessage) const wrapper = mount(MessageDisplay) await flushPromises() expect(getMessage).toHaveBeenCalledTimes(1) // check that call happened once // check that component displays message }) ``` 我们只是简单地运行`.toHaveBeenCalledTimes()`方法,并传递我们希望`getMessage`被调用的次数是:`1`。现在我们已经确保我们不会意外地多次访问我们的服务器。 接下来,检查组件是否显示了从 `getMessage` 请求接收到的消息。在 `MessageDisplay` 组件的模板中,显示消息的`p`标记有一个用于测试的 id:`data-testid="message"` *MessageDisplay.vue*: ~~~ <template> <p v-if="error" data-testid="message-error">{{ error }}</p> <p v-else data-testid="message">{{ message }}</p> </template> ~~~ 我们在前面已经学习了这些测试 id。我们将使用该 id 来`find`元素,然后断言其文本内容应等于我们的模拟`getMessage`请求所解析的值:`mockMessage` *MessageDisplay.spec.js*: ``` it('Calls getMessage once and displays message', async () => { const mockMessage = 'Hello from the db' getMessage.mockResolvedValueOnce({ text: mockMessage }) const wrapper = mount(MessageDisplay) await flushPromises() expect(getMessage).toHaveBeenCalledTimes(1) const message = wrapper.find('[data-testid="message"]').element.textContent expect(message).toEqual(mockMessage) }) ``` 如果在终端中运行`npm run test:unit`,将看到新编写的测试通过!可以继续进行第二个测试,我们将模拟失败的 getMessage 请求,并检查组件是否显示错误。 ## 模拟一个失败的请求 第一步,模仿失败的 API 调用,非常类似于我们的第一个测试。 *MessageDisplay.spec.js*: ~~~ it('Displays an error when getMessage call fails', async () => { const mockError = 'Oops! Something went wrong.' getMessage.mockRejectedValueOnce(mockError) const wrapper = mount(MessageDisplay) await flushPromises() // check that call happened once // check that component displays error }) ~~~ 请注意我们是如何使用 `mockRejectedValueOnce` 来模拟失败的获取请求的,并且我们将 `mockError` 传递给它来解决它。 在等待 promises 刷新(flushing)之后,我们可以检查调用是否只发生一次,并验证组件的模板是否显示预期的 `mockError`。 *MessageDisplay.spec.js*: ``` it('Displays an error when getMessage call fails', async () => { const mockError = 'Oops! Something went wrong.' getMessage.mockRejectedValueOnce(mockError) const wrapper = mount(MessageDisplay) await flushPromises() expect(getMessage).toHaveBeenCalledTimes(1) const displayedError = wrapper.find('[data-testid="message-error"]').element .textContent expect(displayedError).toEqual(mockError) }) ``` 就像我们的第一个测试,我们使用`.toHaveBeenCalledTimes(1)` 来确保 API 调用没有超出我们应该的范围,我们正在寻找显示错误消息的元素,并检查其文本内容与返回的模拟失败请求返回的 `mockError` 对比。 现在运行这些测试,会发生什么?测试失败了: **Expected number of calls: 1 Received number of calls: 2** 为什么呢?因为在第一个测试中,已经调用了 `getMessage`,在第二个测试中又调用了它。在运行第二个测试之前,没有清除模拟的 `getMessage` 函数。解决办法很简单。 ## 清除所有模拟 在创建 jest `mock` 的下面,可以添加解决方案,清除所有模拟。 *MessageDisplay.spec.js*: ~~~ jest.mock('@/services/axios') beforeEach(() => { jest.clearAllMocks() }) ~~~ 在运行每个测试之前,将确保清除了 `getMessage` 模拟,这把它被调回的次数重置为 0。 现在,运行测试时,它们都会通过了! ## 完整代码 *MessageDisplay.spec.js*: ``` import MessageDisplay from '@/components/MessageDisplay' import { mount } from '@vue/test-utils' import { getMessage } from '@/services/axios' import flushPromises from 'flush-promises' jest.mock('@/services/axios') beforeEach(() => { jest.clearAllMocks() }) describe('MessageDisplay', () => { it('Calls getMessage and displays message', async () => { const mockMessage = 'Hello from the db' getMessage.mockResolvedValueOnce({ text: mockMessage }) const wrapper = mount(MessageDisplay) await flushPromises() expect(getMessage).toHaveBeenCalledTimes(1) const message = wrapper.find('[data-testid="message"]').element.textContent expect(message).toEqual(mockMessage) }) it('Displays an error when getMessage call fails', async () => { const mockError = 'Oops! Something went wrong.' getMessage.mockRejectedValueOnce(mockError) const wrapper = mount(MessageDisplay) await flushPromises() expect(getMessage).toHaveBeenCalledTimes(1) const displayedError = wrapper.find('[data-testid="message-error"]').element .textContent expect(displayedError).toEqual(mockError) }) }) ``` ## 小结 我们已经学习来,在测试 API 调用时,同样的基本规则也适用:关注组件的输入(请求的响应)和输出(显示的消息或错误),同时注意避免测试和组件的实现细节之间的紧密耦合 (例如,通过测试 id 查找元素,通过元素类型查找元素)。还学习了如何使用 jest 来模拟我们的调用,以及第三方库 flush-promises 来等待我们的生命周期钩子中的异步行为。 接着,我们将学习什么是 stub ,以及它如何帮助我们测试父组件。 # stub 子组件 我们已经研究了模拟 axios 模块的过程,以测试我们的组件已设置为进行 API 调用并使用返回的数据,而不必碰到实际的服务器或不必要地将测试耦合到后端。 在我们的单元测试中,*模拟*某些东西的概念比模拟模块更广泛,不管它们是 axios 还是其他外部依赖。我们将深入探讨这个主题,并研究组件测试中的另一种伪造形式,称为 stubbing,以及为什么和什么时候这种方法可能有用。 ## Children with Baggage 为了探索这个概念,我想向您介绍 `MessageContainer`,它是 `MessageDisplay` (我们在前面中测试的组件) 的父组件。 *MessageContainer.vue*: ``` <template> <MessageDisplay /> </template> <script> import MessageDisplay from '@/components/MessageDisplay' export default { components: { MessageDisplay } } </script> ``` 正如您所看到的,`MessageContainer` 只是导入和包装 `MessageDisplay`。这意味着当 MessageContainer 被呈现时,`MessageDisplay` 也被呈现。所以我们遇到了上一课中的同样问题。我们并不希望真正触发 `MessageDisplay` 在`created`时发生的 axios `get` 请求。 *MessageDisplay.vue*: ~~~ async created() { try { this.message = await getMessage() // Don't want this to fire in parent test } catch (err) { this.error = 'Oops! Something went wrong.' } } ~~~ 那么解决方案是什么呢?我们如何测试 `MessageContainer` 而不触发它的子 axios 请求?或者说得更笼统一些:当子组件具有模块依赖性,而我们又不想在测试中使用真正的版本时,我们该怎么办? 问题的答案也许并不令人满意。因为答案是:这要看情况(it depends)。它取决于子模块的复杂性和数量。对于这个例子来说,东西是相当轻量级的。我们只有一个模块,因此我们可以简单地在 `MessageContainer` 的测试中模仿 axios,就像我们在 `MessageDisplay.spec.js `中所做的那样。但是如果我们的子组件有多个模块依赖项会怎样呢?在更复杂的情况下,更简单的方法通常是跳过模拟子组件的模块包袱(baggage),而是模拟子组件本身。换句话说:我们可以使用子组件的 **存根(stub)** 或假占位符版本。 完成了所有这些智能样板文件之后,继续这个例子,了解如何在`MessageContainer`的测试中对`MessageDisplay`进行存根处理。 ## MessageContainer 测试 我们从一个脚手架(scaffold)开始: *MessageContainer.spec.js*: ``` import MessageContainer from '@/components/MessageContainer' import { mount } from '@vue/test-utils' describe('MessageContainer', () => { it('Wraps the MessageDisplay component', () => { const wrapper = mount(MessageContainer) }) }) ``` 在这个测试中,我们在哪里以及如何给我们的孩子存根?请记住,正是 `MessageContainer` 的挂载将创建并挂载它的子元素,从而触发子元素的 API 调用。因此,在挂载其父组件时对子组件进行存根是很有意义的。为此,我们将在 `mount` 方法中添加一个 `stubs` 属性作为第二个参数。 *MessageContainer.spec.js*: ~~~ import MessageContainer from '@/components/MessageContainer' import { mount } from '@vue/test-utils' describe('MessageContainer', () => { it('Wraps the MessageDisplay component', () => { const wrapper = mount(MessageContainer, { stubs: { MessageDisplay: '<p data-testid="message">Hello from the db!</p>' } }) }) }) ~~~ 请注意,我们是如何将 `MessageDisplay` 组件添加到`stubs`属性中的,并且它的值是我们希望在实际挂载子组件时呈现的HTML。同样,这个存根是一个占位符,当我们挂载父类时它会被挂载。这是一种固定的反应;代替真实子组件的替代品。 现在,为了确保 `MessageContainer` 执行了包装 `MessageDisplay` 组件的 ****工作,我们需要查看挂载的内容,并查看是否能够找到来自`MessageDisplay`(我们的存根版本)的正确消息。 *MessageContainer.spec.js*: ``` describe('MessageContainer', () => { it('Wraps MessageDisplay component', () => { const wrapper = mount(MessageContainer, { stubs: { MessageDisplay: '<p data-testid="message">Hello from the db!</p>' } }) const message = wrapper.find('[data-testid="message"]').element.textContent expect(message).toEqual('Hello from the db!') }) }) ``` 我们将创建一个常量来存储我们期望被呈现的 `stubMessage`,在断言中,我们把它与挂载的消息(来自存根)进行比较。 在命令行中运行 `npm run test:unit`,确实可以看到我们的测试正在通过,并且我们已经确认 `MessageContainer` 正在做它的工作:包装 (被存根的) `MessageDisplay` 组件,该组件显示真正的子组件所具有的内容。太好了。 ## 存根的缺点 虽然存根可以帮助我们简化包含负担沉重的子组件的测试,但我们需要花一点时间考虑存根的缺点。 由于存根实际上只是子组件的占位符,如果实际组件的行为发生了变化,我们可能需要相应地更新存根。随着应用程序的发展,这可能会导致维护存根的**维护成本(maintenance costs)**。一般来说,存根可以在测试和组件的实现细节之间创建耦合。 此外,由于存根并不是*实际*的完全呈现的件,这样就减少了实际组件代码库的测试覆盖率,从而降低了测试给您的应用程序提供真实反馈的信心。 我提出这些观点不是为了阻止你使用存根,而是为了鼓励你明智而谨慎地使用它们,记住,像我们在其他章节中看到的那样,关注模拟模块和服务层通常是更好的实践。 ## 那么 ShallowMount 的呢? 您可能已经看到过在其他人的测试代码中使用浅挂载(`shallowMount`)。你可能已经听说过,这是一种只挂载顶级父层而不挂载其子层的简便方法(因此:*浅*,不深入子层)。那么,我们为什么不使用它呢?为什么我们要手动对子组件进行存根(stubbing)? 首先,`shallowMount`受到存根同样的缺点 (如果不是更多的话) 的影响:信心降低,耦合和维护增加。其次,如果你使用其他的测试库,比如 [Vue Testing Library](https://testing-library.com/docs/vue-testing-library/intro),你会发现它不支持`shallowMount`。这就是为什么没有教授它的原因。有关这方面的更多信息,可以参考 testinglibrary 的维护人员 Kent c. Dodds 撰写的[这篇文章](https://kentcdodds.com/blog/why-i-never-use-shallow-rendering)。 ## 总结 终于结束了这篇。希望您在这篇关于 Vue 应用单元测试的介绍中学到了很多。还有很多测试主题和生产级实践要讲,即将到来的单元测试生产课程会学到更多,该课程将在未来几个月内发布。敬请期待! 更多可以参考:[Vue 测试指南](https://lmiller1990.github.io/vue-testing-handbook/zh-CN/) # 参考 [组件单元测试的指导原则](https://zhuanlan.zhihu.com/p/140919158) [Testing logic inside a Vue.js watcher](https://vuedose.tips/testing-logic-inside-a-vue-js-watcher/) [vue-test-utils](https://vue-test-utils.vuejs.org/api/wrapper/) [VueMastery - Unit testing](https://coursehunters.online/t/vuemastery-unit-testing/2938)