In this article, we’ll take a look at how you can start testing Vue components.
At some point, every frontend developer realizes that it would be great to somehow verify that their code is working as expected. For me, this moment came after a painful migration from Vue 2 to Vue 3, with tons of bugs and defects. It left me with yet another strand of gray hair, and I wasn’t the only one suffering.
The current project I’m working on is a classic CRM, full of forms upon forms, list rendering, modals, and constant data transformation. The technology stack includes Vue 3, Pinia, Vite, and ElementPlus, which allows for fast and flexible interface development.
After reading up on the theory behind pyramids and testing cups, I concluded that it’s easier to gradually add “unit” tests for new components and features, as well as cover any bugs with tests.
Just a quick note on the testing pyramid. This concept, introduced by Martin Fowler, helps organize tests in a project. It involves three levels of testing:
The word "unit" is in quotes here because, when testing components in a framework, there’s a nuance: testing components isn’t always about isolating them completely.
Ideally, unit tests check isolated functions, (like the famous SUM () function), but in reality, we cannot entirely "mock" the framework. So, tests for Vue components are technically integration tests. However, for simplicity, the community continues to call them “unit tests” and I’ll stick with that terminology.
Of course, there were other options. For example, I considered E2E testing with Playwright, which runs tests in real browsers simulating user actions. While Playwright is a powerful tool,I faced several infrastructure difficulties, which I’ll discuss later in the article.
Spoiler: the backend wasn't ready for this, and realistically, E2E tests should be written under the guidance of QA engineers - they are experts at breaking systems.
Ultimately, I decided to go with Vitest and Vue Test Utils as the main tools for testing. Vitest, as a test runner, was ideal, since Vite was already installed in the project, and Vue Test Utils provided all the necessary tools for mounting and modifying Vue components.
Why Vue Test Utils and not Testing Library? First, Vue Text Urils is recommended by the Vue community (Testing Library was a bit late in supporting Vue 3). Second, I follow "London School" of testing, where we mock everything around our component to keep tests as fair isolated as possible. In addition, the Testing Library is built on top of VTU, and I wanted fewer dependencies.
To get started, we need to install and configure Vitest and Vue test utils.
npm i -D vitest @vue/test-utils
Since our tests will run in a Node.js environment, we need DOM implementers. Vitest recommends either happy-dom or jsdom.
I chose jsdom because it’s more popular and faster, though happy-dom is an option as well as you can switch quickly.
npm i jsdom -D
Let's add the necessary configuration to vite.config.js.
defineConfig({
...
test: {
environment: 'jsdom',
deps: {
inline: ['element-plus'], // optional field
},
},
})
Since Pinia is in the stack, we can either use a real store or a test store for testing. I opted for a test store, as it’s easier to mutate directly during tests.
npm i -D @pinia/testing
Let's take a component with a button and a modal as an example.
Component diagram and description
Here’s the basic flow:
Component code:
<template>
<div>
<button :disabled="!isAddDocumentAvailable" class="button button--primary" @click="addIncomingMail">Add incoming correspondence</button>
<popup drawer :is-form-open="isFormOpen" @closed="closeForm">
<add-incoming-mail-modal :lawsuit-id="lawsuitId" @closeForm="closeForm" @mail-added="onMailChanged" />
</popup>
</div>
</template>
<script setup >
import { ref } from 'vue';
import AddIncomingMailModal from '@/.../AddIncomingMailModal.vue';
const isFormOpen = ref(false);
const emits = defineEmits(['mail-added']);
defineProps({
lawsuitId: {
type: Number,
required: true,
},
});
function addIncomingMail() {
isFormOpen.value = true;
}
function closeForm() {
isFormOpen.value = false;
}
function onMailChanged() {
emits('mail-added');
}
const isAddDocumentAvailable = computed(() => {
return checkAvailabilityByClaim(...);
});
</script>
A bit of theory.
To start, we need to decide what should be tested. The general advice is to follow the user's behavior and avoid focusing on implementation details, which makes sense. Essentially, we’ll treat the component as a black box: changing (preparing) its input data and testing the output.
Note: if the component has a complex function with heavy calculations, it should ideally be extracted and tested separately as a traditional unit test.
Mocks and stubs play a key role in isolating tests. Mocks replace real dependencies, while stubs simplify interaction with external systems (API, for example). The main rule is not to mock everything: only mock what affects the functionality being tested. This reduces test dependence on the mock.
Input data includes properties, components, dependency injections, external stores, and slots. For a fair test, we should replace real components, stores, and DIs with stubs. This minimizes the risk of false positives.
Output: we should test the resulting HTMLcheck API calls (which should also be stubbed to avoid affecting the backend), and verify that events are emitted and handled correctly outside the component.
Let's move on to writing the test. First, we need to define what we are testing (as they say, "draw a circle").
describe('Adding incoming correspondence - AddIncomingMail', () => {
it.todo('The "Add incoming correspondence" button is active if the user has rights', async () => {});
it.todo('By clicking on the "Add incoming correspondence" button, a modal window for adding a new document opens', async () => {});
it.todo('When adding incoming correspondence, we generate an event outside the component', async () => {});
});
Notice how these test descriptions serve as self-documenting code — in theory.
I prefer placing tests as close to the components as possible. This approach seems better suited for understanding how the component works.
In general, we draw another circle, add details, and - we have a test.
import { getButtonByText } from '@/mocks/helpersForTesting/searchElements/index.js';
import { mount } from '@vue/test-utils';
import AddIncomingMail from '@/components/AddIncomingMail.vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
let wrapper; // storing a component instance
beforeEach(() => { // before each test we mount the component with the initial settings
wrapper = mount(AddIncomingMail, {
global: {
plugins:[
createTestingPinia({ // Create a test instance of Pinia
createSpy: vi.fn,
stubActions: false,
initialState: {
user: {
state: {
claims: [1,2],
},
},
},
}),]
stubs: { // Since this is unit testing, we replace all child components with stubs
AddIncomingMailModal: {
name: 'AddIncomingMailModal',
emits: ['mail-added'],
template: '<div><h2 >Add correspondence</h2></div>',
},
popup: {
props: { isFormOpen: false },
template: '<div v-if="isFormOpen"><slot/></div>',
},
},
},
props: {
lawsuitId: 15,
},
});
});
afterEach(() => { // After each test, we reset the stubs and destroy the instance
wrapper.unmount();
vi.resetAllMocks();
});
describe('Adding incoming correspondence - AddIncomingMail', () => {
it('The "Add incoming correspondence" button is active if the user has rights', async () => {
const button = getButtonByText({ wrapper, buttonText: 'Add incoming correspondence' }); // Made a helper for quick search of buttons
expect(button.element.disabled).toBe(false);
});
it('By clicking on the "Add incoming correspondence" button, a modal window for adding a new document opens', async () => {
const button = getButtonByText({ wrapper, buttonText: 'Add incoming correspondence' });
await button.trigger('click');
await wrapper.vm.$nextTick(); //Since Vue makes changes asynchronously, it is important to wait
expect(wrapper.text()).contain('Add correspondence');
});
it('When adding incoming correspondence, we generate an event outside the component', async () => {
const button = getButtonByText({ wrapper, buttonText: 'Add incoming correspondence' });
await button.trigger('click');
await wrapper.vm.$nextTick();
const modalStub = wrapper.findComponent({ name: 'AddIncomingMailModal' });
modalStub.vm.$emit('mail-added');
expect(wrapper.emitted()).toHaveProperty('mail-added');
});
});
I would like to draw your attention to how element searching occurs. You should try to search for elements the way a user would search. A less recommended, but more popular approach is using data-testid=”foo”.
Note: settings like the ones below:
afterEach(() => { // After each test, we reset the stubs and destroy the instance
wrapper.unmount();
vi.resetAllMocks();
});
can be moved to external files and connected in the vite.config.js settings as a global setting:
// vite.config.js
export default defineConfig({
test: {
// ...
setupFiles: ['path to your file'],
// ...
},
});
Ultimately, unit tests help establish a contract between components. However, there's a downside: if we change the modal’s code, for example, its emitted events, the test for AddIncomingMail won’t fail.
This is where integration tests (as mentioned earlier) come in, testing larger sections of the app.
I haven’t fully implemented those tests yet, as I’m still thinking about the infrastructure and mocks (particularly for server requests), through a conditional msw. However, there is a problem with maintaining the relevance of mocked responses (fixtures). Ideally, I'd like to use “Vitest browser mode” or “Playwright CT”, but these tools are still in their infancy.
Start testing your code early and don’t treat testing as something separate from development. Yes, at first it will be tough, and yes, it will continue to be tough, but soon enough, you’ll just get used to it.
It's easy to start working with us. Just fill the brief or call us.