Testing It
Unit tests for the store and composables, component tests for the screens, and one end-to-end run through sign-in.
mount(Component, { props, global: { plugins: [createTestingPinia()] } })Three kinds of test, each answering a different question, and each costing more than the last.
Unit tests
These cover the parts with no DOM: the store's actions and getters, the error mapper, a composable (a plain function that uses Vue's reactivity APIs). They run in Vitest with no component mounted. setActivePinia(createPinia()) in a beforeEach gives each test a store with no leftover state, and the API client is replaced with vi.mock so nothing touches the network.
Component tests
These mount one component with Vue Test Utils and assert on what it renders and what it emits. mount returns a wrapper; wrapper.get finds an element, setValue and trigger act on it, and wrapper.emitted('toggle') returns an array of the arguments each emission carried. Write the component so this is easy: a data-testid attribute is a selector that styling changes cannot break.
When a mounted component reads a store, createTestingPinia() from @pinia/testing supplies one whose actions are replaced with spies, and initialState sets its starting values.
One end-to-end test
Playwright drives a real browser against the built application. One journey is enough: sign in, create a task, see it in the list. It is the only test that proves the router, the guard, the store and the server agree.
What trips people
Asserting on markup instead of behaviour. A test that reads a class name fails on the next restyle.
Example
<script setup lang="ts">
import type { Task } from '@/types/task'
const props = defineProps<{ task: Task }>()
const emit = defineEmits<{ toggle: [id: string, done: boolean] }>()
function onToggle(event: Event) {
const input = event.target as HTMLInputElement
emit('toggle', props.task.id, input.checked)
}
</script>
<template>
<li>
<label>
<input
type="checkbox"
:checked="props.task.done"
data-testid="task-done"
@change="onToggle"
/>
<span>{{ props.task.title }}</span>
</label>
</li>
</template>
<style scoped>
li {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>When to use it
- Proving a store's getter filters correctly without rendering anything.
- Checking that a row emits the event its parent listens for, after a real click.
- Catching a broken auth guard before a deploy, by signing in for real once.
- Replacing the API client in tests so the suite runs offline and deterministically.
More examples
Unit test: the store's getter and status
vi.mock replaces the API client for the whole file, so load resolves with fixed data and never opens a socket. setActivePinia gives a fresh store per test. This is the cheapest place to test a filter, a sort or a status transition, and the first place to add a test when a bug report describes wrong data rather than a wrong screen.
// src/stores/tasks.spec.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { request } from '@/api/client'
import { useTasksStore } from './tasks'
import type { Task } from '@/types/task'
vi.mock('@/api/client', () => ({
request: vi.fn(),
ApiError: class ApiError extends Error {},
}))
const loaded: Task[] = [
{ id: '1', title: 'Write docs', notes: '', done: false, updatedAt: '2026-01-01' },
{ id: '2', title: 'Ship it', notes: '', done: true, updatedAt: '2026-01-02' },
]
describe('tasks store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(request).mockReset()
})
it('exposes only the unfinished tasks once loaded', async () => {
vi.mocked(request).mockResolvedValue(loaded)
const tasks = useTasksStore()
await tasks.load()
expect(tasks.status).toBe('ready')
expect(tasks.open.map((task) => task.title)).toEqual(['Write docs'])
})
it('records the message when the request fails', async () => {
vi.mocked(request).mockRejectedValue(new Error('Bad gateway'))
const tasks = useTasksStore()
await tasks.load()
expect(tasks.status).toBe('error')
expect(tasks.error).toBe('Bad gateway')
})
})Component test: the row emits what the parent needs
The component is mounted with a props object, the checkbox is found by its data-testid and set, and the assertion is on emitted arguments rather than on rendered markup. emitted returns an array of argument arrays, one per emission, so the tuple shape declared in defineEmits is exactly what the test checks.
// src/components/TaskRow.spec.ts
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import TaskRow from './TaskRow.vue'
import type { Task } from '@/types/task'
const task: Task = {
id: '7',
title: 'Write docs',
notes: '',
done: false,
updatedAt: '2026-01-01',
}
describe('TaskRow', () => {
it('renders the title', () => {
const wrapper = mount(TaskRow, { props: { task } })
expect(wrapper.text()).toContain('Write docs')
})
it('emits toggle with the id and the new checked state', async () => {
const wrapper = mount(TaskRow, { props: { task } })
await wrapper.get('[data-testid="task-done"]').setValue(true)
expect(wrapper.emitted('toggle')).toEqual([['7', true]])
})
})One end-to-end journey through sign-in
Playwright signs in, follows the guard's redirect, creates a task and looks for it in the list. The locators are roles and labels, which means the test only passes if the form is reachable the way a screen reader reaches it. Keep one or two of these: they are slow, and they fail for reasons no unit test can see, such as a broken SPA rewrite.
// e2e/sign-in.spec.ts
import { expect, test } from '@playwright/test'
test('a signed-in user can create a task', async ({ page }) => {
await page.goto('/tasks')
await expect(page).toHaveURL(/\/sign-in/)
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('correct-horse-battery')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page).toHaveURL('/tasks')
await page.getByRole('link', { name: 'New task' }).click()
await page.getByLabel('Title').fill('Pack the van')
await page.getByRole('button', { name: 'Save' }).click()
await page.getByRole('link', { name: 'Tasks' }).click()
await expect(
page.getByRole('listitem').filter({ hasText: 'Pack the van' }),
).toBeVisible()
})
Discussion