3 分钟了解 Vue 应用测试

813 阅读9分钟

最近阅读了一本书《Vue.js 应用测试》,感觉收获满满。

这本书是 Vue 官方测试工具作者 Edd Yerburgh亲笔撰写,语言幽默风趣,简洁明了,对 vue 应用的测试的基本流程和测试方法都做了详尽的介绍。

一、测试类型概览

为了降低手动测试的成本,提高测试效率,前端自动化测试应运而生。前端自动化测试包括三种类型:单元测试、快照测试、端对端测试。

  • 单元测试:对应用程序最小的部分(单元)运行测试的过程;
  • 快照测试:对应用程序的输出保存快照,在后续运行中与保存的快照进行对比进行测试的过程;
  • 端对端测试:通过浏览器运行应用,并通过测试程序模拟交互过程进行测试的过程; 这三种类型的测试构成了一个"测试金字塔",通过该金字塔表达了三种类型的占比和重要性。

image.png

遵循测试驱动开发 TDD,即在编写源代码之前先编写测试代码的工作流程,则作者推荐的编写一个 vue 组件的顺序如下:

  1. 确定需要编写的组件;
  2. 为每个组件编写单元测试和源代码;
  3. 调整组件的样式;
  4. 为已完成的组件添加快照测试;
  5. 在浏览器中手动测试代码;
  6. 编写端对端测试; 当然,不是应用中的每个组件都包含以上的每个流程,编写测试是为了以后节省更多的时间和提高应用的可靠性,只需要编写满足这两个条件的测试即可。

二、测试工具包

vue 应用测试需要用到的工具包括:

  • jest:单元测试和快照测试使用的测试框架;
  • babel-jest:将 ES module 转为 jest 可以识别的模块;
  • vue-jest:将 vue 转化为 jest 可以识别的模块;
  • @vue/test-utils:vue 官方测试套件,提供测试 vue 的丰富 API;
  • @vue/server-test-utils:服务端渲染的 vue 测试套件;
  • NightWatch:端对端测试框架;

三、Vue 应用单元测试

1. @vue/test-utils 基本 API

  • mount() 该方法接收一个组件,并返回一些辅助方法,可以实现组件挂载、与组件交互、以及断言组件输出;
import { mount } from '@vue/test-utils'
import Item from '../item.vue'
describe('Item.vue', () => {
    test('renders item', () => {
        const wrapper = mount(Item);
        expect(wrapper.vm.$el.textContent).toContain('item');
    });
})
  • shallowMount() 该方法和 mount() 方法类似,但是不会像 mount 一样渲染整个组件树,它只渲染一层组件树,然后子组件只返回组件的存根。

2. 测试渲染组件输出

2-1. 渲染文本测试

测试组件是否渲染了文本,主要用到两种断言判断:

  • toBe() 是否相等
  • toContain() 是否包含 下面的例子,测试是否渲染了一个使用了 item.title 作为链接文本内容的 a 标签,item 属性是作为 prop 传给 Item 组件的,使用 propsData 对象模拟传给组件 prop 数据。
test('renders a link to the item.url with item.title as text', () => {
    const item = {
        title: 'some title'
    }
    const wrapper = shallowMount(Item, {
        propsData: {
            item,
        },
    });
    expect(wrapper.find('a').text()).toBe(item.title);
})

2-2. 测试 DOM 属性

Vue Test Utils 包装器有一个 attributes 方法,可以返回组件属性对象。

test('renders a link to the item.url with item.title as text', () => {
    const item = {
        url: 'http://site.com',
        title: 'some-title'
    }
    const wrapper = shallowMount(Item, {
        propsData: { item }
    })
    const a = wrapper.find('a')
    expect(a.text()).toBe(item.title);
    expect(a.attributes().href).toBe(item.url)
})

2-3. 测试渲染组件的数量

书中使用了 findAll 方法获得组件列表,但是在新版本测试套件中,需要使用 findAllComponents 方法获取。 相应的,查找某一个特定组件,书中使用的是 find 方法,新版本中需要使用 findComponent 方法。 然后在断言中使用 toHaveLength 方法进行长度比较。

const length = 10;
const wrapper = mount(ItemList)
expect(wrapper.findAllComponents()).toHaveLength(length)

2-4. 测试 prop

