Vue.js course Β· Module 12: Testing & Deploy

Testing Vue Components

7 min read
In this lesson9

Welcome to the NOVA LAB Launch Platform! The year is 2087 - before any system is sent to Mars, every piece of code must pass rigorous tests. Just as rocket systems are checked before launch, we test our Vue applications before deployment.

You checked the telemetry panel by hand: every button works. A week from now someone on the team will fix one component, and the "+" button will stop working in a place nobody clicks anymore. Manual checking does not scale. An automated test is a pre-launch procedure written in code - you run it with a single command before every launch.

What you will learn

At the Launch Platform you will prepare the application for flight:

  • write component tests with Vitest and Vue Test Utils,
  • test composables and Pinia stores without mounting the interface,
  • verify how modules work together in integration and E2E tests,
  • build the production version and deploy it to hosting,
  • automate the whole process in a CI/CD pipeline.

Why test, and what kinds of tests exist

Tests ensure code quality, catch bugs early and make refactoring easier: you change a component's internals, and green tests confirm that its behaviour has not changed. The kinds of tests form a pyramid. At the bottom sit unit tests of a single function or component - the fastest and cheapest, so you write the most of them. Above them stand integration tests, which check several parts at once. At the top are end-to-end (E2E) tests, clicking through the real application in a browser - the slowest and most expensive, so a few for the most important paths are enough.

Vitest - the Testing Framework

Vitest is a modern testing framework for Vue, recommended by the Vue documentation for projects built on Vite:

  • Fast like Vite
  • Jest-compatible
  • Native ESM
  • TypeScript out of the box

The speed comes from the fact that Vitest uses the same configuration and file processing as Vite, so it understands .vue files right away, and its Jest-compatible API makes describe, it and expect look familiar.

Installation

To Vitest we add two packages: Vue Test Utils (@vue/test-utils), the official library for mounting and testing Vue components in isolation, and happy-dom, which simulates a browser DOM in Node.js:

1npm install -D vitest @vue/test-utils happy-dom

The -D flag saves the packages in devDependencies - lab tools will not end up in the production build. A Vite project already has the @vitejs/plugin-vue plugin.

Configuration

In the vitest.config.js file, the test section enables globals: true, which makes describe, it and expect work without an import, and environment: 'happy-dom', the simulated DOM:

1// vitest.config.js
2import { defineConfig } from 'vitest/config'
3import vue from '@vitejs/plugin-vue'
4
5export default defineConfig({
6  plugins: [vue()],
7  test: {
8    globals: true,
9    environment: 'happy-dom'
10  }
11})

You can also add the test section to vite.config.js; when both files exist, vitest.config.js wins. What remains are the scripts in package.json:

1// package.json
2{
3  "scripts": {
4    "test": "vitest",
5    "test:ui": "vitest --ui",
6    "test:coverage": "vitest --coverage"
7  }
8}

The vitest command runs in watch mode and repeats the tests after every change, while in CI it runs them once. vitest --ui opens a dashboard in the browser and requires the @vitest/ui package. vitest --coverage measures test coverage, that is the percentage of statements, branches, functions and lines of source code executed by the tests; it needs the @vitest/coverage-v8 package.

The component under test

We will test a simple counter with + and - buttons and an initialCount prop:

1<!-- Counter.vue -->
2<template>
3  <div class="counter">
4    <p>Count: {{ count }}</p>
5    <button class="increment" @click="count++">+</button>
6    <button class="decrement" @click="count--">-</button>
7  </div>
8</template>
9
10<script setup>
11import { ref } from 'vue'
12
13const props = defineProps({
14  initialCount: {
15    type: Number,
16    default: 0
17  }
18})
19
20const count = ref(props.initialCount)
21</script>

The counter keeps its state in a ref, and the prop is only a starting value. The button classes will serve the tests as selectors.

First Test

A test is built from three Vitest functions: describe groups cases under a common name, it describes a single case, and expect sets a condition on the result with a matcher, for example toContain. The mount function from Vue Test Utils mounts the component and returns a wrapper - an object through which you read text with the text() method and look for elements with the find() method:

1// components/Counter.spec.js
2import { describe, it, expect } from 'vitest'
3import { mount } from '@vue/test-utils'
4import Counter from './Counter.vue'
5
6describe('Counter', () => {
7  it('renders count', () => {
8    const wrapper = mount(Counter)
9    expect(wrapper.text()).toContain('Count: 0')
10  })

The test mounts the counter without props and checks its text - without a browser, in a simulated DOM. The second case simulates a click: find takes a CSS selector, and trigger('click') sends the event to the button it found:

1  // Counter.spec.js - continuation of describe('Counter')
2  it('increments count on button click', async () => {
3    const wrapper = mount(Counter)
4
5    await wrapper.find('button.increment').trigger('click')
6
7    expect(wrapper.text()).toContain('Count: 1')
8  })

The await keyword is essential: Vue updates the DOM asynchronously, and trigger returns a promise that resolves only after the re-render. Without it the assertion would read the old text Count: 0. The third case passes a prop in the props field of the options object, the second argument of mount:

1  // Counter.spec.js - continuation of describe('Counter')
2  it('accepts initial count prop', () => {
3    const wrapper = mount(Counter, {
4      props: {
5        initialCount: 10
6      }
7    })
8
9    expect(wrapper.text()).toContain('Count: 10')
10  })
11})

