使用 VueJS 和 TypeScript 编写的可测试代码
太长不看
太长不看
这是一篇关于约100行代码的详细教程。本教程的成果可以在以下代码库中找到:
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
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/"
}
}
打开tsconfig.json并添加@types/jest到“类型”部分:
//package.json
{
//...
"types": [
"@nuxt/types",
"@nuxtjs/axios",
"@types/jest"
]
}
此外,如果“scripts”部分package.json没有“test”脚本,请添加以下内容:
//package.json
{
//..
"scripts": {
//...
"test": "NODE_ENV=test jest"
},
}
2. 设置 Babel 插件
这是可选步骤,但建议执行。如果您正在构建通用应用程序,这将dynamic imports非常有用。您可能需要在客户端动态导入库,因为某些 UI 库不关心服务器环境以及引用window和document对象。
打开package.json并添加以下配置:
//package.json
{
//....
"babel": {
"plugins": [
[
"dynamic-import-node",
{
"noInterop": true
}
]
],
"env": {
"test": {
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
]
]
}
}
}
}
代码整理
让我们停下来思考一下如何组织应用程序代码。
目前的应用结构:
.
├── [assets]
├── [components]
├── [layouts]
├── [middleware]
├── [pages]
├── [static]
├── [store]
├── [types]
├── nuxt.config.ts
├── package-lock.json
├── package.json
└── tsconfig.json
大多数人到此为止,直接使用默认的样板代码。因为初始应用程序框架是自描述的,所以你无需考虑组件应该放在哪里。当你需要创建一个简单的应用程序或一个五页的网站时,这种方法确实有效。但是,如果你的应用程序扩展到数百个视图/页面呢?如果你需要大多数视图都足够可定制,以便在不同项目之间迁移呢?你该如何实现这一点?
模块
与其使用样板代码编写应用程序,我建议将默认应用程序结构视为独立模块的组装点。我所说的“模块”含义更广,不仅限于 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
基于类的组件。
将 Vue 组件编写成类可以编写出更简洁、更易于维护的代码。此外,它还让我们有机会以更简洁的方式使用继承和应用面向对象编程 (OOP) 模式。
以下库可以帮助我们以基于类的方式编写组件:
vuex-module-decorators和nuxt-property-decorator。稍后我们将详细了解它们的工作原理。
编写一个简单的应用程序
我们来写一个简单的待办事项应用。我相信你以前也做过类似的,但这次我们不会直接跳到应用的视觉部分,而是先构建数据模型,从创建 Vuex store 开始。更重要的是,我们要先为 Vuex store 编写一个规范。“规范”其实就是“测试”的另一种说法。
在开发过程中,规范是首要的调试工具。如果你以前从未编写过测试,可以把它想象成一个功能更强大的“控制台日志”。
测试 Vuex 模块
首先,在我们的示例模块中创建两个新文件:store/todos.ts和store/__tests__/TodosStore.spec.ts。
[modules]
|
└──[example]
|
├── [store]
| ├──[__tests__]
| | └── TodosStore.spec.ts
. └──todos.ts
我们todos.ts暂时先导出为空文件:
// store/todos.ts
export default {}
将以下代码添加到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()
})
})
规格结构
- 进口
- 为了创建一个 Vue 示例,我们将使用
createLocalVue()from@vue/test-utils - 要将 Vuex 模块用作类实例,我们将使用
getModule()详细信息。
- 工厂功能
- 工厂函数应该构建并返回我们的可测试组件。如果工厂函数比较复杂,我们可以将其放在一个单独的文件中。
- 测试用例
- 你输入的所有内容都
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
测试应该会失败,因为我们还没有实现商店模块。
以下是TDD流程的大部分时间安排:
- 你编写了一个失败的测试。
- 你通过了测试。
- 你编写下一个失败的测试,然后返回第一步。
实际上,情况并非总是如此。有时需要在编写测试用例之前先编写测试对象,但如果你使用测试用例进行调试,这一点就无关紧要了。此外,并非所有内容都需要测试——只有那些影响程序正确性的部分才需要测试。
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 {
}
别忘了随时添加类型定义:
// store/types.d.ts
export interface ITodosStore {
}
测试输出:
PASS modules/example/store/__tests__/TodosStore.spec.ts
TodosStore
✓ has to get a store instance (7ms)
第一次测试成功后,我们可以确定我们的商店实例已正确构建,可以继续创建实际的应用程序模型。
Vuex 状态和突变
在为 TypeScript 应用程序设计数据模型时,最好的起点是类型声明。让我们声明一个接口ITodo来描述待办事项的结构:
// store/types.d.ts
export interface ITodosStore {
todos: ITodo[]
}
export interface ITodo {
id?: string,
text: string
timeCreated: Date
isComplete?: boolean
}
现在,让我们来具体说明负责改变状态的方法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
}
此时运行测试会因为类型错误而失败。因为我们的 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) {
}
}
检测突变
商店结构设计完成后,就可以开始实现变更操作了。
我们将从编写测试开始:
// 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)
})
⌄...
这些测试应该失败,因为我们的程序中存在一个小错误。如果您运行这些测试,第二个测试的输出会提示待办事项对象与预期不符。但实际上,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 {
⌄...
}
现在测试应该可以通过了,可能存在的错误原因也已解决。
依赖注入
现在我们需要考虑与服务器的通信问题。标准的应用程序模板建议使用Axios作为 Nuxt 插件来发送请求。我们将使用 Axios,但不会将其作为全局插件。
我不喜欢将这类依赖项与 Vuex store 耦合在一起。为了理解原因,不妨想象一下,你想把我们的待办事项模块复制粘贴到另一个应用程序中。如果新环境使用相同的 API,那就一切都很顺利了。但通常情况下并非如此,你唯一的选择就是深入研究代码,尝试让它在新环境中运行。我见过有人用大型组件做这种事,感觉并不轻松。
为了避免此类复杂情况,我们的模块应该依赖于抽象接口,而不是特定的 axios 实例。换句话说,我们应该能够配置我们的 store,以便在需要从不同的 API 获取数据时使用不同的 axios 实例。
为了使我们的模块可配置并抽象化某些依赖项,我们使用了实践控制反转技术的模式。这些模式包括依赖注入或提供/消费模式的某些变体(例如 Vue 的 provide/inject、高阶组件等)。
对于 Vue 基于类的组件,我决定编写类装饰器,使其对 Vue 组件和 Vuex 模块的工作方式相同。
安装provide-consume-decorator库:
~$ npm install provide-consume-decorator
添加 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;
⌄...
}
我们通过替换一个对象,实现了更改组件依赖项的功能。
模拟服务器
通常情况下,客户端应用程序的开发会领先于后端开发。无论出于何种原因,最好是确保 UI 能够处理实际的 HTTP 请求。Axios 社区提供了多种模拟 HTTP 请求的解决方案,方便您在本地复现 API 端点。这非常有用,但我建议您使用实际的后端服务进行测试,并且只模拟尚未实现的方法。
就我们而言,我们可以在客户端模拟整个 API。
我发现axios-mock-adapter库非常有用:
~$ npm i -D axios-mock-adapter
以下是我编写一个模拟 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;
让我们把它付诸实践__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)
}
⌄...
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()
})
⌄...
测试失败后,让我们创建实际的 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})
}
⌄...
测试通过后,我们的待办事项商店就准备就绪了!
请考虑以下情况:我们没有将我们的应用商店连接到 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
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>
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>
测试 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
})
}
这里我们使用@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)
})
})
第一个测试检查组件是否正确挂载。在本例中,我们期望组件具有一个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>
这个组件的工作原理如下:该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('')
})
⌄...
在这个测试中,我们会获取组件的上下文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
}
⌄...
对于 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>
添加组件标记
Nuxt 应用设置并运行后,我们来看看添加一些标记会发生什么AddTodo.vue。我的标记如下所示:
<template>
<section>
<input type="text" v-model="text" />
<button @click="create">+ add</button>
</section>
</template>
让我们用浏览器和Vue Devtools来测试一下。
~$ npm run dev
我不知道你们的情况如何,但我的组件运行正常。通常情况下,它一次就能成功。请注意,这是我们第一次启动浏览器,如果我不写这篇文章,我需要花十五分钟才能完成这一步。考虑到我们已经完成了大部分50%工作,而且只依赖单元测试,这十五分钟并不算长。现在,开发过程将会快得多。
接下来会发生什么?
我们还需要完成一些工作才能最终完成这个应用程序。不过,任何后续工作都只是重复我上面描述的步骤。所以我直接把结果分享在这个代码库里,或者如果你能读完这篇文章,也可以自己动手试试。
干杯!