Vue+Apollo 测试:2020 版
大约两年前,我以一篇关于Vue + Apollo 组合单元测试的文章开启了我的dev.to 之旅。在此期间,我收到了许多关于模拟 Apollo 客户端并将其纳入测试流程的请求——就像 React 使用@apollo/react-testing库那样。这样我们就可以测试查询和变更钩子以及缓存更新。我尝试了很多方法来模拟客户端,现在终于可以分享一些示例了。
本文中的测试更偏向于集成测试而非单元测试,因为我们需要同时测试组件和 Apollo Client。
本文假设读者已具备Vue.js、Apollo Client以及使用Jest和Vue Test Utils进行单元测试的一些基础知识。
我们将要测试的内容
我决定沿用上一篇文章中测试的同一个项目。这个项目包含一个庞大的App.vue组件,其中包含从 Vue 社区获取成员列表、添加新成员或删除现有成员的逻辑。
在这个组件中,我们有一个查询:
// Here we're fetching a list of people to render
apollo: {
allHeroes: {
query: allHeroesQuery,
error(e) {
this.queryError = true
},
},
},
还有两个变更(一个用于添加新英雄,一个用于删除现有英雄)。它们的测试非常相似,因此本文仅介绍“添加新英雄”的情况。如果您想查看删除英雄的测试,请点击此处查看源代码。
// This is a mutation to add a new hero to the list
// and update the Apollo cache on a successful response
this.$apollo
.mutate({
mutation: addHeroMutation,
variables: {
hero,
},
update(store, { data: { addHero } }) {
const data = store.readQuery({ query: allHeroesQuery });
data.allHeroes.push(addHero);
store.writeQuery({ query: allHeroesQuery, data });
},
})
.finally(() => {
this.isSaving = false;
});
我们需要核实一下
- 当Vue heroes
loading查询进行时,该组件能够正确渲染状态; - 当查询成功时,该组件能够正确渲染响应(也应该测试一下英雄数为 0 的“空状态”);
- 如果查询出错,该组件会显示错误消息;
- 该组件发送
addHero带有正确变量的变更,在成功响应后正确更新缓存,并重新渲染英雄列表;
让我们开始旅程吧!
createComponent使用工厂设置单元测试
说实话,这部分内容并非专门针对 Apollo 测试,而是一种在挂载组件时避免重复编写代码的实用技巧。我们先创建一个App.spec.js文件,从 vue-test-utils 导入一些方法,并添加一个用于挂载组件的工厂。
// App.spec.js
import { shallowMount } from '@vue/test-utils'
import AppComponent from '@/App.vue'
describe('App component', () => {
let wrapper
const createComponent = () => {
wrapper = shallowMount(AppComponent, {})
};
// We want to destroy mounted component after every test case
afterEach(() => {
wrapper.destroy()
})
})
现在我们可以createComponent在测试中直接调用方法了!下一节中,我们将为其添加更多功能和参数。
使用处理程序模拟 Apollo 客户端
首先,我们需要模拟一个 Apollo Client,以便能够为查询和变更指定处理程序。我们将使用mock-apollo-client库来实现这一点:
npm --save-dev mock-apollo-client
## OR
yarn add -D mock-apollo-client
此外,我们还需要vue-apollo向模拟组件添加全局插件。为此,我们需要创建一个本地 Vue 实例,并调用use()方法将其添加为 VueApollo:
// App.spec.js
import { shallowMount, createLocalVue } from '@vue/test-utils'
import AppComponent from '@/App.vue'
import VueApollo from 'vue-apollo'
const localVue = createLocalVue()
localVue.use(VueApollo)
...
const createComponent = () => {
wrapper = shallowMount(AppComponent, {
localVue
});
};
现在我们需要创建一个模拟客户端,并将其提供给模拟组件:
...
import { createMockClient } from 'mock-apollo-client'
...
describe('App component', () => {
let wrapper
// We define these variables here to clean them up on afterEach
let mockClient
let apolloProvider
const createComponent = () => {
mockClient = createMockClient()
apolloProvider = new VueApollo({
defaultClient: mockClient,
})
wrapper = shallowMount(AppComponent, {
localVue,
apolloProvider,
})
}
afterEach(() => {
wrapper.destroy()
mockClient = null
apolloProvider = null
})
})
现在我们已经$apollo为已挂载的组件添加了属性,我们可以编写第一个测试来确保没有出现任何问题:
it('renders a Vue component', () => {
createComponent()
expect(wrapper.exists()).toBe(true)
expect(wrapper.vm.$apollo.queries.allHeroes).toBeTruthy()
});
太好了!让我们把第一个处理程序添加到模拟客户端来测试查询allHeroes。
测试成功查询响应
为了测试查询,我们需要定义查询解析完成后返回的响应setRequestHandler。我们可以使用`getQueryResponse` 方法来实现这一点mock-apollo-client。为了使我们的测试在未来更加灵活,我们将定义一个对象,其中包含默认的请求处理程序以及我们想要传递给createComponent工厂的任何其他处理程序:
let wrapper
let mockClient
let apolloProvider
let requestHandlers
const createComponent = (handlers) => {
mockClient = createMockClient()
apolloProvider = new VueApollo({
defaultClient: mockClient,
})
requestHandlers = {
...handlers,
}
...
}
我们还需要在测试文件的顶部添加一个新的常量,用于存储模拟的查询响应:
// imports are here
const heroListMock = {
data: {
allHeroes: [
{
github: 'test-github',
id: '-1',
image: 'image-link',
name: 'Anonymous Vue Hero',
twitter: 'some-twitter',
},
{
github: 'test-github2',
id: '-2',
image: 'image-link2',
name: 'another Vue Hero',
twitter: 'some-twitter2',
},
],
},
};
在这里模拟你期望从 GraphQL API 获取到的结构(包括根属性)至关重要
data!否则你的测试会惨败😅
现在我们可以定义一个allHeroes查询处理程序:
requestHandlers = {
allHeroesQueryHandler: jest.fn().mockResolvedValue(heroListMock),
...handlers,
};
并将此处理程序添加到我们的模拟客户端中。
import allHeroesQuery from '@/graphql/allHeroes.query.gql'
...
mockClient = createMockClient()
apolloProvider = new VueApollo({
defaultClient: mockClient,
})
requestHandlers = {
allHeroesQueryHandler: jest.fn().mockResolvedValue(heroListMock),
...handlers,
}
mockClient.setRequestHandler(
allHeroesQuery,
requestHandlers.allHeroesQueryHandler
)
现在,当测试中已挂载的组件尝试获取数据时allHeroes,它会收到heroListMock响应——即查询成功后。在此之前,组件会显示加载状态。
我们的组件中App.vue有以下代码:
<h2 v-if="queryError" class="test-error">
Something went wrong. Please try again in a minute
</h2>
<div v-else-if="$apollo.queries.allHeroes.loading" class="test-loading">
Loading...
</div>
让我们检查一下test-loading该区块是否已渲染:
it('renders a loading block when query is in progress', () => {
createComponent()
expect(wrapper.find('.test-loading').exists()).toBe(true)
expect(wrapper.html()).toMatchSnapshot()
})
太好了!加载状态已经处理完毕,现在是时候看看查询解析完成后会发生什么了。在 Vue 测试中,这意味着我们需要等待下一个 tick:
import VueHero from '@/components/VueHero'
...
it('renders a list of two heroes when query is resolved', async () => {
createComponent()
// Waiting for promise to resolve here
await wrapper.vm.$nextTick()
expect(wrapper.find('.test-loading').exists()).toBe(false)
expect(wrapper.html()).toMatchSnapshot()
expect(wrapper.findAllComponents(VueHero)).toHaveLength(2)
})
修改处理程序以测试空列表
我们的代码中App.vue还有一个特殊的代码块,用于在英雄列表为空时渲染:
<h3 class="test-empty-list" v-if="allHeroes.length === 0">
No heroes found 😭
</h3>
让我们为此添加一个新的测试,现在让我们传递一个处理程序来覆盖默认的处理程序:
it('renders a message about no heroes when heroes list is empty', async () => {
createComponent({
// We pass a new handler here
allHeroesQueryHandler: jest
.fn()
.mockResolvedValue({ data: { allHeroes: [] } }),
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.test-empty-list').exists()).toBe(true);
});
如您所见,我们模拟的处理程序非常灵活——我们可以在不同的测试中更改它们。这里还有一些优化空间:我们可以将requestHandlers查询作为键,并遍历它们来添加处理程序,但为了简单起见,本文暂不赘述。
测试查询错误
如果查询失败,我们的应用程序也会显示错误信息:
apollo: {
allHeroes: {
query: allHeroesQuery,
error(e) {
this.queryError = true
},
},
},
<h2 v-if="queryError" class="test-error">
Something went wrong. Please try again in a minute
</h2>
让我们为错误情况创建一个测试。我们需要将模拟的已解析值替换为被拒绝的值:
it('renders error if query fails', async () => {
createComponent({
allHeroesQueryHandler: jest
.fn()
.mockRejectedValue(new Error('GraphQL error')),
})
// For some reason, when we reject the promise, it requires +1 tick to render an error
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.find('.test-error').exists()).toBe(true)
})
测试一种突变,以添加一位新英雄
查询操作已经涵盖了!那么变异操作呢?我们是否也能正确地测试它们?答案是肯定的YES!首先,让我们来看一下变异操作的代码:
const hero = {
name: this.name,
image: this.image,
twitter: this.twitter,
github: this.github,
};
...
this.$apollo
.mutate({
mutation: addHeroMutation,
variables: {
hero,
},
update(store, { data: { addHero } }) {
const data = store.readQuery({ query: allHeroesQuery });
data.allHeroes.push(addHero);
store.writeQuery({ query: allHeroesQuery, data });
},
})
让我们在模拟对象中添加两个新常量:第一个常量用于hero作为 mutation 参数传递的变量,第二个常量用于成功的 mutation 响应。
...
import allHeroesQuery from '@/graphql/allHeroes.query.gql'
import addHeroMutation from '@/graphql/addHero.mutation.gql'
const heroListMock = {...}
const heroInputMock = {
name: 'New Hero',
github: '1000-contributions-a-day',
twitter: 'new-hero',
image: 'img.jpg',
}
const newHeroMockResponse = {
data: {
addHero: {
__typename: 'Hero',
id: '123',
...heroInputMock,
},
},
}
再次提醒,模拟响应时务必格外小心!你需要使用正确的类型名称来匹配 GraphQL schema。此外,你的响应结构应该与真实的 GraphQL API 响应完全一致——否则,测试将会出错。
现在,我们向处理程序中添加一个变更处理程序:
requestHandlers = {
allHeroesQueryHandler: jest.fn().mockResolvedValue(heroListMock),
addHeroMutationHandler: jest.fn().mockResolvedValue(newHeroMockResponse),
...handlers,
};
mockClient.setRequestHandler(
addHeroMutation,
requestHandlers.addHeroMutationHandler
);
现在是时候开始编写变更测试了!我们将跳过加载状态的测试,直接检查成功响应。首先,我们需要createComponent稍微修改一下工厂函数,使其能够设置组件data(我们需要它来“填充表单”,以便在变更操作中将正确的变量发送到 API):
const createComponent = (handlers, data) => {
...
wrapper = shallowMount(AppComponent, {
localVue,
apolloProvider,
data() {
return {
...data,
};
},
});
};
现在我们可以开始创建变异测试了。让我们检查一下变异是否真的被调用了:
it('adds a new hero to cache on addHero mutation', async () => {
// Open the dialog form and fill it with data
createComponent({}, { ...heroInputMock, dialog: true })
// Waiting for query promise to resolve and populate heroes list
await wrapper.vm.$nextTick()
// Submit the form to call the mutation
wrapper.find('.test-submit').vm.$emit("click")
expect(requestHandlers.addHeroMutationHandler).toHaveBeenCalledWith({
hero: {
...heroInputMock,
},
});
});
下一步是等待 mutation 生效,并检查 Apollo Client 缓存是否已正确更新:
it('adds a new hero to cache on addHero mutation', async () => {
...
expect(requestHandlers.addHeroMutationHandler).toHaveBeenCalledWith({
hero: {
...heroInputMock,
},
});
// We wait for mutation promise to resolve and then we check if a new hero is added to the cache
await wrapper.vm.$nextTick()
expect(
mockClient.cache.readQuery({ query: allHeroesQuery }).allHeroes
).toHaveLength(3)
});
最后,我们可以再等待一刻,让 Vue 重新渲染模板,然后检查实际渲染结果:
it('adds a new hero to cache on addHero mutation', async () => {
createComponent({}, { ...heroInputMock, dialog: true });
await wrapper.vm.$nextTick()
wrapper.find('.test-submit').vm.$emit("click")
expect(requestHandlers.addHeroMutationHandler).toHaveBeenCalledWith({
hero: {
...heroInputMock,
},
})
await wrapper.vm.$nextTick();
expect(
mockClient.cache.readQuery({ query: allHeroesQuery }).allHeroes
).toHaveLength(3);
// We wait for one more tick for component to re-render updated cache data
await wrapper.vm.$nextTick()
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.findAllComponents(VueHero)).toHaveLength(3);
});
就这样!我们也可以像模拟查询错误那样模拟变更错误,但我认为这篇文章已经够长够无聊了😅
您可以在这里找到测试的完整源代码。
文章来源:https://dev.to/n_tepluhina/testing-vue-apollo-2020-edition-2l2p