quasarframework / quasar-testing

Testing Harness App Extensions for the Quasar Framework 2.0+
https://testing.quasar.dev
MIT License
179 stars 66 forks source link

Improvements to mountQuasar #122

Closed tschut closed 4 years ago

tschut commented 4 years ago

After starting with jest + Quasar I have a couple of proposals which I think will greatly improve the usability of the supplied mountQuasar utility.

Support using either mount or shallowMount

This could easily be implemented by adding an option to the options object (which is already there) and switching on that. The default can stay shallowMount so it's backwards compatible.

Add stubs to the options array and pass them to mount

Together with the possibility to switch the used mount function this gives a lot of flexibility, for instance making it possible to mount fully and stubbing out selectively.

Add an option to set the resolution

This allow testing responsiveness, for instance components that should render different content based on $q.screen... I have this working in my project like this:

const resizeWindow = ({ x, y }) => {
  global.innerWidth = x
  global.innerHeight = y
  global.dispatchEvent(new Event('resize'))
}

This has to run before createLocalVue() is called (I think). This too can be part of the options object.

Make it possible to mount a component without creating a new localVue

This makes it easy to do multiple mounts in on test file, which is useful when you want to test props that have an effect in created() (that's one usecase, I'm sure there are more). In my current project I solved this by refactoring out the code that calls createLocalVue and having the test call that separately, but I'm sure there's more elegant solutions.

Evertvdw commented 4 years ago

I also had similar issues with the provided mountQuasar function. What we did is add the Quasar logic in jest.setup.ts and call .use on Vue instead of localVue because each localVue is just a copy what is on Vue at that moment :)

That way when you create a new localVue in mountQuasar all quasar components are setup.

This is the code we have in jest.setup.ts at the moment:

import * as All from "quasar";
import { VueConstructor } from "vue";
import Vue from "vue";

const { Quasar, Notify, Cookies, Dialog } = All;

function isComponent(value: any): value is VueConstructor {
    return value && value.component && value.component.name != null;
}

export const components = Object.keys(All).reduce<{ [index: string]: VueConstructor }>((object, key) => {
    const val = (All as any)[key];
    if (isComponent(val)) {
        object[key] = val;
    }
    return object;
}, {});

Vue.use(Quasar, { components, plugins: { Notify, Cookies, Dialog } });

Notify.create = () => {
    return jest.fn();
};
IlCallo commented 4 years ago

Thanks for putting this all in one place @tschut, working on this right now :+1:

IlCallo commented 4 years ago

@tschut @Evertvdw

Work in progress, if you wanna check.

Things I integrated from your post:

Some things not yet working:

import VueCompositionApi from '@vue/composition-api';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import { Cookies, Quasar, QuasarPluginOptions } from 'quasar';
import Vue, { ComponentOptions } from 'vue';

/**
 * Utility type to declare an extended Vue constructor
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type VueClass<V extends Vue> = (new (...args: any[]) => V) & typeof Vue;

/**
 * Extract props from provided Vue instance
 */
type ExtractProps<C> = C extends ComponentOptions<
  Vue,
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  any,
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  any,
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  any,
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  any,
  infer PropsTypes
>
  ? PropsTypes // eslint-disable-next-line @typescript-eslint/no-explicit-any
  : Record<string, any>;

/**
 * `mountQuasar` options interface
 */
interface QuasarMountOptions<C> {
  // @vue/test-utils "mount" and "shallowMount" options signature are the same on v1.0.3
  // If they'll diverge in future, it should be possible to model a discriminated union over "shallow" property
  // TODO: remove Quasar-managed mount options from this type
  mount?: {
    shallow?: boolean;
  } & Parameters<typeof mount>[1];
  quasar?: Partial<QuasarPluginOptions>;
  ssr?: boolean;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  cookies?: Record<string, any>;
  propsData?: ExtractProps<C>;
}

const mockSsrContext = () => {
  return {
    req: {
      headers: {}
    },
    res: {
      setHeader: () => undefined
    }
  };
};

