Vue.js 70 🟢 Testing Pinia Stores, Vue Router Navigation, and Async Operations
A Vue component does not exist in isolation. It reads from a Pinia store, navigates with Vue Router, and fetches data asynchronously. A test that mounts the component without providing these dependencies fails with an injection error, a missing $router, or an assertion that runs before the data arrives. The three dependencies require three different setup strategies: Pinia needs a testing instance that stubs actions, Vue Router needs either a real router with a memory history or a mock that records push calls, and async operations need flushPromises to drain the microtask queue before the assertion. Each strategy is a deliberate choice about what the test is verifying—the component’s logic, or the integration between the component and its dependencies.
The Pinia documentation states the problem directly: stores are used in many places, and testing them without the right setup produces the “injection Symbol(pinia) not found” error . Vue Test Utils does not provide special functions for Vue Router, because the router is not the thing under test in a unit test; the component’s reaction to the router is . And Vue’s DOM updates are asynchronous, which means every assertion after an async operation must wait for the flush. This chapter covers the three setups, the stubbing strategies, and the async synchronization that makes the assertions reliable.
Key point: Use setActivePinia(createPinia()) in beforeEach for store unit tests, and createTestingPinia() in the global.plugins array for component tests that use stores. Mock $route and $router with global.mocks for unit tests that only care about navigation calls, and use a real router with createMemoryHistory for integration tests that need actual route changes. Use await flushPromises() after any async operation before asserting on the rendered DOM.
Why each dependency needs a different setup
The Pinia injection problem. A component that calls useStore() expects an active Pinia instance to be available through Vue’s injection system. In the application, app.use(pinia) installs the instance, and every component in the tree can access it. In a test, mount() creates a new Vue application, and without createTestingPinia() in the global.plugins array, the injection is missing and the store call throws . The testing instance is not the same as the application instance; it stubs the actions by default, which means the component’s logic can be tested without the action’s side effects .
The router context problem. A component that uses useRouter() or useRoute() expects the router to be installed. The router installation adds $route and $router as read-only properties on the Vue prototype in Vue 2, which is why the old documentation warns against installing it globally in tests . In Vue 3, the composables useRouter() and useRoute() read from the injected router, and the test must provide it. The mock approach provides $route and $router through global.mocks, which is simpler for a unit test that only asserts on push calls. The real router approach uses createRouter with createMemoryHistory, which is closer to production but requires more setup .
The async timing problem. Vue’s reactivity is synchronous for state changes but asynchronous for DOM updates. When a component fetches data in onMounted, the fetch resolves in a microtask, the state updates, and Vue schedules a DOM update for the next tick. An assertion that runs immediately after mount sees the loading state, not the loaded state. The flushPromises function drains the microtask queue, and the assertion after it sees the final DOM . This is why the loading state test asserts before the flush and the loaded state test asserts after it—the two assertions are testing different moments in the component’s lifecycle .
The action stubbing problem. createTestingPinia() stubs every action by default, so the action is recorded as a spy but its body does not run . This is the right default for a component test: the test verifies that the component called the action, not that the action’s implementation works. The action’s implementation is tested in the store’s own unit test, where setActivePinia(createPinia()) provides a real Pinia instance and the action runs for real . The stubActions: false option disables the stubbing when the component test needs the real action.
a. Pinia: store unit tests versus component tests
The store unit test creates a fresh Pinia instance in beforeEach and calls the store’s actions directly. The component test mounts the component with createTestingPinia and asserts on the component’s output and the stubbed action’s calls.
// stores/counter.spec.js
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from './counter'
describe('Counter Store', () => {
beforeEach(() => {
// Create a fresh Pinia instance for each test
// The setActivePinia call makes it the active instance
// so useCounterStore() picks it up automatically
setActivePinia(createPinia())
})
it('increments the count', () => {
// Create the store from the active Pinia
const counter = useCounterStore()
// The initial state is 0
expect(counter.n).toBe(0)
// The action runs for real because this is a store unit test
counter.increment()
// The state is updated
expect(counter.n).toBe(1)
})
})
The setActivePinia(createPinia()) call is the mandatory setup. Without it, useCounterStore() throws because there is no active Pinia instance . The createPinia() call creates a new instance, and setActivePinia makes it the one that the store composable uses. The beforeEach ensures that each test gets a clean state, because the store’s state is not shared between tests.
The component test uses createTestingPinia from @pinia/testing. The instance is passed to the component’s global.plugins array, and the actions are stubbed.
// Counter.spec.js
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import Counter from './Counter.vue'
import { useCounterStore } from './counter'
describe('Counter component', () => {
it('calls the increment action when clicked', async () => {
// Mount with the testing Pinia
// createSpy is required when not using Vitest globals
const wrapper = mount(Counter, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
// The initial state overrides the store's default
initialState: { counter: { n: 20 } },
}),
],
},
})
// The store is retrieved after mounting
// It uses the testing Pinia instance
const store = useCounterStore()
// The rendered output reflects the initial state
expect(wrapper.text()).toContain('20')
// Trigger the button click
await wrapper.find('button').trigger('click')
// The action is stubbed, so it is recorded but does not run
expect(store.increment).toHaveBeenCalledTimes(1)
})
})
The initialState option patches the store’s state when the component creates it. The store’s n is 20, not the default 0, and the component renders 20. The createSpy: vi.fn is required when globals: true is not set in the Vitest configuration . The action is stubbed, so store.increment() does not change store.n; the test asserts that the action was called, not that the state changed.
b. Vue Router: the mock router and the real router
The mock router is the simpler approach for a unit test. The component receives $route and $router through global.mocks, and the test asserts on the push calls.
// EditButton.spec.js
import { mount } from '@vue/test-utils'
import EditButton from './EditButton.vue'
test('redirects an authenticated user to the edit page', async () => {
// The mock route has the params the component reads
const mockRoute = { params: { id: 1 } }
// The mock router records the push calls
const mockRouter = { push: vi.fn() }
// Mount the component with the mocks
const wrapper = mount(EditButton, {
props: { isAuthenticated: true },
global: {
mocks: {
$route: mockRoute,
$router: mockRouter,
},
},
})
// Trigger the click
await wrapper.find('button').trigger('click')
// Assert that the router was asked to navigate
expect(mockRouter.push).toHaveBeenCalledWith('/posts/1/edit')
})
The global.mocks option provides the $route and $router properties that the component accesses. The mock route has the params.id that the component reads. The mock router has a push function that is a Vitest spy. The assertion checks the argument of push, which is the navigation target. This is the approach that the Vue Test Utils documentation recommends for unit tests that do not need actual route changes .
The real router approach uses createRouter with createMemoryHistory. The router is installed in the component’s global.plugins, and the test navigates with router.push before asserting.
// ChatView.spec.js
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ChatView from './ChatView.vue'
test('reads the chat id from the route params', async () => {
// Create a real router with memory history
// The memory history does not touch the browser URL
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/chat/:id',
name: 'ChatView',
component: { template: '<div />' },
},
],
})
// Mount the component with the router installed
const wrapper = mount(ChatView, {
global: { plugins: [router] },
})
// Navigate to the route with the param
await router.push('/chat/chat-123')
// Wait for the router to resolve and Vue to update
await wrapper.vm.$nextTick()
// The component read the param from the route
expect(wrapper.vm.currentChatId).toBe('chat-123')
})
The createMemoryHistory is the key. It provides a router that works in a test environment without a browser URL. The router.push call updates the route, and the nextTick waits for Vue to apply the change. The real router is the right choice for an integration test that verifies the component’s response to route changes, not just the push call .
c. Async operations: flushPromises and the loading state
The flushPromises function from Vue Test Utils drains the microtask queue. It is the correct tool for any component that fetches data asynchronously, because it waits for the promise callbacks to run before the assertion.
// UserList.spec.js
import { mount, flushPromises } from '@vue/test-utils'
import UserList from './UserList.vue'
import { getUsers } from './api/users'
// Mock the API module
vi.mock('./api/users')
test('shows the loading state before the request settles', () => {
// The mock returns a pending promise that resolves later
getUsers.mockResolvedValue([{ id: 1, name: 'Alice' }])
const wrapper = mount(UserList)
// Before the flush, the promise has not resolved
// The component shows the loading state
expect(wrapper.find('[data-test="loading"]').exists()).toBe(true)
// The user list is not rendered yet
expect(wrapper.find('[data-test="user"]').exists()).toBe(false)
})
test('shows the users after the request settles', async () => {
getUsers.mockResolvedValue([{ id: 1, name: 'Alice' }])
const wrapper = mount(UserList)
// Drain the microtask queue
// The promise resolves, the state updates, and Vue renders
await flushPromises()
// The loading state is gone and the users are rendered
expect(wrapper.find('[data-test="loading"]').exists()).toBe(false)
expect(wrapper.findAll('[data-test="user"]')).toHaveLength(1)
})
The two tests are testing the two moments. The first asserts the loading state before the flush, which is the only point where the loading state exists. The second asserts the loaded state after the flush. A single test that only asserts after the flush would never verify that the loading state is rendered at all . The flushPromises is not a setTimeout; it is a deterministic drain of the promise queue, which makes the test fast and reliable.
The flushPromises is also the tool for a component with async setup(). A component that uses await at the top level of <script setup> requires a <Suspense> wrapper to render. The test mounts a wrapper component with <Suspense>, and after flushPromises, the actual component is available through findComponent . Without the Suspense wrapper, the component never renders, and the test fails with a confusing error .
Complete Example Session
The following session builds a component that uses a Pinia store, reads a route param, and fetches data asynchronously, and tests all three dependencies.
// ============================================
// PART 1: THE COMPONENT
// ============================================
// UserProfile.vue
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from './user-store'
import { fetchUser } from './api'
// The route provides the user id
const route = useRoute()
// The router is used to navigate on error
const router = useRouter()
// The store holds the user's preferences
const store = useUserStore()
// The fetched user data
const user = ref(null)
// The loading flag
const loading = ref(true)
onMounted(async () => {
try {
// Fetch the user by the route param
user.value = await fetchUser(route.params.id)
} catch (e) {
// Navigate to the error page on failure
router.push('/error')
} finally {
loading.value = false
}
})
</script>
<template>
<div>
<!-- The loading state -->
<div v-if="loading" data-test="loading">Loading...</div>
<!-- The loaded state -->
<div v-else data-test="user">{{ user.name }}</div>
<!-- The store state -->
<span data-test="theme">{{ store.theme }}</span>
</div>
</template>
// ============================================
// PART 2: THE TEST
// ============================================
import { mount, flushPromises } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import UserProfile from './UserProfile.vue'
import { fetchUser } from './api'
// Mock the API module
vi.mock('./api')
test('loads the user and reads the store theme', async () => {
// The API mock resolves with the user
fetchUser.mockResolvedValue({ id: 1, name: 'Alice' })
// The mock route has the id param
const mockRoute = { params: { id: 1 } }
// The mock router records the push calls
const mockRouter = { push: vi.fn() }
// Mount with the testing Pinia and the mocks
const wrapper = mount(UserProfile, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
// The store's theme is set for the test
initialState: { user: { theme: 'dark' } },
}),
],
mocks: {
$route: mockRoute,
$router: mockRouter,
},
},
})
// Before the flush, the loading state is shown
expect(wrapper.find('[data-test="loading"]').exists()).toBe(true)
// Drain the promise queue
await flushPromises()
// After the flush, the user is rendered
expect(wrapper.find('[data-test="user"]').text()).toContain('Alice')
// The store theme is rendered
expect(wrapper.find('[data-test="theme"]').text()).toBe('dark')
// The router was not called because the fetch succeeded
expect(mockRouter.push).not.toHaveBeenCalled()
})
test('navigates to the error page on fetch failure', async () => {
// The API mock rejects
fetchUser.mockRejectedValue(new Error('Not found'))
const mockRoute = { params: { id: 999 } }
const mockRouter = { push: vi.fn() }
const wrapper = mount(UserProfile, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
mocks: { $route: mockRoute, $router: mockRouter },
},
})
// Drain the promise queue
await flushPromises()
// The router was asked to navigate to the error page
expect(mockRouter.push).toHaveBeenCalledWith('/error')
})
The two parts cover the component with the three dependencies, and the two tests: the success path that asserts the loaded user and the store theme, and the failure path that asserts the router navigation. What the session demonstrates is that the Pinia instance is in global.plugins, the router is in global.mocks, and the async wait is flushPromises.
Quick Reference
Pinia Setup
| Context | Setup |
|---|---|
| Store unit test | setActivePinia(createPinia()) in beforeEach |
| Component test | createTestingPinia() in global.plugins |
| Action stubbing | Default; stubActions: false to run real |
| Initial state | initialState: { storeId: { ... } } |
Router Setup
| Context | Setup |
|---|---|
| Mock (unit) | global.mocks: { $route, $router } |
| Real (integration) | createRouter + createMemoryHistory in global.plugins |
Async Utilities
| Utility | Purpose |
|---|---|
flushPromises() | Drain microtask queue |
nextTick() | Wait for Vue DOM update |
await trigger() | Trigger and wait for update |
Common Pinia Errors
| Error | Cause | Fix |
|---|---|---|
injection Symbol(pinia) not found | No Pinia in test | createTestingPinia() |
no active Pinia | No setActivePinia | setActivePinia(createPinia()) |
| Action not stubbed | stubActions: false | Remove the option |
Best Practices
✅ Do This:
// Store unit test: fresh Pinia per test
beforeEach(() => setActivePinia(createPinia()))
// Component test: testing Pinia in plugins
mount(Comp, { global: { plugins: [createTestingPinia()] } })
// Mock router for unit tests
global: { mocks: { $route, $router } }
// Flush promises before asserting async data
await flushPromises()
❌ Don’t Do This:
// Mount without Pinia
mount(UserProfile) // injection error
// Call useStore without active Pinia
const store = useStore() // error
// Assert immediately after mount for async data
expect(wrapper.text()).toContain('Alice') // sees loading state
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Store injection error | No Pinia plugin | createTestingPinia() |
| Action spy not called | Store retrieved before mount | Retrieve after mount |
| Router push not recorded | Used real router without navigation | Mock $router.push |
| Async assertion fails | No flushPromises | await flushPromises() |
Suspense required | async setup() in component | Wrap in <Suspense> |
Real-World Examples
1. Store unit test setup
beforeEach(() => setActivePinia(createPinia()))
2. Component test with Pinia
mount(Comp, { global: { plugins: [createTestingPinia()] } })
3. Initial store state
createTestingPinia({ initialState: { counter: { n: 20 } } })
4. Assert stubbed action
expect(store.increment).toHaveBeenCalledTimes(1)
5. Mock router
global: { mocks: { $router: { push: vi.fn() } } }
6. Assert navigation
expect(mockRouter.push).toHaveBeenCalledWith('/error')
7. Real router
createRouter({ history: createMemoryHistory(), routes })
8. Navigate and wait
await router.push('/chat/1')
await nextTick()
9. Flush async data
await flushPromises()
10. Assert loading state
expect(wrapper.find('[data-test="loading"]').exists()).toBe(true)
Visual
Pinia Setup Flow
┌──────────────────────────────────────────────┐
│ Component test │
│ │ │
│ ▼ │
│ createTestingPinia() │
│ │ │
│ ▼ │
│ global.plugins: [testingPinia] │
│ │ │
│ ▼ │
│ mount(Comp, options) │
│ │ │
│ ▼ │
│ useStore() works with stubbed actions │
└──────────────────────────────────────────────┘
Router Mock vs Real
┌──────────────────────────────────────────────┐
│ MOCK │
│ global.mocks: { $router: { push: vi.fn() } }│
│ → assert push calls │
│ → no actual navigation │
│ │
│ REAL │
│ createRouter + createMemoryHistory │
│ → router.push('/path') │
│ → component reacts to route change │
└──────────────────────────────────────────────┘
Async Assertion Timeline
┌──────────────────────────────────────────────┐
│ mount() │
│ │ │
│ ▼ │
│ loading state (assert here) │
│ │ │
│ ▼ │
│ flushPromises() │
│ │ │
│ ▼ │
│ loaded state (assert here) │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Store unit test | setActivePinia(createPinia()) |
| Component test | createTestingPinia() in plugins |
| Action stubbing | Default in testing Pinia |
| Mock router | global.mocks: { $route, $router } |
| Real router | createRouter + createMemoryHistory |
| Async wait | await flushPromises() |
| Loading state | Assert before flush |
| Loaded state | Assert after flush |
Suspense | Required for async setup() |
Key takeaways:
- Pinia needs an active instance in every test. The store unit test uses
setActivePinia(createPinia())inbeforeEach. The component test usescreateTestingPinia()in theglobal.pluginsarray. Without either, the store call throws . createTestingPiniastubs actions by default. The component test asserts that the action was called, not that its implementation works. The action’s implementation is tested in the store’s own unit test, where the real Pinia instance runs the real action .- The mock router is for unit tests.
global.mocksprovides$routeand$router, and the test asserts onpushcalls. It is the simpler setup for a component test that only cares about navigation targets . - The real router is for integration tests.
createRouterwithcreateMemoryHistoryprovides actual route changes, and the test navigates withrouter.pushand waits withnextTick. This is the right choice when the component’s behavior depends on the route, not just thepushcall . flushPromisesis the async synchronization. It drains the microtask queue, which resolves the pending promises and lets Vue render the final state. The assertion after the flush sees the loaded state; the assertion before the flush sees the loading state .- The loading state is only visible before the flush. A test that only asserts after the flush never verifies that the loading state is rendered. The two assertions together cover both moments .
Remember: Testing a component with Pinia, Vue Router, and async operations is testing three separate contracts. Pinia needs the testing instance in the plugins array, and the store’s actions are stubbed by default. Vue Router needs either a mock that records push calls or a real router with memory history that performs actual navigation. Async operations need flushPromises before the assertion, because Vue’s DOM updates are asynchronous and the promise callbacks run in the microtask queue. The test is not complete until all three are set up and the assertion runs at the correct moment in the lifecycle.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!