Vue.js course Β· Module 12: Testing & Deploy
Testing Vue Components
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-domThe -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. Why is testing crucial in Vue applications?
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: