vue-testing-library icon indicating copy to clipboard operation
vue-testing-library copied to clipboard

Vue 3: Async component with suspense wrapper

Open sand4rt opened this issue 4 years ago • 10 comments

Struggling to test a async component. I created a suspense wrapper component with defineComponent which i think should work but it doesn't:

it("renders a async component with a suspense wrapper", () => {
  const Component = defineComponent({
    async setup() {
      return () => h("div", "AsyncSetup");
    },
  });

  const { getByText } = render(
    defineComponent({
      render() {
        return h(Suspense, null, {
          default: h(Component),
          fallback: h("div", "fallback"),
        });
      },
    })
  );

  expect(getByText("AsyncSetup")).toBeInTheDocument(); // fails
});

Also created a repository for reproducing the issue.

sand4rt avatar May 14 '21 13:05 sand4rt

Related to: https://github.com/vuejs/vue-test-utils-next/issues/108

sand4rt avatar Jul 23 '21 21:07 sand4rt

Has anyone gotten this to work yet? It seems that there is currently no way to test an asynchronous component with vue testing library.

sand4rt avatar Aug 16 '21 13:08 sand4rt

Hi!

this is most likely an upstream issue in vue-test-utils-next (as seen in the issue you linked a few days ago)

afontcu avatar Aug 18 '21 10:08 afontcu

@afontcu After some debugging i found out that the wrapper on line 27 needs to be resolved first, e.g. with a await flushPromises() like on line 32 before calling the getQueriesForElement(baseElement) on line 51

Here is a test that demonstrates this.

Are you sure this should be fixed in @vue/vue-test-utils?

sand4rt avatar Sep 25 '21 21:09 sand4rt

Hi!

Thank you for taking the time to looking into this! Looks like you're right about the problem, too. Have you tried stubbing Suspense and make it simply render #default? It's far from ideal, but having an async render method is, too.

Thanks!

afontcu avatar Sep 27 '21 17:09 afontcu

Thanks for your reply!

Could you elaborate on what you mean by stubbing Suspense? A component with an async setup() must be wrapped with Suspense right? Otherwise the component will not resolve.

sand4rt avatar Sep 28 '21 15:09 sand4rt

As workaround i'm using a modified version of the render function called renderAsync for now. The code can be viewed here.

After many problems with JSDOM/HappyDOM and issues like this i decided not to use VTL anymore. Playwright component testing has a almost identical API, is fast as well and runs in a real browser..

sand4rt avatar Dec 02 '21 21:12 sand4rt

Any updates on this? We have embraced Suspense and async components very heavily in our project and this is really holding us back in writing tests. I've noticed it's the last part of making this library fully Vue 3 compatible.

Ragura avatar Sep 18 '22 18:09 Ragura

After a lot of debugging and trial and error finally got something that works with the current code base and isn't async. If you do pass an async component to this function then you just have to do some findBy* after the mount call. Alternative is making a separate async function that will flush promises as others have stated, I find one universal function more appealing. I just wrap the below in another async function when I test an async component.

Note below I left in where I setup quasar, pinia, and vue router mocks/stubs. Also the test runner I use is vitest

import { createTestingPinia } from '@pinia/testing';
import getQuasarOptions from '@rtvision/configs/quasar';
import { render } from '@testing-library/vue';
import { config as vueTestUtilsConfig, RouterLinkStub } from '@vue/test-utils';
import { Quasar } from 'quasar';
import { vi } from 'vitest';
import { defineComponent, reactive } from 'vue';


// Note need the wrapping div around suspense otherwise won't load correctly
// due to this line https://github.com/sand4rt/suspense-test/blob/master/tests/unit/renderAsync.js#L36
const SUSPENSE_TEST_TEMPLATE = `
<div id="TestRoot">
  <suspense>
    <async-component v-bind="$attrs" v-on="emitListeners">
      <template v-for="(_, slot) of $slots" :key="slot" #[slot]="scope">
        <slot key="" :name="slot" v-bind="scope" />
      </template>
    </async-component>
    <template #fallback>
      Suspense Fallback
    </template>
  </suspense>
</div>
`;
function getSuspenseWrapper(component) {
    return defineComponent({
        setup(_props, {
            emit
        }) {
            const emitListeners = reactive({});
            if ('emits' in component && Array.isArray(component.emits)) {
                for (const emitName of component.emits) {
                    emitListeners[emitName] = (...args) => {
                        emit(emitName, ...args);
                    };
                }
            }
            return {
                emitListeners
            };
        },
        emits: 'emits' in component && Array.isArray(component.emits) ? component.emits : [],
        components: {
            AsyncComponent: component
        },
        inheritAttrs: false,
        template: SUSPENSE_TEST_TEMPLATE
    });
}
/**
 * @param initialState Used to set initial set of pinia stores
 **/
export function mountComponent(
  component,
  { props, initialState, slots } = {}
) {
  return render(getSuspenseWrapper(component), {
    props,
    slots,
    global: {
      plugins: [
        [Quasar, getQuasarOptions()],
        createTestingPinia({
          createSpy: vi.fn,
          initialState
        })
      ],
      provide: {
        ...vueTestUtilsConfig.global.provide
      },
      components: {
        AsyncComponent: component
      },
      stubs: {
        icon: true,
        RouterLink: RouterLinkStub
      }                                                                                                                                                                                                                                                                                                                                                                                     
    }                                                                                                                                                                                                                                                                                                                                                                                     
  });
} 

Example of usage with async component

// CommonTable.spec.ts
async function mountCommonTable(props, slots = {}) {
  const tableWrapper = mountComponent(CommonTable, { props, slots });
  await tableWrapper.findByRole('table');
  return tableWrapper;
}

kalvenschraut avatar Feb 21 '23 15:02 kalvenschraut

This will probably not cover all the cases, but by copying together code, this seems to work:

helper functions:

const scheduler = typeof setImmediate === 'function' ? setImmediate : setTimeout

export function flushPromises(): Promise<void> {
  return new Promise((resolve) => {
    scheduler(resolve, 0)
  })
}

export function wrapInSuspense(
  component: ReturnType<typeof defineComponent>,
  { props }: { props: object },
): ReturnType<typeof defineComponent> {
  return defineComponent({
    render() {
      return h(
        'div',
        { id: 'root' },
        h(Suspense, null, {
          default() {
            return h(component, props)
          },
          fallback: h('div', 'fallback'),
        }),
      )
    },
  })
}

tests:

render(wrapInSuspense(MyAsyncComponent, { props: { } }))
await flushPromises()
expect(screen.getByText('text in component')).toBeVisible()

dwin0 avatar Apr 06 '23 10:04 dwin0

@dwin0 Works fine for me, thank you ♥️

mutoe avatar Sep 06 '23 08:09 mutoe