Unit Testing a Pinia Component

Fotis Adamakis

--

Pinia is the undisputed state management champion in the Vue 3 world. It provides a more powerful and scalable architecture with an elegant code style following the composition API syntax.

Getting started is straightforward especially if you are coming from Vuex but some subtle differences in unit testing might catch you off guard. Let's explore some real-life scenarios that might save you time in the future.

Unit testing a Pinia store

Before we dive into component testing let's explore how a simple store can be tested in isolation. For this, we will use the counter store that comes with the default vue-cli installation boilerplate and looks like this.

import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
const count = ref(0)

const doubleCount = computed(() => count.value * 2)

function increment(amount = 1) {
count.value += amount
}

return { count, doubleCount, increment }
})

If you are not familiar with Pinia setup stores the following rules apply:

  • Everything declared with ref and reactive will become the state
  • Methods will become actions
  • Computed will become getters
  • Mutations don't exist, actions update the state directly.

Testing the store above should be straightforward:

import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from './counter'

describe('Counter Store', () => {

beforeEach(() => {
// creates a fresh pinia and make it active so it's automatically picked
// up by any useStore() call without having to pass it to it:
// `useStore(pinia)`
setActivePinia(createPinia())
})

it('increment with no parameters should add one to the counter', () => {
const counter = useCounterStore()
expect(counter.count).toBe(0)
counter.increment()
expect(counter.count).toBe(1)
})

it('increment by amount should update the counter', () => {
const counter = useCounterStore()
counter.increment(10)
expect(counter.count).toBe(10)
})

it('doubleCount getter should be double the counter at all times', () => {
const counter = useCounterStore()
expect(counter.doubleCount).toBe(0)
counter.increment()
expect(counter.count).toBe(1)
expect(counter.doubleCount).toBe(2)
})
})

We are using vittest which is a vite powered test runner with the same API as Jest but significantly faster execution times.

Please notice that in the beforeEach hook, a pinia instance is created and activated. A store cannot work without it and the following error will be thrown if we omit it:

Error: [🍍]: "getActivePinia()" was called but there was no active Pinia. Did you forget to install pinia?
const pinia = createPinia()
app.use(pinia)
This will fail in production.

Unit Testing Components

A simple component that uses the counter store should be easy to implement.

<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
</script>

<template>
<div class="counter">
<h1>Counter</h1>
<p class="count">The current count is: <span>{{ store.count }}</span></p>
<p class="double-count">The double count is: <span>{{ store.doubleCount }}</span></p>
<button @click="store.increment()">
Increment
</button>
</div>
</template>

Tip: StoreToRefs could make this component cleaner by using object destructuring. You can see how in the advanced example that is coming next.

The component is small and simple so writing some basic unit tests are also straightforward.

import { mount } from '@vue/test-utils'
import TheCounter from '@/components/TheCounter.vue'
import { setActivePinia, createPinia } from 'pinia'

describe('Counter Component', () => {

beforeEach(() => {
setActivePinia(createPinia())
})

test('the count initially should be 0', async () => {
const wrapper = mount(TheCounter)
expect(wrapper.find('.count span').text()).toBe('0')
})

test('clicking the button should increment counter by 1', async () => {
const wrapper = mount(TheCounter)
await wrapper.find('button').trigger('click')
expect(wrapper.find('.count span').text()).toBe('1')
})

test('double counter should be twice the counter value', async () => {
const wrapper = mount(TheCounter)
expect(wrapper.find('.count span').text()).toBe('0')
expect(wrapper.find('.double-count span').text()).toBe('0')
await wrapper.find('button').trigger('click')
expect(wrapper.find('.count span').text()).toBe('1')
expect(wrapper.find('.double-count span').text()).toBe('2')
})

})

Notice that again we need to create and activate a pinia instance in the beforeEach hook.

