← Back to DevBytes

Testing Pinia Components: From Unit to E2E Tests

Testing Pinia Components: From Unit to E2E Tests

Pinia has become the de facto state management library for Vue 3 applications, replacing Vuex with a simpler, more intuitive API. But with great state management comes great responsibility — and that responsibility is testing. Whether you're building a small dashboard or a large-scale enterprise application, having a robust testing strategy for your Pinia stores and the components that consume them is essential for long-term maintainability.

In this tutorial, we'll walk through a complete testing strategy that spans the entire testing pyramid: from isolated unit tests for individual stores, to component tests that verify store-component integration, all the way up to end-to-end tests that validate entire user flows. By the end, you'll have a clear blueprint for testing any Pinia-powered Vue application.

Why Testing Pinia Matters

State management is the backbone of most Vue applications. When a Pinia store breaks, the ripple effects can be catastrophic — components render incorrect data, user actions fail silently, and debugging becomes a nightmare. Testing your stores and the components that depend on them provides several key benefits:

Project Setup

Before diving into tests, let's set up a sample project with the necessary dependencies. We'll use Vitest as our test runner, Vue Test Utils for component testing, and Playwright for end-to-end tests.

# Create a new Vue project
npm create vue@latest pinia-testing-app

# Install dependencies
cd pinia-testing-app
npm install pinia

# Install testing dependencies
npm install -D vitest @vue/test-utils jsdom @testing-library/vue
npm install -D @playwright/test

Next, configure Vitest in your vite.config.ts file:

import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'jsdom',
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

Building a Sample Store

To make our examples concrete, let's build a simple but realistic todo store. This store will handle adding, completing, filtering, and removing todos — functionality complex enough to demonstrate meaningful testing patterns.

// src/stores/todoStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export type Todo = {
  id: number
  title: string
  completed: boolean
  createdAt: Date
}

export type FilterType = 'all' | 'active' | 'completed'

export const useTodoStore = defineStore('todos', () => {
  const todos = ref<Todo[]>([])
  const filter = ref<FilterType>('all')
  const nextId = ref(1)

  const filteredTodos = computed(() => {
    switch (filter.value) {
      case 'active':
        return todos.value.filter((t) => !t.completed)
      case 'completed':
        return todos.value.filter((t) => t.completed)
      default:
        return todos.value
    }
  })

  const remainingCount = computed(
    () => todos.value.filter((t) => !t.completed).length
  )

  const completedCount = computed(
    () => todos.value.filter((t) => t.completed).length
  )

  function addTodo(title: string) {
    if (!title.trim()) return
    todos.value.push({
      id: nextId.value++,
      title: title.trim(),
      completed: false,
      createdAt: new Date(),
    })
  }

  function toggleTodo(id: number) {
    const todo = todos.value.find((t) => t.id === id)
    if (todo) todo.completed = !todo.completed
  }

  function removeTodo(id: number) {
    const index = todos.value.findIndex((t) => t.id === id)
    if (index !== -1) todos.value.splice(index, 1)
  }

  function setFilter(newFilter: FilterType) {
    filter.value = newFilter
  }

  function clearCompleted() {
    todos.value = todos.value.filter((t) => !t.completed)
  }

  return {
    todos,
    filter,
    filteredTodos,
    remainingCount,
    completedCount,
    addTodo,
    toggleTodo,
    removeTodo,
    setFilter,
    clearCompleted,
  }
})

Unit Testing Pinia Stores

Unit tests focus on the store in isolation. We test the state, getters, and actions directly without involving any Vue components. This is the foundation of your testing strategy — if the store logic is broken, nothing downstream will work correctly.

Setting Up Store Tests

The key to testing Pinia stores is creating an active Pinia instance before each test. This ensures tests are isolated and don't share state between them.

// src/stores/__tests__/todoStore.spec.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useTodoStore } from '../todoStore'

