testing-library / vue-testing-library

🦎 Simple and complete Vue.js testing utilities that encourage good testing practices.
http://testing-library.com/vue
MIT License
1.07k stars 111 forks source link

Vue 3: Async component with suspense wrapper #230

Open sand4rt opened 3 years ago

sand4rt commented 3 years ago

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 commented 3 years ago

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

sand4rt commented 3 years ago

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

afontcu commented 3 years ago

Hi!

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

sand4rt commented 2 years ago

@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?

afontcu commented 2 years ago

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!

sand4rt commented 2 years ago

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 commented 2 years ago

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..

Ragura commented 1 year ago

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.

kalvenschraut commented 1 year ago

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;
}
dwin0 commented 1 year ago

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()
mutoe commented 11 months ago

@dwin0 Works fine for me, thank you ♥️