// https://eddyerburgh.me/mock-vuex-in-vue-unit-tests
// We cannot infer component type from `shallowMount` using `Parameters<typeof shallowMount>`
//  because it has overloads but the last signature isn't the most general one, while `Parameters<...>`
//  will automatically resolve to the last signature thinking it's the most generic one.
// See https://github.com/Microsoft/TypeScript/issues/24275#issuecomment-390701982
// TODO: doesn't support components defined via `Vue.extend`
export const mountQuasar = <V extends Vue, C extends ComponentOptions<V>>(
  component: C,
  options: QuasarMountOptions<C> = {}
) => {
  const localVue = createLocalVue();

  // TODO: Vuex and VueRouter not available on localVue instance, must be reported
  // See https://forum.quasar-framework.org/topic/3461/quasar-testing-vue-warn-error-in-render-typeerror-cannot-read-property-lang-of-undefined/7
  // localVue.use(Vuex);
  // localVue.use(VueRouter);
  localVue.use(Quasar, options.quasar);
  localVue.use(VueCompositionApi);
  // const store = new Vuex.Store({});
  // const router = new VueRouter();

  if (options) {
    const ssrContext = options.ssr ? mockSsrContext() : null;

    if (options.cookies) {
      const cookieStorage = ssrContext ? Cookies.parseSSR(ssrContext) : Cookies;
      const cookies = options.cookies;
      Object.entries(cookies).forEach(([key, value]) => {
        cookieStorage.set(key, value);
      });
    }
  }

  // mock vue-i18n
  const $t = jest.fn();
  const $tc = jest.fn();
  const $n = jest.fn();
  const $d = jest.fn();

  // If 'mount.shallow' exists and is false, we use full 'mount'
  // Otherwise the fallback is 'shallowMount'
  const mountFn = options.mount?.shallow === false ? mount : shallowMount;

  // mount functions usually require a
  // See https://github.com/vuejs/vue-jest/issues/188
  return mountFn(component, {
    propsData: options.propsData,
    localVue,
    // store,
    // router,
    mocks: { $t, $tc, $n, $d },
    // Injections for Components with a QPage root Element
    provide: {
      pageContainer: true,
      layout: {
        header: {},
        right: {},
        footer: {},
        left: {}
      }
    }
  });
};

export function mountFactory<V extends Vue, C extends ComponentOptions<V>>(
  component: C,
  options: QuasarMountOptions<C> = {}
) {
  // You can model `propsData` object to be mandatory when there is at least one required prop
  // See: https://github.com/microsoft/TypeScript/issues/12400#issuecomment-619368188
  return (propsData?: typeof options['propsData']) =>
    mountQuasar<V, C>(component, { ...options, propsData });
}

Usage:

import { QBtn } from 'quasar'; // <= cherry pick only the components you actually use

import QBtnDemo from './demo/QBtn-demo';
import { mountFactory } from '../utils';

const factory = mountFactory(QBtnDemo, {
  // mount: { shallow: false } <= uncomment this line to use `mount`; `shallowMount` is used by default as it will stub all **registered** components found into the template
  quasar: { components: { QBtn } },
  propsData: {}
});

describe('QBtnDemo', () => {
  // DUMMY test, you should remove this and add your own tests
  test('mounts without errors', () => {
    const wrapper = factory(); // <= when no props are needed
    // const wrapper = factory({ propName: propValue }); <= when props are needed
    expect(wrapper).toBeTruthy();
  });
});
Evertvdw commented 4 years ago

Hi there @IlCallo , good that you are working on this! I think it is important that you also try to use the mountQuasar function multiple times in one file, because as @tschut said, we also experienced a lot of issues with that.

Also a nice addition might be to call preFetch if that is defined on the component. This is something I just added in my own mount function. I also use the store and router in my mount function, if you would like to see how I set it up, feel free to ask!

IlCallo commented 4 years ago