describe('Todo Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  describe('initial state', () => {
    it('starts with an empty todo list', () => {
      const store = useTodoStore()
      expect(store.todos).toEqual([])
    })

    it('starts with "all" filter selected', () => {
      const store = useTodoStore()
      expect(store.filter).toBe('all')
    })
  })

  describe('addTodo', () => {
    it('adds a new todo to the list', () => {
      const store = useTodoStore()
      store.addTodo('Buy groceries')
      expect(store.todos).toHaveLength(1)
      expect(store.todos[0].title).toBe('Buy groceries')
      expect(store.todos[0].completed).toBe(false)
    })

    it('ignores empty or whitespace-only titles', () => {
      const store = useTodoStore()
      store.addTodo('   ')
      store.addTodo('')
      expect(store.todos).toHaveLength(0)
    })

    it('trims whitespace from titles', () => {
      const store = useTodoStore()
      store.addTodo('  Walk the dog  ')
      expect(store.todos[0].title).toBe('Walk the dog')
    })

    it('increments the id for each new todo', () => {
      const store = useTodoStore()
      store.addTodo('First')
      store.addTodo('Second')
      expect(store.todos[0].id).toBe(1)
      expect(store.todos[1].id).toBe(2)
    })
  })

  describe('toggleTodo', () => {
    it('marks an incomplete todo as completed', () => {
      const store = useTodoStore()
      store.addTodo('Test the app')
      store.toggleTodo(1)
      expect(store.todos[0].completed).toBe(true)
    })

    it('marks a completed todo as incomplete', () => {
      const store = useTodoStore()
      store.addTodo('Test the app')
      store.toggleTodo(1)
      store.toggleTodo(1)
      expect(store.todos[0].completed).toBe(false)
    })

    it('does nothing for a non-existent id', () => {
      const store = useTodoStore()
      store.addTodo('Test the app')
      store.toggleTodo(999)
      expect(store.todos[0].completed).toBe(false)
    })
  })

  describe('removeTodo', () => {
    it('removes a todo by id', () => {
      const store = useTodoStore()
      store.addTodo('First')
      store.addTodo('Second')
      store.removeTodo(1)
      expect(store.todos).toHaveLength(1)
      expect(store.todos[0].title).toBe('Second')
    })
  })

  describe('getters', () => {
    beforeEach(() => {
      const store = useTodoStore()
      store.addTodo('Active task')
      store.addTodo('Completed task')
      store.addTodo('Another active task')
      store.toggleTodo(2) // Complete the second one
    })

    it('filteredTodos returns all todos when filter is "all"', () => {
      const store = useTodoStore()
      store.setFilter('all')
      expect(store.filteredTodos).toHaveLength(3)
    })

    it('filteredTodos returns only active todos', () => {
      const store = useTodoStore()
      store.setFilter('active')
      expect(store.filteredTodos).toHaveLength(2)
      expect(store.filteredTodos.every((t) => !t.completed)).toBe(true)
    })

    it('filteredTodos returns only completed todos', () => {
      const store = useTodoStore()
      store.setFilter('completed')
      expect(store.filteredTodos).toHaveLength(1)
      expect(store.filteredTodos[0].title).toBe('Completed task')
    })

    it('remainingCount returns the number of incomplete todos', () => {
      const store = useTodoStore()
      expect(store.remainingCount).toBe(2)
    })

    it('completedCount returns the number of completed todos', () => {
      const store = useTodoStore()
      expect(store.completedCount).toBe(1)
    })
  })

  describe('clearCompleted', () => {
    it('removes all completed todos', () => {
      const store = useTodoStore()
      store.addTodo('Active')
      store.addTodo('Done')
      store.toggleTodo(2)
      store.clearCompleted()
      expect(store.todos).toHaveLength(1)
      expect(store.todos[0].title).toBe('Active')
    })
  })
})

Testing Asynchronous Actions

Real-world stores often make API calls. Let's extend our store with an async action and see how to test it. First, let's add a fetch action:

// src/stores/todoStore.ts (additions)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// ... existing code ...

