发布于 2026-01-06 4 阅读
0

使用 VueJS 和 TypeScript 编写可测试代码(TL;DR)

使用 VueJS 和 TypeScript 编写的可测试代码

太长不看

太长不看

这是一篇关于约100行代码的详细教程。本教程的成果可以在以下代码库中找到:

GitHub 标志 nesterow / nuxt-testable

Nuxt 可测试

每当我编写代码时,单元测试总是我首先使用的调试工具,用来验证一切是否按预期运行。有时我会觉得,如果没有编写测试,开发过程简直无法想象。然而,在某些项目中,我根本无法使用测试驱动开发(TDD),因为遗留代码库要么不遵循任何好的原则(SOLID、GoF),要么开发人员根本不知道如何使用 VueJS 编写可测试的代码。更令我失望的是,我找不到任何关于如何使用 VueJS 编写客户端 JavaScript 应用并进行测试的可靠资料。

在本教程中,我想分享一些帮助我编写可测试的 VueJS 应用的模式。我将使用来自 Nuxt 社区的NuxtJS TypeScript 模板,以及基于类的 Vue 和 Vuex 组件设计风格。


设置环境

1. 生成应用程序框架并安装依赖项:

~$ vue init nuxt-community/typescript-template vue-testable
~$ cd vue-testable
~$ npm install
~$ npm install vuex-module-decorators
~$ npm install -D @babel/core @types/jest @typescript-eslint/eslint-plugin @typescript-eslint/parser @vue/eslint-config-typescript @vue/test-utils babel-core@7.0.0-bridge.0 babel-eslint babel-jest babel-plugin-dynamic-import-node babel-plugin-transform-decorators eslint eslint-config-google eslint-plugin-nuxt eslint-plugin-vue jest ts-jest vue-jest -D
Enter fullscreen mode Exit fullscreen mode

2. 设置 Jest

打开您的配置package.json文件并添加以下配置:

//package.json
{
 //....
 "jest": {
    "testRegex": "(/__tests__/*|(\\.|/)spec)\\.(jsx?|tsx?)$",
    "moduleFileExtensions": [
      "js",
      "ts",
      "json",
      "vue"
    ],
    "transform": {
      ".*\\.(vue)$": "vue-jest",
      "^.+\\.ts?$": "ts-jest",
      "^.+\\.js$": "babel-jest"
    },
    "testURL": "http://localhost/"
  }

}
Enter fullscreen mode Exit fullscreen mode

打开tsconfig.json并添加@types/jest到“类型”部分:

//package.json
{
 //...
 "types": [
   "@nuxt/types",
   "@nuxtjs/axios",
   "@types/jest"
 ]
}
Enter fullscreen mode Exit fullscreen mode

此外,如果“scripts”部分package.json没有“test”脚本,请添加以下内容:

//package.json
{
 //..
 "scripts": {
    //...
    "test": "NODE_ENV=test jest"
  },
}

Enter fullscreen mode Exit fullscreen mode

2. 设置 Babel 插件

这是可选步骤,但建议执行。如果您正在构建通用应用程序,这将dynamic imports非常有用。您可能需要在客户端动态导入库,因为某些 UI 库不关心服务器环境以及引用windowdocument对象。

打开package.json并添加以下配置:

