Agent Skills: Vue Testing Patterns (@data-client/vue)

Test @data-client/vue composables and components - renderDataCompose, mountDataClient, fixtures, jest, nock, Vue 3 reactive props, useSuspense testing

UncategorizedID: reactive/data-client/data-client-vue-testing

Repository

reactiveLicense: Apache-2.0
2,02999

Install this agent skill to your local

pnpm dlx add-skill https://github.com/reactive/data-client/tree/HEAD/.cursor/skills/data-client-vue-testing

Skill Files

Browse the full folder contents for data-client-vue-testing.

Download Skill

Loading file tree…

.cursor/skills/data-client-vue-testing/SKILL.md

Skill Metadata

Name
data-client-vue-testing
Description
Test @data-client/vue composables and components - renderDataCompose, mountDataClient, fixtures, jest, nock HTTP mocking, polling/subscription tests with fake timers, useSuspense, useLive, useSubscription, Vue 3 reactive props. Use when writing or debugging tests for composables or components built on @data-client/vue.

Vue Testing Patterns (@data-client/vue)

Composable Testing with renderDataCompose()

import { renderDataCompose } from '../test';
import { reactive, computed } from 'vue';

it('useQuery() should return cached data', () => {
  const { result } = renderDataCompose(
    () => useQuery(Article, { id: 5 }),
    {
      initialFixtures: [
        {
          endpoint: ArticleResource.get,
          args: [{ id: 5 }],
          response: { id: 5, title: 'hi ho', content: 'whatever' },
        },
      ],
    },
  );
  expect(result.current?.value).toEqual(Article.fromJS({ id: 5, title: 'hi ho', content: 'whatever' }));
});

Options:

  • initialFixtures - Pre-populate store state (static fixtures)
  • resolverFixtures - Intercept requests with dynamic responses
  • props - Reactive props object (use reactive())
  • managers, initialState, gcPolicy - Custom configuration

Return values:

  • result.current - Composable return value (undefined when suspended, Promise when resolved for useSuspense)
  • controller - Controller instance for manual actions
  • wrapper - Vue Test Utils wrapper
  • cleanup() - Cleanup function (always call in afterEach/after test)
  • allSettled() - Wait for all pending promises
  • waitForNextUpdate() - Wait for composable to resolve from suspended state

Component Testing with mountDataClient()

import { mountDataClient } from '../test';
import { defineComponent, h, reactive } from 'vue';

it('should render article component', async () => {
  const ArticleComp = defineComponent({
    props: { id: Number },
    async setup(props) {
      const article = await useSuspense(ArticleResource.get, { id: props.id });
      return () => h('div', [
        h('h3', article.value.title),
        h('p', article.value.content),
      ]);
    },
  });

  const props = reactive({ id: 5 });
  const { wrapper, cleanup } = mountDataClient(ArticleComp, {
    props,
    initialFixtures: [
      {
        endpoint: ArticleResource.get,
        args: [{ id: 5 }],
        response: { id: 5, title: 'hi ho', content: 'whatever' },
      },
    ],
  });

  await flushUntil(wrapper, () => wrapper.find('h3').exists());
  expect(wrapper.find('h3').text()).toBe('hi ho');
  cleanup();
});

Features:

  • Suspense is automatically integrated (shows fallback while loading)
  • Use data-testid="suspense-fallback" to test loading state
  • Returns same utilities as renderDataCompose() plus wrapper

Async Waiting Patterns

flushUntil helper (for component tests):

async function flushUntil(wrapper: any, predicate: () => boolean, tries = 100) {
  for (let i = 0; i < tries; i++) {
    if (predicate()) return;
    await Promise.resolve();
    await nextTick();
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

// Usage:
await flushUntil(wrapper, () => wrapper.find('h3').exists());
await flushUntil(wrapper, () => wrapper.find('h3').text() === 'Expected Title');

waitForNextUpdate (for composable tests):

const { result, waitForNextUpdate } = renderDataCompose(() => useSuspense(...));

// Initially suspended
expect(result.current).toBeUndefined();

// Wait for resolution
await waitForNextUpdate();
expect(result.current).toBeInstanceOf(Promise);

// Await the promise to get the reactive ComputedRef
const dataRef = await result.current;
expect(dataRef.value.title).toBe('hi ho');

Reactive Props Testing

Pattern 1: Testing prop changes:

const props = reactive({ id: 1 });
const { result } = renderDataCompose(
  () => useQuery(Article, computed(() => ({ id: props.id }))),
  {
    initialFixtures: [
      { endpoint: ArticleResource.get, args: [{ id: 1 }], response: { id: 1, title: 'First' } },
      { endpoint: ArticleResource.get, args: [{ id: 2 }], response: { id: 2, title: 'Second' } },
    ],
  },
);

expect(result.current?.value?.title).toBe('First');

// Change props - result automatically updates
props.id = 2;
expect(result.current?.value?.title).toBe('Second');

Pattern 2: Conditional arguments (null handling):

const props = reactive({ id: 1 as number | null });
const { result } = renderDataCompose(
  (props: { id: number | null }) => 
    useSuspense(ArticleResource.get, computed(() => props.id !== null ? { id: props.id } : null)),
  { props },
);

await waitForNextUpdate();
const articleRef = await result.current;
expect(articleRef.value).toBeDefined();

// Set to null - becomes undefined
props.id = null;
await nextTick();
expect(articleRef.value).toBeUndefined();

Fixtures and Interceptors

Static Fixture:

{
  endpoint: ArticleResource.get,
  args: [{ id: 5 }],
  response: { id: 5, title: 'hi ho', content: 'whatever' },
}

Dynamic Interceptor:

resolverFixtures: [
  {
    endpoint: ArticleResource.get,
    response: ({ id }) => ({ id, title: `Article ${id}`, content: 'dynamic' }),
  },
]

Error Fixture:

{
  endpoint: ArticleResource.get,
  args: [{ id: 5 }],
  response: new Error('Not found'),
  error: true,
}

Testing Mutations

it('should update collection when pushed', async () => {
  const { result, controller, waitForNextUpdate } = renderDataCompose(
    () => useQuery(ArticleResource.getList.schema, {}),
    {
      initialFixtures: [
        { endpoint: ArticleResource.getList, args: [], response: [{ id: 1, title: 'First' }] },
      ],
      resolverFixtures: [
        { endpoint: ArticleResource.getList.push, response: (body) => body },
      ],
    },
  );

  expect(result.current?.value?.length).toBe(1);

  await controller.fetch(ArticleResource.getList.push, {
    id: 2,
    title: 'Second',
    content: 'new',
  });
  await waitForNextUpdate();

  expect(result.current?.value?.length).toBe(2);
});

Testing with Controller

setResponse() for instant updates:

const { controller } = renderDataCompose(...);
await waitForNextUpdate();
const dataRef = await result.current;

expect(dataRef.value.title).toBe('Original');

controller.setResponse(
  ArticleResource.get,
  { id: 5 },
  { id: 5, title: 'Updated', content: 'new content' }
);

await nextTick();
expect(dataRef.value.title).toBe('Updated'); // Reactive!

fetch() for mutations:

await controller.fetch(
  ArticleResource.update,
  { id: 5 },
  { title: 'Mutated', content: 'mutated content' }
);
await nextTick();

Testing with nock (HTTP Mocking)

Use nock when a test must exercise the real fetch path — verifying URL construction, headers, request bodies, retries, or anything in your RestEndpoint/Resource networking layer. For pure store/state behavior, prefer initialFixtures/resolverFixtures (lighter and faster).

Minimal shape:

import nock from 'nock';

beforeAll(() => {
  nock(/.*/)
    .persist()
    .defaultReplyHeaders({ 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' })
    .options(/.*/).reply(200)            // CORS preflight (required in JSDOM)
    .get('/article/5').reply(200, { id: 5, title: 'hi ho' });
});

afterAll(() => nock.cleanAll());

For dynamic server state, mutating-closure replies, request spying with jest.fn(), error responses, and mixing nock with fixtures, see references/nock-http-mocking.md.

Testing Polling and Subscriptions

For composables with pollFrequency, useLive, or useSubscription, use fake timers so polls fire deterministically. Core flow:

  1. jest.useFakeTimers() before mount/render (so the interval is created under fake timers).
  2. Render, then jest.advanceTimersByTime(frequency) to drive the initial fetch.
  3. Mutate the response (e.g. responseMock.mockReturnValue(...)), advance time again, await allSettled() and await nextTick().
  4. Restore real timers in afterEach: jest.useRealTimers().

Quick example:

jest.useFakeTimers();
const responseMock = jest.fn(() => payload);

const { result, allSettled, waitForNextUpdate, cleanup } = await renderDataCompose(
  () => useSuspense(PollingArticleResource.get, { id: payload.id }),
  { resolverFixtures: [{ endpoint: PollingArticleResource.get, response: responseMock }] },
);

jest.advanceTimersByTime(frequency);
await allSettled();
await waitForNextUpdate();
const articleRef = await result;

responseMock.mockReturnValue({ ...payload, title: 'updated' });
jest.advanceTimersByTime(frequency);
await allSettled();
await nextTick();

expect(articleRef!.value.title).toBe('updated');
jest.useRealTimers();
cleanup();

For unsubscribe patterns, component-level polling tests, fake-timer-safe flushUntil, polling via nock, and common pitfalls, see references/polling-subscriptions.md.

Vue Suspense Behavior

useSuspense() returns Promise → ComputedRef:

const { result, waitForNextUpdate } = renderDataCompose(() =>
  useSuspense(ArticleResource.get, { id: 5 })
);

// Initially suspended (undefined)
expect(result.current).toBeUndefined();

// Wait for resolution
await waitForNextUpdate();

// Now it's a Promise
expect(result.current).toBeInstanceOf(Promise);

// Await once to get reactive ComputedRef
const articleRef = await result.current;

// The ref is reactive - updates automatically
expect(articleRef.value.title).toBe('hi ho');

// After controller.setResponse() or controller.fetch():
await nextTick();
expect(articleRef.value.title).toBe('Updated'); // Auto-updated!

useQuery() returns ComputedRef directly:

const { result } = renderDataCompose(() => useQuery(Article, { id: 5 }));

// Synchronously available (or undefined if not in store)
expect(result.current?.value).toBeDefined();
expect(result.current?.value?.title).toBe('hi ho');

// Also reactive - updates automatically

Best Practices

  • Always call cleanup() - Prevents memory leaks and test pollution
  • Use renderDataCompose() for composables (useQuery, useSuspense, useLive)
  • Use mountDataClient() for components
  • Use reactive() for props - Enables testing prop changes
  • Use computed() when passing reactive props to composables - Ensures proper reactivity tracking
  • Use flushUntil() in component tests - More reliable than fixed delays
  • Use waitForNextUpdate() in composable tests - Wait for suspension to resolve
  • Remember nextTick() - After mutations/setResponse to allow Vue reactivity to propagate
  • Use initialFixtures for initial state - Pre-populate the store
  • Use resolverFixtures for dynamic responses - Intercept requests with functions
  • useSuspense returns Promise → ComputedRef - Await once, then access .value
  • Test both empty and populated states - Verify undefined behavior
  • Test reactive prop changes - Use reactive() and verify updates
  • Don't test with async setup + prop changes - Async setup only runs once; use non-async patterns or useFetch + watchEffect instead

References

For detailed API documentation, see the references directory:

Common Patterns

Empty state test:

const { result } = renderDataCompose(() => useQuery(Article, { id: 5 }), {});
expect(result.current?.value).toBe(undefined);

Changing to non-existent entity:

const props = reactive({ id: 1 });
// ... initial setup ...
expect(result.current?.value?.id).toBe(1);

props.id = 999; // Not in store
expect(result.current?.value).toBe(undefined);

Testing nested collections:

const userTodos = new Collection(new schema.Array(Todo), {
  argsKey: ({ userId }) => ({ userId }),
});

const { result } = renderDataCompose(
  () => useQuery(userTodos, { userId: '1' }),
  { initialFixtures: [/* ... */] },
);

expect(result.current?.value?.length).toBe(2);
expect(result.current?.value?.[0]).toBeInstanceOf(Todo);