Yep, please share because I don't have much experience (expecially with Vuex)

Evertvdw commented 4 years ago

Ok, here is a zip file with some example files of our setup. I haven't tested this exact version because we use quite a few custom functions/additions to vue so I had to trim down the wrapper function. But I think you can manage from this, temp.zip

Small explanation: jest.setup.ts - In this file we add Quasar to Vue, (notice Vue and not localVue) wrapper.ts - This file exports a createWrapper function (what you call mountQuasar) which takes some arguments defined in the WrapperOptions interface. example.ts - This file shows how to use this function in a test, I included an example of a Quasar component with a dependency so that it is also clear how to handle that. Probaby good to add such an example to the demo code as well. Also this component uses the store and also shows how to mock a store action.

If there is anything unclear feel free to ask! A video chat or something like that is of course also possible.

IlCallo commented 4 years ago

Couldn't integrate VueRouter and Vuex yet, but I advanced a bit and got a working version (unluckily with no autocomplete for component props). If anyone wants to try it out, please clone the repo, install the app extension locally and invoke it with quasar ext invoke @quasar/testing-unit-test: https://github.com/quasarframework/quasar-testing/pull/117

IlCallo commented 4 years ago

@Evertvdw @tschut I released a new beta version with upgraded mountQuasar I decided to keep Vuex and VueRouter out of the mount function, but to show in the docs how to add them. Once a best-practice emerge, we can update it additively to provide a better DX experience.

You can find the documentation here

tschut commented 4 years ago

Going through the docs it looks like it's much more useful than the previous version. One thing I noticed is that mountQuasar has mount.type: full | shallow, while mountFactory has mount: { shallow: false }. Would be nice to use the same approach for both I think.

I'll try to do some actual testing soon!

tschut commented 4 years ago

Ah, I see now that the above mentioned discrepancy is in the docs only. Actually mountFactory just passes the options to mountQuasar.

So far I find especially the mountFactory function super useful.

One question about how you're supposed to use this: to get it to work I had to copy/paste utils/index.js (which has these functions) into my project. I'm assuming there's a better way, but how? At least, just setting the version in my package.json to the latest beta wasn't enough. Probably not a bug but just me net yet being super familiar with Quasar.

tschut commented 4 years ago

Also running into some problems where I get

[Vue warn]: Unknown custom element: <q-btn> - did you register the component correctly? For recursive components, make sure to provide the "name" option.

This is while testing a component that wraps a QBtn. I didn't have that before, when I used my custom method to run createLocalVue(), which was setting up Quasar with all components in it like so:

function getComponents () {
  return Object.keys(All).reduce((object, key) => {
    const val = All[key]
    if (val && val.component && val.component.name != null) {
      object[key] = val
    }
    return object
  }, {})
}

Which I realize is suboptimal, but it did work 🤔

IlCallo commented 4 years ago

Ah, I see now that the above mentioned discrepancy is in the docs only. Actually mountFactory just passes the options to mountQuasar.

Yep, I missed that comment when I changed the option

I had to copy/paste utils/index.js into my project

Unluckily, it's exactly how it should work right now. I agree with you that it's not optimal and subsequent updates won't provide you new "goodies", I'm trying to come up with a way to serve it from package with something nicer than import { mountQuasar } from '@quasar/app-extension-testing-unit-jest', but every jest mapping would also need a new TS alias and increase the complexity of the configuration

About the QBtn, have you provided it as explained in the docs?

