Advanced Testing

This page covers advanced testing techniques for composables, Pinia stores, asynchronous operations, and test lifecycle management.

Testing Composables

Test Vue composables in isolation without mounting a component:

import { describe, test, expect } from 'vitest'
import { useCounter } from '../composables/useCounter'

describe('useCounter', () => {
    test('initializes with zero', () => {
        const { count, increment, decrement } = useCounter()

        expect(count.value).toBe(0)
    })

    test('increments count', () => {
        const { count, increment } = useCounter()

        increment()
        expect(count.value).toBe(1)

        increment()
        expect(count.value).toBe(2)
    })

    test('accepts initial value', () => {
        const { count } = useCounter(10)

        expect(count.value).toBe(10)
    })
})

Testing Composables with Side Effects

import { describe, test, expect, vi } from 'vitest'
import { useApiCall } from '../composables/useApiCall'

vi.mock('../api/client', () => ({
    fetchData: vi.fn().mockResolvedValue({ data: 'mocked' })
}))

describe('useApiCall', () => {
    test('fetches data and updates state', async () => {
        const { data, loading, error, fetch } = useApiCall()

        expect(loading.value).toBe(false)
        expect(data.value).toBeNull()

        const promise = fetch('/api/users')
        expect(loading.value).toBe(true)

        await promise

        expect(loading.value).toBe(false)
        expect(data.value).toEqual({ data: 'mocked' })
        expect(error.value).toBeNull()
    })

    test('handles errors correctly', async () => {
        const { data, loading, error, fetch } = useApiCall()

        // Mock error
        vi.mocked(fetchData).mockRejectedValueOnce(new Error('API Error'))

        await fetch('/api/users')

        expect(loading.value).toBe(false)
        expect(data.value).toBeNull()
        expect(error.value?.message).toBe('API Error')
    })
})

Testing Pinia Stores

Test stores with Pinia's test helpers:

import { describe, test, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '../stores/userStore'

describe('useUserStore', () => {
    beforeEach(() => {
        // Create a fresh Pinia instance for each test
        setActivePinia(createPinia())
    })

    test('initializes with default state', () => {
        const store = useUserStore()

        expect(store.users).toEqual([])
        expect(store.loading).toBe(false)
        expect(store.currentUser).toBeNull()
    })

    test('adds user to store', () => {
        const store = useUserStore()
        const user = { id: 1, name: 'Alice' }

        store.addUser(user)

        expect(store.users).toHaveLength(1)
        expect(store.users[0]).toEqual(user)
    })

    test('removes user by id', () => {
        const store = useUserStore()
        store.users = [
            { id: 1, name: 'Alice' },
            { id: 2, name: 'Bob' }
        ]

        store.removeUser(1)

        expect(store.users).toHaveLength(1)
        expect(store.users[0].id).toBe(2)
    })
})

Testing Store Getters

test('computes active users correctly', () => {
    const store = useUserStore()
    store.users = [
        { id: 1, name: 'Alice', active: true },
        { id: 2, name: 'Bob', active: false },
        { id: 3, name: 'Charlie', active: true }
    ]

    expect(store.activeUsers).toHaveLength(2)
    expect(store.activeUsers[0].name).toBe('Alice')
    expect(store.activeUsers[1].name).toBe('Charlie')
})

Testing Store Actions

import { describe, test, expect, beforeEach, vi } from 'vitest'
import axios from 'axios'

vi.mock('axios')

describe('userStore actions', () => {
    beforeEach(() => {
        setActivePinia(createPinia())
    })

    test('fetchUsers updates state on success', async () => {
        const mockUsers = [{ id: 1, name: 'Alice' }]
        vi.mocked(axios.get).mockResolvedValue({ data: mockUsers })

        const store = useUserStore()
        await store.fetchUsers()

        expect(store.loading).toBe(false)
        expect(store.users).toEqual(mockUsers)
        expect(store.error).toBeNull()
    })

    test('fetchUsers handles errors', async () => {
        vi.mocked(axios.get).mockRejectedValue(new Error('Network error'))

        const store = useUserStore()
        await store.fetchUsers()

        expect(store.loading).toBe(false)
        expect(store.users).toEqual([])
        expect(store.error).toBe('Network error')
    })
})

Testing Async Code

Use async/await for asynchronous operations:

import { describe, test, expect, vi } from 'vitest'
import axios from 'axios'

vi.mock('axios')

describe('Async operations', () => {
    test('fetches data from API', async () => {
        const mockData = { id: 1, name: 'Test' }
        vi.mocked(axios.get).mockResolvedValue({ data: mockData })

        const response = await axios.get('/api/data')

        expect(response.data).toEqual(mockData)
        expect(axios.get).toHaveBeenCalledWith('/api/data')
    })

    test('handles API errors', async () => {
        vi.mocked(axios.get).mockRejectedValue(new Error('Network error'))

        await expect(axios.get('/api/data')).rejects.toThrow('Network error')
    })
})

Testing Components with Async Data

test('displays data after loading', async () => {
    vi.mocked(axios.get).mockResolvedValue({
        data: { users: [{ id: 1, name: 'Alice' }] }
    })

    const wrapper = mount(UserList)

    // Initially shows loading state
    expect(wrapper.find('[data-test="spinner"]').exists()).toBe(true)

    // Wait for async operations to complete
    await wrapper.vm.$nextTick()
    await flushPromises() // Helper to flush all pending promises

    // Now shows data
    expect(wrapper.find('[data-test="spinner"]').exists()).toBe(false)
    expect(wrapper.text()).toContain('Alice')
})

Flush Promises Helper

Create a helper to wait for all pending promises:

// app/assets/tests/helpers.ts
export const flushPromises = () => new Promise(resolve => setImmediate(resolve))
import { flushPromises } from './helpers'

test('handles async updates', async () => {
    const wrapper = mount(AsyncComponent)
    
    await flushPromises()
    
    expect(wrapper.text()).toContain('Loaded')
})

Test Lifecycle Hooks

Vitest provides hooks for setup and teardown:

import { describe, test, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'

describe('User Management', () => {
    // Runs once before all tests
    beforeAll(() => {
        console.log('Setting up test suite')
    })

    // Runs once after all tests
    afterAll(() => {
        console.log('Tearing down test suite')
    })

    // Runs before each test
    beforeEach(() => {
        setActivePinia(createPinia())
        vi.clearAllMocks()
    })

    // Runs after each test
    afterEach(() => {
        vi.resetAllMocks()
    })

    test('creates user', () => {
        // Test code
    })

    test('updates user', () => {
        // Test code
    })
})

Nested Describe Blocks

Organize related tests with nested describe blocks:

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

    describe('state management', () => {
        test('initializes correctly', () => {
            // Test initialization
        })

        test('updates state', () => {
            // Test state updates
        })
    })

    describe('API integration', () => {
        beforeEach(() => {
            // Additional setup for API tests
            vi.mock('axios')
        })

        test('fetches data', async () => {
            // Test API fetch
        })

        test('handles errors', async () => {
            // Test error handling
        })
    })
})

Testing with watchers

Test Vue's watch and watchEffect:

import { describe, test, expect, vi } from 'vitest'
import { ref, watch } from 'vue'

test('watcher triggers on value change', async () => {
    const count = ref(0)
    const callback = vi.fn()

    watch(count, callback)

    count.value = 1
    await nextTick() // Wait for watchers to trigger

    expect(callback).toHaveBeenCalledWith(1, 0, expect.anything())
})

test('component watcher updates correctly', async () => {
    const wrapper = mount(SearchBox)

    await wrapper.find('input').setValue('test')
    await wrapper.vm.$nextTick()

    // Verify watcher side effect
    expect(wrapper.vm.debouncedSearch).toHaveBeenCalled()
})

Testing Error Boundaries

Test error handling in components:

test('catches and displays errors', async () => {
    // Mock component that throws error
    const ErrorComponent = {
        setup() {
            throw new Error('Component error')
        },
        template: '<div>Should not render</div>'
    }

    const wrapper = mount(ErrorBoundary, {
        slots: {
            default: ErrorComponent
        }
    })

    expect(wrapper.find('[data-test="error-message"]').exists()).toBe(true)
    expect(wrapper.text()).toContain('Something went wrong')
})

Testing Teleport

Test components using <Teleport>:

test('renders modal in portal', () => {
    // Create target element
    const target = document.createElement('div')
    target.id = 'modal-root'
    document.body.appendChild(target)

    const wrapper = mount(ModalComponent, {
        props: { isOpen: true },
        attachTo: document.body
    })

    expect(target.innerHTML).toContain('Modal content')

    wrapper.unmount()
    document.body.removeChild(target)
})

Testing Transitions

Test components with Vue transitions:

test('applies transition classes', async () => {
    const wrapper = mount(TransitionComponent)

    // Trigger transition
    await wrapper.setProps({ show: true })

    // Check for enter classes
    expect(wrapper.find('.fade-enter-active').exists()).toBe(true)

    // Wait for transition to complete
    await new Promise(resolve => setTimeout(resolve, 300))

    expect(wrapper.find('.fade-enter-active').exists()).toBe(false)
})

Performance Testing

Test component performance:

test('renders large list efficiently', () => {
    const items = Array.from({ length: 10000 }, (_, i) => ({
        id: i,
        name: `Item ${i}`
    }))

    const start = performance.now()
    const wrapper = mount(VirtualList, {
        props: { items }
    })
    const duration = performance.now() - start

    expect(duration).toBeLessThan(100) // Should render in <100ms
    expect(wrapper.findAll('[data-test="list-item"]')).toHaveLength(expect.any(Number))
})

Next Steps

You've mastered advanced testing techniques! Now learn best practices:

  • Best Practices: Write maintainable, effective tests with real-world examples