一篇文章学会 Vue 项目单元测试

一: 搭建基于 jest 的 vue 单元测试环境

二: 使用 vue-test-util 提高测试编码效率

三: 复杂场景下的测试(模块,异步,rxjs)


搭建基于 jest 的 vue 单元测试环境

因为 jest 包含了 karma + mocha + chai + sinon 的所有常用功能,零配置开箱即用,所以这个教程只讲解 jest。

安装依赖

 npm install jest vue-jest babel-jest @vue/test-utils -D

配置文件

// ./test/unit/jest.conf.js
const path = require('path');

module.exports = {
  rootDir: path.resolve(__dirname, '../../'), // 类似 webpack.context
  moduleFileExtensions: [ // 类似 webpack.resolve.extensions
    'js',
    'json',
    'vue',
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1', // 类似 webpack.resolve.alias
  },
  transform: { // 类似 webpack.module.rules
    '^.+\\.js$': '<rootDir>/node_modules/babel-jest',
    '.*\\.(vue)$': '<rootDir>/node_modules/vue-jest',
  },
  setupFiles: ['<rootDir>/test/unit/setup'], // 类似 webpack.entry
  coverageDirectory: '<rootDir>/test/unit/coverage', // 类似 webpack.output
  collectCoverageFrom: [ // 类似 webpack 的 rule.include
    'src/**/*.{js,vue}',
    '!src/main.js',
    '!src/router/index.js',
    '!**/node_modules/**',
  ],
};

启动文件

// ./test/unit/setup.js
import Vue from 'vue';

Vue.config.productionTip = false;

4.加入启动 jest 的 npm script

"scripts": {
  "unit": "jest --config test/unit/jest.conf.js --coverage",
},

编写测试

有一个组件

// ./src/components/hello-world/hello-world.vue

<template>
  <div>
    <h1>{{ msg }}</h1>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  data() {
    return {
      msg: 'Hello Jest',
    };
  },
};
</script>

对该组件进行测试

// ./src/components/hello-world/hello-world.spec.js

import { shallowMount } from '@vue/test-utils';
import HelloWorld from './hello-world';

describe('<hello-world/>', () => {
  it('should render correct contents', () => {
    const wrapper = shallowMount(HelloWorld);
    expect(wrapper.find('.hello h1').text())
      .toEqual('Welcome to Your Vue.js App');
  });
});

启动测试

npm run unit

jest 会自动扫描项目目录下所有文件名以 .spec.js/.test.js 结尾的测试文件,并执行测试用例。

img

最后优化一下测试编码体验

到上一步我们已经可以开始编写测试代码了,但每次对代码的改动都需要手动执行 jest 启动命令,没有类似 hot-reload 的功能很难受。

可能你有一百种方式可以解决这个需求,但是我现在想告诉你一个最简单且体验最好的一种方式 -> 在 vscode 编辑器安装一个名为 jest 的插件

img

但是安装后它可能还不能很好的工作,因为 vscode-jest 暂时并不知道我们的 jest 配置文件在哪里。

你可以选用下面任意一种方式解决这个问题:

\1. 修改 vscode 配置文件,将 jest.pathToConfig 指向我们刚才编写的配置文件

img

\2. 将 jest 配置写在 package.json 中的 jest 字段

\3. 将 jest 配置文件提到项目根目录,并且更名为 jest.config.js 或者 jest.json

现在 vs-code-jest 会根据 git 修改记录自动执行应该执行的测试文件,并在控制台实时给出测试结果。至此第一部分大功告成。

img

使用 vue-test-util 提高效率

因为 vue-test-util 的官方文档写的实在是太好了,不再赘述其 API,重点说明一点,为什么推荐使用 vue-test-util 来编写 Vue 组件单元测试,因为它不仅提供了很多实用的 API ,还同步了 DOM 的更新,也就是说我们的测试代码里不会再充斥着 vm.$nextTick() 等代码,举个例子

有一个组件

// ./src/components/hello-world/hello-world.vue
<template>
  <div>
    <h1>{{ msg }}</h1>
    <button @click="onClick">click me</button>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  data() {
    return {
      msg: 'Hello Jest',
    };
  },
  methods: {
    onClick() {
      this.msg = 'new message';
    },
  },
};
</script>

对该组件进行测试

// ./src/components/hello-world/hello-world.spec.js

import { shallowMount } from '@vue/test-utils';
import HelloWorld from './hello-world';

describe('<hello-world/>', () => {
  const wrapper = shallowMount(HelloWorld);
  it("update 'msg' correctly", () => {
    // 点击 button
    wrapper.find('button').trigger('click');
    // 可以立即获取 msg 最新的值,不再需要 wrapper.vm.$nextTick();
    expect(wrapper.find('h1').text())
      .toEqual('new message');
  });
});

如果需要做一些全局的 vue-test-util 的配置,可以在 setup.js 里指定,比如在每个组件实例化时候注入一个 GLOBAL 对象。

// ./test/unit/setup.js
import Vue from 'vue';
import { config } from '@vue/test-utils';

Vue.config.productionTip = false;

// provide 的模拟
config.provide.GLOBAL = {
  logined: false,
};

有一个组件注入了 GLOBAL 对象

