loading...
Cover image for Testing Vue+Apollo: 2020 edition

Testing Vue+Apollo: 2020 edition

n_tepluhina profile image Natalia Tepluhina ・8 min read

Almost two years ago I started my dev.to journey with the article about unit testing Vue + Apollo combination. During this time I had multiple requests about mocking an Apollo client and including it to the equation - just like React does with @apollo/react-testing library. This would allow us to test queries and mutations hooks as well as cache updates. There were many attempts from my side to mock a client and finally I am ready to share some examples.

Tests in this article are rather integration that unit tests because we test component and Apollo Client in combination

This article assumes some prior knowledge of Vue.js, Apollo Client and unit testing with Jest and Vue Test Utils

What we're going to test

I decided to go with the same project I was testing in the scope of the previous article. Here, we have a single huge App.vue component that contains logic to fetch a list of people from Vue community, add a new member there or delete an existing one.

In this component, we have one query:

// Here we're fetching a list of people to render

apollo: {
  allHeroes: {
    query: allHeroesQuery,
    error(e) {
      this.queryError = true
    },
  },
},

And two mutations (one to add a new hero and one to delete an existing one). Their tests are pretty similar, that's why we will cover only 'adding a new hero' case in the article. However, if you want to check the test for deleting hero, here is the source code

// 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;
  });

We would need to check that

  • the component renders a loading state correctly when the query for Vue heroes is in progress;
  • the component renders a response correctly when the query is resolved (an 'empty state' with 0 heroes should be tested too);
  • the component renders an error message if we had an error on the query;
  • the component sends addHero mutation with correct variables, updates a cache correctly on successful response and re-renders a list of heroes;

Let's start our journey!

Setting up a unit test with createComponent factory

Honestly, this section is not specific to Apollo testing, it's rather a useful technique to prevent repeating yourself when you mount a component. Let's start with creating an App.spec.js file, importing some methods from vue-test-utils and adding a factory for mounting a component

// 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()
  })
})

Now we can just call a createComponent method in our tests! In the next section, we will extend it with more functionality and arguments.

Mocking Apollo client with handlers

First of all, we need to mock an Apollo Client so we'd be able to specify handlers for queries and mutations. We will use mock-apollo-client library for this:

npm --save-dev mock-apollo-client
## OR
yarn add -D mock-apollo-client

Also, we would need to add vue-apollo global plugin to our mocked component. To do so, we need to create a local Vue instance and call use() method to add VueApollo to it:

// 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
  });
};

Now we need to create a mock client and provide it to the mocked component:

...
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
  })
})

Now we have $apollo property in our mounted component and we can write the first test just to ensure we didn't fail anywhere:

it('renders a Vue component', () => {
  createComponent()

  expect(wrapper.exists()).toBe(true)
  expect(wrapper.vm.$apollo.queries.allHeroes).toBeTruthy()
});

Great! Let's add the first handler to our mocked client to test the allHeroes query

Testing successful query response

To test a query, we would need to define a query response that we'll have when the query is resolved. We can do this with the setRequestHandler method of mock-apollo-client. To make our tests more flexible in the future, we will define an object containing default request handlers plus any additional handlers we want to pass to createComponent factory:

let wrapper
let mockClient
let apolloProvider
let requestHandlers

const createComponent = (handlers) => {
  mockClient = createMockClient()
  apolloProvider = new VueApollo({
    defaultClient: mockClient,
  })

  requestHandlers = {
    ...handlers,
  }
  ...
}

Let's also add a new constant at the top of the test file with the mocked query response:

// 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',
      },
    ],
  },
};

It's very important to mock here exactly the same structure you expect to get from your GraphQL API (including root data property)! Otherwise your test will fail miserably 😅

Now we can define a handler for allHeroes query:

requestHandlers = {
  allHeroesQueryHandler: jest.fn().mockResolvedValue(heroListMock),
  ...handlers,
};

...and add this handler to our mocked client

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
)

Now, when the mounted component in the test will try to fetch allHeroes, it will get the heroListMock as a response - i.e. when the query is resolved. Until then, the component will show us a loading state.

In our App.vue component we have this code:

<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>

Let's check if test-loading block is rendered:

it('renders a loading block when query is in progress', () => {
  createComponent()

  expect(wrapper.find('.test-loading').exists()).toBe(true)
  expect(wrapper.html()).toMatchSnapshot()
})

Great! Loading state is covered, now it's a good time to see what happens when the query is resolved. In Vue tests this means we need to wait for the next 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)
})

Changing a handler to test an empty list

On our App.vue code we also have a special block to render when heroes list is empty:

<h3 class="test-empty-list" v-if="allHeroes.length === 0">
  No heroes found 😭
</h3>

Let's add a new test for this and let's now pass a handler to override a default one:

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);
});

As you can see, our mocked handlers are flexible - we can change them on different tests. There is some space for further optimization here: we could change requestHandlers to have queries as keys and iterate over them to add handlers, but for the sake of simplicity I won't do this in the article.

Testing query error

Our application also renders an error in the case of failed query:

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>

Let's create a test for the error case. We would need to replace mocked resolved value with the rejected one:

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)
})

Testing a mutation to add a new hero

Queries are covered! What about mutations, are we capable to test them properly as well? The answer is YES! First, let's take a look at our mutation code:

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 });
    },
  })

Let's add two new constants to our mocks: the first one for the hero variable passed as a mutation parameter, and a second one - for the successful mutation response

...
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,
    },
  },
}

Again, be very careful about mocking a response! You would need to use a correct typename to match the GraphQL schema. Also, your response shape should be the same as a real GraphQL API response - otherwise, you will get a hiccup on tests

Now, we add a mutation handler to our handlers:

requestHandlers = {
  allHeroesQueryHandler: jest.fn().mockResolvedValue(heroListMock),
  addHeroMutationHandler: jest.fn().mockResolvedValue(newHeroMockResponse),
  ...handlers,
};

mockClient.setRequestHandler(
  addHeroMutation,
  requestHandlers.addHeroMutationHandler
);

It's time to start writing a mutation test! We will skip testing loading state here and we will check the successful response right away. First, we would need to modify our createComponent factory slightly to make it able to set component data (we need this to 'fill the form' to have correct variables sent to the API with the mutation):

const createComponent = (handlers, data) => {
  ...
  wrapper = shallowMount(AppComponent, {
    localVue,
    apolloProvider,
    data() {
      return {
        ...data,
      };
    },
  });
};

Now we can start creating a mutation test. Let's check if the mutation is actually called:

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,
    },
  });
});

Next step is to wait until mutation is resolved and check if it updated Apollo Client cache correctly:

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)
});

Finally, we can wait for one more tick so Vue could re-render the template and we will check the actual rendered result:

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);
});

That's it! We also can mock mutation error the same way as we did for the query error but I believe this article is already long and boring enough 😅

You can find the full source code for the test here

Posted on by:

n_tepluhina profile

Natalia Tepluhina

@n_tepluhina

Vue.js core team member. Google Developer Expert, conference speaker and all this stuff. Needs coffee to operate

Discussion

markdown guide
 

Amazing topic, thank you. But how are you fixing warnings about local queries and mutations?

Found @client directives in a query but no ApolloClient resolvers were specified. This means ApolloClient local resolver handling has been disabled, and @client directives will be passed through to your link chain.

I have this error when I'm using @client directive on my local queries