mountFactory(MyComponent, { quasar: { components: { QBtn } }

You should manually cherry pick the components you are using (both the ones from Quasar and your own).

Or you can just provide all components as

mountFactory(MyComponent, { quasar: { components: getComponents() }

But I'd advide against it, you lose control on the "isolation" of the test and possibly slow down the execution

Evertvdw commented 4 years ago

As @tschut already mentioned the mountFactory does not work when you call it two times in the same file. I get the following error:

console.error
    [Vue warn]: Unknown custom element: <q-btn> - did you register the component correctly? For recursive components, make sure to provide the "name" option.

If you can't use the function multiple times in a file it is pretty unusable. Here is my test file:

import { mountFactory } from 'app/test/jest/utils';
import { QBtn } from 'quasar'; // <= cherry pick only the components you actually use
import ClassComponent from './../ClassComponent.vue';

const defaultMountOptions = {
  quasar: { components: { QBtn } },
  propsData: {
    title: 'Hello there',
    todos: [
      { id: 0, content: 'Get groceries' },
      { id: 1, content: 'Walk the dog' }
    ]
  }
};

const factory = mountFactory(ClassComponent, defaultMountOptions);

describe('QBtnDemo', () => {
  // DUMMY test, you should remove this and add your own tests
  test('mounts without errors', () => {
    // const wrapper = mountFactory(ClassComponent, defaultMountOptions)(); // <= when no props are needed
    const wrapper = factory(); // <= when no props are needed
    // const wrapper = factory({ propName: propValue }); <= when props are needed
    console.log(wrapper.html());
    expect(wrapper).toBeTruthy();
  });

  test('should increment click', () => {
    // const wrapper = mountFactory(ClassComponent, defaultMountOptions)();
    const wrapper = factory(); // <= when no props are needed
    expect(wrapper).toBeTruthy();
    console.log(wrapper.html());
    expect(wrapper.vm.$data.clickCount).toBe(0);
  });
});

And the component I test on:

<template>
  <div>
    <p>{{ title }}</p>
    <ul>
      <li v-for="todo in todos" :key="todo.id" @click="increment">
        {{ todo.id }} - {{ todo.content }}
        <q-btn @click="increment" :label="todo.id"></q-btn>
      </li>
    </ul>
    <p>Clicks on todos: {{ clickCount }}</p>
  </div>
</template>

<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator';
// import { Todo } from './models';
interface Todo {
  id: number;
  content: string;
}

@Component
export default class ClassComponent extends Vue {
  @Prop({ type: String, required: true }) readonly title!: string;
  @Prop({ type: Array, default: () => [] }) readonly todos!: Todo[];

  clickCount = 0;

  increment() {
    this.clickCount += 1;
  }

  get todoCount() {
    return this.todos.length;
  }
}
</script>

The first time the factory function is called it returns a wrapper with a <q-btn-stub> element in there, the second time it has a <q-btn> instead. This is what I was struggling with at first to, but as I mentioned in previous comments here the solution is to NOT call localVue.use(Quasar, options.quasar); multiple times, but only once for every test file.

What I did was move this call to jest.setup.ts which is called once for every test file, and call .use(Quasar) on Vue instead of localVue and when you then call createLocalVue() it creates a copy of Vue with the quasar components already in there.

IlCallo commented 4 years ago

Found out the problem Quasar skips installation if it already did it one time, this results in components not being registered (and then stubbed). Checking if I can solve this in some way

IlCallo commented 4 years ago

@Evertvdw @tschut can you please add Quasar.__qInstalled = undefined; right before the localVue.use(Quasar, options.quasar) line? Should fix the problem. For mountFactory the localVue creation can actually be cached as options will never change, I'll do it with next beta release

Evertvdw commented 4 years ago

@IlCallo Yes, that does solve that issue.

IlCallo commented 4 years ago

@Evertvdw @tschut I released a new beta version. It fixs the problem with multiple helpers calls and now all helpers are exposed by the AE and can be imported as you would do with a normal NPM package.

You can find the updated documentation here

Blfrg commented 4 years ago

Please let me know if I should open a new issue instead of appending here but this is regarding the progressive improvements to mountQuasar()

I've been following along and I've tested with the latest beta: "@quasar/quasar-app-extension-testing-unit-jest": "^1.1.0-beta.5" and I have some feedback to offer.

First, I know this is a WIP, and thanks a lot for your efforts @IlCallo [and testers] it's resolved a number of long standing issues already!

I've been sticking to the current quasar-starter-kit as much as possible to ensure I'm testing the recommended implementation.

Note: I'm using @vue/composition-api rather than vue-class-component or vue-property-decorator since that seems like the recommended future path by the Vue devs. I'm also using pug but I'll provide examples without so there's no concern that it's interfering.

I ran into an issue when trying to test a component which uses @vue/composition-api and vue-router To keep it simple here's an example based on the main App.vue

<template>
  <div id="q-app" :class="classes">
    <router-view />
  </div>
</template>

<script lang="ts">
import { defineComponent } from '@vue/composition-api';

export default defineComponent({
  name: 'App',
  setup() {
    const classes = 'foo';

    return { classes };
  }
});
</script>

The corresponding test [following the beta docs]:

import { mountFactory } from '@quasar/quasar-app-extension-testing-unit-jest';
import VueRouter from 'vue-router';
import { createLocalVue } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';

import App from 'src/App.vue';

const localVue = createLocalVue();
localVue.use(VueRouter);

const factory = mountFactory(App, {
  mount: {
    localVue,
    router: new VueRouter()
  },
  plugins: [VueCompositionAPI]
});

describe('App', () => {
  test('data classes equals foo', async () => {
    const wrapper = factory();

    // need to async/await for $data to populate
    await wrapper.vm.$nextTick();

    expect(wrapper.vm.$data.classes).toBe('foo');
  });
});

The above test results in:

    expect(received).toBe(expected) // Object.is equality

    Expected: "foo"
    Received: undefined

Note: If localVue and vue-router weren't present on this component the more simplified mountFactory() test would pass using plugins.

It seems the plugins might not get attached to the localVue when present. If I move plugins: [VueCompositionAPI] to localVue.use(VueCompositionAPI) then the test will pass:

import { mountFactory } from '@quasar/quasar-app-extension-testing-unit-jest';
import VueRouter from 'vue-router';
import { createLocalVue } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';

import App from 'src/App.vue';

const localVue = createLocalVue();
localVue.use(VueRouter);
localVue.use(VueCompositionAPI);

const factory = mountFactory(App, {
  mount: {
    localVue,
    router: new VueRouter()
  },
});

describe('App', () => {
  test('data classes equals foo', async () => {
    const wrapper = factory();

    // need to async/await for $data to populate
    await wrapper.vm.$nextTick();

    expect(wrapper.vm.$data.classes).toBe('foo');
  });
});

The beta docs might need to describe this alternate implementation when using localVue with plugins or this combination might need to be considered when improving the plugins feature otherwise plugins may become a moot option for @vue/composition-api tests.

Blfrg commented 4 years ago

I looked further into what mountQuasar() and mountFactory() were doing and I found that if mount.localVue is declared, then plugins gets discarded:

// mount-quasar.ts:23 (effectively the same in mount-factory.ts:17-23)
const localVue = options.mount?.localVue ?? createLocalVueForQuasar(options);

// for reference the function deconstructs into:
createLocalVueForQuasar({ quasar, plugins })

Since it's either/or and not both I tried VueRouter as a plugin and it worked:

import { mountFactory } from '@quasar/quasar-app-extension-testing-unit-jest';
import VueCompositionAPI from '@vue/composition-api';
import VueRouter from 'vue-router';

import App from 'src/App.vue';

const factory = mountFactory(App, {
  mount: {
    router: new VueRouter()
  },
  plugins: [VueCompositionAPI, VueRouter]
});

describe('App', () => {
  test('data classes equals foo', async () => {
    const wrapper = factory();

    // need to async/await for $data to populate
    await wrapper.vm.$nextTick();

    expect(wrapper.vm.$data.classes).toBe('foo');
  });
});

This even simplifies things a bit since I was able to remove everything related to localVue letting it be instantiated by the plugins array

I'm not sure if this will have issues in other scenarios, but I tested on a more complex component and this solution worked there as well. I also confirmed I had no issues with multiple mountFactory() in the same test suite.

If this example reflects the intended usage; The docs could be updated to describe this structure for "Use with VueRouter" ~and "Use with Vuex"~ (see correction below) A "Use with localVue" section could be added in "Caveats" describing localVue.use() implementation mentioning that mount.localVue overrides plugins.

[Vuex] Correction:

[vuex] must call Vue.use(Vuex) before creating a store instance.

      15 |   mount: {
      16 |     router: new VueRouter(),
    > 17 |     store: new Vuex.Store({})
         |            ^
      18 |   },
      19 |   plugins: [VueCompositionAPI, VueRouter, Vuex]

Since it seemed like mount.localVue and plugins should be able to co-exist I tested with modified beta to the effect of:

function createLocalVueForQuasar(options: QuasarMountOptions) {
  const localVue = options.mount?.localVue ?? createLocalVue();

// ...
    localVue.use(plugin, ...plugin_options);
// ...
}

and confirmed this allows the use of both implementations simultaneously. You may have avoided this intentionally for some scenario I'm not aware of, but I wanted to point out it seems to be generally ok.

IlCallo commented 4 years ago

Hey @Blfrg, VueRouter and Vuex are left outside plugin options due to what you already discovered: you must add the Vue.use() command before creating the relative instances. I'm in doubt now if this is mandatory for VueRouter as it is for Vuex, but I prefer to avoid possible strange edge cases and keep it consistent with Vuex.

But you're right about plugin option being ignored when localVue is provided: they must work together. In addition, Quasar plugin wasn't added when localVue was provided.

This problems were related to mountFactory reusing mountQuasar, I extracted the needed code and pushed a new beta version in which everything should work correctly. I also updated a bunch of dependencies and devDependencies, but there shouldn't be any public facing breaking changes

Can you test it?

Blfrg commented 4 years ago

@IlCallo Thank you for the update and consideration.

I've tested with the new beta.6 and confirmed plugins now works with localVue+Vue.use(). VueRouter can be added either way, but of course Vuex still needs to be added via Vue.use().

You may want to note in the readme, regarding VueRouter that either method is supported.

IlCallo commented 4 years ago

You may want to note in the readme, regarding VueRouter that either method is supported. Don't really wanna add this as there could be some unseen side effects and adding it after the creation of VueRouter instance isn't future proof.

For example, VueRouter (like some other plugins) auto-register itself when included in the codebase if it finds a global Vue reference so you can avoid the Vue.use(VueRouter) in some scenarios, but with Vue3 that will not be possible anymore breaking the codebase of who relied on that mechanism.

Takeoff: just because you currently can, it doesn't mean you should, nor that it will keep working in the future.

That said, thank you for your feedback and for taking the time to test this out :) Unless more feedback comes up, I plan to release the stable version in next weeks as v2, as there has been some breaking changes

Blfrg commented 4 years ago

For example, VueRouter (like some other plugins) auto-register itself when included in the codebase if it finds a global Vue reference so you can avoid the Vue.use(VueRouter) in some scenarios, but with Vue3 that will not be possible anymore breaking the codebase of who relied on that mechanism.

Ahh, I was not aware of that, I'm glad you are aware and planning ahead!

I currently am not experiencing any further issues and I'm looking forward to a v2 release. I hope it will breath new life into this repo and encourage further adoption of Quasar and testing. Especially for those who may have been discouraged by these issues, namely anyone wanting to implement TDD, this would have been a non-starter.

Once v2 lands if I notice any local tweaks I've made that improved the test environment I'll offer some PRs.

Thank you and those who brought up these issues and contributed solutions for all the help and hard work!

IlCallo commented 4 years ago

Once v2 lands if I notice any local tweaks I've made that improved the test environment I'll offer some PRs.

Go ahead! We can improve and add helpers when best practices emerge.

Thank you and those who brought up these issues and contributed solutions for all the help and hard work!

Thank you for the feedback!