// ./src/components/hello-world/hello-world.vue
<template>
  <div>
    <h1 v-show="GLOBAL.logined">{{ msg }}</h1>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  inject: ['GLOBAL'],
  data() {
    return {
      msg: 'Hello Jest',
    };
  },
};
</script>

对该组件进行测试

// ./src/components/hello-world/hello-world.spec.js
import { shallowMount } from '@vue/test-utils';
import HelloWorld from './hello-world';

describe('<hello-world/>', () => {
  const wrapper = shallowMount(HelloWorld);
  it('should render correct contents', () => {
    expect(wrapper.find('h1').isVisible()).toBe(false);
  });
});

复杂场景下的测试

组件发起了API 请求,我只想知道它发没发,不想让它真实发出去

有一个组件在会在 created 时候发起一个 http 请求

// ./src/components/user-info/user-info.vue
<template>
  <div class="user-info">
    <div class="name">{{user.name}}</div>
    <div class="desc">{{user.desc}}</div>
  </div>
</template>

<script>

import UserApi from '../../apis/user';

export default {
  name: 'UserInfo',
  data() {
    return {
      user: {},
    };
  },
  created() {
    UserApi.getUserInfo()
      .then((user) => {
        this.user = user;
      });
  },
};
</script>

API 接口如下

// ./src/apis/user.js
function getUserInfo() {
  return $http.get('/user');
}

export default {
  getUserInfo,
};

对该组件进行测试

// ./src/components/user-info/user-info.spec.js
import { shallowMount } from '@vue/test-utils';
import UserInfo from './user-info';
import UserApi from '../../apis/user';

// mock 掉 user 模块
jest.mock('../../apis/user');

// 指定 getUserInfo 方法返回假数据
UserApi.getUserInfo.mockResolvedValue({
  name: 'olive',
  desc: 'software engineer',
});

describe('<user-info/>', () => {
  const wrapper = shallowMount(UserInfo);
  test('getUserInfo 有且只 call 了一次', () => {
    expect(UserApi.getUserInfo.mock.calls.length).toBe(1);
  });
  it('用户信息渲染正确', () => {
    expect(wrapper.find('.name').text()).toEqual('olive');
    expect(wrapper.find('.desc').text()).toEqual('software engineer');
  });
});

简单的 A 组件依赖了一个复杂的 B 组件,但是我只想测试 A 的逻辑,不想拉起 B 的逻辑

这种场景其实很常见,比如某些复杂组件 import 了某些会自执行的代码,这个时候为了保证单元测试的纯粹,我们应该忽略掉所依赖的子组件的逻辑。

// ./src/components/simple/simple.vue
<template>
  <div>
    <div class="header">{{msg}}</div>
    <div>
      <complex></complex>
      // 即使 vue-test-util 可以通过存根的方式将这个组件渲染为 complex-stub
      // 但其内部的其他代码可能依然被执行
    </div>
  </div>
</template>

<script>

import Complex from './children/complex';

export default {
  name: 'Simple',
  data() {
    return {
      msg: 'simple',
    };
  },
  components: {
    Complex,
  },
};

</script>

对该组件进行测试

// ./src/components/simple/simple.spec.js
import { shallowMount } from '@vue/test-utils';
import Simple from './simple';

// 拦截掉 .vue 文件的内容
jest.mock('./children/complex.vue', () => ({
  render(h) {
    h();
  },
}));

describe('<simple/>', () => {
  const wrapper = shallowMount(Simple, {
    stubs: ['user-info'],
  });

  it('文本渲染正确', () => {
    expect(wrapper.find('.header').text()).toEqual('simple');
  });
});

测试 Rx.JS

假设有一个 subject,订阅的时候会发射 ‘hello-rx’

// ./src/components/rx-demo/msg.stream.js

import { BehaviorSubject } from 'rxjs';

const msg$$ = new BehaviorSubject('hello-rx');

export default msg$$;

有一个组件订阅该 subject

// ./src/components/rx-demo/rx-demo.vue

<template>
  <div class="rx-demo">{{msg}}</div>
</template>

<script>
import msg$$ from './msg.stream';

export default {
  name: 'RxDemo',
  data() {
    return {
      msg: '',
    };
  },
  created() {
    msg$$.subscribe((res) => {
      this.msg = res;
    });
  },
};
</script>

对该组件进行测试

// ./test/utils/index.js
import { BehaviorSubject } from 'rxjs';

function mockSubject(data) {
  return new BehaviorSubject(data);
}

export {
  mockSubject,
};
// ./src/components/rx-demo/rx-demo.spec.js

import { shallowMount } from '@vue/test-utils';
import RxDemo from './rx-demo';

jest.mock('./msg.stream.js', () => {
  //这一部分会被 babel-jest 提升到代码顶部,所以需要这么动态去 require 才可以保证代码顺序是正确的
  const { mockSubject } = require('../../../test/unit/util');
  return mockSubject('mock-data');
});

describe('<rx-demo/>', () => {
  it('Rx 订阅成功,文本渲染正确', () => {
    const wrapper = shallowMount(RxDemo);
    expect(wrapper.find('.rx-demo').text()).toEqual('mock-data');
  });
});

总得来说,把测试目标组件范围外的不好测试的模块全部 mock 掉,然后再根据你们对单元测试要求的细粒度进行断言。

其他简单场景不再做赘述,遇到问题请随时联系我,谢谢。

参考资料