Unit testing components
@data-client/vue/test mounts components with DataClientPlugin,
a <Suspense> boundary and Fixtures, so tests can check what a component
renders without a network fetch cycle. For composables on their own, see
Unit testing composables.
Setup
The test utilities are built on Vue Test Utils, so install it alongside your test runner. Jest needs the jsdom environment.
- NPM
- Yarn
- pnpm
- esm.sh
yarn add --dev @vue/test-utils jest-environment-jsdom
npm install --saveDev @vue/test-utils jest-environment-jsdom
pnpm add -D @vue/test-utils jest-environment-jsdom
<script type="module">
import * from 'https://esm.sh/@vue/test-utils?dev';
import * from 'https://esm.sh/jest-environment-jsdom?dev';
</script>
mountDataClient()
initialFixtures
fill the store before the first render, so a component that awaits
useSuspense() renders its data right away.
import { mountDataClient } from '@data-client/vue/test';
import { useSuspense } from '@data-client/vue';
import { defineComponent, h } from 'vue';
import { ArticleResource } from './resources';
const ArticleDetail = defineComponent({
props: { id: { type: Number, required: true } },
async setup(props) {
// a getter so the fetch follows props.id
const article = await useSuspense(ArticleResource.get, () => ({
id: props.id,
}));
return () => h('h3', article.value.title);
},
});
it('renders the article', async () => {
const { wrapper, cleanup } = mountDataClient(ArticleDetail, {
props: { id: 5 },
initialFixtures: [
{
endpoint: ArticleResource.get,
args: [{ id: 5 }],
response: { id: 5, title: 'hi ho', content: 'whatever' },
},
],
});
await flushUntil(() => wrapper.find('h3').exists());
expect(wrapper.find('h3').text()).toBe('hi ho');
cleanup();
});
Always call cleanup() at the end of a test; it unmounts the app and its managers.
Waiting for renders
Async setup() and Suspense resolve over several microtasks and ticks, so wait for what the
test expects rather than for a fixed number of ticks:
import { nextTick } from 'vue';
export async function flushUntil(predicate: () => boolean, tries = 100) {
for (let i = 0; i < tries; i++) {
if (predicate()) return;
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
}
throw new Error('flushUntil: condition never became true');
}
Loading state
While a component is suspended, mountDataClient() renders an element with
data-testid="suspense-fallback". Use resolverFixtures to answer fetches the store
doesn't have yet:
it('shows the fallback until the fetch resolves', async () => {
const { wrapper, cleanup } = mountDataClient(ArticleDetail, {
props: { id: 5 },
resolverFixtures: [
{
endpoint: ArticleResource.get,
response: ({ id }) => ({ id, title: `Article ${id}`, content: '' }),
},
],
});
expect(wrapper.find('[data-testid="suspense-fallback"]').exists()).toBe(true);
await flushUntil(() => wrapper.find('h3').exists());
expect(wrapper.find('h3').text()).toBe('Article 5');
cleanup();
});
An interceptor's response receives the endpoint's arguments, so one fixture can answer every
id. Add error: true to a fixture to make it reject; see Fixtures for every
fixture shape.
Changing props
Pass a reactive() object as props, then set its members to change the component's props:
import { reactive } from 'vue';
it('fetches the new article when id changes', async () => {
const props = reactive({ id: 1 });
const { wrapper, cleanup } = mountDataClient(ArticleDetail, {
props,
resolverFixtures: [
{
endpoint: ArticleResource.get,
response: ({ id }) => ({ id, title: `Article ${id}`, content: '' }),
},
],
});
await flushUntil(() => wrapper.text() === 'Article 1');
props.id = 2;
await flushUntil(() => wrapper.text() === 'Article 2');
cleanup();
});
An async setup() runs once per component instance, so pass prop-derived arguments as a getter
(() => ({ id: props.id })) or computed(). A plain { id: props.id } is read once and won't
follow prop changes.
Mutations
The returned controller is the app's Controller. Run mutations or
setResponse() with it, then wait for the component to update:
it('re-renders when the store changes', async () => {
const { wrapper, controller, cleanup } = mountDataClient(ArticleDetail, {
props: { id: 5 },
initialFixtures: [
{
endpoint: ArticleResource.get,
args: [{ id: 5 }],
response: { id: 5, title: 'hi ho', content: 'whatever' },
},
],
});
await flushUntil(() => wrapper.find('h3').exists());
controller.setResponse(
ArticleResource.get,
{ id: 5 },
{ id: 5, title: 'updated', content: 'whatever' },
);
await flushUntil(() => wrapper.find('h3').text() === 'updated');
cleanup();
});
API
mountDataClient(component, options?)
Returns
| Member | Description |
|---|---|
wrapper | Vue Test Utils VueWrapper of the mounted tree |
controller | The app's Controller |
app | The Vue app instance |
cleanup() | Unmounts and stops the managers |
allSettled() | Resolves once every in-flight fetch settles |
Options
mountDataClient() and renderDataCompose() take the same options.
interface RenderDataClientOptions<P = any> {
/** Props for the component; pass a reactive() object to change them */
props?: Reactive<P>;
/** Responses in the store before the first render */
initialFixtures?: readonly Fixture[];
/** Answer fetches made during the test */
resolverFixtures?: readonly (Fixture | Interceptor)[];
/** Initial `this` for interceptors */
getInitialInterceptorData?: () => any;
/** Replace the default NetworkManager and SubscriptionManager */
managers?: Manager[];
/** Replace the state built from initialFixtures */
initialState?: State<unknown>;
gcPolicy?: GCInterface;
/** Component rendered around the tested component (receives the same props) */
wrapper?: Component;
}
mockInitialState() builds initialState from fixtures, for
mounting with your own DataClientPlugin setup.