//package.json
{
 //....
 "babel": {
    "plugins": [
      [
        "dynamic-import-node",
        {
          "noInterop": true
        }
      ]
    ],
    "env": {
      "test": {
        "presets": [
          [
            "@babel/preset-env",
            {
              "targets": {
                "node": "current"
              }
            }
          ]
        ]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

代码整理

让我们停下来思考一下如何组织应用程序代码。

目前的应用结构:

.
├── [assets]
├── [components]
├── [layouts]
├── [middleware]
├── [pages]
├── [static]
├── [store]
├── [types]
├── nuxt.config.ts
├── package-lock.json
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

大多数人到此为止,直接使用默认的样板代码。因为初始应用程序框架是自描述的,所以你无需考虑组件应该放在哪里。当你需要创建一个简单的应用程序或一个五页的网站时,这种方法确实有效。但是,如果你的应用程序扩展到数百个视图/页面呢?如果你需要大多数视图都足够可定制,以便在不同项目之间迁移呢?你该如何实现这一点?


模块

与其使用样板代码编写应用程序,我建议将默认应用程序结构视为独立模块组装点。我所说的“模块”含义更广,不仅限于 Nuxt 模块。在这种情况下,一个模块应该适用于任何基于 Vuex 的应用程序。

我们来看看 Vue/Vuex 应用的模块结构是什么样的。一个模块应该包含以下实体:组件、Vuex store、样式、REST API/中间件、类型定义等等。

现在,我们可以从应用程序中移除“components”和“middleware”目录,并添加“modules”目录:

.
├── [modules]
|     |
|     └──[module]
|          ├── [__tests__]
|          ├── [components]
|          ├── [store]
|          ├── index.vue
|          └── index.ts
|
├── [layouts]
├── [pages]
├── [static]
├── [store]
├── [types]
├── nuxt.config.ts
├── package-lock.json
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

基于类的组件。

将 Vue 组件编写成类可以编写出更简洁、更易于维护的代码。此外,它还让我们有机会以更简洁的方式使用继承和应用面向对象编程 (OOP) 模式。

以下库可以帮助我们以基于类的方式编写组件:
vuex-module-decoratorsnuxt-property-decorator。稍后我们将详细了解它们的工作原理。

编写一个简单的应用程序

我们来写一个简单的待办事项应用。我相信你以前也做过类似的,但这次我们不会直接跳到应用的视觉部分,而是先构建数据模型,从创建 Vuex store 开始。更重要的是,我们要先为 Vuex store 编写一个规范。“规范”其实就是“测试”的另一种说法。

在开发过程中,规范是首要的调试工具。如果你以前从未编写过测试,可以把它想象成一个功能更强大的“控制台日志”。

测试 Vuex 模块

首先,在我们的示例模块中创建两个新文件:store/todos.tsstore/__tests__/TodosStore.spec.ts

[modules]
    |
    └──[example]
        |
        ├── [store]
        |      ├──[__tests__]
        |      |        └── TodosStore.spec.ts
        .      └──todos.ts
Enter fullscreen mode Exit fullscreen mode

我们todos.ts暂时先导出为空文件:

// store/todos.ts
export default {}
Enter fullscreen mode Exit fullscreen mode

将以下代码添加到TodosStore.spec.ts

// store/__tests__/TodosStore.spec.ts

import Vuex from 'vuex'
import {createLocalVue} from '@vue/test-utils'
import {getModule} from 'vuex-module-decorators'
import TodosStore from '../todos'

const Vue = createLocalVue()
Vue.use(Vuex)

/**
 * Factory function returns a new store instance
 */
const factory = () => {
  const store = new Vuex.Store({
    modules: {
      todos: TodosStore
    }
  })
  return getModule(TodosStore, store)
}

/**
 * The test case
 */
describe('TodosStore', () => {
  it('has to get a store instance', async (done) => {
    const service = factory()
    expect(service).toBeInstanceOf(Object)
    done()
  })
})


Enter fullscreen mode Exit fullscreen mode
规格结构
  1. 进口
  • 为了创建一个 Vue 示例,我们将使用createLocalVue()from@vue/test-utils
  • 要将 Vuex 模块用作类实例,我们将使用getModule() 详细信息。
  1. 工厂功能
  • 工厂函数应该构建并返回我们的可测试组件。如果工厂函数比较复杂,我们可以将其放在一个单独的文件中。
  1. 测试用例
  • 你输入的所有内容都describe()应该与一个用例相关。
  • 单元测试位于内部it()
运行测试

我们先来尝试执行第一次测试:

~$ npm test

Error:
  Type '{}' provides no match for the signature 'new (...args: any[]): VuexModule<ThisType<any>, any>'.

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Enter fullscreen mode Exit fullscreen mode

测试应该会失败,因为我们还没有实现商店模块。

以下是TDD流程的大部分时间安排:

  1. 你编写了一个失败的测试。
  2. 你通过了测试。
  3. 你编写下一个失败的测试,然后返回第一步。

实际上,情况并非总是如此。有时需要在编写测试用例之前先编写测试对象,但如果你使用测试用例进行调试,这一点就无关紧要了。此外,并非所有内容都需要测试——只有那些影响程序正确性的部分才需要测试。

Vuex 模块

现在,我们来让测试通过。目前,只要我们创建一个完整的 Vuex 模块,测试就应该通过。

实用小贴士:

运行npm test -- --watch此命令可在每次保存文件时自动运行测试。

// store/todos.ts

import {Module, VuexModule, Mutation, Action} from 'vuex-module-decorators';
import {ITodosStore} from './types'

@Module({
  name: 'todos',
  namespaced: true
})
export default class extends VuexModule implements ITodosStore {

}
Enter fullscreen mode Exit fullscreen mode

别忘了随时添加类型定义:

// store/types.d.ts

export interface ITodosStore {

} 
Enter fullscreen mode Exit fullscreen mode
测试输出:
 PASS  modules/example/store/__tests__/TodosStore.spec.ts
  TodosStore
    ✓ has to get a store instance (7ms)
Enter fullscreen mode Exit fullscreen mode

第一次测试成功后,我们可以确定我们的商店实例已正确构建,可以继续创建实际的应用程序模型。

Vuex 状态和突变

在为 TypeScript 应用程序设计数据模型时,最好的起点是类型声明。让我们声明一个接口ITodo来描述待办事项的结构:

// store/types.d.ts

export interface ITodosStore {
  todos: ITodo[]
} 

export interface ITodo {
  id?: string,
  text: string
  timeCreated: Date
  isComplete?: boolean
}

Enter fullscreen mode Exit fullscreen mode

现在,让我们来具体说明负责改变状态的方法todos
我假设 Vuex 操作是异步的,并且返回一个Promise对象,但实际上 Vuex 操作是同步的,不应该返回任何值:

// store/types.d.ts

export interface ITodosStore {
  todos: ITodo[]
  setTodos: (todos: ITodo[]) => void
  pushTodo: (todo: ITodo) => void
  getTodos: () => Promise<ITodo[]>
  createTodo: (todo: ITodo) => Promise<ITodo>
  deleteTodo: (todo: ITodo) => Promise<any>
  setTodoComplete: (opts: {id: string, data: any}) => Promise<any>
} 

export interface ITodo {
  id?: string,
  text: string
  timeCreated: Date
  isComplete?: boolean
}

Enter fullscreen mode Exit fullscreen mode

此时运行测试会因为类型错误而失败。因为我们的 store 没有ITodosStore按预期实现接口。让我们来修复它:

// store/todos.ts

import {Module, VuexModule, Mutation, Action} from 'vuex-module-decorators';
import {ITodosStore, ITodo} from './types'

@Module({
  name: 'todos',
  namespaced: true,
})
export default class extends VuexModule implements ITodosStore {
  /**
   * Todos state
   */
  todos: ITodo[] = [];
  /**
   * Todos mutation
   * @param todos: ITodo[]
   */
  @Mutation
  setTodos(todos: ITodo[]) {
    this.todos = todos;
  }
  /**
   * pushTodo
   * @param todo: ITodo
   */
  @Mutation
  pushTodo(todo: ITodo) {
    this.todos.push(todo);
  }
  /**
   * getTodos
   * @returns Promise<ITodo[]>
   */
  @Action
  async getTodos(): Promise<ITodo[]> {
    this.setTodos([])
    return []
  }
  /**
   * createTodo 
   */
  @Action
  async createTodo(todo: ITodo) {
    return todo
  }
  /**
   * deleteTodo 
   */
  @Action
  async deleteTodo(todo: ITodo) {

  }
  /**
   * setTodoComplete 
   */
  @Action
  async setTodoComplete(todo: ITodo, isComplete: boolean) {

  }
}
Enter fullscreen mode Exit fullscreen mode

检测突变

商店结构设计完成后,就可以开始实现变更操作了。
我们将从编写测试开始:

// store/__tests__/TodosStore.spec.ts

...
it('setTodos', () => {
  const service = factory()
  const todo: ITodo = {
    id: '1',
    text: 'test',
    timeCreated: new Date,
    isComplete: false
  }
  service.setTodos([todo])
  expect(service.todos[0]).toBe(todo)
});
it('pushTodos', () => {
  const service = factory()
  const todo: ITodo = {
    id: '2',
    text: 'test',
    timeCreated: new Date,
    isComplete: false
  }
  service.pushTodo(todo)
  expect(service.todos[0]).toBe(todo)
})
...

Enter fullscreen mode Exit fullscreen mode

这些测试应该失败,因为我们的程序中存在一个小错误。如果您运行这些测试,第二个测试的输出会提示待办事项对象与预期不符。但实际上,store 中的对象与我们在前一个测试中检查的对象是匹配的。
要理解为什么会发生这种情况,我们需要了解 JavaScript 导入的工作原理,以及为什么导入factory是 JavaScript 中最常用的模式之一。出现这种情况的原因是模块缓存,它可以防止在不同的组件中导入相同的依赖项时重复执行。每次导入时,您都会从缓存中获取相同的实例。这就是为什么 Vue 要求您从工厂方法返回组件的状态data()。对于 Vuex store 来说,这似乎并不重要,但当您想要构建一个通用/SSR 应用程序时,每个客户端应用程序都必须接收自己的全局状态实例,这就显得尤为重要了。

为了解决这个问题,应该使用工厂方法构建 store 状态。在我们的例子中,我们需要stateFactory: true向 vuex 模块添加以下选项:

// store/todos.ts

import {Module, VuexModule, Mutation, Action} from 'vuex-module-decorators';
import {ITodosStore, ITodo} from './types'

@Module({
  name: 'todos',
  namespaced: true,
  stateFactory: true
})
export default class extends VuexModule implements ITodosStore {
...
}
Enter fullscreen mode Exit fullscreen mode

现在测试应该可以通过了,可能存在的错误原因也已解决。

依赖注入

现在我们需要考虑与服务器的通信问题。标准的应用程序模板建议使用Axios作为 Nuxt 插件来发送请求。我们将使用 Axios,但不会将其作为全局插件。

我不喜欢将这类依赖项与 Vuex store 耦合在一起。为了理解原因,不妨想象一下,你想把我们的待办事项模块复制粘贴到另一个应用程序中。如果新环境使用相同的 API,那就一切都很顺利了。但通常情况下并非如此,你唯一的选择就是深入研究代码,尝试让它在新环境中运行。我见过有人用大型组件做这种事,感觉并不轻松。

为了避免此类复杂情况,我们的模块应该依赖于抽象接口,而不是特定的 axios 实例。换句话说,我们应该能够配置我们的 store,以便在需要从不同的 API 获取数据时使用不同的 axios 实例。

为了使我们的模块可配置并抽象化某些依赖项,我们使用了实践控制反转技术的模式。这些模式包括依赖注入或提供/消费模式的某些变体(例如 Vue 的 provide/inject、高阶组件等)。

对于 Vue 基于类的组件,我决定编写类装饰器,使其对 Vue 组件和 Vuex 模块的工作方式相同。

安装provide-consume-decorator库:

~$ npm install provide-consume-decorator
Enter fullscreen mode Exit fullscreen mode

添加 Axios

在 `<style>` 标签中store/todos.ts,我们添加api一个引用 axios 实例的属性,并用以下方式装饰该类@provideVuex

// store/todos.ts
import axios, {AxiosInstance} from 'axios';
import {provideVuex, consume} from 'provide-consume-decorator';
import {Module, VuexModule, Mutation, Action} from 'vuex-module-decorators';
import {ITodosStore, ITodo} from './types'

@Module({
  name: 'todos',
  namespaced: true,
  stateFactory: true
})
@provideVuex({
  axios: ()=> axios.create()
})
export default class extends VuexModule implements ITodosStore {

  @consume('axios') api!: AxiosInstance;

  ...
}
Enter fullscreen mode Exit fullscreen mode

我们通过替换一个对象,实现了更改组件依赖项的功能。

模拟服务器

通常情况下,客户端应用程序的开发会领先于后端开发。无论出于何种原因,最好是确保 UI 能够处理实际的 HTTP 请求。Axios 社区提供了多种模拟 HTTP 请求的解决方案,方便您在本地复现 API 端点。这非常有用,但我建议您使用实际的后端服务进行测试,并且只模拟尚未实现的方法。

就我们而言,我们可以在客户端模拟整个 API。

我发现axios-mock-adapter库非常有用

~$ npm i -D axios-mock-adapter
Enter fullscreen mode Exit fullscreen mode

以下是我编写一个模拟 API 的方法axios-mock-adapter

// __tests__/todos.api.mock.ts

import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { ITodo } from '../types';
const $instance = axios.create();
const mock = new MockAdapter($instance);


const todos: ITodo[] = []

/**
 * get todos
 */
mock.onGet('/todos').reply((config) => {
  return [200, JSON.stringify(todos)]
})

/**
 * create a new todo
 */
mock.onPost('/todos').reply((config) => {
  const todo: ITodo = JSON.parse(config.data);
  todo.id = Math.random().toString();
  todos.push(todo);
  return [200, todo]
})

/**
 * update todo
 */
mock.onPut(/\/todos\/\.*/).reply((config) => {
  const id = config.url!.replace('/todos/', '')
  const data = JSON.parse(config.data)
  delete data.id;
  const index = todos.map((t) => t.id).indexOf(id)
  Object.assign(todos[index], data)
  return [200, 'ok']
})

/**
 * delete todo
 */
mock.onDelete(/\/todos\/\.*/).reply((config) => {
  const id = config.url!.replace('/todos/', '')
  const index = todos.map((t) => t.id).indexOf(id)
  todos.splice(index, 1)
  return [200, 'ok']
})

export default $instance;
Enter fullscreen mode Exit fullscreen mode

让我们把它付诸实践__tests__/todos.api.mock.ts

测试 Vuex 操作

准备好服务器模拟(或实际服务器)后,就该将其与我们的测试环境对接了。

为了在测试中使用不同的 API,我们将使用@provideVuex装饰器,只是这次我们将提供测试环境的依赖项。

让我们打开TodosStore.spec.ts并编辑工厂函数,如下所示:

// store/__tests__/TodosStore.spec.ts
...
import { provideVuex } from 'provide-consume-decorator'
import apiMock from './todos.api.mock'

const factory = () => {

  @provideVuex({
    axios: () => apiMock
  })
  class TodosStoreMock extends TodosStore {}

  const store = new Vuex.Store({
    modules: {
      todos: TodosStoreMock
    }
  })
  return getModule(TodosStoreMock, store)
}
...
Enter fullscreen mode Exit fullscreen mode

TodosStoreMock我们添加了一个继承自 actual 的新类TodosStore。然后我们提供了一个模拟的 axios 实例。对于任何依赖项,其工作方式都相同,例如,您可以localStorage为测试提供另一个配置对象或一个测试用例。

现在让我们来实现这些操作。和往常一样,我们将从编写测试开始:

// store/__tests__/TodosStore.spec.ts
...
it('createTodo/getTodos', async (done) => {
  const service = factory()
  await service.createTodo({
    id: '3',
    text: 'test1',
    timeCreated: new Date,
  })
  const todos = await service.getTodos()
  const todo = todos.find((e: ITodo) => e.text === 'test1')
  expect(todo).toBeInstanceOf(Object)
  expect(todo!.text).toEqual('test1')
  // getTodos should also save todos locally
  const localTodo = service.todos.find(e => e.text === 'test1')
  expect(localTodo).toBeInstanceOf(Object)
  expect(localTodo!.text).toEqual('test1')
  done()
})
...
Enter fullscreen mode Exit fullscreen mode

测试失败后,让我们创建实际的 API 调用store/todos.ts

...
  /**
   * getTodos
   * @returns Promise<ITodo[]>
   */
  @Action
  async getTodos(): Promise<ITodo[]> {
    return this.api!.get('/todos').then((res) => {
      this.setTodos(res.data)
      return res.data
    })
  }

  /**
   * createTodo 
   */
  @Action
  async createTodo(todo: ITodo) {
    return this.api!.post('/todos', todo).then((res) => {
      return res.data
    })
  }

  /**
   * deleteTodo 
   */
  @Action
  async deleteTodo(todo: ITodo) {
    return this.api!.delete(`/todos/${todo.id}`)
  }

  /**
   * setTodoComplete 
   */
  @Action
  async setTodoComplete(opts: {id: string, data: any}) {
    return this.api!.put(`/todos/${opts.id}`, {...opts.data})
  }
...
Enter fullscreen mode Exit fullscreen mode

测试通过后,我们的待办事项商店就准备就绪了!

请考虑以下情况:我们没有将我们的应用商店连接到 Nuxt 应用,但我们有充分的证据证明它是可行的。这在团队合作中非常重要,因为规范还可以作为其他开发者的使用示例。


编写 Vue 组件

再次提醒,在急于编写代码之前,我建议停下来思考一下,我们将如何在 UI 组件内部与 vuex store 进行通信。

Vuex 默认建议通过全局插件访问 store,该插件提供上下文$store。但根据常识,我不希望我们的 UI 依赖于特定的 Vuex store 实现。为了理解这一点,不妨想象一下,你想在另一个完全不使用 Vuex 的应用程序中使用我们的 UI 组件。

为了达到这种抽象级别,我们将使 UI 组件依赖于接口ITodosStore。因此,如果您将我们的 UI 复制粘贴到另一个未使用 Vuex 的 Vue.js 应用中,您只需提供ITodosStore适合该应用架构的实现即可。

我们实现这一目标的方法如下:

我们的父组件(index.vue)会获取一个实例ITodosStore并将其提供给子组件。至少有两种方法可以实现这一点。第一种是使用 Vue 的 `@Application` 函数Provide/Inject。第二种是通过组件的 props 传递依赖项。我将使用第二种方法,因为在这种情况下它更明确,但是Provide/Inject对于更复杂的应用程序,Vue 的 `@Application` 函数可能更好。

让我们在模块目录中创建组件骨架。我们需要三个组件:AddTodo.vue,,TodoList.vue以及index.vue我们的父组件。

目前为止的目录结构:

[module]
    ├──[__tests__]
    └──[components]
    |    ├── AddTodo.vue
    |    └── TodoList.vue
    ├──[store]  
    ├── index.ts
    └── index.vue     
Enter fullscreen mode Exit fullscreen mode

components/AddTodo.vue- 儿童组件(消费者):

<template>
  <div/>
</template>

<script lang="ts">
import {
  Component,
  Prop,
  Vue
} from "nuxt-property-decorator"
import { State } from "vuex-class"
import {ITodosStore} from '../store/types'

@Component
export default class extends Vue {
  @Prop() ds!: ITodosStore;
}
</script>

Enter fullscreen mode Exit fullscreen mode

index.vue- 父组件(提供程序、装配点):

<template>
  <section>
    <add-todo :ds="ds" />
    <todo-list :ds="ds" />
  </section>
</template>

<script lang="ts">
import {
  Component,
  Vue
} from "nuxt-property-decorator"
import { State } from "vuex-class"
import {provide, consume} from 'provide-consume-decorator'
import { getModule } from "vuex-module-decorators"
import TodosStore from './store/todos'

import AddTodo from './components/AddTodo.vue';
import TodoList from './components/TodoList.vue';

@Component({
  components: {
    AddTodo,
    TodoList
  }
})
@provide({
  //provide a data store
  dataStore() {
    return getModule(TodosStore, this.$store)
  }

})
export default class extends Vue {

  @consume('dataStore') ds!: TodosStore;

}
</script>

Enter fullscreen mode Exit fullscreen mode

测试 Vue 组件

测试 Vue 组件与测试 Vuex 模块类似,但需要更多配置,因为现在我们的测试必须使用已挂载且已连接 Vuex store 的 Vue 组件。

我们将编写一个工厂函数,该函数返回已挂载的、包含我们 store 模块的组件。此外,我们还要使这个工厂函数可重用,因为现在我们需要测试多个组件。

__tests__/__factory.ts创建包含以下内容的文件:

import Vuex from 'vuex'
import {createLocalVue, mount, config, VueClass} from "@vue/test-utils";
import TodosStore from '../store/todos'
import apiMock from '../store/__tests__/todos.api.mock'

import { getModule } from "vuex-module-decorators"
import { provideVuex, provide } from 'provide-consume-decorator'
import {Component, Vue } from "nuxt-property-decorator"

export default (VueComponent: VueClass<Vue>, props?: any, attrs?: any) => {

  // store mock
  @provideVuex({
    axios: () => apiMock
  })
  class TodosStoreMock extends TodosStore {}

  // we also provide `dataStore` to components
  @Component
  @provide({
    dataStore() {
      return getModule(TodosStore, this.$store)
    }
  })
  class VueComponentMock extends VueComponent {}

  const localVue = createLocalVue()
  localVue.use(Vuex)
  const store = new Vuex.Store({
    modules: {
      'todos': TodosStoreMock
    }
  })
  return mount(VueComponentMock, {
    props,
    attrs,
    store,
    localVue
  })
}
Enter fullscreen mode Exit fullscreen mode

这里我们使用@vue/test-utilsstore 来挂载我们的组件,并且需要 props。

我们新的工厂接收一个 Vue 组件,然后配置 Vuex 模块,并扩展 Vue 组件以提供所需的属性。最终,它会返回一个已挂载的组件实例。使用工厂来提高代码复用性通常是一种很好的实践。

编写测试

现在我们来编写一个测试AddTodo.vue。创建组件__tests__/AddTodo.spec.ts。编写测试时,我总是尽量让测试用例看起来“声明式”,因为其他开发人员可能需要查看测试用例。最好将组件的选项放在文件顶部。

// __tests__/AddTodo.spec.ts
import factory from './__factory'
import TodosStore from '../store/todos'
import { getModule } from "vuex-module-decorators"

//@ts-ignore
import AddTodo from '../components/AddTodo.vue';

const createComponent = () => {
  const component = factory(AddTodo)
  //props
  const props = {
    ds: getModule(TodosStore, component.vm.$store)
  }
  //reactive data
  const data = {

  }
  //component
  component.setProps(props)
  component.setData(data)
  return component

}

describe("AddTodo.vue", () => {
  it('mounts with store', () => {
    const wrap = createComponent()
    expect(wrap.vm).toBeInstanceOf(Object)
    expect((wrap.vm as any).ds.todos).toBeInstanceOf(Array)
  })
})
Enter fullscreen mode Exit fullscreen mode

第一个测试检查组件是否正确挂载。在本例中,我们期望组件具有一个ds提供数据的属性(datastore)TodosStore。此测试成功运行将确保 Vuex 模块已正确初始化。

我们的组件已经具备了该ds属性,第一个测试应该能够通过。那么,让我们创建另一个测试,并思考一下我们的组件应该如何工作。

TDD 和 Vue 组件

编写应用程序(而非 UI 工具包)时,不要让单元测试依赖于组件的标记。诚然,Vue 测试工具提供了测试 HTML 标记的工具,但在开发过程中,HTML 代码更新频繁,维护测试会耗费大量精力。避免这种情况的最佳方法是,只针对与标记无关的 JavaScript 上下文编写测试。或者,以不依赖复杂 CSS 选择器的方式测试标记。我的做法很简单——单元测试中不涉及标记,因为手动操作(使用浏览器)效果更好。HTML 标记可以e2e在预发布阶段通过测试进行测试(如果贵公司有预发布阶段的话)。

回到代码。现在我们需要为组件添加实际功能。有时候,我会先编写方法再编写测试,因为在组件内部设计其行为更方便。所以,当我们弄清楚组件如何工作后,再回到测试部分。

让我们AddTodo.vue按以下方式修改组件:

<template>
  <div/>
</template>

<script lang="ts">
import {
  Component,
  Prop,
  Vue
} from "nuxt-property-decorator"
import {ITodosStore, ITodo} from '../store/types'

@Component
export default class extends Vue {
  //props
  @Prop() ds!: ITodosStore;

  //data()
  text: string = "";

  //getters
  get todo(): ITodo {
    return {
      text: this.text,
      timeCreated: new Date,
      isComplete: false
    }
  }

  //methods
  async create() {
    const todo = this.todo;
    await this.ds.createTodo(todo)
      .then(() => this.ds.getTodos())
    this.text = ""
  }

}
</script>
Enter fullscreen mode Exit fullscreen mode

这个组件的工作原理如下:该create()方法引用this.todo一个返回对象的 getter ITodo,然后使用 Vuex 模块中的一个 action 发布新的待办事项。如果 action 成功,则重置该对象this.text。之后,我们将把它用作this.text文本输入框的模型。当然,实际应用中需要更多流程来发出请求(加载/错误状态、try-catch 语句),但在这个例子中已经足够了。

那么,写完这段代码后,我们需要验证什么呢?两件事:1. 我们需要确认 store 是否发生了变化。2. 我们需要知道组件的状态是否更新了。

编写测试题:

...
it('create()', async () => {
  const wrap = createComponent()
  const ctx = wrap.vm as any // as Vue & IAddTodo if want it typed
  wrap.setData({
    text: 'test'
  })
  await ctx.create()
  const todo = ctx.ds.todos[0]
  expect(todo.text).toBe('test')
  expect(ctx.text).toBe('')
})
...
Enter fullscreen mode Exit fullscreen mode

在这个测试中,我们会获取组件的上下文wrap.vm,然后设置响应式数据属性,并在请求完成后检查数据存储是否已更改,如果更改则将ctx.text其重置为初始值。和往常一样,如果测试失败,我们需要使其通过。

连接点

现在是时候将我们的模块连接到 Nuxt 应用程序,以便继续进行 UI 开发了。
这很简单,我们需要将 Vuex 模块添加到全局 store 中,并将父组件挂载到某个位置。

连接 store 模块通常很简单,只需将其导入~/store/index.ts并添加到modules对象中即可。但是,您还记得我们目前还没有实际的 API 吗?在开发过程中,使用模拟 API 是很常见的。如果能为开发环境设置配置,以便在拥有dev实际 API 时使用所需的实体,那就更好了。但在这个简单的例子中,我将直接配置开发环境中的 store:

...
// ~/store/index.ts

import TodosStore from '~/modules/example/store/todos';

//TODO: apply only for dev environ
import {provideVuex} from 'provide-consume-decorator';
import axiosMock from '~/modules/example/store/__tests__/todos.api.mock'
@provideVuex({
  axios: ()=> axiosMock
})
class TodosStoreMock extends TodosStore {}

export const modules = {
  'todos': TodosStoreMock
}

export const modules = {
  'todos': TodosStoreMock
}
...
Enter fullscreen mode Exit fullscreen mode

对于 Vue 组件,我们可以根据 Vue 应用允许的任何方式进行挂载。在本例中,我将组件直接挂载到 index/路由重写页面~/pages/index.vue

// ~/pages/index.vue

<script lang="ts">
import {
  Component,
  Vue
} from "nuxt-property-decorator"
import Todos from '~/modules/example'

@Component
export default class extends Todos {

}
</script>

Enter fullscreen mode Exit fullscreen mode

添加组件标记

Nuxt 应用设置并运行后,我们来看看添加一些标记会发生什么AddTodo.vue。我的标记如下所示:

<template>
  <section>
    <input type="text" v-model="text" /> 
    <button @click="create">+ add</button>
  </section>
</template>
Enter fullscreen mode Exit fullscreen mode

让我们用浏览器和Vue Devtools来测试一下。

~$ npm run dev
Enter fullscreen mode Exit fullscreen mode

我不知道你们的情况如何,但我的组件运行正常。通常情况下,它一次就能成功。请注意,这是我们第一次启动浏览器,如果我不写这篇文章,我需要花十五分钟才能完成这一步。考虑到我们已经完成了大部分50%工作,而且只依赖单元测试,这十五分钟并不算长。现在,开发过程将会快得多。

接下来会发生什么?

我们还需要完成一些工作才能最终完成这个应用程序。不过,任何后续工作都只是重复我上面描述的步骤。所以我直接把结果分享在这个代码库里,或者如果你能读完这篇文章,也可以自己动手试试。

干杯!

GitHub 标志 nesterow / nuxt-testable

Nuxt 可测试

文章来源:https://dev.to/nesterow/testable-code-with-vuejs-and-typescript-4eeb