Using Pinia: Setup and Testing (with Cypress)

May 6th, 2022

I recently began migrating an application from Vuex to Pinia. At first, there are plenty of little puzzles to sort out. I actually enjoy chasing down each hiccup and getting all the little tetris-like pieces to line up neatly and behave correctly. These are my takeaways from this week in coding.

State Management: From Vuex to Pinia

Stores

Example of a Vuex Store

// src/store/modules/todo.js

const state = {
  todo: []
}
const actions = {
  addTodo({commit}, newTodo){
    commit('ADD_TODO', newTodo)
  }
}
  const mutation = {
  ADD_TODO(state, newTodo){
    state.todo.push(newTodo)
  }
}
export default {
  state,
  actions,
  mutations,
  namespaced: true,
}

// src/store/index.js
import { createStore } from 'vuex'
import todo from './modules/todo'

export default createStore({
  modules: {
    todo,
  },
  state: {
    loading: false,
  },
    setLoading(state, value) {
      state.loading = value // Boolean
    },
  },
})

Example of a Pinia Store

// src/stores/TodoStore.js
import { defineStore } from 'pinia'

export const useTodoStore = defineStore('TodoStore', {
  state: () => ({
    todo:[]
  }),
  actions: {
    addTodo(newTodo){
      this.todo.push(newTodo)
    }
  }
})

In Vuex, we create a main store file, usually in a src/store folder, and often use modules to separate different aspects of application wide state. For example, we can have a todo module, a user module, etc. Each module is loaded into the store and the whole store is loaded into the application when it is created.

In Pinia, each file is its own store, and can be used modularly. An instance of Pinia is provided to the application when it's created. However, each store is imported in the particular components that require their bit of state. So, the application is not always tracking the whole store, but installs each store when needed.

Usage in Components

Vuex Example

// src/views/todo.vue
<template>
  <div>
    <ul>
      <li v-for="(todo, index) in todos" :key="index">{{ todo }}</li>
    </ul>
    <label for="todo-input">Add a todo</label>
    <input type="text" id="todo-input" v-model="newTodo" />
    <button @click="addATodo">Add</button>
  </div>
</template>

<script>
import { computed, ref } from 'vue'
import { useStore } from 'vuex'
export default {
  setup() {
    const store = useStore()
    const todos = computed(() => store.state.todo.todo)
    const newTodo = ref('')
    const addATodo = () => {
      store.dispatch('todo/addTodo', newTodo.value)
    }
    return {
      todos,
      newTodo,
      addATodo,
    }
  },
}
</script>

Pinia Example

// src/views/todo.vue
<template>
  <div>
    <ul>
      <li v-for="(todo, index) in TodoStore.todo" :key="index">{{ todo }}</li>
    </ul>
    <label for="todo-input">Add a todo</label>
    <input id="todo-input" v-model="newTodo" type="text"/>
    <button @click="addATodo">Add</button>
  </div>
</template>

<script>
import {useTodoStore} from '../stores/TodoStore'
import {ref} from 'vue'
export default {
  setup() {
    const TodoStore = useTodoStore()
    const newTodo = ref('')
    const addATodo = () => {
      TodoStore.addATodo(newTodo.value)
    }
    return {
      TodoStore,
      newTodo,
      addATodo
    }
  },
}
</script>

In Vuex, we insulate the alteration of state. A component dispatches an action, which calls a mutation to change the state, using strings to specify the module/action. We access the state through computed properties.

In Pinia, the state is directly available and mutable in components. We import and call the defining function. We can return the whole store and use it in the template. The actions are normal function calls (instead of dispatching strings in Vuex), which give us access to autocomplete 🎉 and less typos.

I'm really enjoying using Pinia right now.

The code is much less verbose, and clearer.

In Vuex, we have to write a lot more code to accomplish the same things. The mutations add a lot of unnecessary boiler plate. In components, we have to import and initiate the useStore hook, and assign state to computed properties. Actions are dispatched with strings. Pinia is more concise and direct. Import the store you need and access its state and actions with normal JavaScript object syntax. The state is reactive and doesn't require the computed wrapper.

In Vuex, we pull the piece of state from the store and set it to a new variable. So, a quick glance in the template doesn't give us a lot of information about where that todo array came from or how to interact with it. In Pinia however, we can return the store from the setup hook and use it directly in the template. It makes it immediately obvious where the todos came from and where we can look for more information. The import statement provides the file name, and makes it easier for the IDE to provide direct navigation to its implementation and other usages. Being able to directly access and mutate the state in setup allows us to avoid writing unnecessary actions calling mutations, particularly if we need a specific starting state for a test.

Testing

Vuex Example

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import './styles.css'

const app = createApp(App)

app.use(store).use(router)

if (window.Cypress) {
  window.__store__ = store
}
app.mount('#app')

Pinia Example

// src/main.js
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './styles.css'
import { useTodoStore } from './stores/TodoStore'

const app = createApp(App)

app.use(createPinia()).use(router)

if (window.Cypress) {
  const TodoStore = useTodoStore()
  window.__store__ = TodoStore
}

app.mount('#app')

To access a store during tests, we can simply set it on the window object.

In Vuex, we can set the whole store.

With Pinia, we must call each store we want to give Cypress access to, and set them on the window object. If you want to set many stores, you can utilize this syntax:

if(window.Cypress){
 const TodoStore = useTodoStore()
  const UserStore = useUserStore()
  window.__store__ = {TodoStore, UserStore}
}

Then, in our test files, we can access the state that drives our application and utilize it similarly as we might in a component.

With Vuex

describe('Todo', () => {
  const getStore = () => cy.window().its('__store__')

  beforeEach(() => {
    cy.visit('/')
  })

  it('sets a new todo', () => {
    getStore().then((store) => {
      store.dispatch('todo/addTodo', 'Make more coffee')
    })
    cy.get('li').should('have.text', 'Make more coffee')
  })
 })

With Pinia

describe('Todo', () => {
  const getStore = () => cy.window().its('__store__')

  beforeEach(() => {
    cy.visit('/')
  })

  it('sets a new todo', () => {
    getStore().then((store) => {
      store.addTodo('Make more coffee')
    })
    cy.get('li').should('have.text', 'Make more coffee')
  })
 })

Learn more about Pinia

Learn more about Vuex

Want to learn more about testing with Cypress and Vue Apps? Try this blog post by Gleb Bahmutov:

Testing Vue web applications with Vuex data store & REST backend

It's a few years old. The basics are the same, but some of the syntax may need adjusting depending on your particular implementation.

I hope this article has been helpful!

Any questions / comments / etc? Feel free to get in touch!

© 2025 Rebecca Page