Skip to content

Testing Migration Guide

This guide helps you maintain and improve testing strategies during the migration from Vuex to Pinia, ensuring code quality and functional integrity throughout the migration process.

Overview

Testing migration is a critical part of the entire state management migration process, requiring consideration of:

  • Maintaining existing Vuex tests
  • Writing new Pinia tests
  • Parallel testing during migration
  • Adapting testing tools and frameworks
  • Maintaining test coverage

Test Environment Setup

1. Basic Test Configuration

ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./tests/setup.ts']
  }
})
ts
// tests/setup.ts
import { config } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createStore } from 'vuex'

// Global test setup
beforeEach(() => {
  // Create new Pinia instance for each test
  setActivePinia(createPinia())
})

// Configure Vue Test Utils
config.global.plugins = [
  createPinia(),
  // Add Vuex store if needed
]

2. Test Utility Functions

ts
// tests/utils/test-helpers.ts
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { createStore, type Store } from 'vuex'
import { mount, type VueWrapper } from '@vue/test-utils'
import type { Component } from 'vue'

// Pinia test helper
export function createTestPinia(): Pinia {
  const pinia = createPinia()
  setActivePinia(pinia)
  return pinia
}

// Vuex test helper
export function createTestVuexStore(modules: any = {}): Store<any> {
  return createStore({
    modules,
    strict: false // Disable strict mode in test environment
  })
}

// Component mounting helper
export function mountWithStores(
  component: Component,
  options: {
    pinia?: Pinia
    vuexStore?: Store<any>
    props?: Record<string, any>
    [key: string]: any
  } = {}
): VueWrapper {
  const { pinia = createTestPinia(), vuexStore, props, ...mountOptions } = options
  
  const plugins = [pinia]
  if (vuexStore) {
    plugins.push(vuexStore)
  }
  
  return mount(component, {
    props,
    global: {
      plugins,
      ...mountOptions.global
    },
    ...mountOptions
  })
}

// Async operation wait helper
export async function waitForStoreAction(
  action: () => Promise<any>,
  timeout: number = 1000
): Promise<void> {
  const start = Date.now()
  
  await action()
  
  // Wait for all microtasks to complete
  await new Promise(resolve => setTimeout(resolve, 0))
  
  if (Date.now() - start > timeout) {
    throw new Error(`Store action timeout after ${timeout}ms`)
  }
}

Vuex Test Migration

1. Existing Vuex Test Pattern

ts
// tests/store/user.test.ts (Vuex version)
import { createStore } from 'vuex'
import userModule from '@/store/modules/user'

describe('User Store (Vuex)', () => {
  let store: any
  
  beforeEach(() => {
    store = createStore({
      modules: {
        user: {
          ...userModule,
          namespaced: true
        }
      }
    })
  })
  
  describe('mutations', () => {
    it('should set user', () => {
      const user = { id: 1, name: 'John Doe' }
      store.commit('user/SET_USER', user)
      
      expect(store.state.user.user).toEqual(user)
      expect(store.getters['user/isLoggedIn']).toBe(true)
    })
    
    it('should clear user on logout', () => {
      store.commit('user/SET_USER', { id: 1, name: 'John Doe' })
      store.commit('user/LOGOUT')
      
      expect(store.state.user.user).toBeNull()
      expect(store.getters['user/isLoggedIn']).toBe(false)
    })
  })
  
  describe('actions', () => {
    it('should login user', async () => {
      const mockApi = vi.fn().mockResolvedValue({ id: 1, name: 'John Doe' })
      
      // Mock API
      vi.doMock('@/api/auth', () => ({ login: mockApi }))
      
      await store.dispatch('user/login', { email: 'test@example.com', password: 'password' })
      
      expect(mockApi).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password' })
      expect(store.getters['user/isLoggedIn']).toBe(true)
    })
  })
})

2. Converting to Pinia Tests

ts
// tests/stores/user.test.ts (Pinia version)
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
import { createTestPinia } from '../utils/test-helpers'

// Mock API
vi.mock('@/api/auth', () => ({
  login: vi.fn(),
  logout: vi.fn()
}))

