microsoft / playwright

Playwright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API.
https://playwright.dev
Apache License 2.0
66.83k stars 3.66k forks source link

[Feature] Component testing Vue plugins #15472

Closed sand4rt closed 2 years ago

sand4rt commented 2 years ago

Is there a way to run component tests for Vue Router? For example when sombody wants to test if a link is rendered correctly (see test below) or to test if the user can click on a link and is able to navigate to the correct page?

Related to issue: Plugin Mounting Options

ComponentWorksNot.test.ts:

import { test, expect } from '@playwright/experimental-ct-vue';
import ComponentWorksNot from './ComponentWorksNot.vue';

test('renders a link', async ({ mount }) => {
    const component = await mount(ComponentWorksNot, {
        props: {
            test: 'test'
        }
    });

    await expect(component).toHaveAttribute('href', '/'); // fails because RouterLink is not resolved
});

ComponentWorksNot.vue:

<script lang="ts" setup>
import { RouteName } from '../router';

const props = defineProps<{ test: string }>()
</script>

<template>
 <RouterLink :to="{ name: RouteName.TEST }">{{ props.test }}</RouterLink>
</template>

Full repo can be viewed here

aslushnikov commented 2 years ago

cc'ing @pavelfeldman

pavelfeldman commented 2 years ago

Thanks for the repro! What approach were you using with Vue Test Utils (if you did use them)? Did you pass custom routers as plugins? As per your recommendation, I'd like to keep our API aligned to the test utils, so interested in how it is achieved there.

sand4rt commented 2 years ago

Yeah, i was an early user of Vue Test Utils. Moved to Testing Library and later to Cypress component testing, each of which has its quirks..

Correct its done by passing Vue Router as a plugin (Vue router, Vuetify, Vue Query and many other common libraries used with Vue are often a Vue plugin).

The router is usually defined in router/index.ts and registered in main.ts.

When testing using VTL it can be registered on a test by test basis like (see docs):

mount(Component, { global: { plugins: [router] }});

Or globally for all tests (see docs):

import { config } from '@vue/test-utils';
config.plugins.VueWrapper.install(MyPlugin, { someOption: true })

Globally installing plugins is not supported by Cypress and Testing Library i believe. And i think this is not that useful because you can create a function wrapping mount like:

function mountWithRouter(Component, options) {
  return mount(Component, { global: { plugins: [router] }});
}

With playwright this will be a uglier because the mount function is retrieved as a function prop like:

