Vue 3 Composition API Unit Testing Patterns

Vue 3 Composition API Unit Testing Patterns

The Vue 3 Composition API changed how we write Vue components — and it changed how we test them. Composables can be tested in isolation, reactive state is explicit and predictable, and <script setup> components are simpler to reason about. But the patterns for testing them effectively aren't obvious.

This guide covers the most important Vue 3 Composition API testing patterns using Vitest and @vue/test-utils.

Setup

npm install -D vitest @vue/test-utils happy-dom @vitejs/plugin-vue

vitest.config.ts:

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

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'happy-dom',
    globals: true,
  },
});

Testing Composables Directly

The biggest win of the Composition API is that you can test composable logic entirely without mounting a component. Just call the composable inside a wrapper:

// composables/useSearch.ts
import { ref, computed, watch } from 'vue';

export function useSearch(items: Ref<string[]>) {
  const query = ref('');
  const debouncedQuery = ref('');
  let debounceTimer: ReturnType<typeof setTimeout>;

  watch(query, (value) => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
      debouncedQuery.value = value;
    }, 300);
  });

  const results = computed(() =>
    debouncedQuery.value
      ? items.value.filter((item) =>
          item.toLowerCase().includes(debouncedQuery.value.toLowerCase())
        )
      : items.value
  );

  return { query, results };
}
// tests/composables/useSearch.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref, nextTick } from 'vue';
import { useSearch } from '~/composables/useSearch';

describe('useSearch', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('returns all items when query is empty', () => {
    const items = ref(['apple', 'banana', 'cherry']);
    const { results } = useSearch(items);
    
    expect(results.value).toEqual(['apple', 'banana', 'cherry']);
  });

  it('filters items after debounce delay', async () => {
    const items = ref(['apple', 'banana', 'cherry']);
    const { query, results } = useSearch(items);
    
    query.value = 'an';
    await nextTick();
    
    // Not filtered yet — debounce hasn't fired
    expect(results.value).toEqual(['apple', 'banana', 'cherry']);
    
    // Advance timers past the debounce
    vi.advanceTimersByTime(300);
    await nextTick();
    
    expect(results.value).toEqual(['banana']);
  });

  it('is case-insensitive', async () => {
    const items = ref(['Apple', 'Banana', 'Cherry']);
    const { query, results } = useSearch(items);
    
    query.value = 'apple';
    vi.advanceTimersByTime(300);
    await nextTick();
    
    expect(results.value).toEqual(['Apple']);
  });

  it('returns all items when query is cleared', async () => {
    const items = ref(['apple', 'banana']);
    const { query, results } = useSearch(items);
    
    query.value = 'apple';
    vi.advanceTimersByTime(300);
    await nextTick();
    
    query.value = '';
    vi.advanceTimersByTime(300);
    await nextTick();
    
    expect(results.value).toEqual(['apple', 'banana']);
  });
});

Testing with withSetup

Some composables use lifecycle hooks (onMounted, onUnmounted) that only work inside a component setup context. Use a helper to provide that context:

// test-utils/withSetup.ts
import { createApp, defineComponent } from 'vue';

export function withSetup<T>(composable: () => T): [T, () => void] {
  let result: T;
  
  const app = createApp(
    defineComponent({
      setup() {
        result = composable();
        return () => {};
      },
    })
  );
  
  const el = document.createElement('div');
  app.mount(el);
  
  const cleanup = () => app.unmount();
  
  return [result!, cleanup];
}
// composables/useWindowSize.ts
import { ref, onMounted, onUnmounted } from 'vue';

export function useWindowSize() {
  const width = ref(window.innerWidth);
  const height = ref(window.innerHeight);
  
  const update = () => {
    width.value = window.innerWidth;
    height.value = window.innerHeight;
  };
  
  onMounted(() => window.addEventListener('resize', update));
  onUnmounted(() => window.removeEventListener('resize', update));
  
  return { width, height };
}
// tests/composables/useWindowSize.test.ts
import { describe, it, expect } from 'vitest';
import { withSetup } from '~/test-utils/withSetup';
import { useWindowSize } from '~/composables/useWindowSize';

describe('useWindowSize', () => {
  it('returns current window dimensions', () => {
    const [{ width, height }, cleanup] = withSetup(() => useWindowSize());
    
    expect(width.value).toBe(window.innerWidth);
    expect(height.value).toBe(window.innerHeight);
    
    cleanup();
  });

  it('updates on window resize', async () => {
    const [{ width }, cleanup] = withSetup(() => useWindowSize());
    
    // Simulate resize
    Object.defineProperty(window, 'innerWidth', { value: 1200, configurable: true });
    window.dispatchEvent(new Event('resize'));
    
    await nextTick();
    expect(width.value).toBe(1200);
    
    cleanup();
  });

  it('removes event listener on unmount', () => {
    const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
    
    const [, cleanup] = withSetup(() => useWindowSize());
    cleanup();
    
    expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
  });
});

Testing provide/inject

The provide/inject pattern is common in Vue 3 for dependency injection. Test both the provider and consumer:

// composables/useTheme.ts
import { provide, inject, ref, InjectionKey } from 'vue';

export type Theme = 'light' | 'dark';
export const ThemeKey: InjectionKey<ReturnType<typeof useThemeProvider>> = Symbol('theme');

export function useThemeProvider() {
  const theme = ref<Theme>('light');
  
  const toggle = () => {
    theme.value = theme.value === 'light' ? 'dark' : 'light';
  };
  
  provide(ThemeKey, { theme, toggle });
  
  return { theme, toggle };
}

export function useTheme() {
  const context = inject(ThemeKey);
  if (!context) throw new Error('useTheme must be used within a ThemeProvider');
  return context;
}
// tests/composables/useTheme.test.ts
import { describe, it, expect } from 'vitest';
import { mount, shallowMount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import { ThemeKey, useThemeProvider, useTheme } from '~/composables/useTheme';

describe('useThemeProvider', () => {
  it('provides theme to children', () => {
    let childTheme: any;
    
    const Child = defineComponent({
      setup() {
        childTheme = useTheme();
        return () => h('div');
      },
    });
    
    const Parent = defineComponent({
      setup() {
        useThemeProvider();
        return () => h(Child);
      },
    });
    
    mount(Parent);
    
    expect(childTheme.theme.value).toBe('light');
  });

  it('toggles theme correctly', async () => {
    let provider: any;
    
    const App = defineComponent({
      setup() {
        provider = useThemeProvider();
        return () => h('div');
      },
    });
    
    mount(App);
    
    expect(provider.theme.value).toBe('light');
    provider.toggle();
    await nextTick();
    expect(provider.theme.value).toBe('dark');
  });
});

describe('useTheme', () => {
  it('throws when used outside provider', () => {
    const Consumer = defineComponent({
      setup() {
        useTheme(); // Should throw
        return () => h('div');
      },
    });
    
    expect(() => mount(Consumer)).toThrow('useTheme must be used within a ThemeProvider');
  });
});

Testing Async Composables

Vue 3 composables often involve async operations. Test pending, success, and error states:

// composables/useApi.ts
import { ref } from 'vue';

export function useApi<T>(fetcher: () => Promise<T>) {
  const data = ref<T | null>(null);
  const loading = ref(false);
  const error = ref<Error | null>(null);
  
  const execute = async () => {
    loading.value = true;
    error.value = null;
    
    try {
      data.value = await fetcher();
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e));
    } finally {
      loading.value = false;
    }
  };
  
  return { data, loading, error, execute };
}
// tests/composables/useApi.test.ts
import { describe, it, expect, vi } from 'vitest';
import { useApi } from '~/composables/useApi';

describe('useApi', () => {
  it('starts in idle state', () => {
    const { data, loading, error } = useApi(() => Promise.resolve('test'));
    
    expect(data.value).toBeNull();
    expect(loading.value).toBe(false);
    expect(error.value).toBeNull();
  });

  it('sets loading state while fetching', async () => {
    let resolvePromise!: (value: string) => void;
    const promise = new Promise<string>((res) => { resolvePromise = res; });
    
    const { loading, execute } = useApi(() => promise);
    
    const executePromise = execute();
    await nextTick();
    
    expect(loading.value).toBe(true);
    
    resolvePromise('data');
    await executePromise;
    
    expect(loading.value).toBe(false);
  });

  it('sets data on success', async () => {
    const { data, execute } = useApi(() => Promise.resolve({ id: 1, name: 'Test' }));
    
    await execute();
    
    expect(data.value).toEqual({ id: 1, name: 'Test' });
  });

  it('sets error on failure', async () => {
    const { error, execute } = useApi(() => Promise.reject(new Error('Network error')));
    
    await execute();
    
    expect(error.value?.message).toBe('Network error');
  });

  it('clears previous error on retry', async () => {
    let shouldFail = true;
    const { error, execute } = useApi(() =>
      shouldFail ? Promise.reject(new Error('Failed')) : Promise.resolve('ok')
    );
    
    await execute();
    expect(error.value).not.toBeNull();
    
    shouldFail = false;
    await execute();
    expect(error.value).toBeNull();
  });
});

Testing Reactive Props in Components

Test that components react correctly to prop changes:

// components/UserCard.vue
<script setup lang="ts">
const props = defineProps<{
  userId: number;
}>();

const { data: user, loading } = useApi(() => fetch(`/api/users/${props.userId}`).then(r => r.json()));

watchEffect(() => {
  if (props.userId) {
    execute();
  }
});
</script>
// tests/components/UserCard.test.ts
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';

global.fetch = vi.fn();

describe('UserCard', () => {
  it('fetches user when userId prop changes', async () => {
    const fetchMock = vi.mocked(fetch);
    fetchMock.mockResolvedValue({
      json: () => Promise.resolve({ id: 1, name: 'Alice' }),
    } as Response);
    
    const wrapper = mount(UserCard, { props: { userId: 1 } });
    await flushPromises();
    
    expect(fetchMock).toHaveBeenCalledWith('/api/users/1');
    
    // Change the prop
    await wrapper.setProps({ userId: 2 });
    await flushPromises();
    
    expect(fetchMock).toHaveBeenCalledWith('/api/users/2');
  });
});

Common Gotchas

Forgetting await nextTick(): Reactive updates are async in Vue 3. After changing a reactive value, always await nextTick() before asserting on the DOM or computed values.

Not cleaning up effects: Composables that register event listeners or intervals need cleanup. Test that onUnmounted cleanup runs correctly — memory leaks show up in long-running test suites.

Testing implementation details: Don't assert on the internal reactive state of a component — only on what the template renders. Testing implementation details makes refactoring hard.

Mocking too aggressively: If you mock fetch globally and forget to reset it between tests, state leaks between test cases. Use beforeEach/afterEach to reset mocks.

Connecting to HelpMeTest

Unit tests catch logic errors, but Vue 3 apps fail in the browser too — composables work differently when network requests are slow, reactive chains break on rapid prop changes, or hydration mismatches cause silent errors. HelpMeTest monitors your live Vue 3 application 24/7 with automated browser tests, catching issues that don't surface in Vitest.

Summary

Vue 3 Composition API testing patterns:

  • Test composables directly — no component mounting required for pure logic
  • Use withSetup for composables that need lifecycle hooks
  • Test provide/inject pairs — both the provider and consumer
  • Cover all async states — idle, loading, success, and error
  • Use await nextTick() after reactive changes before asserting

The Composition API makes Vue 3 code significantly more testable than the Options API. Logic extracted into composables can be tested with the speed and clarity of pure function tests, without the overhead of mounting a full component.

Read more