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-vuevitest.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
withSetupfor composables that need lifecycle hooks - Test
provide/injectpairs — 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.