test('some test', ({ mount }) = > {} 

So we have to do something like this?:

function mountWithRouter(mount, Component, options) {
  return mount(Component, { global: { plugins: [router] }});
}

Its a bit messy but this is how Testing Library (which uses VTU) does it: https://github.com/testing-library/vue-testing-library/blob/main/src/__tests__/vue-router.js.

Let me know if you need more info!

pavelfeldman commented 2 years ago

I had a chance to look at the apis, but it wasn't clear how they are typically used (per-test vs global). Your comment suggests that there is no strong pattern, so we should account for both. In terms of usage patterns, we can apply test.use() pattern, which makes it pretty flexible. But given our out of process nature, it would rather be something like:

// playwright/index.ts
import { registerPlugin } from '@playwright/experimental-ct-vue'
import router from '../router'

// Instantiate, configure and name configured plugins
registerPlugin('myRouter', router)
// src/Component.test.ts

// In the whole test file scope, or in the test.describe scope,
// specify the plugins you want to use for enclosed tests:
test.use({
  vuePlugins: ['myRouter']
})

test('basic', async ({mount}) => {
  // mount as usually
  await mount(Component)
});

we can then project this concept to the rest of the VTU global options. Would that work for you?

sand4rt commented 2 years ago

Looks promising! Which approach to take (global or per-test) depends on what you are optimising for, ease of use vs performance.

  1. If all plugins are registered globally some tests will register plugins which are not used, which is slower but less labor.
  2. If each plugin is specified manually for each test only when needed, then you have to do more labor but it's faster.

I personally always take the first approach.

For the global plugins: Is it possible to keep the API closer to the Vue API that people are already familiar with? Maybe this could open the possibility to add app.directive(...) and others as well? (let me know if i need to write a use case for directives first). Like:

import { app } from '@playwright/experimental-ct-vue'
import router from '../router'

// Instantiate, configure and name configured plugins
app.use('myRouter', router)

Or maybe you could do something clever by reusing the plugins (and possible other injections) which are already defined in main.ts?

Will it be possible to override the globally defined plugin options on a test by test basis?

test.use({
  vuePlugins: ['myPlugin', {
    // optional plugin options
  }]
})

test('basic', async ({mount}) => {
  // mount as usually
  await mount(Component)
});

Is it an idea to move the test.use({ vuePlugins: ['myRouter'] }) closer to the Vue Test Utils global mount options?

something like:

test.use({
  vue: { 
    plugins: [
       ["myPlugin", "option", "another option"],
       "myRouter"
    ]
  },
});

Where the vue property is close to typeof GlobalMountOptions.

I still find it a quirky that you have to specify this in test.use instead of mount but i guess this is because of the out of process nature of playwright. I'm wondering if there is another way.

pavelfeldman commented 2 years ago

For a global configuration, we could give you the app in your playwright/index.ts, so that you could configure your component container once for all the tests. We could also give you a number for such environments (think several index.ts files with meaningful names) that would allow for the flexibility of configuring sets of tests. You would pick a container (preset) in your mount call. Would that work better for you?

sand4rt commented 2 years ago

Exposing the app in ‘playwright/index.ts’ sounds like a good idea to me! However i think it’s stil useful to that users are able to override the globally defined plugin options per test. Creating separate files for each override would be a lot of work.

Maybe we can start with the global plugins? And see later how to override the plugin options per test?

pavelfeldman commented 2 years ago

In the attached patch:

// index.ts
// We can sort out the typing nicely if this general approach works.

(window as any).configureApp = (app: App, config: any) => {
  // Configure your app the way you like based on the object you pass into `mount`.
  // Note that `config` object needs to be JSON-serializable.
  if (config.route === 'A')
    app.use(routeA);
}
// Button.test.ts
test('props should work', async ({ mount }) => {
  const component = await mount(Button, {
    props: {
      title: 'Submit'
    },
    config: { // Pass the config object if your config varies test by test.
      route: 'A'
    }
  })
})
sand4rt commented 2 years ago

Thanks for your quick reply! Unfortunately i don't think we can assume that the plugin options/configuration are JSON serializable. I said earlier that the plugin options should be overwritable, but I meant the whole plugin instead.. In the case of Vue Router this doesn't make much sense.

See for example Testing Library's VueX tests. They are registering the VueX store in a specific state per test.

NOTE: I see that VueX will be deprecated in the future and i'm not sure yet how this works with it's new replacement called Pinia. But i think a similar pattern is used there (see initial state).

Is there no other way than sending JSON from node to the browser?

pavelfeldman commented 2 years ago

Is there no other way than sending JSON from node to the browser?

Pretty much, in the example you shared above, createStore would need to be in the browser, i.e. in the index.ts. The only way to exchange live objects is to keep them in the same heap, which essentially means that the tests need to run inside the browser. That would significantly limit their capabilities (no full Node powers in tests), we would need different reliability model, isolation will suffer and eventually lead to worse performance when isolated. But it still is an option, if we learn that ability to provide live mocks that are defined in tests into the components is more important to the users than other considerations, we can turn it around.

So far, Vue seems to be more sensitive to this limitation than the others, probably due to the VTU being different from RTL in spirit. But that might just be the way that the feedback makes its way to us, so we might soon see a wave of similar requests in other frameworks.

sand4rt commented 2 years ago

Thanks for explaining! It seems to me that Vue is sensitive to this limitation rather than VTU. Would also be good to know what other people think about this issue.

I still believe that global plugin registration would be a huge benefit and will make playwright Vue usable in most cases. Is this something that can already be added? Maybe we can progress later on the "per test plugin registration/overrides"?

Edit:

@pavelfeldman Playwright React community has similar problems:

#15325 How can I use component testing with Redux components

#14750 Need a complex example to test react component which requires internationalization and redux store

#14707 Add feature to allow mocking useRouter in Next.js for component testing

#14345 Allow custom mount fixture for component testing

lmiller1990 commented 2 years ago

Testing components that rely on a specific route has been a pain point for both Vue and React, iirc, for a long time. Basically, router libraries are designed assuming many things about your entire app, but components tend to only care about some specific property of the route (params, path etc). The fact these are from the router is generally not even relevant to the test, in my experience - it's usually more like "if params === X, then do Y, otherwise Z".

The way I've generally approached this in general is simply to decouple my components from the router, so I can unit test the components. If the components are too big and complex to decouple, or are tightly coupled to the router, I usually just E2E test them.

Ultimately, I've found granular, unit-like tests for components involving a router to not be very resilient to refactors and difficult to maintain, and I tend to avoid writing them. I came to this conculsion after many years of working on Test Utils and Testing Library, where people often want to testing things around routing. Ultimately, a router is global variable, and the solution to unit tests relating to global variables is pass them as an argument (or a prop, in this case).


This philosophy does not relate to the original issue in general of "testing Vue plugins", which definitely should be supported, but is specific to router tests. The best place to start, imo, would be an API to support testing components using Pinia (like Vuex, a reactive store: https://pinia.vuejs.org/), which is probably the most common plugin after Vuex and Vue Router.