export const useTodoStore = defineStore('todos', () => {
  // ... existing state and getters ...

  const isLoading = ref(false)
  const error = ref<string | null>(null)

  async function fetchTodos() {
    isLoading.value = true
    error.value = null
    try {
      const response = await fetch('https://api.example.com/todos')
      if (!response.ok) throw new Error('Failed to fetch todos')
      const data = await response.json()
      todos.value = data.map((item: any) => ({
        id: item.id,
        title: item.title,
        completed: item.completed,
        createdAt: new Date(item.createdAt),
      }))
      nextId.value = Math.max(...todos.value.map((t) => t.id), 0) + 1
    } catch (e: any) {
      error.value = e.message
    } finally {
      isLoading.value = false
    }
  }

  return {
    // ... existing returns ...
    isLoading,
    error,
    fetchTodos,
  }
})

Now let's test the async action by mocking the global fetch function:

// src/stores/__tests__/todoStore.async.spec.ts
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useTodoStore } from '../todoStore'

describe('Todo Store - Async Actions', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  afterEach(() => {
    vi.restoreAllMocks()
  })

  it('fetches todos successfully', async () => {
    const mockTodos = [
      { id: 1, title: 'Learn Pinia', completed: false, createdAt: '2024-01-01' },
      { id: 2, title: 'Write tests', completed: true, createdAt: '2024-01-02' },
    ]

    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockTodos),
    }))

    const store = useTodoStore()
    await store.fetchTodos()

    expect(store.todos).toHaveLength(2)
    expect(store.todos[0].title).toBe('Learn Pinia')
    expect(store.isLoading).toBe(false)
    expect(store.error).toBeNull()
  })

  it('sets error when fetch fails', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: false,
      status: 500,
    }))

    const store = useTodoStore()
    await store.fetchTodos()

    expect(store.todos).toHaveLength(0)
    expect(store.error).toBe('Failed to fetch todos')
    expect(store.isLoading).toBe(false)
  })

  it('sets error when network throws', async () => {
    vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')))

    const store = useTodoStore()
    await store.fetchTodos()

    expect(store.error).toBe('Network error')
    expect(store.isLoading).toBe(false)
  })
})

Component Testing with Pinia

Once your store logic is solid, the next layer is testing components that use the store. Vue Test Utils makes it straightforward to mount components with an active Pinia instance. The key decision here is whether to use the real store or a mock store — both approaches have their place.

Building a Todo Component

Let's create a component that consumes our todo store:

<!-- src/components/TodoList.vue -->
<template>
  <div class="todo-list">
    <h2>Todo List</h2>

    <div class="add-todo">
      <input
        v-model="newTodoTitle"
        @keyup.enter="handleAdd"
        placeholder="What needs to be done?"
        data-testid="todo-input"
      />
      <button @click="handleAdd" data-testid="add-button">Add</button>
    </div>

    <div class="filters">
      <button
        v-for="f in filters"
        :key="f"
        @click="store.setFilter(f)"
        :class="{ active: store.filter === f }"
        :data-testid="`filter-${f}`"
      >
        {{ f }}
      </button>
    </div>

    <ul data-testid="todo-items">
      <li
        v-for="todo in store.filteredTodos"
        :key="todo.id"
        :data-testid="`todo-${todo.id}`"
      >
        <input
          type="checkbox"
          :checked="todo.completed"
          @change="store.toggleTodo(todo.id)"
          :data-testid="`checkbox-${todo.id}`"
        />
        <span :class="{ completed: todo.completed }">{{ todo.title }}</span>
        <button
          @click="store.removeTodo(todo.id)"
          :data-testid="`remove-${todo.id}`"
        >
          Delete
        </button>
      </li>
    </ul>

    <div class="summary">
      <span data-testid="remaining-count">
        {{ store.remainingCount }} items left
      </span>
      <button
        v-if="store.completedCount > 0"
        @click="store.clearCompleted"
        data-testid="clear-completed"
      >
        Clear completed
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useTodoStore } from '@/stores/todoStore'
import type { FilterType } from '@/stores/todoStore'

const store = useTodoStore()
const newTodoTitle = ref('')
const filters: FilterType[] = ['all', 'active', 'completed']