Our basic test suite runs successfully but there is a not-so-obvious issue with it. We are using the actual useCounterStore implementation and we are not testing the Counter component in isolation. It is easy to miss now because our component and store are small but in a real-life scenario everything should be mocked. To mock the store we will use the createTestingPinia helper from the @pinia/testing package. (Install it with npm i -D @pinia/testing if you haven't already). createTestingPinia will provide many useful behaviours so we can test our component in isolation.

First, we need to install it as a plugin when mounting a component.

import { mount } from '@vue/test-utils'
import TheCounter from '@/components/TheCounter.vue'
import { createTestingPinia } from '@pinia/testing'

describe('Counter Component', () => {
test('the count initially should be 0', async () => {
const wrapper = mount(TheCounter, {
global: {
plugins: [
createTestingPinia()
]
}
})
expect(wrapper.find('.count span').text()).toBe('0')
})
})

We can change the initial state of our store with the initialState configuration.

import { mount } from '@vue/test-utils'
import TheCounter from '@/components/TheCounter.vue'
import { createTestingPinia } from '@pinia/testing'

describe('Counter Component', () => {
test('the count initially should be 20', async () => {
const wrapper = mount(TheCounter, {
global: {
plugins: [
createTestingPinia({
initialState: {
counter: {
count: 20
}
}
})
]
}
})
expect(wrapper.find('.count span').text()).toBe('20')
})
})

By default, every action is mocked and will not be executed. We need to implement the behaviour of the action ourselves (see advanced use case below) or we can revert this behaviour with the stubActions configuration. This way the action will be executed but the behavior can be monitored by using spies.

  test('clicking the button should increment counter by 1', async () => {
const wrapper = mount(TheCounter, {
global: {
plugins: [createTestingPinia({ stubActions: false })]
}
})
await wrapper.find('button').trigger('click')
expect(wrapper.find('.count span').text()).toBe('1')
})

Advanced Use Case

Testing a simple counter component is essential to understand the basics but real-life complexity comes when testing asynchronous code. Let's complicate things a bit.

For this, we will use the product listing component again, also featured in my previous Vue Query and Data Provider Pattern articles.

The store that will do all the heavy lifting looks like this:

import { ref } from 'vue'
import { defineStore } from 'pinia'

export type Item = {
id: number;
title: string;
description: string;
price: number;
rating: number;
brand: string;
};

export const useProducts = defineStore('products', () => {
const products = ref([])
const loading = ref(false)

async function fetchData() {
loading.value = true
const response = await fetch(`https://dummyjson.com/products?limit=10`).then((res) =>
res.json()
)
products.value = response.products
loading.value = false
}

const selectedProduct = ref()

function onSelect(item: Item) {
selectedProduct.value = item
}

return { products, loading, fetchData, selectedProduct, onSelect }
})

The product page will show a loader, the table, an empty state or a product modal depending on the current state.

<script setup lang="ts">
import TheTable from "@/components/TheTable.vue";
import ProductModal from "@/components/ProductModal.vue";
import { storeToRefs } from "pinia";
import { useProducts } from "@/stores/products";
import TheLoader from "@/components/TheLoader.vue";

const productsStore = useProducts();
const { fetchData, onSelect } = productsStore;
const { products, loading, selectedProduct } = storeToRefs(productsStore);

fetchData();
</script>

<template>
<div class="container">
<TheLoader v-if="loading" />
<TheTable :items="products" v-else-if="products.length" @select="onSelect" />
<div v-else class="empty-state">No products found</div>
<ProductModal
v-if="selectedProduct"
:product-id="selectedProduct.id"
@close="selectedProduct = null"
/>
</div>
</template>

It’s important to understand that the product store internals are not a concern of this component. The store should be tested of course but in isolation and on it’s own spec file.

On the other hand every possible state of this component should be mocked and tested. This includes the hidden logic behind every v-if in the template.

Let's start with a simple test. When loading is true the loader component should be shown.

import { mount } from '@vue/test-utils'
import ProductPage from '@/components/ProductsPage.vue'
import { createTestingPinia } from '@pinia/testing'
import TheLoader from "@/components/TheLoader.vue";


describe('Products Page', () => {
test('the loader is shown when is loading is true', async () => {
const wrapper = mount(ProductPage, {
global: {
plugins: [
createTestingPinia({
initialState: {
products: {
loading: true
}
}
})
]
}
})
expect(wrapper.findComponent(TheLoader).exists()).toBe(true)
})
})

Notice that we are mocking the store using createTestingPinia and we are setting the initial state of the loading variable to true. Then we are using the findComponent helper to verify that It is visible.

In the same manner, we can mock all the different states and assert that the expected components are shown.

import { mount } from '@vue/test-utils'
import ProductPage from '@/components/ProductsPage.vue'
import { createTestingPinia } from '@pinia/testing'
import TheTable from "@/components/TheTable.vue";
import ProductModal from "@/components/ProductModal.vue";

describe('Products Page', () => {
test('the table is shown when products exists and the loading state is false', async () => {
const wrapper = mount(ProductPage, {
global: {
plugins: [
createTestingPinia({
initialState: {
products: {
loading: false,
products: mockProducts,
}
}
})
]
}
})
expect(wrapper.findComponent(TheLoader).exists()).toBe(false)
expect(wrapper.findComponent(TheTable).exists()).toBe(true)
})
test('the table is shown when products exists and the loading state is false', async () => {
const wrapper = mount(ProductPage, {
global: {
plugins: [
createTestingPinia({
initialState: {
products: {
loading: false,
products: mockProducts,
}
}
})
]
}
})
expect(wrapper.findComponent(TheLoader).exists()).toBe(false)
expect(wrapper.findComponent(TheTable).exists()).toBe(true)
})

test('the empty state is shown when products dont exist and the loading state is false', async () => {
const wrapper = mount(ProductPage, {
global: {
plugins: [
createTestingPinia({
initialState: {
products: {
loading: false,
products: [],
}
}
})
]
}
})
expect(wrapper.findComponent(TheLoader).exists()).toBe(false)
expect(wrapper.findComponent(TheTable).exists()).toBe(false)
expect(wrapper.find('.empty-state').exists()).toBe(true)
})

test('the empty state is shown when products dont exist and the loading state is false', async () => {
const wrapper = mount(ProductPage, {
global: {
plugins: [
createTestingPinia({
initialState: {
products: {
selectedProduct: mockProducts[0],
}
}
})
]
}
})
expect(wrapper.findComponent(ProductModal).exists()).toBe(true)
})
})

const mockProducts = [
{
id: 1,
title: "Mechanical Keyboard",
description: "Noisy Mechanical Keyboard",
price: 100,
rating: 3,
brand: "Logitech",
},
{
id: 2,
title: "Macbook Pro",

description: "M2",
price: 1200,
rating: 5,
brand: "Apple",
}]

Every possible state of the component is tested by successfully mocking the store and respecting the test boundaries of our component. The complete suite can be found on GitHub.

Bonus: Running the Tests on CI/CD

We recently learned how to deploy a similar application to GitHub pages. But what about verifying that the test suite is successful before a deployment? We can do that by an additional step that runs the unit tests in the pipeline.

# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages

on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]

# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

# Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write

# Allow one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3

- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: 18
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm run test:unit
deploy:
needs: test
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: 18
cache: "npm"
- name: Install dependencies
run: npm install
- name: Build
run: npm run build
- name: Setup Pages
uses: actions/configure-pages@v3
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
# Upload dist repository
path: "./dist"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1

What we added on top of the deploy step is a required test step that prepares the environment and runs npm run test:unit

In case a test fails the deployment will not go through.

Conclusion

Pinia is the safe bet when it comes to the future of state management in the Vue ecosystem. While the key concepts are similar to Vuex, it may take some time to become comfortable with the new API.

The most important key takeaway from this article is to respect the component boundaries when unit testing by mocking every external dependency and providing the data to cover every possible state of your component.

--

--

« Senior Software Engineer · Author · International Speaker · Vue.js Athens Meetup Organizer »