The last bracket closes the describe block. Notice what the test does not check: it does not look into the count variable, only at the text the user sees, so it survives a change of implementation.

Testing events

Components also emit events. The emitted() method returns an object with all emitted events, and emitted('select') returns the array of emissions of one event, in which each emission is an array of arguments. A plain wrapper.trigger clicks the component's root element:

1import { mount } from '@vue/test-utils'
2import ArtworkCard from './ArtworkCard.vue'
3
4describe('ArtworkCard', () => {
5  it('emits select event on click', async () => {
6    const artwork = {
7      id: 1,
8      title: 'Mona Lisa',
9      artist: 'Leonardo'
10    }
11
12    const wrapper = mount(ArtworkCard, {
13      props: { artwork }
14    })
15
16    await wrapper.trigger('click')
17
18    expect(wrapper.emitted()).toHaveProperty('select')
19    expect(wrapper.emitted('select')[0]).toEqual([artwork])
20  })
21
22  it('emits favorite event on heart click', async () => {
23    const wrapper = mount(ArtworkCard, {
24      props: {
25        artwork: { id: 1, title: 'Test' }
26      }
27    })
28
29    await wrapper.find('.favorite-btn').trigger('click')
30
31    expect(wrapper.emitted('favorite')).toBeTruthy()
32  })
33})

The expression emitted('select')[0] is the first emission, and the toEqual matcher compares its content, not the object's identity - more on that in the next lesson. When an event was never emitted, emitted('favorite') returns undefined, so toBeTruthy() checks that it happened. The missing import of describe, it and expect does no harm thanks to globals: true.

Testing slots

You pass slot content in the slots option: the key is the slot name, and the value is an HTML fragment. A slot without a name is called default:

1import { mount } from '@vue/test-utils'
2import BaseCard from './BaseCard.vue'
3
4describe('BaseCard', () => {
5  it('renders default slot', () => {
6    const wrapper = mount(BaseCard, {
7      slots: {
8        default: '<p>Test content</p>'
9      }
10    })
11
12    expect(wrapper.html()).toContain('Test content')
13  })
14
15  it('renders named slots', () => {
16    const wrapper = mount(BaseCard, {
17      slots: {
18        header: '<h2>Header</h2>',
19        default: '<p>Body</p>',
20        footer: '<footer>Footer</footer>'
21      }
22    })
23
24    expect(wrapper.find('h2').text()).toBe('Header')
25    expect(wrapper.find('p').text()).toBe('Body')
26    expect(wrapper.find('footer').text()).toBe('Footer')
27  })
28})

The first case checks the whole HTML with the html() method, the second compares the text of specific elements with the toBe matcher. The BaseCard component does not know it is running in a test - it receives slots just as it would from a parent.

My advice: test behaviour visible from the outside - text, events and slots - not the component's internal variables, because such a test survives refactoring. In the next lesson you will go under the floor of the panel and test composables and Pinia stores.

Remember: a test is a pre-launch procedure written in code - every module goes through it before it flies to Mars.

