Files
stack/packages/mosaic/framework/skills/vue-testing-best-practices/reference/testing-composables-helper-wrapper.md
T
fargo 1a822493ba format: apply repo prettier (3.8.1) to the folded skills tree
963 markdown files reformatted with the repository's pinned prettier so
pnpm format:check covers the folded tree like every other repo file.

The formatter's embedded-language pass also normalized code fences
(TS semicolons, closed HTML tags in examples, lowercased CSS hex colors,
one renumbered list that skipped an index). Alphanumeric token deltas vs
the fold commit were audited file-by-file; all are formatter-equivalent
markup normalizations plus the four sanitized skills.
2026-08-19 14:37:17 -05:00

6.1 KiB

title, impact, impactDescription, type, tags
title impact impactDescription type tags
Test Complex Composables with Host Component Wrapper MEDIUM Composables using lifecycle hooks or provide/inject fail when tested directly without a component context capability
vue3
testing
composables
vitest
lifecycle-hooks
provide-inject

Test Complex Composables with Host Component Wrapper

Impact: MEDIUM - Composables that use Vue lifecycle hooks (onMounted, onUnmounted) or dependency injection (inject) require a component context to function. Testing them directly will cause errors or incorrect behavior.

Simple composables using only reactivity APIs can be tested directly. Complex composables need a helper function that creates a host component context.

Task Checklist

  • Identify if composable uses lifecycle hooks or inject
  • For simple composables (refs, computed only): test directly
  • For complex composables: use withSetup helper pattern
  • Clean up by unmounting the test app after each test
  • Use app.provide() to mock injected dependencies

Simple Composable - Test Directly:

// composables/useCounter.js
import { ref, computed } from 'vue';

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);
  const doubled = computed(() => count.value * 2);
  const increment = () => count.value++;

  return { count, doubled, increment };
}
// useCounter.test.js
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';

// CORRECT: Simple composable can be tested directly
describe('useCounter', () => {
  it('initializes with default value', () => {
    const { count } = useCounter();
    expect(count.value).toBe(0);
  });

  it('increments count', () => {
    const { count, increment } = useCounter();
    increment();
    expect(count.value).toBe(1);
  });

  it('computes doubled value', () => {
    const { count, doubled, increment } = useCounter(5);
    expect(doubled.value).toBe(10);
    increment();
    expect(doubled.value).toBe(12);
  });
});

Complex Composable - Use Host Wrapper:

// composables/useFetch.js
import { ref, onMounted, onUnmounted, inject } from 'vue';

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(true);
  let controller = null;

  // Uses inject - needs component context
  const apiClient = inject('apiClient');

  // Uses lifecycle hooks - needs component context
  onMounted(async () => {
    controller = new AbortController();
    try {
      const response = await apiClient.get(url, { signal: controller.signal });
      data.value = response.data;
    } catch (e) {
      if (e.name !== 'AbortError') error.value = e;
    } finally {
      loading.value = false;
    }
  });

  onUnmounted(() => {
    controller?.abort();
  });

  return { data, error, loading };
}
// test-utils.js
import { createApp } from 'vue';

/**
 * Helper to test composables that need component context
 */
export function withSetup(composable) {
  let result;

  const app = createApp({
    setup() {
      result = composable();
      // Return a render function to suppress warnings
      return () => {};
    },
  });

  app.mount(document.createElement('div'));

  return [result, app];
}
// useFetch.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { flushPromises } from '@vue/test-utils';
import { withSetup } from './test-utils';
import { useFetch } from './useFetch';

describe('useFetch', () => {
  let app;
  const mockApiClient = {
    get: vi.fn(),
  };

  afterEach(() => {
    // IMPORTANT: Clean up to trigger onUnmounted
    app?.unmount();
  });

  it('fetches data on mount', async () => {
    mockApiClient.get.mockResolvedValue({ data: { id: 1, name: 'Test' } });

    const [result, testApp] = withSetup(() => useFetch('/api/test'));
    app = testApp;

    // Provide mocked dependency
    app.provide('apiClient', mockApiClient);

    // Wait for async operations
    await flushPromises();

    expect(result.data.value).toEqual({ id: 1, name: 'Test' });
    expect(result.loading.value).toBe(false);
    expect(result.error.value).toBeNull();
  });

  it('handles errors', async () => {
    const testError = new Error('Network error');
    mockApiClient.get.mockRejectedValue(testError);

    const [result, testApp] = withSetup(() => useFetch('/api/test'));
    app = testApp;
    app.provide('apiClient', mockApiClient);

    await flushPromises();

    expect(result.error.value).toBe(testError);
    expect(result.data.value).toBeNull();
  });
});

Enhanced withSetup Helper with Provide Support

// test-utils.js
export function withSetup(composable, options = {}) {
  let result;

  const app = createApp({
    setup() {
      result = composable();
      return () => {};
    },
  });

  // Apply global provides before mounting
  if (options.provide) {
    Object.entries(options.provide).forEach(([key, value]) => {
      app.provide(key, value);
    });
  }

  app.mount(document.createElement('div'));

  return [result, app];
}

// Usage
const [result, app] = withSetup(() => useMyComposable(), {
  provide: {
    apiClient: mockApiClient,
    currentUser: { id: 1, name: 'Test User' },
  },
});

Testing with @vue/test-utils mount

import { mount } from '@vue/test-utils';
import { defineComponent } from 'vue';
import { useFetch } from './useFetch';

test('useFetch in component context', async () => {
  const TestComponent = defineComponent({
    setup() {
      const { data, loading } = useFetch('/api/users');
      return { data, loading };
    },
    template: '<div>{{ loading ? "Loading..." : data }}</div>',
  });

  const wrapper = mount(TestComponent, {
    global: {
      provide: {
        apiClient: mockApiClient,
      },
    },
  });

  await flushPromises();
  expect(wrapper.text()).toContain('Test data');
});

Reference