function handleAdd() {
  store.addTodo(newTodoTitle.value)
  newTodoTitle.value = ''
}
</script>

<style scoped>
.completed {
  text-decoration: line-through;
  color: #999;
}
</style>

Testing Components with the Real Store

The simplest approach is to mount the component with a real Pinia instance. This tests the actual integration between the component and the store:

// src/components/__tests__/TodoList.spec.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import TodoList from '../TodoList.vue'
import { useTodoStore } from '@/stores/todoStore'

describe('TodoList Component', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('renders an empty list initially', () => {
    const wrapper = mount(TodoList)
    const items = wrapper.findAll('[data-testid^="todo-"]')
    expect(items).toHaveLength(0)
  })

  it('adds a new todo when pressing Enter', async () => {
    const wrapper = mount(TodoList)
    const input = wrapper.find('[data-testid="todo-input"]')

    await input.setValue('Buy milk')
    await input.trigger('keyup.enter')

    const store = useTodoStore()
    expect(store.todos).toHaveLength(1)
    expect(store.todos[0].title).toBe('Buy milk')
    expect(input.element.value).toBe('') // Input is cleared after adding
  })

  it('adds a new todo when clicking the Add button', async () => {
    const wrapper = mount(TodoList)
    const input = wrapper.find('[data-testid="todo-input"]')
    const button = wrapper.find('[data-testid="add-button"]')

    await input.setValue('Walk the dog')
    await button.trigger('click')

    const store = useTodoStore()
    expect(store.todos).toHaveLength(1)
    expect(store.todos[0].title).toBe('Walk the dog')
  })

  it('toggles todo completion when checkbox is clicked', async () => {
    const store = useTodoStore()
    store.addTodo('Test toggling')

    const wrapper = mount(TodoList)
    const checkbox = wrapper.find('[data-testid="checkbox-1"]')

    expect(checkbox.element.checked).toBe(false)
    await checkbox.trigger('change')
    expect(store.todos[0].completed).toBe(true)
  })

  it('removes a todo when Delete is clicked', async () => {
    const store = useTodoStore()
    store.addTodo('To be deleted')

    const wrapper = mount(TodoList)
    const deleteButton = wrapper.find('[data-testid="remove-1"]')

    await deleteButton.trigger('click')
    expect(store.todos).toHaveLength(0)
  })

  it('filters todos based on selected filter', async () => {
    const store = useTodoStore()
    store.addTodo('Active task')
    store.addTodo('Completed task')
    store.toggleTodo(2)

    const wrapper = mount(TodoList)

    // Initially shows all
    expect(wrapper.findAll('li')).toHaveLength(2)

    // Click "active" filter
    await wrapper.find('[data-testid="filter-active"]').trigger('click')
    expect(wrapper.findAll('li')).toHaveLength(1)
    expect(wrapper.find('li span').text()).toBe('Active task')

    // Click "completed" filter
    await wrapper.find('[data-testid="filter-completed"]').trigger('click')
    expect(wrapper.findAll('li')).toHaveLength(1)
    expect(wrapper.find('li span').text()).toBe('Completed task')
  })

  it('displays the remaining count', async () => {
    const store = useTodoStore()
    store.addTodo('Task 1')
    store.addTodo('Task 2')
    store.addTodo('Task 3')
    store.toggleTodo(1)

    const wrapper = mount(TodoList)
    const remaining = wrapper.find('[data-testid="remaining-count"]')
    expect(remaining.text()).toContain('2 items left')
  })

  it('shows Clear completed button only when there are completed todos', async () => {
    const store = useTodoStore()
    store.addTodo('Active task')

    const wrapper = mount(TodoList)
    expect(wrapper.find('[data-testid="clear-completed"]').exists()).toBe(false)

    store.toggleTodo(1)
    await wrapper.vm.$nextTick()

    expect(wrapper.find('[data-testid="clear-completed"]').exists()).toBe(true)
  })

  it('clears completed todos when button is clicked', async () => {
    const store = useTodoStore()
    store.addTodo('Active')
    store.addTodo('Completed')
    store.toggleTodo(2)

    const wrapper = mount(TodoList)
    await wrapper.find('[data-testid="clear-completed"]').trigger('click')

    expect(store.todos).toHaveLength(1)
    expect(store.todos[0].title).toBe('Active')
  })

  it('applies completed styling to completed todos', async () => {
    const store = useTodoStore()
    store.addTodo('Done task')
    store.toggleTodo(1)

    const wrapper = mount(TodoList)
    const span = wrapper.find('li span')
    expect(span.classes()).toContain('completed')
  })
})

Testing Components with Mocked Stores

Sometimes you want to test a component in complete isolation from the store — for example, when the store has complex dependencies or you want to test edge cases that are hard to set up with the real store. Pinia makes this easy with the createTestingPinia helper from @pinia/testing.

npm install -D @pinia/testing
// src/components/__tests__/TodoList.mocked.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import TodoList from '../TodoList.vue'
import { useTodoStore } from '@/stores/todoStore'

describe('TodoList Component - Mocked Store', () => {
  let store: ReturnType<typeof useTodoStore>

  beforeEach(() => {
    const pinia = createTestingPinia({
      createSpy: vi.fn,
      stubActions: false,
    })
    store = useTodoStore()

    // Set up mock state
    store.todos = [
      { id: 1, title: 'Mock task 1', completed: false, createdAt: new Date() },
      { id: 2, title: 'Mock task 2', completed: true, createdAt: new Date() },
    ]
    store.filter = 'all'
  })

  it('renders todos from mocked state', () => {
    const wrapper = mount(TodoList, {
      global: { plugins: [createTestingPinia({ createSpy: vi.fn })] },
    })

    // Re-get store from the testing pinia
    const testStore = useTodoStore()
    testStore.todos = [
      { id: 1, title: 'Mock task', completed: false, createdAt: new Date() },
    ]

    expect(wrapper.findAll('li')).toHaveLength(1)
  })

  it('calls store.addTodo when adding a todo', async () => {
    const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: true })
    const wrapper = mount(TodoList, {
      global: { plugins: [pinia] },
    })
    const testStore = useTodoStore()

    const input = wrapper.find('[data-testid="todo-input"]')
    await input.setValue('New mock todo')
    await input.trigger('keyup.enter')

    expect(testStore.addTodo).toHaveBeenCalledWith('New mock todo')
    expect(testStore.addTodo).toHaveBeenCalledTimes(1)
  })

  it('calls store.toggleTodo when checkbox is clicked', async () => {
    const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: true })
    const testStore = useTodoStore()

    // Provide computed values for rendering
    vi.mocked(testStore.filteredTodos).mockReturnValue([
      { id: 1, title: 'Task', completed: false, createdAt: new Date() },
    ])

    const wrapper = mount(TodoList, {
      global: { plugins: [pinia] },
    })

    const checkbox = wrapper.find('[data-testid="checkbox-1"]')
    await checkbox.trigger('change')

    expect(testStore.toggleTodo).toHaveBeenCalledWith(1)
  })

  it('calls store.removeTodo when Delete is clicked', async () => {
    const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: true })
    const testStore = useTodoStore()

    vi.mocked(testStore.filteredTodos).mockReturnValue([
      { id: 1, title: 'Task', completed: false, createdAt: new Date() },
    ])

    const wrapper = mount(TodoList, {
      global: { plugins: [pinia] },
    })

    await wrapper.find('[data-testid="remove-1"]').trigger('click')
    expect(testStore.removeTodo).toHaveBeenCalledWith(1)
  })
})

Testing Components That Use Multiple Stores

In real applications, components often depend on multiple stores. Here's an example of a component that uses both a todo store and an auth store:

// src/stores/authStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useAuthStore = defineStore('auth', () => {
  const user = ref<{ id: number; name: string } | null>(null)

  const isAuthenticated = computed(() => user.value !== null)

  function login(name: string) {
    user.value = { id: 1, name }
  }

  function logout() {
    user.value = null
  }

  return { user, isAuthenticated, login, logout }
})
// src/components/__tests__/MultiStore.spec.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useTodoStore } from '@/stores/todoStore'
import { useAuthStore } from '@/stores/authStore'

// Example component that uses both stores
const TodoApp = {
  template: `
    <div>
      <div v-if="auth.isAuthenticated" data-testid="user-todos">
        <p>Welcome, {{ auth.user.name }}! You have {{ todo.remainingCount }} todos.</p>
      </div>
      <div v-else data-testid="login-prompt">
        Please log in to see your todos.
      </div>
    </div>
  `,
  setup() {
    const todo = useTodoStore()
    const auth = useAuthStore()
    return { todo, auth }
  },
}

describe('Multi-Store Component', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('shows login prompt when not authenticated', () => {
    const wrapper = mount(TodoApp)
    expect(wrapper.find('[data-testid="login-prompt"]').exists()).toBe(true)
    expect(wrapper.find('[data-testid="user-todos"]').exists()).toBe(false)
  })

  it('shows user todos when authenticated', () => {
    const auth = useAuthStore()
    const todo = useTodoStore()
    auth.login('Alice')
    todo.addTodo('Task 1')
    todo.addTodo('Task 2')

    const wrapper = mount(TodoApp)
    expect(wrapper.find('[data-testid="user-todos"]').exists()).toBe(true)
    expect(wrapper.find('[data-testid="user-todos"]').text()).toContain('Alice')
    expect(wrapper.find('[data-testid="user-todos"]').text()).toContain('2 todos')
  })
})

End-to-End Testing with Playwright

End-to-end tests validate entire user flows through the real application, including the browser, the Vue runtime, and Pinia stores. These tests give you the highest confidence that your application works as expected from the user's perspective.

Setting Up Playwright

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './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'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
})

Writing E2E Tests

// e2e/todo-app.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Todo App', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/')
  })

  test('displays the todo list heading', async ({ page }) => {
    await expect(page.locator('h2')).toHaveText('Todo List')
  })

  test('adds a new todo', async ({ page }) => {
    const input = page.getByTestId('todo-input')
    const addButton = page.getByTestId('add-button')

    await input.fill('Write E2E tests')
    await addButton.click()

    await expect(page.getByTestId('todo-items')).toContainText('Write E2E tests')
  })

  test('adds a todo with Enter key', async ({ page }) => {
    const input = page.getByTestId('todo-input')

    await input.fill('Press enter to add')
    await input.press('Enter')

    await expect(page.getByTestId('todo-items')).toContainText('Press enter to add')
    await expect(input).toHaveValue('')
  })

  test('completes a todo', async ({ page }) => {
    await page.getByTestId('todo-input').fill('Complete me')
    await page.getByTestId('add-button').click()

    const checkbox = page.getByTestId('checkbox-1')
    await checkbox.check()

    const todoText = page.locator('li span').first()
    await expect(todoText).toHaveClass(/completed/)
  })

  test('deletes a todo', async ({ page }) => {
    await page.getByTestId('todo-input').fill('Delete me')
    await page.getByTestId('add-button').click()

    await expect(page.getByTestId('todo-1')).toBeVisible()

    await page.getByTestId('remove-1').click()

    await expect(page.getByTestId('todo-1')).toHaveCount(0)
  })

  test('filters todos correctly', async ({ page }) => {
    // Add three todos
    await page.getByTestId('todo-input').fill('Active task')
    await page.getByTestId('add-button').click()
    await page.getByTestId('todo-input').fill('Completed task')
    await page.getByTestId('add-button').click()
    await page.getByTestId('todo-input').fill('Another active task')
    await page.getByTestId('add-button').click()

    // Complete the second one
    await page.getByTestId('checkbox-2').check()

    // Verify all filter shows 3 items
    await page.getByTestId('filter-all').click()
    await expect(page.locator('li')).toHaveCount(3)

    // Verify active filter shows 2 items
    await page.getByTestId('filter-active').click()
    await expect(page.locator('li')).toHaveCount(2)
    await expect(page.locator('li')).toContainText(['Active task', 'Another active task'])

    // Verify completed filter shows 1 item
    await page.getByTestId('filter-completed').click()
    await expect(page.locator('li')).toHaveCount(1)
    await expect(page.locator('li')).toContainText('Completed task')
  })

  test('displays remaining count', async ({ page }) => {
    await page.getByTestId('todo-input').fill('Task 1')
    await page.getByTestId('add-button').click()
    await page.getByTestId('todo-input').fill('Task 2')
    await page.getByTestId('add-button').click()

    await expect(page.getByTestId('remaining-count')).toContainText('2 items left')

    await page.getByTestId('checkbox-1').check()
    await expect(page.getByTestId('remaining-count')).toContainText('1 item left')
  })

  test('clears completed todos', async ({ page }) => {
    await page.getByTestId('todo-input').fill('Keep me')
    await page.getByTestId('add-button').click()
    await page.getByTestId('todo-input').fill('Remove me')
    await page.getByTestId('add-button').click()

    await page.getByTestId('checkbox-2').check()

    await expect(page.getByTestId('clear-completed')).toBeVisible()
    await page.getByTestId('clear-completed').click()

    await expect(page.locator('li')).toHaveCount(1)
    await expect(page.locator('li')).toContainText('Keep me')
    await expect(page.getByTestId('clear-completed')).toHaveCount(0)
  })

  test('full user workflow', async ({ page }) => {
    // Add multiple todos
    for (const task of ['Buy groceries', 'Walk the dog', 'Write tests', 'Deploy app']) {
      await page.getByTestId('todo-input').fill(task)
      await page.getByTestId('add-button').click()
    }

    // Complete two of them
    await page.getByTestId('checkbox-1').check()
    await page.getByTestId('checkbox-3').check()

    // Verify remaining count
    await expect(page.getByTestId('remaining-count')).toContainText('2 items left')

    // Filter to active
    await page.getByTestId('filter-active').click()
    await expect(page.locator('li')).toHaveCount(2)

    // Clear completed
    await page.getByTestId('filter-all').click()
    await page.getByTestId('clear-completed').click()
    await expect(page.locator('li')).toHaveCount(2)

    // Delete one remaining
    await page.getByTestId('remove-2').click()
    await expect(page.locator('li')).toHaveCount(1)
  })
})

Interacting with Pinia Stores in E2E Tests

Sometimes you need to inspect or manipulate Pinia store state directly during E2E tests — for example, to set up a specific state before a test or to assert on store values after a user interaction. Playwright allows you to evaluate code in the browser context:

// e2e/store-interaction.spec.ts
import { test, expect } from '@playwright/test'

test('can read Pinia store state from the browser', async ({ page }) => {
  await page.goto('/')

  // Add a todo through the UI
  await page.getByTestId('todo-input').fill('Test store access')
  await page.getByTestId('add-button').click()

  // Read the store state directly
  const todoCount = await page.evaluate(() => {
    // Access the Pinia instance from the app
    const pinia = (window as any).__pinia
    const todoStore = pinia._s.get('todos')
    return todoStore.todos.length
  })

  expect(todoCount).toBe(1)
})

test('can set Pinia store state from the test', async ({ page }) => {
  await page.goto('/')

  // Pre-populate the store with data
  await page.evaluate(() => {
    const pinia = (window as any).__pinia
    const todoStore = pinia._s.get('todos')
    todoStore.addTodo('Pre-loaded task 1')
    todoStore.addTodo('Pre-loaded task 2')
    todoStore.addTodo('Pre-loaded task 3')
  })

  // Verify the UI reflects the store state
  await expect(page.locator('li')).toHaveCount(3)
  await expect(page.getByTestId('remaining-count')).toContainText('3 items left')
})

To make the Pinia instance accessible in your E2E tests, expose it on the window object during development:

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App

— Ad —

Google AdSense will appear here after approval

← Back to all articles