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

Integration & E2E Testing

7 min read
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. 1. How do integration tests differ from unit tests?

  2. 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:

Useful articles