props是一个 Vue Test Utils 包装器方法,返回一个对象,其中包含一个包装器组件实例,以及他们的值的 prop。

const wrapper = shallowMount(TestComponent);
expect(
    wrapper.findComponent(ChildComponent)
        .props()
        .propsA
).toBe('example prop');

2-5. 测试 class

Vue Test Utils 中的 classes 包装器方法会在包装器根元素上返回一个 class 数组。

const wrapper = shallowMount(Progressbar);
expect(wrapper.classes()).toContain('hidden');

2-6. 测试样式

每个包装器都包含一个 element 属性,它是对包装器包含的 DOM 根结点的引用。

const wrapper = shallowMount(ProgressBar)
expect(wrapper.element.style.width).toBe('0%')

2-7. 何时测试渲染的组件输出

  • 仅测试动态生成的输出;
  • 仅测试组件契约部分的输出;

3. 测试组件方法

3-1. 测试组件方法

测试组件方法过程很简单,调用组件方法并断言方法调用正确地影响了组件输出。 比如 ProgressBar 组件有个 start 方法,该方法移除组件根元素的hidden样式类。

const wrapper = shallowMount(ProgressBar)
expect(wrapper.classes()).toContain('hidden')
wrapper.vm.start();
expect(wrapper.classes()).not.toContain('hidden')

注意,这里涉及到 DOM 更新,vue 的 DOM 是异步更新的,所以在上面的测试代码片段最终测试不通过。需要等待$nextTick()执行完成,再进行断言。

// ....
wrapper.vm.start()
await wrapper.vm.$nextTick();
// ....

3-2. 测试使用定时器功能的代码

jest 测试代码是同步执行的,如果源代码中存在定时器,则需要通过某种手段模拟定时器延迟执行。 通过 jest.useFakeTimers 方法,Jest 假定时器会替换全局定时器函数来工作。定时器被替换后,可以使用 runTimersToTime 推进假时间。 下面的例子,假设 ProgressBar 组件的 start 方法中调用 setInterval方法,每 100ms,进度条宽度增加 1%。

jest.useFakeTimers();
const wrapper = shallowMount(ProgressBar)
wrapper.vm.start()
jest.runTimersToTime(100);
await wrapper.vm.$nextTick();
expect(wrapper.element.style.width).toBe('1%');

setInterval 调用的存在,则得有clearInterval调用的存在,在 ProgressBar 组件中,当数据加载完成后,需要执行 finish方法,清除定时器,问题是,在 jest 中,如何得知源代码中调用了 clearInterval 方法呢? 答案是,使用jest.spyOn方法,该方法可以监听方法是否被调用。 使用window.clearInterval方法时,需要将setInterval方法的返回值作为参数,所以还要通过setInterval.mockReturnValue方法,模拟返回值。 最后断言时通过toHaveBeenCalledWith方法判断是否正确地被调用。

jest.spyOn('window', 'clearInterval')
setInterval.mockReturnValue(123)
const wrapper = shallowMount(ProgressBar)
wrapper.vm.start()
wrapper.vm.finish()
expect(window.clearInterval).toHaveBeenCalledWith(123)

3-3. 使用 mock 测试代码

通过jest.spyOn可以监听浏览器 API 调用,如果是自定义的对象方法,则需要使用jest.fn()模拟对象方法。 假设Vue.prototype 挂载了$bar对象,$bar对象上存在 startfinish方法,通过这个对象,其他组件可以控制ProgressBar组件。 当我们在测试某个组件时,该组件是没有显示引入$bar对象的,所以需要在测试时,模拟该对象,在 mount 方法或者 shallowMount 方法第二个参数中,通过 mocks 配置项传入模拟的对象,组件在执行过程中,则调用模拟对象的方法。

const $bar = {
    start: jest.fn(),
    finish: () => {},
}
shallowMount(ItemList, {
    mocks: { $bar }
})
expect($bar.start).toHaveBeenCalledTimes(1);

3-4. 模拟模块依赖关系

当一个 JavaScript 文件导入另一个模块时,被导入的吗模块将成为一个模块依赖。当我们对某个组件进行单元测试时,我们并不希望受到模块依赖的影响,所以需要对模块依赖进行模拟,减少模块依赖副作用的影响。 比如 ItemList 中导入了 api 模块中的fetchListData方法,通过该方法或者列表数据,该方法发起一个 HTTP 请求,在测试 ItemList 组件时,不应该受到 HTTP 请求的影响,所以需要对该方法进行模拟。 首先在 api 模块所在目录创建__mocks__目录,在该目录下创建一个 mock 文件,文件名称与药模拟的文件名称相同。 然后在编写测试时,通过jest.mock('./src/api.js')告诉 Jest 模拟文件,测试代码遇到该模块文件时,会到__mocks__目录中获取模拟的模块。 在/src/__mocks__/api.js中模拟fetchListData方法:

export const fetchListData = jest.fn(() => Promise.resolve([]));

3-5. 测试异步代码

在上面模拟fetchListData方法的例子中,返回的是 Promise 实例,在测试代码里,我们可以通过 async/await 得到 Promise 实例执行完成状态的结果。

test('fetches data', async () => {
    expect.assertions(1)
    const data = await fetchListData();
    expect(data).toBe('some data);
})

但是还存在另外一种情况,比如组件中某个方法如下:

export default {
    methods: {
        func() {
            Promise.reslove().then(() => doSomething())
        }
    }
}

如果要等待该方法中的 Promise 到完成状态再进行断言,就不能使用 async/await 等待了。此时可以通过浏览器事件循环机制,await 某个宏任务完成,再进行断言。 可以自行实现,也可以直接使用 flush-promises 模块,具体实现代码可以参考该模块源码。

import flushPromises from 'flush-promises'
describe('xxx', () => {
    test('awaits promises', async () => {
        expect.assertions(1)
        let hasResolved = false
        Promise.resolove().then(() => {
            hasResolved = true;
        })
        await flushPromises();
        expect(hasResolved).toBe(true)
    })
})

4. 测试组件事件

4-1. 测试原生 DOM 事件

通过 Vue Test Utils 的 trigger 方法触发原生 DOM 事件,然后使用 toHaveBeenCalled 方法判断事件方法是否被调用。

const onClose = jest.fn();
const wrapper = shallowMount(Modal, {
    propsData: {
        onClose,
    }
});
wrapper.find('button').trigger('click');
expect(onClose).toHaveBeenCalled();

4-2. 测试自定义 Vue 事件

自定义事件即通过this.$emit('eventName', payload)触发的事件,测试的思路是 trigger 某个元素的 DOM 事件后,判断是否触发了自定义事件。Vue Test Utils 通过包装器 emitted 方法测试组件是否发射了事件,该方法返回包含了每个发射事件的 payload 数组。

const wrapper = shallowMount(Modal)
wrapper.find('button').trigger('click')
expect(wrapper.emitted('close-modal')).toHaveLength(1);

5. 测试 vuex

5-1. 为Vuex store 的 mutation、action、getter 编写单元测试

mutation、action、getter 都是一组方法的对象,所以只需要测试这些方法调用后是否得到预期的 state 即可。

  • 测试 mutation
import mutations from '../mutations'
describe('mutations', () => {
    test('setItems sets state.items to items', () => {
        const items = [{id: 1}, {id: 2}];
        const state = {
            items: []
        }
        mutations.setItems(state, { items });
        expect(state.items).toBe(items);
    });
})
  • 测试 action 测试actions.fetchListData方法,该方法通过 api 模块中的 fetchListData 获取列表数据,并调用mutations.setItems方法设置state.items数据。 因为涉及到异步代码,所以需要模拟 api 的 fetchListData 方法以及引入 flush-promises 模块。
import actions from '../actions'
import { fetchListData } from '../../api/api'
import flushPromises from 'flush-promises'
jest.mock('../../api/api')
describe('actions', () => {
    test('fetchListData calls commit with the result of fetchListData', () => {
        expect.assertions(1);
        const items = [{}, {}];
        const type = 'top'
        // 模拟 fetchListData 方法实现
        fetchListData.mockImplementationOnce(calledWith => {
            return calledWith === type ? Promise.resolve(items) : Promise.resolve();
        })
        const context = { commit: jest.fn() }
        actions.fetchListData(context, { type });
        await flushPromises();
        expect(context.commit).toHaveBeenCalledWith('setItems', {items})
    });
})
  • 测试 getter 测试返回前 20 条数据的 getter。
import getters from '../getters'
describe('getters', () => {
    test('displayItems returns the first 20 items from state.items', () => {
        const items = Array(21).fill().map((item, i) => i);
        const state = { items }
        expect(
            getters.displayItems(state)
        ).toEqual(items.slice(0, 20))
    })
})

5-2. 为一个 Vuex store 实例编写单元测试

除了单独测试 Vuex store 的 mutaion、action 和 getter,也可以直接测试 store 实例。

const storeConfig = {state, getters, mutations, actions}
test('increment updates state.count by 1', () => {
    Vue.use(Vuex);
    const store = new Vuex.Store(storeConfig)
    expect(store.state.count).toBe(0)
    store.commit('increment')
    expect(store.state.count).toBe(1)
})

但是这样测试会有一个问题,测试代码会污染应用的 store,可能导致后续其他测试结果出现异常。为了解决这个问题,Vue Test Utils 提供了 createLocalVue 方法,该方法返回一个独立的 Vue 构造函数,然后将 vuex 安装到 localVue 上。

import { createLocalVue, shallowMount } from '@vue/test-utils'
// ....
const localVue = createLocalVue()
localVue.use(Vuex)
shallowMount(Testcomponent, {
    localVue,
})

5-3. 为连接 Vuex 的组件编写单元测试

当我们测试组件中的 store 时,我们要测试什么呢? 测试点通常包括:组件是否正确地使用了 store 中的 state 或者 getters;组件是否正确的调用了 mutations 或者 actions 方法等。

const items = [{}, {}, {}];
storeOptions.getters.displayItems.mockRetrunValue(items)
const wrapper = shallowMount(ItemList, {
    localVue,
    store,
})
const Items = wrapper.findAllComponents(Item)
// Items 组件渲染的个数与 getters 中获取的数组长度一致
expect(Items).toHaveLength(items.length)
Items.wrappers.forEach((wrapper, i) => {
    // 每个 Item 组件的 props.item 属性值都与 items 对应的值相等
    expect(wrapper.vm.item).toBe(item[i])
})

测试一个组件中被调用的 dispatch:

store.dispatch = jest.fn(() => Promise.resolve())
shallowMount(ItemList, { localVue, store })
expect(store.dispatch).toHaveBeenCalledWith('fetchListData', {
    type: 'top'
})

6. 测试 vue-router

6-1. 测试路由属性

通常测试路由属性包括了$route.params$route.query,通过挂载组件并模拟$route对象,断言参数输出是否正确进行测试。 简单的测试输出是否和参数值一致。

const wrapper = shallowMount(Testcomponent, {
    mocks: {
        $route: {
            params: {
                id: 123
            }
        }
    }
});
expect(wrapper.text()).contain('123');

现实项目中,路由参数的用途更为复杂,比如可能作为获取数据的请求参数,可能根据不同的参数值显示不同的子组件等,但基本思路不变。

6-2. 测试 $router 属性

$router对象提供了一系列路由相关的辅助方法,测试该属性时,只需要断言属性方法是否正确被调用即可。基本思路跟$route属性一样,通过挂载组件方法,mocks 中添加 mock 的属性对象。 下面例子展示了,当路由参数 page 小于 1 时,重定向到 page 等于 1 的页面,路由为/list/1。

const mocks = {
        $route: {
            page: 0,
        },
        $router: {
            replace: jest.fn()
        }
    }
const wrapper = shallowMount(TestComponent, {
    mocks,
})
expect(mocks.$router.replace).toHaveBeenCalledWith('/list/1')

6-3. 测试 RouterLink 组件

当我们需要测试组件内是否渲染了某个子组件时,可以使用wrapper.findComponent()方法去查找特定组件,但是vue-router并没有导出RouterLink组件,所以无法通过该方法测试是否渲染了RouterLink组件。这种情况,需要用到 Vue Test Utils 的 stubs 选项覆盖子组件的解析过程。 Vue Test Utils 提供了一个表现就像RouterLinkRouterLinkStub组件。

import { shallowMount, RouterLinkStub } from '@vue/test-utils'
// ...
const wrapper = shallowMount(ParentComponent, {
    stubs: {
        RouterLink: RouterLinkStub
    }
})
expect(wrapper.findComponent(RouterLinkStub).props().to).toBe('/path')

7. 测试 mixin 和过滤器

7-1. 测试 mixin

测试 mixin 的过程很简单,在组件中注册 mixin,挂载组件,然后坚持 amixin 是否产生了预期的行为。 假设需要编写一个设置document.title的 mixin 模块,当 vue 组件传入 title 选项时,将document.title设置为 title 的值,如果没有传入 title 选项,则不修改document.title

import titleMixin from '../titleMixin'
// ...
test('set document.title using component title property', () => {
    const Component = {
        render() {},
        title: 'dummy title',
        mixins: [titleMixin]
    }
    mount(Component)
    expect(document.title).toBe('dummy title');
})
test('does not set document.title if title property does not exist', () => {
    document.title = 'some title'
    const Component = {
        render() {},
        mixins: [titleMixin]
    }
    mount(Component)
    expect(document.title).toBe('some title');
})

7-2. 测试过滤器

过滤器是返回转换值的函数,可以通过参数调用并断言他们返回值是否正确进行测试。 假设要测试的截断字符串的过滤器truncate,该过滤器额外接收两个参数,截断后的字符串长度以及被截断的字符串的替换字符串。

import { truncate } from '../filters'
// ...
test('如果不传额外参数,则默认不处理', () => {
    expect(truncate('123')).toBe('123')
})
test('如果传入截断的字符串长度,返回从 0 到参数值长度的子字符串', () => {
    expect(truncate('12345678', 3)).toBe('123')
})
// ....

四、快照测试

使用toMatchSnapshot()方法,即可创建一个快照测试,执行测试后,如果是第一次执行快照测试,则会在对应的测试目录下创建__snapshots__目录,并生成扩展名为.snap的快照文件。如果不是第一次执行,则会与之前生成的快照进行对比测试。

test('renders list item correctly', () => {
    const wrapper = shallowMount(List);
    expect(wrapper.element).toMatchSnapshot()
})

当组件发生了变化,则需要在执行测试时添加更新快照选项:

npm run test:unit -- --updateSnapshot

可以简写为:

npm run test:unit -- -u

执行以上命令后,则会重新生成快照文件。 快照测试的编写,应该在组件单元测试完成,并且在浏览器手动测试组件通过后,再进行编写。在测试金字塔中,快照测试大概占 30%。所以并不需要给所有组件都添加快照测试,只有当组件 HTML 需要稳定输出时,才有必要编写快照测试,比如添加国际化内容后是否能正确显示内容等。

五、端对端测试

端对端测试的使用场景比较少,在测试金字塔中占比 10%。端对端测试的编写通常在系统操作流程稳定后再进行,主要模拟用户关键交互流程并测试是否正确。 编写端对端测试,需要用的nightwatchselenium-server模块,测试的时候还需要打开浏览器进行测试,还需要安装浏览器驱动,比如 Chrome 浏览器的话,需要安装chromedriver模块。 然后在根目录添加nightwatch.json配置文件。

{
  "src_folders" : ["e2e/specs"],
  "output_folder": ["e2e/reports"],
  "selenium": {
    "start_process": true,
    "server_path": "node_modules/.bin/selenium-server",
    "host": "127.0.0.1",
    "port": 4444,
    "cli_args": {
      "WebDriver.chrome.driver": "node_modules/.bin/chromedriver",
    }
  },
  "test_settings" : {
    "chrome" : {
      "desiredCapabilities": {
        "browserName": "chrome"
      }
    }
  }
}

除了 json 格式配置,也支持 JavaScript 配置文件(nightwatch.conf.js),具体可参考 nightwatch 官网。 配置完毕后,就可以开始编写端对端测试代码了。 在根目录下创建端对端测试代码目录e2e/specs,创建端对端测试代码文件journeys.js

module.exports = {
    'sanity test': function(browser) {
        browser
            .url('http://localhost:8080')
            .waitForElementVisible('.item-list', 2000)
            .end()
    }
}

执行端对端测试命令,并启动应用服务,即可开始测试:

npx nightwatch --config nightwatch.json --env chrome

六、结尾

强烈推荐大家看一下这本书,最后使用作者结尾部分的一段话结束和共勉。

正如刚从大学毕业的学生,你的测试之旅才刚刚开始。在广阔的世界中,你将碰到从未见过的代码以及难以预料的问题,这些问题将挑战你的测试能力。但我相信,有了这本书所学的技巧,你会找到解决办法,战胜这些未知的挑战。