ARTICLE
Testing Events in Vue.js, Part 2
From Testing Vue.js Applications by Edd Yerburgh
__________________________________________________________________
Save 37% off Testing Vue.js Applications. Just enter code fccyerburgh into the discount code box at checkout at manning.com.
__________________________________________________________________
Part 2 covers:
- Testing input forms
- Limitations of jsdom
Continuing on with how to test events in Vue.js, we’re going to build on what we talked about in part 1 and talk about testing input forms and the limitations of jsdom.
Testing input forms
From contact forms, to signup forms, to login forms, input forms are everywhere! Input forms can contain a lot of logic to handle validation and perform actions with input values, and that logic needs to be tested.
In this section you’ll learn how to test forms by writing tests for a Form component. The form has an email input for users to enter their email address, two radio buttons for users to select whether they want to enter a competition, and a subscribe button (figure 1).
When a user submits the form, the component should send a POST request to an API with the email address the user entered and the value of the radio buttons. To keep things simple, it won’t include any validation logic.
The specs for the form are:
- It should POST the value of email input on submit
- It should POST the value of enter competition radio buttons on submit
The first test to write checks that you send a POST request with the email entered by the user. To do this, you need to learn how to test text input values.
Testing text control inputs
Input elements are used to collect data entered by the user. Often, applications use this data to perform an action, like sending the data to an external API.
An interesting thing to note about input elements is that they have their own state. Different element types store their state in different properties. Text control inputs, like text
, email
, and address
, store their state in the value
property.
To test that input elements use a value
correctly, you need to be able to control the value
property of an input in your tests. A lot of people get confused about how to set the value of an input form. A common misconception is that simulating a keydown
event with a key
property changes the element value. This is incorrect. To change the value
property of an input in JavaScript, you need to set the value
property on the element directly:
document.querySelector('input[type="text"]').value = 'some value'
When you write a test that uses an input value
, you must set the value
manually before triggering the test input:
wrapper.find('input[type="text"]').value = 'Edd' wrapper.find('input[type="text"]').trigger('change') expect(wrapper.text()).toContain('Edd')
In Vue, it’s common to use the v-model
directive to create a two-way binding between an input value
and component instance data. For a bound value, any changes a user makes to the form value updates the component instance data value, and any changes to the instance property value’s applied to the input value
property (listing 8).
INFO If you aren’t familiar with the v-model
directive, you can read about it in the Vue docs—https://vuejs.org/v2/api/#v-model
Listing 8 Using v-model to bind data
new Vue({
el: '#app',
data: {
message: 'initial message' // #A
},
template: '<input type="text" v-model="message" />', // #B
mounted() {
setTimeout(() => this.message = '2 seconds', 2000) // #C
}
})
#A Initial value of message
#B Bind input element to message data. The initial value of the input element’s the initial message
#C Causes input element value to be updated to two seconds
Unfortunately, setting the input value
property directly won’t update the bound value. To update the v-model
of a text input, you need to set the value
on the element and then trigger a change
event on the element to force the bound value to update. This is due to the implementation of v-model in Vue core, and is liable to change in the future. Rather than relying on the internal implementation of v-model, you can use the wrapper setValue
method. The setValue
method sets a value on an input and updates the bound data to use the new value (listing 9).
Listing 9 Updating the value and v-model value of an input in a test
const wrapper = shallowMount(Form) const input = wrapper.find('input[type="email"]') // #A input.setValue('email@gmail.com') // #B
#A Get a wrapper of an input element
#B Set the value of the input element and update the bound data
In the test that you’re writing you need to set the value of an input element with setValue
, trigger a form submit, and checks that the data is sent in a POST request. To assert that a POST request is sent, you need to decide how you’ll make a POST request.
A common way to make HTTP requests is to use a library. You’ll use the popular axios library. You can send a POST request using the axios post
method, which takes a URL and an optional data object as arguments:
axios.post('https://google.com', { data: 'some data' })
INFO axios is a library for making HTTP requests, similar to the native fetch
method. No special reason exists to use this library over another HTTP library; you’re using it as an example.
The application you’re working on is already set up to use axios. It uses the vue-axios library to add an axios
Vue instance property (you can see this in src/main.js). That means you can call axios from a component:
this.axios.post('https://google.com', { data: 'some data' })
To ensure that the test checks that you’ve called the axios post
method, create a mock axios object as an instance property, and check that it was called with the correct arguments.
You can assert that a Jest mock function was called with the correct arguments by using the toHaveBeenCalledWith
matcher.
The toHaveBeenCalledWith
matcher asserts that a mock was called with the arguments that it’s passed. In this test, you’re checking that the axios post
was called with the correct URL and an object containing the email property:
expect(axios.post).toHaveBeenCalledWith(url, {
email: 'email@gmail.com'
})
The problem is, when you add extra properties to the axios data in the next test, the test fails because the argument objects don’t equal each other. You can future-proof this test by using the Jest expect.objectContaining
function. This helper is used to match some properties in the data object, rather than testing that an object matches exactly (listing 10).
Listing 10 Using objectContaining
const data = expect.objectContaining({
email: 'email@gmail.com'
})
expect(axios.post).toHaveBeenCalledWith(url, data)
Now the test always passes as long as the email property is sent with the correct value.
It’s time to add the test. It looks quite big, but if you break it down it’s a lot of setup before you trigger the submit event. Copy the code from listing 11 into src/components/__tests__/Form.spec.js.
Listing 11 Testing a mock was called with a v-model bound input form value
test('sends post request with email on submit', () => {
const axios = { // #A
post: jest.fn()
}
const wrapper = shallowMount(Form, { // #B
mocks: {
axios
}
})
const input = wrapper.find('input[type="email"]') // #C
input.setValue('email@gmail.com') // #D
wrapper.find('button').trigger('submit') // #F
const url = 'http://demo7437963.mockable.io/validate'
const expectedData = expect.objectContaining({
email: 'email@gmail.com'
})
expect(axios.post).toHaveBeenCalledWith(url, expectedData) // #G
})
#A Create a mock axios object with a post property
#B Shallow mount the Form with axios mocked
#C Get a wrapper of the email input element
#D Set the value of the input
#F Submit the form
#G Assert that axios.post was called with the correct URL value as the first argument
Before you push ahead and run the tests, you need to update the previous test. Currently the previous test will error because the Form component tries to call axios.post
, which is undefined
. This is the leaky bucket problem in practice. The instance property dependency doesn’t exist, and you need to patch the holes (mock the instance property) to avoid errors in the test.
In src/components/__tests__/Form.spec.js, replace the code to create the wrapper in the emits form-submitted when form is submitted test with the following code snippets:
const wrapper = shallowMount(Form, {
mocks: { axios: { post: jest.fn() } }
})
Now update the component with the code from listing 12.
Listing 12 Form component
<template>
<form name="email-form" @submit="onSubmit">
<input type="email" v-model="email" /> // #A
<button type="submit">Submit</button>
</form>
</template>
<script>
export default {
data: () => ({
email: null
}),
methods: {
onSubmit (event) {
this.axios.post('http://demo7437963.mockable.io/validate', { // #B
email: this.email
})
this.$emit('form-submitted')
}
}
}
</script>
#A Bind the input to the component email property with the v-model directive
#B Call axios.post
You’ve seen how to write a test for components that use an input element’s value in the assertion. You can use setValue
for all input elements that use a text control, like text
, textarea
, and email
. You need to use a different method for other input types, like radio buttons.
Testing radio buttons
Radio buttons are buttons you can select. You can only select one button from a radio group at a time. Testing radio buttons is slightly different from testing a text input element.
Our website is having a competition! Everybody who sees the signup modal has the chance to enter the competition. When the form is submitted, you’ll send the users selection (using the radio buttons value) in the POST request to the API. I sense another test to write!
Testing radio buttons is similar to testing input forms. Instead of the internal state being value
, the internal state of radio buttons is checked
. To change the selected radio button, you need to set the checked
property of a radio button input directly (listing 13).
INFO The checked
property is like the value
property. It’s the state of a radio button which is changed by a user interacting with the radio input.
Listing 13 Updating the value and v-model value of a radio button input in a test
const wrapper = shallowMount(Form)
const radioInput = wrapper.find('input[type="radio"]') // #A radioInput.element.checked = true // #B
#A Get a wrapper of a radio input element
#B Set the checked property of the radio input element directly
Setting the checked value directly suffers the same problem as setting a text control value directly. The v-model
isn’t updated. Instead, you should use the setChecked
method:
wrapper.find('input[type="radio"]').setChecked()
Two tests should be written. The first test checks that the Form sends enterCompetition
as true by default, because the yes checkbox is selected by default. For brevity, I won’t show you how to write that test. The test you’ll write checks the no radio button, submits the form, and asserts that enterCompetition
is false.
This is a big old test, but once again it’s mainly setup. Add the code from listing 14 to the describe
block in src/components/__tests__/Form.spec.js.
Listing 14 Testing a component’s called with the correct values
test('sends post request with enterCompetition checkbox value on submit', () => {
const axios = {
post: jest.fn()
}
const wrapper = shallowMount(Form, { // #A
mocks: {
axios
}
})
const url = 'http://demo7437963.mockable.io/validate'
wrapper.find('input[value="no"]').setChecked() // #B
wrapper.find('button').trigger('submit') // #C
expect(axios.post).toHaveBeenCalledWith(url, expect.objectContaining({ // #D
enterCompetition: false
}))
})
#A Shallow mount Form component with an axios mock object
#B Set the no radio button as checked
#C Submit the form
#D Assert axios.post was called with the correct enterCompetition value
To make the tests pass, you need to add the radio inputs and update the onSubmit
method to add the enterCompetition
value to the data object sent with axios.post
. Add the following radio inputs to the <template> block in src/components/Form.vue:
<input
v-model="enterCompetition"
value="yes"
type="radio"
name="enterCompetition"
/>
<input
v-model="enterCompetition"
value="no"
type="radio"
name="enterCompetition"
/>
Add enterCompetition to the default object:
data: () => ({
email: null,
enterCompetition: 'yes'
}),
Finally, update the axios call to send an enterCompetition
property. The test expects a Boolean value, but the values of the radio buttons are strings, and you can use the strict equals operator to set enterCompetition as a Boolean value:
this.axios.post('http://demo7437963.mockable.io/validate', {
email: this.email,
enterCompetition: this.enterCompetition === 'yes'
})
Run the unit tests to watch them pass: npm run test:unit
. You’ve added all the tests that you can to test the form functionality. I’d like to add one more test to check that submitting the form doesn’t cause a reload, but using jsdom makes it impossible to write. Like every parent dreads the birds and the bee’s conversation, I always dread the limitations of jsdom conversation.
Understanding the limitations of jsdom
To run Vue unit tests in Node, you need to use jsdom to simulate a DOM environment. Most of the time this works great, but sometimes you’ll run into issues with unimplemented features.
In jsdom, there are two unimplemented parts of the web platform:
Layout is about calculating element positions, causing DOM methods like Element.getBoundingClientRects
to behave in an unexpected way. You don’t encounter any problems with this in this article, but you can run into it if you’re using the position of elements to calculate style in your components.
The other unimplemented part is navigation. jsdom doesn’t have the concept of pages, and you can’t make requests and navigate to other pages. This means submit events don’t behave like they do in a browser. In a browser, by default a submit event makes a GET request, which causes the page to reload. This is almost never desired behavior, and you need to write code to prevent the event from making a GET request to reload the page.
Ideally, you’d write a unit test to check that you prevent a page reload. With jsdom, you can’t do that without extreme mocking, which isn’t worth the time investment.
Instead of writing a unit test, you need to write an end-to-end test to check that a form submission doesn’t reload the page. I don’t cover writing end-to-end tests , but for now, you’ll add the code without a test.
To stop the page reloading, you can add an event modifier to the v-bind
directive. Open src/components/Form.vue and add a .prevent
event modifier to the submit v-bind
:
<form name="email-form" @submit.prevent="onSubmit">
The modifier calls event.preventDefault
, which stops the page reloading on submit.
The two parts of jsdom that aren’t implemented are navigation and layout. It’s important to understand these limitations, and you can guard against them. When you encounter the limitations, instead of mocking, you should supplement your unit tests with end-to-end tests that check functionality that relies on unimplemented jsdom features.
Now that you’re preventing default, you have a full functioning form. You can open the dev server and have a look: npm run serve
. Now, obviously this form is nowhere near ready for public consumption. It has no styling and is incredibly ugly. The point is, you now have a suite of unit tests that check the core functionality, and can freely add style without being slowed down by unit tests.
NOTE To see what the finished application looks like, you can go to https://github.com/eddyerburgh/vue-email-signup-form-application.
Let’s recap what you learned in this article.
Summary
In this article, you learned the following:
- You can
trigger
native DOM events with the wrapper trigger method - You can test that a component responds to emitted events by calling
$emit
on a child component instance - You can test that a component emitted a Vue custom event with the wrapper
emitted
method - jsdom doesn’t implement navigation or layout
Exercises
- How do you simulate a native DOM event in tests?
Answer: Using the wrapper trigger
method
2. How would you test that a parent component responds to a child component emitting an event?
Answer: By emitted an event on the child component instance with the $emit
method
If you want to learn more about the book, check it out on our liveBook reader here and see this slide deck.
About the author:
Edd Yerburgh is an experienced JavaScript developer and Vue core contributor. He is the main author of the official Vue test library and a prominent figure in the Vue testing community.
Originally published at freecontent.manning.com.