| |

Vue.js 70 🟢 Testing Pinia Stores, Vue Router Navigation, and Async Operations

⏱️ Estimated read: 16 min | |

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

ContextSetup
Store unit testsetActivePinia(createPinia()) in beforeEach
Component testcreateTestingPinia() in global.plugins
Action stubbingDefault; stubActions: false to run real
Initial stateinitialState: { storeId: { ... } }

Router Setup

ContextSetup
Mock (unit)global.mocks: { $route, $router }
Real (integration)createRouter + createMemoryHistory in global.plugins

Async Utilities

UtilityPurpose
flushPromises()Drain microtask queue
nextTick()Wait for Vue DOM update
await trigger()Trigger and wait for update

Common Pinia Errors

ErrorCauseFix
injection Symbol(pinia) not foundNo Pinia in testcreateTestingPinia()
no active PiniaNo setActivePiniasetActivePinia(createPinia())
Action not stubbedstubActions: falseRemove 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

PitfallWhy It HappensFix
Store injection errorNo Pinia plugincreateTestingPinia()
Action spy not calledStore retrieved before mountRetrieve after mount
Router push not recordedUsed real router without navigationMock $router.push
Async assertion failsNo flushPromisesawait flushPromises()
Suspense requiredasync setup() in componentWrap 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

ItemValue
Store unit testsetActivePinia(createPinia())
Component testcreateTestingPinia() in plugins
Action stubbingDefault in testing Pinia
Mock routerglobal.mocks: { $route, $router }
Real routercreateRouter + createMemoryHistory
Async waitawait flushPromises()
Loading stateAssert before flush
Loaded stateAssert after flush
SuspenseRequired for async setup()

Key takeaways:

  • Pinia needs an active instance in every test. The store unit test uses setActivePinia(createPinia()) in beforeEach. The component test uses createTestingPinia() in the global.plugins array. Without either, the store call throws .
  • createTestingPinia stubs 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.mocks provides $route and $router, and the test asserts on push calls. It is the simpler setup for a component test that only cares about navigation targets .
  • The real router is for integration tests. createRouter with createMemoryHistory provides actual route changes, and the test navigates with router.push and waits with nextTick. This is the right choice when the component’s behavior depends on the route, not just the push call .
  • flushPromises is 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.


Was this helpful?


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!