import { login as mockLogin } from '@/api/auth'

describe('User Store (Pinia)', () => {
  beforeEach(() => {
    createTestPinia()
    vi.clearAllMocks()
  })
  
  describe('state management', () => {
    it('should initialize with default state', () => {
      const store = useUserStore()
      
      expect(store.user).toBeNull()
      expect(store.isLoggedIn).toBe(false)
    })
    
    it('should update user state', () => {
      const store = useUserStore()
      const user = { id: 1, name: 'John Doe', email: 'john@example.com' }
      
      // Direct state modification (Pinia allows this)
      store.$patch({ user })
      
      expect(store.user).toEqual(user)
      expect(store.isLoggedIn).toBe(true)
      expect(store.fullName).toBe('John Doe')
    })
  })
  
  describe('actions', () => {
    it('should login user successfully', async () => {
      const store = useUserStore()
      const credentials = { email: 'test@example.com', password: 'password' }
      const userData = { id: 1, name: 'John', lastName: 'Doe', email: 'test@example.com' }
      
      ;(mockLogin as any).mockResolvedValue(userData)
      
      await store.login(credentials)
      
      expect(mockLogin).toHaveBeenCalledWith(credentials)
      expect(store.user).toEqual(userData)
      expect(store.isLoggedIn).toBe(true)
    })
    
    it('should handle login error', async () => {
      const store = useUserStore()
      const error = new Error('Invalid credentials')
      
      ;(mockLogin as any).mockRejectedValue(error)
      
      await expect(store.login({ email: 'test@example.com', password: 'wrong' }))
        .rejects.toThrow('Invalid credentials')
      
      expect(store.user).toBeNull()
      expect(store.isLoggedIn).toBe(false)
    })
    
    it('should logout user', async () => {
      const store = useUserStore()
      
      // Set initial user
      store.$patch({ user: { id: 1, name: 'John Doe' } })
      expect(store.isLoggedIn).toBe(true)
      
      await store.logout()
      
      expect(store.user).toBeNull()
      expect(store.isLoggedIn).toBe(false)
    })
  })
  
  describe('getters', () => {
    it('should compute full name correctly', () => {
      const store = useUserStore()
      
      store.$patch({
        user: { id: 1, name: 'John', lastName: 'Doe' }
      })
      
      expect(store.fullName).toBe('John Doe')
    })
    
    it('should return empty string for missing name parts', () => {
      const store = useUserStore()
      
      store.$patch({
        user: { id: 1, name: 'John' }
      })
      
      expect(store.fullName).toBe('John ')
    })
  })
})

Component Testing

1. Testing Components with Vuex

ts
// tests/components/UserProfile.test.ts (Vuex version)
import { mount } from '@vue/test-utils'
import { createStore } from 'vuex'
import UserProfile from '@/components/UserProfile.vue'
import userModule from '@/store/modules/user'

describe('UserProfile Component (Vuex)', () => {
  let store: any
  
  beforeEach(() => {
    store = createStore({
      modules: {
        user: {
          ...userModule,
          namespaced: true
        }
      }
    })
  })
  
  it('should display user information', () => {
    const user = { id: 1, name: 'John Doe', email: 'john@example.com' }
    store.commit('user/SET_USER', user)
    
    const wrapper = mount(UserProfile, {
      global: {
        plugins: [store]
      }
    })
    
    expect(wrapper.text()).toContain('John Doe')
    expect(wrapper.text()).toContain('john@example.com')
  })
  
  it('should handle logout action', async () => {
    const user = { id: 1, name: 'John Doe' }
    store.commit('user/SET_USER', user)
    
    const wrapper = mount(UserProfile, {
      global: {
        plugins: [store]
      }
    })
    
    await wrapper.find('[data-testid="logout-button"]').trigger('click')
    
    expect(store.state.user.user).toBeNull()
  })
})

2. Testing Components with Pinia

ts
// tests/components/UserProfile.test.ts (Pinia version)
import { mount } from '@vue/test-utils'
import UserProfile from '@/components/UserProfile.vue'
import { useUserStore } from '@/stores/user'
import { createTestPinia, mountWithStores } from '../utils/test-helpers'

// Mock API
vi.mock('@/api/auth', () => ({
  logout: vi.fn().mockResolvedValue(undefined)
}))

describe('UserProfile Component (Pinia)', () => {
  beforeEach(() => {
    createTestPinia()
  })
  
  it('should display user information', () => {
    const wrapper = mountWithStores(UserProfile)
    const store = useUserStore()
    
    // Set user data
    store.$patch({
      user: { id: 1, name: 'John Doe', email: 'john@example.com' }
    })
    
    expect(wrapper.text()).toContain('John Doe')
    expect(wrapper.text()).toContain('john@example.com')
  })
  
  it('should handle logout action', async () => {
    const wrapper = mountWithStores(UserProfile)
    const store = useUserStore()
    
    // Set initial user
    store.$patch({
      user: { id: 1, name: 'John Doe' }
    })
    
    await wrapper.find('[data-testid="logout-button"]').trigger('click')
    
    expect(store.user).toBeNull()
    expect(store.isLoggedIn).toBe(false)
  })
  
  it('should show login form when not logged in', () => {
    const wrapper = mountWithStores(UserProfile)
    const store = useUserStore()
    
    // Ensure user is not logged in
    expect(store.isLoggedIn).toBe(false)
    
    expect(wrapper.find('[data-testid="login-form"]').exists()).toBe(true)
    expect(wrapper.find('[data-testid="user-info"]').exists()).toBe(false)
  })
})

Advanced Testing Patterns

1. Testing Store Composition

ts
// tests/stores/composition.test.ts
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
import { createTestPinia } from '../utils/test-helpers'

describe('Store Composition', () => {
  beforeEach(() => {
    createTestPinia()
  })
  
  it('should work with multiple stores', () => {
    const userStore = useUserStore()
    const cartStore = useCartStore()
    
    // Set up user
    userStore.$patch({
      user: { id: 1, name: 'John Doe' }
    })
    
    // Add items to cart
    cartStore.addItem({ id: 1, name: 'Product 1', price: 10 })
    cartStore.addItem({ id: 2, name: 'Product 2', price: 20 })
    
    expect(userStore.isLoggedIn).toBe(true)
    expect(cartStore.itemCount).toBe(2)
    expect(cartStore.total).toBe(30)
  })
  
  it('should handle cross-store dependencies', async () => {
    const userStore = useUserStore()
    const cartStore = useCartStore()
    
    // Mock checkout API
    vi.mock('@/api/checkout', () => ({
      checkout: vi.fn().mockResolvedValue({ orderId: '123' })
    }))
    
    // Set up stores
    userStore.$patch({ user: { id: 1, name: 'John Doe' } })
    cartStore.addItem({ id: 1, name: 'Product 1', price: 10 })
    
    // Perform checkout (which depends on both stores)
    const result = await cartStore.checkout()
    
    expect(result.orderId).toBe('123')
    expect(cartStore.items).toHaveLength(0) // Cart should be cleared
  })
})

2. Testing with Mocked Stores

ts
// tests/components/ProductList.test.ts
import { mount } from '@vue/test-utils'
import ProductList from '@/components/ProductList.vue'
import { createTestPinia } from '../utils/test-helpers'
import { useProductStore } from '@/stores/product'

// Create mock store
const mockProductStore = {
  products: [
    { id: 1, name: 'Product 1', price: 10 },
    { id: 2, name: 'Product 2', price: 20 }
  ],
  loading: false,
  error: null,
  fetchProducts: vi.fn(),
  addProduct: vi.fn()
}

describe('ProductList Component', () => {
  it('should display products', () => {
    const pinia = createTestPinia({
      createSpy: vi.fn,
      stubActions: false
    })
    
    const wrapper = mount(ProductList, {
      global: {
        plugins: [pinia]
      }
    })
    
    const store = useProductStore()
    
    // Mock store state
    store.$patch(mockProductStore)
    
    expect(wrapper.findAll('[data-testid="product-item"]')).toHaveLength(2)
    expect(wrapper.text()).toContain('Product 1')
    expect(wrapper.text()).toContain('Product 2')
  })
  
  it('should handle loading state', () => {
    const pinia = createTestPinia()
    const wrapper = mount(ProductList, {
      global: { plugins: [pinia] }
    })
    
    const store = useProductStore()
    store.$patch({ loading: true })
    
    expect(wrapper.find('[data-testid="loading"]').exists()).toBe(true)
  })
})

3. Testing SSR Scenarios

ts
// tests/ssr/state-serialization.test.ts
import { createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
import { createTestPinia } from '../utils/test-helpers'

describe('SSR State Serialization', () => {
  it('should serialize and deserialize state correctly', () => {
    // Server-side: create and populate store
    const serverPinia = createPinia()
    const serverStore = useUserStore(serverPinia)
    
    serverStore.$patch({
      user: { id: 1, name: 'John Doe', email: 'john@example.com' }
    })
    
    // Serialize state (as would happen on server)
    const serializedState = JSON.stringify(serverPinia.state.value)
    
    // Client-side: create new store and hydrate
    const clientPinia = createPinia()
    const clientStore = useUserStore(clientPinia)
    
    // Hydrate state (as would happen on client)
    clientPinia.state.value = JSON.parse(serializedState)
    
    // Verify state was transferred correctly
    expect(clientStore.user).toEqual({
      id: 1,
      name: 'John Doe',
      email: 'john@example.com'
    })
    expect(clientStore.isLoggedIn).toBe(true)
  })
  
  it('should handle partial state hydration', () => {
    const pinia = createTestPinia()
    const store = useUserStore()
    
    // Simulate partial state from server
    const partialState = {
      user: {
        user: { id: 1, name: 'John Doe' }
        // Missing other properties
      }
    }
    
    pinia.state.value = partialState
    
    expect(store.user).toEqual({ id: 1, name: 'John Doe' })
    expect(store.isLoggedIn).toBe(true)
  })
})

Migration Testing Strategies

1. Parallel Testing

ts
// tests/migration/parallel-stores.test.ts
import { createStore } from 'vuex'
import { createTestPinia } from '../utils/test-helpers'
import { useUserStore as usePiniaUserStore } from '@/stores/user'
import vuexUserModule from '@/store/modules/user'

describe('Parallel Store Testing', () => {
  it('should maintain same behavior between Vuex and Pinia', async () => {
    // Test Vuex implementation
    const vuexStore = createStore({
      modules: {
        user: { ...vuexUserModule, namespaced: true }
      }
    })
    
    const userData = { id: 1, name: 'John Doe', email: 'john@example.com' }
    
    // Vuex operations
    vuexStore.commit('user/SET_USER', userData)
    const vuexIsLoggedIn = vuexStore.getters['user/isLoggedIn']
    
    // Test Pinia implementation
    createTestPinia()
    const piniaStore = usePiniaUserStore()
    
    // Pinia operations
    piniaStore.$patch({ user: userData })
    const piniaIsLoggedIn = piniaStore.isLoggedIn
    
    // Both should behave the same
    expect(vuexIsLoggedIn).toBe(piniaIsLoggedIn)
    expect(vuexStore.state.user.user).toEqual(piniaStore.user)
  })
})

2. Regression Testing

ts
// tests/migration/regression.test.ts
import { createTestPinia } from '../utils/test-helpers'
import { useUserStore } from '@/stores/user'

describe('Migration Regression Tests', () => {
  beforeEach(() => {
    createTestPinia()
  })
  
  // Test cases that ensure migrated functionality works as expected
  const testCases = [
    {
      name: 'should handle user login flow',
      async test() {
        const store = useUserStore()
        
        // Mock successful login
        vi.mocked(mockLogin).mockResolvedValue({
          id: 1,
          name: 'John',
          lastName: 'Doe',
          email: 'john@example.com'
        })
        
        await store.login({ email: 'john@example.com', password: 'password' })
        
        expect(store.isLoggedIn).toBe(true)
        expect(store.fullName).toBe('John Doe')
      }
    },
    {
      name: 'should handle user logout flow',
      async test() {
        const store = useUserStore()
        
        // Set initial state
        store.$patch({ user: { id: 1, name: 'John Doe' } })
        expect(store.isLoggedIn).toBe(true)
        
        await store.logout()
        
        expect(store.isLoggedIn).toBe(false)
        expect(store.user).toBeNull()
      }
    }
  ]
  
  testCases.forEach(({ name, test }) => {
    it(name, test)
  })
})

Performance Testing

1. Store Performance

ts
// tests/performance/store-performance.test.ts
import { createTestPinia } from '../utils/test-helpers'
import { useUserStore } from '@/stores/user'

describe('Store Performance', () => {
  beforeEach(() => {
    createTestPinia()
  })
  
  it('should handle large state updates efficiently', () => {
    const store = useUserStore()
    const largeDataSet = Array.from({ length: 10000 }, (_, i) => ({
      id: i,
      name: `User ${i}`,
      email: `user${i}@example.com`
    }))
    
    const start = performance.now()
    
    store.$patch({ users: largeDataSet })
    
    const end = performance.now()
    const duration = end - start
    
    // Should complete within reasonable time (adjust threshold as needed)
    expect(duration).toBeLessThan(100) // 100ms
    expect(store.users).toHaveLength(10000)
  })
  
  it('should handle frequent updates without memory leaks', () => {
    const store = useUserStore()
    const initialMemory = (performance as any).memory?.usedJSHeapSize || 0
    
    // Perform many updates
    for (let i = 0; i < 1000; i++) {
      store.$patch({ user: { id: i, name: `User ${i}` } })
    }
    
    // Force garbage collection if available
    if (global.gc) {
      global.gc()
    }
    
    const finalMemory = (performance as any).memory?.usedJSHeapSize || 0
    const memoryIncrease = finalMemory - initialMemory
    
    // Memory increase should be reasonable (adjust threshold as needed)
    expect(memoryIncrease).toBeLessThan(10 * 1024 * 1024) // 10MB
  })
})

Best Practices

1. Test Organization

tests/
├── utils/
│   ├── test-helpers.ts
│   ├── mocks.ts
│   └── fixtures.ts
├── stores/
│   ├── user.test.ts
│   ├── cart.test.ts
│   └── product.test.ts
├── components/
│   ├── UserProfile.test.ts
│   ├── ProductList.test.ts
│   └── ShoppingCart.test.ts
├── migration/
│   ├── parallel-stores.test.ts
│   ├── regression.test.ts
│   └── compatibility.test.ts
├── integration/
│   ├── user-flow.test.ts
│   └── checkout-flow.test.ts
└── performance/
    ├── store-performance.test.ts
    └── component-performance.test.ts

2. Testing Guidelines

  1. Isolate tests: Each test should be independent and not rely on others
  2. Mock external dependencies: Use mocks for APIs, localStorage, etc.
  3. Test behavior, not implementation: Focus on what the store does, not how
  4. Use descriptive test names: Make it clear what each test is verifying
  5. Test edge cases: Include error conditions and boundary cases
  6. Maintain test coverage: Ensure all critical paths are tested

3. Common Pitfalls

  1. Shared state between tests: Always reset store state between tests
  2. Async timing issues: Properly wait for async operations to complete
  3. Mock leakage: Clear mocks between tests to avoid interference
  4. Over-mocking: Don't mock everything; test real behavior when possible
  5. Ignoring error cases: Test both success and failure scenarios

Continuous Integration

1. CI Configuration

yaml
# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run tests
        run: npm run test:coverage
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/lcov.info

2. Test Scripts

json
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage",
    "test:watch": "vitest --watch",
    "test:migration": "vitest tests/migration",
    "test:performance": "vitest tests/performance"
  }
}

Conclusion

Testing during migration from Vuex to Pinia requires careful planning and execution. Key takeaways:

  1. Maintain existing tests while gradually migrating to Pinia patterns
  2. Use parallel testing to ensure behavioral consistency
  3. Focus on integration tests to catch migration issues
  4. Automate regression testing to prevent breaking changes
  5. Monitor performance throughout the migration process

By following these practices, you can ensure a smooth migration while maintaining code quality and confidence in your application's behavior.

See Also

Released under the MIT License.