Vue 3 Testing

As we transition to using Vue 3, it’s important that our tests pass in Vue 3 mode. We’re adding progressively stricter checks to our pipelines to enforce proper Vue 3 testing.

Right now, we fail pipelines if:

  1. A new test file is added that fails in Vue 3 mode.
  2. An existing test file fails under Vue 3 that was previously passing.
  3. One of the known failures on the quarantine list is now passing and has not been removed from the quarantine list.

Running unit tests using Vue 3

To run unit tests using Vue 3, set the VUE_VERSION environment variable to 3 when executing jest.

VUE_VERSION=3 yarn jest #[file-path]

Testing Caveats

Ref management when mocking composables

A common pattern when testing Vue 3 composables is to mock the ref or computed values that these files return.

Consider the following demo composable:

export const useCounter = () => {
  const counter = ref(1)
  const increase = () => { counter.value += 1 }

  return { counter, increase }
}

If we have a component that is currently using this composable and exposing the counter, we will want to write a test to cover the functionality. In some cases such as with this simple example we can get away with not mocking the composable at all, but with more complicated features such as Tanstack Query wrappers or Apollo wrappers leveraging jest.mock may be necessary.

In such cases the test file will require mocking the composable:

<script setup>
const { counter, increase } = useCounter()
</script>

<template>
  <p>Super useful counter: {{ counter }}</p>
  <button @click="increase">+</button>
</template>
import { ref } from 'vue'
import { useCounter } from '~/composables/useCounter'

jest.mock('~/composables/useCounter')

describe('MyComponent', () => {
  const increaseMock = jest.fn()
  const counter = ref(1)

  beforeEach(() => {
    useCounter.mockReturnValue({
      increase: increaseMock,
      counter
    })
  })

  describe('When the counter is 2', () => {
    beforeEach(() => {
      counter.value = 2
      createComponent()
    })

    it('...', () => {})
  })

  it('should default to 1', () => {
    createComponent()

    expect(findSuperUsefulCounter().text()).toBe(1)
    // failure
  })
})

Note in the above example that we are creating both a mock of the function that is returned by the composable and the counter ref - however a very important step is missing the example.

The counter constant is a ref, which means that on every test when we modify it the value we assign to it will be retained. In the example the second it block will fail as the counter will retain the value assigned in some of our previous tests.

The solution and best practice is to always reset your refs on the top most level beforeEach block.

import { ref } from 'vue'
import { useCounter } from '~/composables/useCounter'

jest.mock('~/composables/useCounter')

describe('MyComponent', () => {
  const increaseMock = jest.fn()

  // We can initialize to `undefined` to be extra careful
  const counter = ref(undefined)

  beforeEach(() => {
    counter.value = 1
    useCounter.mockReturnValue({
      increase: increaseMock,
      counter
    })
  })

  describe('When the counter is 2', () => {
    beforeEach(() => {
      counter.value = 2
      createComponent()
    })

    it('...', () => {})
  })

  it('should default to 1', () => {
    createComponent()

    expect(findSuperUsefulCounter().text()).toBe(1)
    // pass
  })
})

Vue router

If you are testing a Vue Router configuration using a real (not mocked) VueRouter object, read the following guidelines. A source of failure is that Vue Router 4 handles routing asynchronously, therefore we should await for the routing operations to be completed. You can use the waitForPromises utility to wait until all promises are flushed.

In the following example, a test asserts that VueRouter navigated to a page after clicking a button. If waitForPromises is not invoked after clicking the button, the assertion would fail because the router’s state hasn’t transitioned to the target page.

it('navigates to /create when clicking New workspace button', async () => {
  expect(findWorkspacesListPage().exists()).toBe(true);

  await findNewWorkspaceButton().trigger('click');
  await waitForPromises();

  expect(findCreateWorkspacePage().exists()).toBe(true);
});

Vue Apollo troubleshooting

You might encounter some unit test failures on components that execute Apollo mutations and update the in-memory query cache, for example:

ApolloError: 'get' on proxy: property '[property]' is a read-only and non-configurable data property on the proxy target but the proxy did not return its actual value (expected '#<Object>' but got '#<Object>')

This error happens because Apollo tries to modify a Vue reactive object when we call the writeQuery or updateQuery methods. Avoid using objects passed through a component’s property in operations that update Apollo’s cache. You should always rely on constructing new objects or data that already exists in the Apollo’s cache. As a last resort, use the cloneDeep utility to remove the Vue’s reactivity proxy from the target object.

In the following example, the component updates the Apollo’s in-memory cache after the mutation succeeds by swapping the agent object between two arrays. The agent object is also available in the agent property, but it is reactive object. The incorrect approach references the agent object passed to the component as a property which causes the proxy error. The correct approach finds the agent object that is already stored in the Apollo’s cache.

<script>
import { toRaw } from 'vue';

export default {
  props: {
    namespace: {
      type: String,
      required: true,
    },
    agent: {
      type: Object,
      required: true,
    },
  },

  methods: {
    async execute() {
      try {
        await this.$apollo.mutate({
          mutation: createClusterAgentMappingMutation,
          update(store) {
            store.updateQuery(
              {
                query: getAgentsWithAuthorizationStatusQuery,
                variables: { namespace },
              },
              (sourceData) =>
                produce(sourceData, (draftData) => {
                  const { mappedAgents, unmappedAgents } = draftData.namespace;

                  /*
                  * BAD: The error described in this section is caused by adding a Vue reactive
                  * object the nodes array. `this.agent` is a component property hence it is wrapped
                  * with a reactivity proxy.
                  */
                  mappedAgents.nodes.push(this.agent);
                  unmappedAgents.nodes = removeFrom.nodes.filter((node) => node.id !== agent.id);

                  /*
                  * PREFERRED FIX: Only use data that already exists in the in-memory cache.
                  */
                  const targetAgentIndex = removeFrom.nodes.findIndex((node) => node.id === agent.id);

                  mappedAgents.nodes.push(removeFrom.nodes[targetAgentIndex]);
                  unmappedAgents.nodes.splice(targetAgentIndex, 1);


                  /*
                  * ALTERNATIVE (LAST RESORT) FIX: Use lodash `cloneDeep` to create a clone
                  * of the object without Vue reactivity:
                  */
                  mappedAgents.nodes.push(cloneDeep(this.agent));
                  unmappedAgents.nodes = removeFrom.nodes.filter((node) => node.id !== agent.id);

                }),
            );
          },
        });
      } catch (e) {
        Sentry.captureException(e);
        this.$emit('error', e);
      }
    },
  },
};
</script>

Quarantine list

The scripts/frontend/quarantined_vue3_specs.txt file is built up of all the known failing Vue 3 test files. In order to not overwhelm us with failing pipelines, these files are skipped on the Vue 3 test job.

If you’re reading this, it’s likely you were sent here by a failing quarantine job. This job is confusing as it fails when a test passes and it passes if they all fail. The reason for this is because all newly passing tests should be removed from the quarantine list. Congratulate yourself on fixing a previously failing test and remove it fom the quarantine list to get this pipeline passing again.

Removing from the quarantine list

If your pipeline is failing because of the vue3 check quarantined jobs, good news! You fixed a previously failing test! What you need to do now is remove the newly-passing test from the quarantine list. This ensures that the test will continue to pass and prevent any further regressions.

Adding to the quarantine list

Don’t do it. This list should only get smaller, not larger. If your MR introduces a new test file or breaks a currently passing one, then you should fix it.

If you are moving a test file from one location to another, then it’s okay to modify the location in the quarantine list. However, before doing so, consider fixing the test first.