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

Vue+Apollo 测试:2020 版

Vue+Apollo 测试:2020 版

大约两年前,我以一篇关于Vue + Apollo 组合单元测试的文章开启了我的dev.to 之旅。在此期间,我收到了许多关于模拟 Apollo 客户端并将其纳入测试流程的请求——就像 React 使用@apollo/react-testing库那样。这样我们就可以测试查询和变更钩子以及缓存更新。我尝试了很多方法来模拟客户端,现在终于可以分享一些示例了。

本文中的测试更偏向于集成测试而非单元测试,因为我们需要同时测试组件和 Apollo Client。

本文假设读者已具备Vue.jsApollo Client以及使用JestVue 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 heroesloading查询进行时,该组件能够正确渲染状态;
  • 当查询成功时,该组件能够正确渲染响应(也应该测试一下英雄数为 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