Vue.js course Β· Module 12: Testing & Deploy
Integration & E2E Testing
In this lesson5
Integration tests verify how systems work together - just like rocket systems must cooperate before launch to Mars.
Every subassembly of the panel has already passed its trials in isolation: the counter, the composable, the archive store. That still does not guarantee they will work together. A component may read a field from the store under a different name than the store exposes, and the router may fail to render the view a link points to. No unit test will catch such faults, because they only appear at the joints. That is why we now put the subassemblies back together.
Integration versus unit tests
A unit test checks a single unit in isolation, and an integration test checks how many parts work together: a component with a store, a component with a router, a parent with its children. This is where the difference between two Vue Test Utils functions matters. mount() renders the full component tree together with the children, while shallowMount() replaces the children with stubs, which look like <child-stub> in the HTML. In an integration test you use mount(), because the children must really work.
Component Integration Tests
The first test checks whether the gallery displays data from a Pinia store. The global.plugins option installs plugins in the test application the same way app.use() does in main.js:
1// ArtworkGallery.spec.js
2import { describe, it, expect, beforeEach } from 'vitest'
3import { mount } from '@vue/test-utils'
4import { createPinia, setActivePinia } from 'pinia'
5import ArtworkGallery from './ArtworkGallery.vue'
6import { useGalleryStore } from '@/stores/gallery'
7
8describe('ArtworkGallery Integration', () => {
9 beforeEach(() => {
10 setActivePinia(createPinia())
11 })
12
13 it('displays artworks from store', async () => {
14 const wrapper = mount(ArtworkGallery, {
15 global: {
16 plugins: [createPinia()]
17 }
18 })
19
20 const store = useGalleryStore()
21
22 store.artworks = [
23 { id: 1, title: 'Mona Lisa', artist: 'Leonardo' },
24 { id: 2, title: 'David', artist: 'Michelangelo' }
25 ]
26
27 await wrapper.vm.$nextTick()
28
29 const cards = wrapper.findAll('.artwork-card')
30 expect(cards).toHaveLength(2)
31 })Installing Pinia through global.plugins makes that instance the active one, so useGalleryStore() in the test and in the component reach the same store. After changing the data we wait for $nextTick(), because Vue re-renders the DOM asynchronously. findAll returns a list of matching elements, and toHaveLength(2) checks how many there are. The second case touches three layers at once: the search field, the filtering getter in the store and the list of cards. The setValue method types text into the field and sends an input event, just as a technician would:
1 // ArtworkGallery.spec.js - continuation of describe
2 it('filters artworks on search input', async () => {
3 const wrapper = mount(ArtworkGallery, {
4 global: {
5 plugins: [createPinia()]
6 }
7 })
8
9 const store = useGalleryStore()
10 store.artworks = [
11 { id: 1, title: 'Mona Lisa', artist: 'Leonardo' },
12 { id: 2, title: 'David', artist: 'Michelangelo' }
13 ]
14
15 await wrapper.vm.$nextTick()
16
17 const searchInput = wrapper.find('input[type="search"]')
18 await searchInput.setValue('mona')
19
20 await wrapper.vm.$nextTick()
21
22 const cards = wrapper.findAll('.artwork-card')
23 expect(cards).toHaveLength(1)
24 expect(cards[0].text()).toContain('Mona Lisa')
25 })
26})The test does not call the getter directly; it goes through the interface, so it also checks that the template is really wired to the store. The store itself has not been replaced with anything - it is the same code that will fly to Mars.
Router Integration
A component with <RouterView> needs a working router. In a test you create it with the createRouter function and the createMemoryHistory history, which keeps the address in memory instead of the browser's address bar, and then you mount the application with the router in global.plugins:
1import { mount } from '@vue/test-utils'
2import { createRouter, createMemoryHistory } from 'vue-router'
3import App from './App.vue'
4
5describe('App with Router', () => {
6 it('navigates to gallery page', async () => {
7 const router = createRouter({
8 history: createMemoryHistory(),
9 routes: [
10 { path: '/', component: { template: '<div>Home</div>' } },
11 { path: '/gallery', component: { template: '<div>Gallery</div>' } }
12 ]
13 })
14
15 const wrapper = mount(App, {
16 global: {
17 plugins: [router]
18 }
19 })
20
21 await router.push('/gallery')
22 await router.isReady()
23
24 expect(wrapper.html()).toContain('Gallery')
25 })
26})router.push returns a navigation promise, and router.isReady() waits for the initial navigation to finish, which is why both calls have await. Placeholder views written as an object with a template field are enough in a test; in an application built by Vite you will use .vue files, because the default browser build of Vue does not compile templates on the fly.
E2E Tests (Cypress/Playwright)
E2E tests work one floor higher: they launch a real browser, open the application at its address and click like a human. You do not mount components here - you visit the page. The Vue documentation recommends Playwright and Cypress for this, and the create-vue scaffolding tool offers Playwright when you create a project. This is what a scenario looks like in Cypress:
1// cypress/e2e/gallery.cy.js
2describe('Gallery E2E', () => {
3 beforeEach(() => {
4 cy.visit('/')
5 })
6
7 it('displays artworks', () => {
8 cy.get('.artwork-card').should('have.length.greaterThan', 0)
9 })
10
11 it('filters artworks by search', () => {
12 cy.get('input[type="search"]').type('mona')
13
14 cy.get('.artwork-card').should('have.length', 1)
15 cy.get('.artwork-card').first().should('contain', 'Mona Lisa')
16 })cy.visit('/') opens the page relative to the base address (baseUrl) from the Cypress configuration. should assertions retry on their own until the condition is met or the timeout expires, so you do not wait for the render by hand. The next cases continue along the operator's path - to the artwork details and favourites:
1 // cypress/e2e/gallery.cy.js - continuation of describe
2 it('opens artwork detail on click', () => {
3 cy.get('.artwork-card').first().click()
4
5 cy.url().should('include', '/artwork/')
6 cy.get('.artwork-detail').should('be.visible')
7 })
8
9 it('adds artwork to favorites', () => {
10 cy.get('.artwork-card').first().find('.favorite-btn').click()
11
12 cy.get('.favorites-count').should('contain', '1')
13 })
14})The test knows no component, store or route - it sees only what the user sees: the address, the elements and their text.
Playwright Example
The same scenario in Playwright. Each test receives a page object, a browser tab, and the steps are always the same: open the application (page.goto), find an element (page.locator), perform an action (click, fill) and check the result (expect):
1// tests/gallery.spec.js
2import { test, expect } from '@playwright/test'
3
4test.describe('Gallery', () => {
5 test('should display artworks', async ({ page }) => {
6 await page.goto('/')
7
8 const cards = page.locator('.artwork-card')
9 await expect(cards).toHaveCount(12)
10 })
11
12 test('should filter artworks', async ({ page }) => {
13 await page.goto('/')
14
15 await page.locator('input[type="search"]').fill('leonardo')
16
17 const cards = page.locator('.artwork-card')
18 await expect(cards).toHaveCount(2)
19 })The page.fill() and page.click() methods that take a selector argument still exist, but the Playwright documentation marks them as discouraged and recommends actions on locators, such as locator.fill(). Assertions like toHaveCount wait for the condition on their own. The third test moves on to the details: first() picks the first matching element, and toHaveURL checks the address with a regular expression:
1 // tests/gallery.spec.js - continuation of test.describe
2 test('should navigate to artwork detail', async ({ page }) => {
3 await page.goto('/')
4
5 await page.locator('.artwork-card').first().click()
6
7 await expect(page).toHaveURL(/.*artwork\/\d+/)
8 await expect(page.locator('.artwork-detail')).toBeVisible()
9 })
10})The toBeVisible assertion confirms that the details panel can actually be seen. The shortest smoke test checks the browser tab's title with the toHaveTitle assertion:
1test('shows the gallery title', async ({ page }) => {
2 await page.goto('/')
3 await expect(page).toHaveTitle('Gallery')
4})The title comes from the <title> element in index.html, so when this test fails, the application most likely did not start at all. All the tests call page.goto('/') with a relative path, which works only with a base address in playwright.config.js:
1// playwright.config.js
2import { defineConfig } from '@playwright/test'
3
4export default defineConfig({
5 use: {
6 baseURL: 'http://localhost:4173'
7 },
8 webServer: {
9 command: 'npm run build && npm run preview',
10 url: 'http://localhost:4173'
11 }
12})The webServer section tells Playwright to build the application itself and start vite preview, whose default port is 4173 - so you test exactly the payload that will fly to production.
My advice: keep the proportions of the pyramid - dozens of unit tests, a few integration tests for the key connections and a handful of E2E tests for the paths without which the mission loses its point. The Vitest coverage report will tell you which parts of the code no test has visited yet. When everything shines green, what remains is to build the payload for shipping - we will take care of that in the next lesson.
Remember: unit tests check the valves, integration tests the whole installations, and E2E is a test flight of the entire rocket.
Code for this lesson: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4// NOVA LAB - Production Build Checklist
5const checklist = ref([
6 { id: 1, task: 'Remove console.log statements', completed: false, critical: true },
7 { id: 2, task: 'Enable production mode', completed: false, critical: true },
8 { id: 3, task: 'Optimize images and assets', completed: false, critical: false },
9 { id: 4, task: 'Enable HTTPS/SSL', completed: false, critical: true },
10 { id: 5, task: 'Set up error tracking', completed: false, critical: false },
11 { id: 6, task: 'Configure CDN', completed: false, critical: false },
12 { id: 7, task: 'Enable compression (gzip/brotli)', completed: false, critical: true },
13 { id: 8, task: 'Test on multiple devices', completed: false, critical: false },
14 { id: 9, task: 'Set up monitoring', completed: false, critical: false },
15 { id: 10, task: 'Configure caching headers', completed: false, critical: true },
16 { id: 11, task: 'Review security headers', completed: false, critical: true },
17 { id: 12, task: 'Backup production data', completed: false, critical: true }
18])
19
20const completedCount = computed(() => {
21 return checklist.value.filter(item => item.completed).length
22})
23
24const criticalCompleted = computed(() => {
25 const critical = checklist.value.filter(item => item.critical)
26 const completed = critical.filter(item => item.completed)
27 return completed.length
28})
29
30const criticalTotal = computed(() => {
31 return checklist.value.filter(item => item.critical).length
32})
33
34const progress = computed(() => {
35 return Math.round((completedCount.value / checklist.value.length) * 100)
36})
37
38const isReadyForLaunch = computed(() => {
39 return criticalCompleted.value === criticalTotal.value
40})
41
42function toggleTask(id) {
43 const task = checklist.value.find(item => item.id === id)
44 if (task) {
45 task.completed = !task.completed
46 }
47}
48
49function resetChecklist() {
50 checklist.value.forEach(item => {
51 item.completed = false
52 })
53}
54</script>
55
56<template>
57 <div class="checklist-app">
58 <div class="header">
59 <h1>Pre-Launch Checklist</h1>
60 <p>NOVA LAB Production Deployment</p>
61 </div>
62
63 <div class="progress-panel">
64 <div class="progress-bar">
65 <div class="progress-fill" :style="{ width: progress + '%' }"></div>
66 </div>
67 <div class="progress-stats">
68 <span>Total: {{ completedCount }}/{{ checklist.length }}</span>
69 <span>Critical: {{ criticalCompleted }}/{{ criticalTotal }}</span>
70 <span>{{ progress }}%</span>
71 </div>
72 </div>
73
74 <div class="launch-status" :class="isReadyForLaunch ? 'ready' : 'not-ready'">
75 <template v-if="isReadyForLaunch">
76 ALL SYSTEMS GO! Ready for deployment!
77 </template>
78 <template v-else>
79 {{ criticalTotal - criticalCompleted }} critical task(s) remaining
80 </template>
81 </div>
82
83 <div class="checklist">
84 <div
85 v-for="item in checklist"
86 :key="item.id"
87 class="checklist-item"
88 :class="{
89 completed: item.completed,
90 critical: item.critical && !item.completed
91 }"
92 @click="toggleTask(item.id)"
93 >
94 <div class="checkbox">
95 <span v-if="item.completed">x</span>
96 </div>
97 <div class="task-content">
98 <span class="task-text">{{ item.task }}</span>
99 <span v-if="item.critical" class="badge">CRITICAL</span>
100 </div>
101 </div>
102 </div>
103
104 <div class="actions">
105 <button @click="resetChecklist" class="reset-btn">
106 Reset Checklist
107 </button>
108 </div>
109 </div>
110</template>
111
112<style scoped>
113.checklist-app {
114 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
115 color: #00ff88;
116 padding: 2rem;
117 min-height: 100vh;
118 font-family: 'Courier New', monospace;
119}
120
121.header {
122 text-align: center;
123 margin-bottom: 2rem;
124 border-bottom: 2px solid #00b4d8;
125 padding-bottom: 1rem;
126}
127
128.header h1 {
129 color: #00ff88;
130 text-shadow: 0 0 10px #00ff88;
131 margin: 0;
132}
133
134.header p {
135 color: #00b4d8;
136 margin: 0.5rem 0 0 0;
137}
138
139.progress-panel {
140 background: rgba(0, 180, 216, 0.1);
141 border: 1px solid #00b4d8;
142 border-radius: 8px;
143 padding: 1.5rem;
144 margin-bottom: 1.5rem;
145}
146
147.progress-bar {
148 height: 30px;
149 background: rgba(0, 255, 136, 0.1);
150 border-radius: 15px;
151 overflow: hidden;
152 margin-bottom: 1rem;
153 border: 1px solid #00b4d8;
154}
155
156.progress-fill {
157 height: 100%;
158 background: linear-gradient(90deg, #00b4d8, #00ff88);
159 transition: width 0.5s ease;
160 box-shadow: 0 0 10px #00ff88;
161}
162
163.progress-stats {
164 display: flex;
165 justify-content: space-between;
166 color: #00b4d8;
167 font-size: 0.9rem;
168}
169
170.launch-status {
171 text-align: center;
172 padding: 1.5rem;
173 margin-bottom: 1.5rem;
174 border-radius: 8px;
175 font-size: 1.2rem;
176 font-weight: bold;
177}
178
179.launch-status.ready {
180 background: rgba(0, 255, 136, 0.2);
181 color: #00ff88;
182 border: 2px solid #00ff88;
183 box-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
184}
185
186.launch-status.not-ready {
187 background: rgba(255, 165, 0, 0.2);
188 color: #ffa500;
189 border: 2px solid #ffa500;
190}
191
192.checklist {
193 display: flex;
194 flex-direction: column;
195 gap: 0.75rem;
196 margin-bottom: 2rem;
197}
198
199.checklist-item {
200 display: flex;
201 align-items: center;
202 gap: 1rem;
203 padding: 1rem;
204 background: rgba(0, 180, 216, 0.1);
205 border: 1px solid #00b4d8;
206 border-radius: 8px;
207 cursor: pointer;
208 transition: all 0.3s;
209}
210
211.checklist-item:hover {
212 background: rgba(0, 180, 216, 0.2);
213 transform: translateX(5px);
214}
215
216.checklist-item.completed {
217 background: rgba(0, 255, 136, 0.1);
218 border-color: #00ff88;
219}
220
221.checklist-item.critical {
222 border-color: #ffa500;
223 border-width: 2px;
224}
225
226.checkbox {
227 width: 24px;
228 height: 24px;
229 border: 2px solid #00b4d8;
230 border-radius: 4px;
231 display: flex;
232 align-items: center;
233 justify-content: center;
234 font-size: 1.2rem;
235 flex-shrink: 0;
236}
237
238.completed .checkbox {
239 background: #00ff88;
240 border-color: #00ff88;
241 color: #0a0e27;
242}
243
244.task-content {
245 flex: 1;
246 display: flex;
247 justify-content: space-between;
248 align-items: center;
249}
250
251.task-text {
252 color: #fff;
253}
254
255.completed .task-text {
256 text-decoration: line-through;
257 opacity: 0.7;
258}
259
260.badge {
261 background: #ffa500;
262 color: #0a0e27;
263 padding: 0.25rem 0.75rem;
264 border-radius: 12px;
265 font-size: 0.75rem;
266 font-weight: bold;
267}
268
269.actions {
270 text-align: center;
271}
272
273.reset-btn {
274 padding: 1rem 2rem;
275 background: rgba(0, 180, 216, 0.2);
276 border: 2px solid #00b4d8;
277 color: #00b4d8;
278 font-size: 1rem;
279 font-family: 'Courier New', monospace;
280 border-radius: 8px;
281 cursor: pointer;
282 transition: all 0.3s;
283}
284
285.reset-btn:hover {
286 background: #00b4d8;
287 color: #0a0e27;
288 box-shadow: 0 0 20px #00b4d8;
289}
290</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. How do integration tests differ from unit tests?
2. What is the difference between mount() and shallowMount() in Vue Test Utils?
These are 2 of 4 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Write an integration test for a component with a Pinia store - verify that the component displays data from the store.
- Vertical ordering
Arrange the steps of an end-to-end test in order:
- Click in order
Arrange the Playwright code that opens a page and checks its title:
- Code editor
Write a Playwright test that verifies gallery filtering - typing text in search and verifying results.
- Horizontal ordering
Arrange the code for mounting a component with router in a test: