Vue.js: Unit Testing & E2E
Last updated: 2026-10-20
Unit testing is the cornerstone of guaranteeing code quality — you can refactor a component without fear of breaking it, CI runs the tests automatically, and new teammates dare to touch the code. Vue 3 recommends Vitest (a next-generation test framework that is 10x faster than Jest) plus Vue Test Utils (the official component testing tool).
E2E testing simulates real user interactions — Playwright is the most popular E2E tool today (faster than Cypress and cross-browser). This lesson covers the complete testing system: unit tests + E2E.
1. What You'll Learn
- Vitest configuration and its 4 core APIs
- Mounting components with Vue Test Utils (@vue/test-utils)
- The 5 component testing scenarios (props / emit / slot / event / async)
- 5 assertion techniques
- Playwright E2E testing
- Coverage reports
- 5 common mistakes
2. The Pain of a Team with "Refactoring Phobia"
(1) Pain point: one broken component crashes the whole site
Alice's team maintains an e-commerce admin with 100+ components:
// ❌ The broken version: no tests, so Alice doesn't dare to refactor
// One line changed in ProductCard.vue, and the entire home page goes blank
// Nobody notices until users complain the next day
// The team: "Don't touch this component, we don't know what it will break"
Product manager Charlie:
"Alice, we need tests. We need to be able to refactor with confidence. We need 1) unit tests for components, 2) E2E tests for the critical flows."
(2) The Vitest + Vue Test Utils solution
// tests/unit/ProductCard.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ProductCard from '@/components/ProductCard.vue'
describe('ProductCard', () => {
it('renders product name and price', () => {
const product = { id: 1, name: 'iPhone', price: 999 }
const wrapper = mount(ProductCard, { props: { product } })
expect(wrapper.text()).toContain('iPhone')
expect(wrapper.text()).toContain('$999')
})
it('emits add-to-cart event', async () => {
const wrapper = mount(ProductCard, {
props: { product: { id: 1, name: 'iPhone', price: 999 } }
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('add-to-cart')).toBeTruthy()
expect(wrapper.emitted('add-to-cart')[0]).toEqual([1])
})
})
Run the tests:
$ npx vitest run
✓ ProductCard > renders product name and price
✓ ProductCard > emits add-to-cart event
2 passed
Runs in 5 seconds, zero false alarms. The moment you finish a change, you know whether you broke something.
(3) The payoff
After adding tests:
- Refactoring confidence: +100% (no fear of breaking things)
- Bugs caught earlier: 80% are caught during development
- Documentation value: the tests are the component's user manual
- CI automation: tests run automatically on every PR
3. Vitest Configuration
(1) Installation
npm install -D vitest @vue/test-utils @vitest/coverage-v8 jsdom
(2) Basic configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
test: {
// Test environment
environment: 'jsdom', // or 'happy-dom' / 'node'
// Files to match
include: ['tests/**/*.test.ts', 'src/**/*.test.ts'],
// Coverage
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json'],
exclude: ['node_modules/', 'tests/']
},
// Global setup
setupFiles: ['./tests/setup.ts']
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
}
})
(3) The 4 core APIs
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// 1. describe: a test suite
describe('Calculator', () => { })
// 2. it: a single test
it('adds 1 + 1 to equal 2', () => { })
// 3. expect: assertions
expect(1 + 1).toBe(2)
expect(arr).toContain('apple')
expect(obj).toMatchObject({ name: 'Alice' })
// 4. vi: mock / spy
vi.fn() // mock function
vi.mock('./api') // mock a module
vi.spyOn(obj, 'method') // spy on a method
(4) Vitest vs Jest
| Dimension | Jest | Vitest |
|---|---|---|
| Startup time | 3-5s | < 1s |
| Watch mode | 2-3s | < 100ms |
| ESM support | Needs configuration | Native |
| TypeScript | Needs ts-jest | Native |
| Compatibility | Huge | Jest-compatible API |
| Recommendation | Legacy | ⭐⭐⭐⭐⭐ |
4. Component Testing with Vue Test Utils
(1) The 5 core APIs
import { mount, shallowMount, RouterLinkStub } from '@vue/test-utils'
// 1. mount: full mount (child components are mounted too)
const wrapper = mount(Component, { props: { ... } })
// 2. shallowMount: shallow mount (child components are not mounted, they are stubbed)
const wrapper = shallowMount(Component, { props: { ... } })
// 3. Finding elements
wrapper.find('button') // first match
wrapper.findAll('li') // all matches
wrapper.findComponent(MyComponent) // find a child component
wrapper.get('#submit') // must exist (throws if not found)
// 4. Triggering events
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('hello')
// 5. Assertions
expect(wrapper.text()).toContain('Hello')
expect(wrapper.find('h1').text()).toBe('Title')
expect(wrapper.emitted('add-to-cart')).toBeTruthy()
(2) The 5 test scenarios
// 1. props test
it('renders props correctly', () => {
const wrapper = mount(MyComponent, { props: { title: 'Hello' } })
expect(wrapper.text()).toContain('Hello')
})
// 2. emit test
it('emits event on click', async () => {
const wrapper = mount(MyComponent)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('submit')).toBeTruthy()
})
// 3. slot test
it('renders default slot', () => {
const wrapper = mount(MyComponent, {
slots: { default: '<p>Slot content</p>' }
})
expect(wrapper.html()).toContain('Slot content')
})
// 4. async test
// Note: flushPromises must be defined yourself or imported from a testing utility library
// const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0))
it('loads data on mount', async () => {
const wrapper = mount(MyComponent)
await flushPromises() // wait for the async work to finish
expect(wrapper.text()).toContain('Loaded')
})
// 5. provide/inject test
it('uses provided value', () => {
const wrapper = mount(MyComponent, {
provide: { theme: 'dark' }
})
expect(wrapper.find('.dark').exists()).toBe(true)
})
(3) The 5 assertion techniques
// 1. Text contains
expect(wrapper.text()).toContain('Hello')
// 2. Class exists
expect(wrapper.find('.active').exists()).toBe(true)
// 3. Element exists
expect(wrapper.find('button').exists()).toBe(true)
// 4. Event emitted
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')[0]).toEqual([arg1, arg2])
// 5. Async operation finished
await wrapper.vm.$nextTick()
await flushPromises()
▶ Example: Verifying props and emit with a Mini Assertion Helper (browser-runnable)
expect / test replacement. The component is mounted into real DOM, clicked, and we assert both the rendered text and the value it emits to the parent.
▶ Expand runnable example (75 lines)
Output:
First render: the page shows the "Hits: 3" button, the hint "Last value received by the parent: (not clicked yet)", and the list shows "running…".
After the script clicks the sandbox button once, the list becomes ✓ props drive the initial render, ✓ a click increments and re-renders, ✓ emit passes the new value to the parent, ✓ 3 passed.
The console prints the same 4 lines; clicking the visible Hits button yourself keeps incrementing it and updates the parent value to 4, 5, 6…
5. Playwright E2E Testing
(1) Installation
npm install -D @playwright/test
npx playwright install
(2) Configuration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry'
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } }
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI
}
})
(3) The 5 E2E scenarios
// 1. Login flow
import { test, expect } from '@playwright/test'
test('user can login', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="username"]', 'alice@example.com')
await page.fill('input[name="password"]', 'password123')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('Welcome, Alice')
})
// 2. Add to cart
test('add product to cart', async ({ page }) => {
await page.goto('/products/1')
await page.click('button:has-text("Add to Cart")')
await expect(page.locator('.cart-count')).toHaveText('1')
})
// 3. Form submission
test('submit form with validation', async ({ page }) => {
await page.goto('/contact')
await page.fill('input[name="email"]', 'invalid-email')
await page.click('button[type="submit"]')
await expect(page.locator('.error')).toBeVisible()
})
// 4. Route navigation
test('navigate to products page', async ({ page }) => {
await page.goto('/')
await page.click('a:has-text("Products")')
await expect(page).toHaveURL('/products')
})
// 5. Async loading
test('loads data after navigation', async ({ page }) => {
await page.goto('/dashboard')
// Wait for the data to load
await page.waitForSelector('.chart-loaded')
await expect(page.locator('.chart-loaded')).toBeVisible()
})
(4) Running the E2E tests
# Run all tests
npx playwright test
# Run a specific file
npx playwright test tests/e2e/login.spec.ts
# Debug mode (opens a browser)
npx playwright test --debug
# UI mode (visual)
npx playwright test --ui
▶ Example: Rehearsing an E2E User Flow (browser-runnable)
expect(locator) with a hand-written check().
▶ Expand runnable example (68 lines)
Output:
First render: the status line reads "Logged out", both "Add to cart" and "Checkout" are greyed out, and the list shows "running…".
The script runs 4 steps automatically (login, add 2 items, checkout), so the list shows 4 ✓ lines plus "✓ E2E flow passed: 4 / 4 steps", and the console prints the same 5 lines.
Afterwards you can keep clicking "Add to cart" (login first) by hand: the cart and the checkout button state stay in sync.
6. Complete Example: The 5 Test Scenarios
▶ Example: The 5 Test Scenarios (⚠️ Requires Vite + Vitest)
// Unit tests: Vitest + @vue/test-utils
// 1. props
it('renders props', () => { /* ... */ })
// 2. emit
it('emits event', async () => { /* ... */ })
// 3. slot
it('renders slot', () => { /* ... */ })
// 4. async
it('loads data', async () => { /* ... */ })
// 5. provide
it('uses provide', () => { /* ... */ })
// The 5 assertion techniques
// 1. Text
expect(wrapper.text()).toContain('Hello')
// 2. Class
expect(wrapper.find('.active').exists()).toBe(true)
// 3. Element
expect(wrapper.find('button').exists()).toBe(true)
// 4. Event
expect(wrapper.emitted('click')).toBeTruthy()
// 5. Async
await wrapper.vm.$nextTick()
// E2E tests: Playwright (5 scenarios)
// 1. Login
test('login', async ({ page }) => { /* ... */ })
// 2. Cart
test('add to cart', async ({ page }) => { /* ... */ })
// 3. Form
test('form submit', async ({ page }) => { /* ... */ })
// 4. Routing
test('navigate', async ({ page }) => { /* ... */ })
// 5. Async
test('async load', async ({ page }) => { /* ... */ })
Output:
Once the placeholders are filled in, Vitest reports all ten unit tests as passed, and Playwright reports the five E2E scenarios as passed for each configured browser:
✓ tests/unit/component.test.js (5)
✓ renders props
✓ emits event
✓ renders slot
✓ loads data
✓ uses provide
✓ tests/unit/assertions.test.js (5)
Test Files 2 passed (2)
Tests 10 passed (10)
Duration ~1.2s
Running 5 tests using 3 workers
✓ 5 [chromium] › flows.spec.ts › login
✓ 5 [chromium] › flows.spec.ts › add to cart
✓ 5 [chromium] › flows.spec.ts › form submit
✓ 5 [chromium] › flows.spec.ts › navigate
✓ 5 [chromium] › flows.spec.ts › async load
5 passed
(1) 📊 Quick Reference for 5 Common Mistakes
| Error | Symptom | Solution |
|---|---|---|
| Test cannot find the component | Import error | Check the path alias |
| Async test does not wait | Always fails | Use await + flushPromises |
| Triggering an event has no effect | Listener never fires | await after trigger |
| E2E times out | Element not found | waitForSelector |
| Low coverage | No tests written | Enforce a coverage threshold |
Output:
The block renders as a five-row troubleshooting table that you scan top to bottom when a test misbehaves:
Error Symptom Solution
--------------------------------- ----------------------- ----------------------------
Test cannot find the component Import error Check the path alias
Async test does not wait Always fails Use await + flushPromises
Triggering an event has no effect Listener never fires await after trigger
E2E times out Element not found waitForSelector
Low coverage No tests written Enforce a coverage threshold
(2) 📊 Comparison of the 5 Testing Tools
| Tool | Speed | Best for |
|---|---|---|
| Vitest | ⭐⭐⭐⭐⭐ | Unit testing |
| Vue Test Utils | ⭐⭐⭐⭐⭐ | Component testing |
| Playwright | ⭐⭐⭐⭐ | E2E testing |
| Cypress | ⭐⭐⭐ | E2E (slow) |
| Testing Library | ⭐⭐⭐⭐ | Component testing |
Output:
The block renders as a five-row comparison table sorted from fastest to slowest:
Tool Speed Best for
----------------- ---------- ------------------
Vitest ⭐⭐⭐⭐⭐ Unit testing
Vue Test Utils ⭐⭐⭐⭐⭐ Component testing
Playwright ⭐⭐⭐⭐ E2E testing
Cypress ⭐⭐⭐ E2E (slow)
Testing Library ⭐⭐⭐⭐ Component testing
❓ FAQ
npm test step to GitHub Actions / GitLab CI. Block PR merges when it fails.📖 Summary
- Vitest is the recommended test framework for Vue 3 (10x faster than Jest)
- Vue Test Utils: mount / shallowMount / the 5 core APIs
- The 5 component tests: props / emit / slot / async / provide
- The 5 assertions: text / class / element / event / async
- Playwright is the recommended E2E tool (fast / cross-browser)
- Test coverage: 80%+ for core code, 60%+ overall
- 70% unit + 20% integration + 10% E2E
📝 Exercises
-
Basic (Difficulty: ⭐) Write 3 tests for ProductCard:
- props rendering
- emit event
- Edge case (stock is 0)
-
Intermediate (Difficulty: ⭐⭐) Write complete tests for the cart store:
- 5 actions (addItem / removeItem / clear / load / save)
- 3 getters (itemCount / totalPrice / isEmpty)
- Pinia test setup
-
Challenge (Difficulty: ⭐⭐⭐) Build a complete "testing system":
- 10 component unit tests (3-5 tests per component)
- 5 composable tests
- 5 E2E tests (login / register / search / cart / checkout)
- 80% coverage threshold
- CI integration (GitHub Actions)
- Vitest + Playwright cross-browser testing