I'm writing a unit test for a vueJS component using Jest and vue test utils , my problem is as follows
i have an input component where it triggers a custom event on 2 way data binding data prop value of input element
but when i try to set the data prop in my test case through wrapper.setData({value : x}) to validate the custom event is triggered
via wrapper.emitted() but it doesn't seem to happened
the wrapper.emitted() is always returning an empty object !!
Component
<template>
<input v-model="value"
:type="type"
:id="id"
:class="className"
:maxlength="maxlength"
:minlength="minlength"
:pattern="pattern"
:placeholder="placeholder"
#input="handleInput"
/>
</template>
<script>
export default {
name : "inputText",
props : {
maxlength: {
type : Number,
// lock it to 100 chars
validator : (maxlength) => {
return maxlength < 100 ? maxlength : 100
}
},
minlength: {
type: Number
},
// regex pattern can be supplied to match
pattern: {
type: String
},
placeholder: {
type : String,
default : "Type it hard"
},
type: {
type : String,
required : true,
validator : (type) => {
return [ "text","tel","password","email","url" ].indexOf(type) !== -1
}
}
},
methods: {
handleInput (e) {
this.$emit("text-input" , e.target.value )
}
},
data: function() {
return {
value: "initial"
}
}
}
</script>
Test Case
import { mount } from "#vue/test-utils"
import InputText from "../InputText.vue"
describe("InputText Component" , () => {
const wrapper = mount(InputText , {
propsData: { maxlength: 10, type: "text" }
})
it("component should have a type" , () => {
expect(wrapper.props()).toHaveProperty("type")
})
it("component type should be of text siblings" , () => {
expect(wrapper.vm.$options.props.type.validator(wrapper.props("type"))).toBe(true)
})
it("component renders an input element" , () => {
expect(wrapper.html()).toContain("input")
})
it("component handles new input value" , () => {
const inputVal = "koko"
wrapper.setData({ value: inputVal })
expect(wrapper.vm.$data.value).toBe(inputVal)
console.log(wrapper)
})
})
The reason here is that setData method ain't update v-model connected component. You should use setValue to test this behavior.
Related
I am trying to create a test for a BaseInput with a v-model prop. The expectation is the component will emit the changed input. When I update the input in the Vitest framework, there does not seem to be an emit triggered.
Component
<template>
<label v-if="label">{{ label }}</label>
<input
v-bind="$attrs"
:value="modelValue"
:placeholder="label"
#input="$emit('update:modelValue', $event.target.value)"
class="field"
/>
</template>
<script>
export default {
props: {
label: {
type: String,
default: "",
},
modelValue: {
type: [String, Number],
default: "",
},
},
};
</script>
Test
import { describe, it, expect, beforeEach } from "vitest";
import { mount } from "#vue/test-utils";
import BaseInput from "#/components/base/BaseInput.vue";
describe("BaseInput", () => {
let wrapper
beforeEach(() => {
wrapper = mount(BaseInput, {
propsData: {
label: 'Test Label',
modelValue: 'Test Value'
}
})
})
it('emits input value when changed', async () => {
// Assert input value gets the new value
await wrapper.find('input').setValue('New Test Value')
expect(wrapper.find('input').element.value).toBe('New Test Value')
// Assert input event is emitted
await wrapper.vm.$nextTick()
expect(wrapper.emitted().input).toBeTruthy() //this fails
})
});
Result: there is nothing emitted from the input when the value is set.
How can the input be set to prove the component emits the new value of the input component?
This is actually discussed as an example in the Vue Test Utils examples: https://test-utils.vuejs.org/guide/advanced/v-model.html#a-simple-example
Here is how you test v-model in Vue 3
import { describe, it, expect, beforeEach } from "vitest";
import { mount } from "#vue/test-utils";
import BaseInput from "#/components/base/BaseInput.vue";
describe("BaseInput", () => {
let wrapper;
beforeEach(() => {
wrapper = mount(BaseInput, {
propsData: {
label: "Test Label",
modelValue: "Test Value",
"onUpdate:modelValue": (e) => wrapper.setProps({ modelValue: e }),
},
});
});
it("emits input value when changed", async () => {
// Assert input value gets the new value
await wrapper.find("input").setValue("New Test Value");
expect(wrapper.props("modelValue")).toBe("New Test Value");
});
});
there are two .vue file and one is parent and another is child.
I am developing 'Sending file' function with Modal.
I will upload file data in parent.vue.
and when I click 'Send', child.vue will be popped up!
and I will choose, target who receive this file data.
and finally, I click 'Send' button, get connection to Backend server.
This is parent.vue
<<templete>>
<script>
export default {
name: 'BillingView',
components: {
EmailSender
},
data() {
return {
uploaded_file: '',
file_data: {},
is_uploaded: false,
popupVal: false
}
},
methods: {
sendEmail() {
if (this.is_uploaded == false) {
alert('Upload your file');
} else {
<< parsing file data>>
this.file_data = JSON.parse(JSON.stringify(this.parsed_uploaded_file));
this.popupVal = (this.popupVal) ? false : true
}
},
popupClose: function ( value ) {
this.popupVal = value
}
}
}
</script>
This is child.vue
<<templete>>
<script>
import axios from 'axios';
export default {
name: 'EmailSender',
props: {
popupVal: Boolean,
file_data: {
type: Object,
default: () => {
return {}
}
}
},
data: () => ({
}),
computed: {
popUp: {
get() {
return this.popupVal
},
}
},
created() {
},
methods: {
sendEmail() {
let req_data = {};
req_data ['file'] = file_data;
axios.post(process.env.VUE_APP_BASE_API + 'email/',
{
req_data ,
headers: {
'Content-Type': 'application/json;charset=UTF-8-sig'
}
}
).then(res => {
console.log('SEND EMAIL SUCCESS!!');
console.log('SEND EMAIL RESPONSE::', res, typeof (res));
})
.catch(function () {
console.log('SEND EMAIL FAILURE!!');
});
}
}
};
</script>
but here, "req_data ['file'] = file_data;" the file_data is empty, {}.
I expect when I update file_data in parent vue, child can know and use updated file data.
how can I do?
You have to make sure you send file_data as a prop to your component. e.g. Your component is 'myPopup' and you should use it as below to send file_data as a prop:
<myPopup :myData="file_data" >
In this case, you can get file_data as 'myData' in the child component as below
After changes, your props should be:
props: {
myData: {
type: Object,
default: () => {
return {}
}
}
},
Your function should be:
Also, don't forget to use 'this' keyword. this.myData instead of myData in function.
methods: {
sendEmail() {
let req_data = {};
req_data['file'] = this.myData;
// axios here
};
},
I am losing state in a multiselect component due to unwanted rerendering. My data is structured like this:
form: [
{ key: 0, value: null },
{ key: 1, value: null },
...
]
Each form value generates a multiselect in my template, but if I add a new value (e.g. this.form.push({ key: this.form.length, value: null });), every multiselect in the template is rerendered. This unwanted change of internal state results in the loss of visual feedback on the select boxes.
I've tried setting :key to prevent rerenders, but that hasn't worked. Any recommendations?
The multiselect component is vueform/multiselect. Here is a jsfiddle that shows the behavior: https://jsfiddle.net/libertie/sey1t4mb/31
Inline Example:
const app = Vue.createApp({
data: () => ({
form: [
{ key: 0, value: null }
]
}),
methods: {
add() {
this.form.push({ key: this.form.length, value: null });
},
async fetchRecords(query) {
const response = await axios.get(API_URL)
.then(response => {
if (Array.isArray(response.data.results)) {
return response.data.results;
}
return [];
});
return await response;
}
}
});
<div v-for="field in form" :key="field.key">
<Multiselect
v-model="field.value"
:filter-results="false"
:options="async (query) => await fetchRecords(query)"
searchable
/>
</div>
<button
#click="add"
type="button"
>Add field</button>
Using StoryBook.js, when I navigate to a component, view its "Docs" and click the "Show Code" button, why do I get code that looks like this...
(args, { argTypes }) => ({
components: { Button },
props: Object.keys(argTypes),
template: '<Button v-bind="$props" />',
})
...as opposed to this...
<Button type="button" class="btn btn-primary">Label</Button>
Button.vue
<template>
<button
:type="type"
:class="'btn btn-' + (outlined ? 'outline-' : '') + variant"
:disabled="disabled">Label</button>
</template>
<script>
export default {
name: "Button",
props: {
disabled: {
type: Boolean,
default: false,
},
outlined: {
type: Boolean,
default: false,
},
type: {
type: String,
default: 'button',
},
variant: {
type: String,
default: 'primary',
validator(value) {
return ['primary', 'success', 'warning', 'danger'].includes(value)
}
}
}
}
</script>
Button.stories.js
import Button from '../components/Button'
export default {
title: 'Button',
component: Button,
parameters: {
componentSubtitle: 'Click to perform an action or submit a form.',
},
argTypes: {
disabled: {
description: 'Make a button appear to be inactive and un-clickable.',
},
outlined: {
description: 'Add a border to the button and remove the fill colour.',
},
type: {
options: ['button', 'submit'],
control: { type: 'inline-radio' },
description: 'Use "submit" when you want to submit a form. Use "button" otherwise.',
},
variant: {
options: ['primary', 'success'],
control: { type: 'select' },
description: 'Bootstrap theme colours.',
},
},
}
const Template = (args, { argTypes }) => ({
components: { Button },
props: Object.keys(argTypes),
template: '<Button v-bind="$props" />',
})
export const Filled = Template.bind({})
Filled.args = { disabled: false, outlined: false, type: 'button', variant: 'primary' }
export const Outlined = Template.bind({})
Outlined.args = { disabled: false, outlined: true, type: 'button', variant: 'primary' }
export const Disabled = Template.bind({})
Disabled.args = { disabled: true, outlined: false, type: 'button', variant: 'primary' }
I thought I followed their guides to the letter, but I just can't understand why the code output doesn't look the way I expect it to.
I simply want any of my colleagues using this to be able to copy the code from the template and paste it into their work if they want to use the component without them having to be careful what they select from the code output.
For anyone else who encounters this issue, I discovered that this is a known issue for StoryBook with Vue 3.
As mine is currently a green-field project at the time of writing this, I put a temporary workaround in place by downgrading Vue to ^2.6.
This is OK for me. I'm using the options API to build my components anyway so I'll happily upgrade to Vue ^3 when Storybook resolve the above linked issue.
One of possible options is to use current workaround that I found in the GH issue mentioned by Simon K https://github.com/storybookjs/storybook/issues/13917:
Create file withSource.js in the .storybook folder with following content:
import { addons, makeDecorator } from "#storybook/addons";
import kebabCase from "lodash.kebabcase"
import { h, onMounted } from "vue";
// this value doesn't seem to be exported by addons-docs
export const SNIPPET_RENDERED = `storybook/docs/snippet-rendered`;
function templateSourceCode (
templateSource,
args,
argTypes,
replacing = 'v-bind="args"',
) {
const componentArgs = {}
for (const [k, t] of Object.entries(argTypes)) {
const val = args[k]
if (typeof val !== 'undefined' && t.table && t.table.category === 'props' && val !== t.defaultValue) {
componentArgs[k] = val
}
}
const propToSource = (key, val) => {
const type = typeof val
switch (type) {
case "boolean":
return val ? key : ""
case "string":
return `${key}="${val}"`
default:
return `:${key}="${val}"`
}
}
return templateSource.replace(
replacing,
Object.keys(componentArgs)
.map((key) => " " + propToSource(kebabCase(key), args[key]))
.join(""),
)
}
export const withSource = makeDecorator({
name: "withSource",
wrapper: (storyFn, context) => {
const story = storyFn(context);
// this returns a new component that computes the source code when mounted
// and emits an events that is handled by addons-docs
// this approach is based on the vue (2) implementation
// see https://github.com/storybookjs/storybook/blob/next/addons/docs/src/frameworks/vue/sourceDecorator.ts
return {
components: {
Story: story,
},
setup() {
onMounted(() => {
try {
// get the story source
const src = context.originalStoryFn().template;
// generate the source code based on the current args
const code = templateSourceCode(
src,
context.args,
context.argTypes
);
const channel = addons.getChannel();
const emitFormattedTemplate = async () => {
const prettier = await import("prettier/standalone");
const prettierHtml = await import("prettier/parser-html");
// emits an event when the transformation is completed
channel.emit(
SNIPPET_RENDERED,
(context || {}).id,
prettier.format(`<template>${code}</template>`, {
parser: "vue",
plugins: [prettierHtml],
htmlWhitespaceSensitivity: "ignore",
})
);
};
setTimeout(emitFormattedTemplate, 0);
} catch (e) {
console.warn("Failed to render code", e);
}
});
return () => h(story);
},
};
},
});
And then add this decorator to preview.js:
import { withSource } from './withSource'
...
export const decorators = [
withSource
]
<template>
<component :is="myComponent" />
</template>
<script>
export default {
props: {
component: String,
},
data() {
return {
myComponent: '',
};
},
computed: {
loader() {
return () => import(`../components/${this.component}`);
},
},
created() {
this.loader().then(res => {
// components can be defined as a function that returns a promise;
this.myComponent = () => this.loader();
},
},
}
</script>
Reference:
https://medium.com/scrumpy/dynamic-component-templates-with-vue-js-d9236ab183bb
Vue js import components dynamically
Console throw error "this.loader() is not a function" or "this.loader().then" is not a function.
Not sure why you're seeing that error, as loader is clearly defined as a computed prop that returns a function.
However, the created hook seems to call loader() twice (the second call is unnecessary). That could be simplified:
export default {
created() {
// Option 1
this.loader().then(res => this.myComponent = res)
// Option 2
this.myComponent = () => this.loader()
}
}
demo 1
Even simpler would be to rename loader with myComponent, getting rid of the myComponent data property:
export default {
//data() {
// return {
// myComponent: '',
// };
//},
computed: {
//loader() {
myComponent() {
return () => import(`../components/${this.component}`);
},
},
}
demo 2