Code for this lesson: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4// NOVA LAB - Build Configuration Demo
5const buildConfig = ref({
6  mode: 'production',
7  minify: true,
8  sourceMap: false,
9  chunkSize: 500,
10  compression: 'gzip'
11})
12
13const buildStats = computed(() => {
14  let size = 1200 // KB base size
15  if (buildConfig.value.minify) size *= 0.6
16  if (buildConfig.value.compression === 'gzip') size *= 0.3
17  if (buildConfig.value.compression === 'brotli') size *= 0.25
18  return Math.round(size)
19})
20
21const optimizationScore = computed(() => {
22  let score = 0
23  if (buildConfig.value.mode === 'production') score += 25
24  if (buildConfig.value.minify) score += 25
25  if (!buildConfig.value.sourceMap) score += 20
26  if (buildConfig.value.chunkSize <= 500) score += 15
27  if (buildConfig.value.compression !== 'none') score += 15
28  return score
29})
30
31function toggleMinify() {
32  buildConfig.value.minify = !buildConfig.value.minify
33}
34
35function toggleSourceMap() {
36  buildConfig.value.sourceMap = !buildConfig.value.sourceMap
37}
38
39function setCompression(type) {
40  buildConfig.value.compression = type
41}
42</script>
43
44<template>
45  <div class="launch-control">
46    <div class="header">
47      <h1>NOVA LAB - Build Configuration</h1>
48      <p>Strefa Wyrzutni - Launch Zone</p>
49    </div>
50
51    <div class="config-panel">
52      <h2>Build Settings</h2>
53
54      <div class="setting">
55        <label>
56          <input type="checkbox" v-model="buildConfig.minify" @change="toggleMinify">
57          Minification (Compression Enabled)
58        </label>
59      </div>
60
61      <div class="setting">
62        <label>
63          <input type="checkbox" v-model="buildConfig.sourceMap" @change="toggleSourceMap">
64          Source Maps (Debug Mode)
65        </label>
66      </div>
67
68      <div class="setting">
69        <label>Compression Type:</label>
70        <select v-model="buildConfig.compression">
71          <option value="none">None</option>
72          <option value="gzip">GZIP</option>
73          <option value="brotli">Brotli</option>
74        </select>
75      </div>
76
77      <div class="setting">
78        <label>Chunk Size Limit (KB):</label>
79        <input type="range" v-model.number="buildConfig.chunkSize" min="100" max="1000" step="50">
80        <span>{{ buildConfig.chunkSize }} KB</span>
81      </div>
82    </div>
83
84    <div class="stats-panel">
85      <h2>Build Statistics</h2>
86      <div class="stat">
87        <span>Bundle Size:</span>
88        <strong :class="{ optimal: buildStats < 400 }">{{ buildStats }} KB</strong>
89      </div>
90      <div class="stat">
91        <span>Optimization Score:</span>
92        <strong :class="{ optimal: optimizationScore >= 80 }">{{ optimizationScore }}/100</strong>
93      </div>
94      <div class="status" :class="optimizationScore >= 80 ? 'ready' : 'warning'">
95        {{ optimizationScore >= 80 ? 'Ready for Launch!' : 'Optimization Needed' }}
96      </div>
97    </div>
98  </div>
99</template>
100
101<style scoped>
102.launch-control {
103  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
104  color: #00ff88;
105  padding: 2rem;
106  min-height: 100vh;
107  font-family: 'Courier New', monospace;
108}
109
110.header {
111  text-align: center;
112  margin-bottom: 2rem;
113  border-bottom: 2px solid #00b4d8;
114  padding-bottom: 1rem;
115}
116
117.header h1 {
118  color: #00ff88;
119  text-shadow: 0 0 10px #00ff88;
120  margin: 0;
121}
122
123.header p {
124  color: #00b4d8;
125  margin: 0.5rem 0 0 0;
126}
127
128.config-panel, .stats-panel {
129  background: rgba(0, 180, 216, 0.1);
130  border: 1px solid #00b4d8;
131  border-radius: 8px;
132  padding: 1.5rem;
133  margin-bottom: 1.5rem;
134}
135
136.config-panel h2, .stats-panel h2 {
137  color: #00b4d8;
138  margin-top: 0;
139  font-size: 1.2rem;
140}
141
142.setting {
143  margin-bottom: 1rem;
144  padding: 0.75rem;
145  background: rgba(0, 255, 136, 0.05);
146  border-radius: 4px;
147}
148
149.setting label {
150  display: flex;
151  align-items: center;
152  gap: 0.5rem;
153  color: #00ff88;
154}
155
156.setting input[type="checkbox"] {
157  width: 20px;
158  height: 20px;
159}
160
161.setting select, .setting input[type="range"] {
162  margin-left: 0.5rem;
163  padding: 0.5rem;
164  background: #0a0e27;
165  border: 1px solid #00b4d8;
166  color: #00ff88;
167  border-radius: 4px;
168}
169
170.stat {
171  display: flex;
172  justify-content: space-between;
173  padding: 0.75rem;
174  margin-bottom: 0.5rem;
175  background: rgba(0, 255, 136, 0.05);
176  border-radius: 4px;
177}
178
179.stat strong {
180  color: #fff;
181  font-size: 1.2rem;
182}
183
184.stat strong.optimal {
185  color: #00ff88;
186  text-shadow: 0 0 5px #00ff88;
187}
188
189.status {
190  text-align: center;
191  padding: 1rem;
192  margin-top: 1rem;
193  border-radius: 4px;
194  font-size: 1.1rem;
195  font-weight: bold;
196}
197
198.status.ready {
199  background: rgba(0, 255, 136, 0.2);
200  color: #00ff88;
201  border: 2px solid #00ff88;
202}
203
204.status.warning {
205  background: rgba(255, 165, 0, 0.2);
206  color: #ffa500;
207  border: 2px solid #ffa500;
208}
209</style>

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Why is testing crucial in Vue applications?

  2. 2. Why is Vitest the recommended testing framework for Vue + Vite?

These are 2 of 5 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Complete the vitest.config.js configuration file to properly set up the Vue testing environment.

  • Click in order

    Arrange the elements of a test structure in the correct order:

  • Code editor

    Write a test that checks whether the Counter component renders correctly and responds to a click.

  • Vertical ordering

    Arrange the steps of writing a Vue component test in order:

  • Horizontal ordering

    Arrange the syntax for mounting a component with props in a test:

  • Code editor

    Write a test that checks whether the component emits a 'select' event on click and passes the artwork data.

  • Vertical ordering

    Arrange the test types from fastest and cheapest to slowest and most expensive:

  • Click in order

    Arrange the code that simulates a button click in a test:

Useful articles