Closed tschut closed 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();
};
Thanks for putting this all in one place @tschut, working on this right now :+1:
@tschut @Evertvdw
Work in progress, if you wanna check.
Things I integrated from your post:
mount
and shallowMount
(the latter being the default)mountQuasar
takes care of them) Some things not yet working:
Vue.extends
won't work yetVueRouter
, Vuex
, VueCompositionAPI
, etcimport 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();
});
});
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!
Yep, please share because I don't have much experience (expecially with Vuex)
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.
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
@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
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!
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.
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 🤔
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
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.
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
@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
@IlCallo Yes, that does solve that issue.
@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
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.
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.
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?
@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.
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
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!
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!
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
orshallowMount
This could easily be implemented by adding an option to the
options
object (which is already there) and switching on that. The default can stayshallowMount
so it's backwards compatible.Add
stubs
to theoptions
array and pass them tomount
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:
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 callscreateLocalVue
and having the test call that separately, but I'm sure there's more elegant solutions.