Vue Test: Mounting component test wrappers using a fluid API
Unit testing components is critical for our web applications to be resilient and easily maintainable. Without automated tests, it’s way too easy to modify some component’s code only to find you’ve broken some other part of the app you didn’t even know was related. But if you’re reading this article you’ve probably already accepted this important truth about testing, so let’s go one step forward.
You acknowledge the importance of writing unit tests for your application, but you find yourself spending too much time fixing component tests after minor modifications, like for example, adding a new prop or mapping a new Vuex action. We, at Glovo, found ourselves in a similar situation and realized the culprit in most of the cases was the component wrapper set up. The mounting of the component wrapper tends to grow uncontrollably if we don’t have a strategy to keep it tidy. After some analysis, we found a good solution that’s already making a noticeable difference for us in terms of test code maintainability.
By the end of this article you’ll be able to use a fluid API to set up your component tests in a way which is very declarative (thus readable) and easy to maintain:
const wrapper = testWrapperBuilder()
.with.cardInfo(...)
.spy.errorMsg(errorMessageSpy)
.build()
Let’s explain it with an example, but if you’re the kind of person who prefers to dive directly into the code, here’s the gist for the example component, here the builder to mount the component’s wrapper and here the test using the builder. Enjoy!
Starting with an example
Suppose you’re working on a Vue component to handle card payments; something as simple as this:
The code for this component could be something like the following (note that we’re using ElementUI’s components):
Now you want to test this component to make sure it’s bug-free. The first step is to mount the component test wrapper.
Using a function to mount the component
To mount the component wrapper, a simple approach (although not optimal in terms of maintainability) is to define the following helper function in your spec file:
function makeTestWrapper({
amountInEuros = 100,
cardNumber = '',
cardHolder = '',
expirationDate = new Date(),
successMsgSpy = jest.fn(),
errorMsgSpy = jest.fn()
}) {
return shallowMount(PaymentWithCard, {
propsData: {
amountInEuros,
cardNumber,
cardHolder,
expirationDate
},
mocks: {
$successMessage: successMsgSpy,
$errorMessage: errorMsgSpy
}
})
}
This function receives an object with all the props, data and spies for the component and assigns them a default value in case we don’t specify a different one. Nice! So now you can write your test like this:
import api from '@/api/paymentAPI'
jest.mock('@/api/paymentAPI', function() {
return { pay: jest.fn() }
})describe('Payment with card', () => {
beforeEach(() => {
jest.clearAllMocks()
}) it('should display a success message', () => {
api.pay.mockReturnValue({ ok: true })
const successMessageSpy = jest.fn() const wrapper = makeTestWrapper({
cardNumber: '123 456 0000 1455 1222',
cardHolder: 'Katherine Evans',
successMessageSpy
}) wrapper.find('button').trigger('click') expect(successMessageSpy).toHaveBeenCalledTimes(1)
})
})
In this test we’re spying on the @/api/paymentAPI
’s pay
function, while the result is mocked as if the payment succeeded. Then, the test checks that the successMessageSpy
is called once in this case. Notice how we create the component’s wrapper with the makeTestWrapper
.
And something very similar can be done for the failure case:
it('should display an error message', () => {
jest.fn().mockReturnValue({ ok: false })
const errorMessageSpy = jest.fn() const wrapper = makeTestWrapper({
cardNumber: '123 456 0000 1455 1222',
cardHolder: 'Katherine Evans',
errorMessageSpy
}) wrapper.find('button').trigger('click') expect(errorMessageSpy).toHaveBeenCalledTimes(1)
})
The only difference being the pay api method is mocked to return a failure and the errorMessageSpy
checked to have been called once. Cool! but what happens when more properties, methods or dependencies are added? Like if we make use of the Router or Vuex store? In this case, our makeTestWrapper
becomes less and less readable with dozens of keys in its parameter’s object.
What’s wrong with this approach?
It started happening to us that the code for mounting Vue component wrappers in our tests became long and complex. Every time an engineer added a new prop/data, method or dependency to the component, the corresponding makeTestWrapper
function needed to be modified to include one more parameter (or key-value pair in the parameter object). We also started to realize that, when this function was called with all the default arguments, it got very hard to understand how the component was mounted (in terms of what properties were set to what). Imagine opening a test file for a big component and reading this:
const wrapper = makeTestWrapper()
To understand what’s being set in the component you need to look for the makeTestWrapper
function and carefully study all the default parameters to the function. That’s no big deal, but we can do better 🤓.
Using a fluid API
We thought about possible ways of fixing this nuisance and we found one. The objective would be to have a very declarative, builder-like interface to initialize tests like so:
const wrapper = testWrapperBuilder()
.with.cardInfo(...)
.spy.errorMsg(errorMessageSpy)
.build()
With an initialization like this, it’s very easy for everyone reading the test to understand how the component is initialized. But it also has another extra benefit: it’s very easy to extend without needing to add new parameters or modify existing code (remember the open-closed principle?).
Our component wrapper builder could be defined in a separate file like this:
Let’s break this down a little bit. We’re exporting a function which returns a builder object. The function first declares the props/data and spies with initial default values. We chose to use 100 for the amountInEuros
prop and null
for the data variables. Then the builder
object is defined, which has a clear structure:
const builder = {
with: {
// functions to set data/props...
}, spy: {
// functions to set spies...
}, build() {
return shallowMount(ComponentName, { ... })
}
}
To allow a fluid-API like experience, every function inside the with
and spy
objects should return the builder
object itself. This is the reason why we store the builder
in a const
: so we have access to it in order to return it. We cannot rely on this
: it’s bound to the external object (with
and spy
objects instead of the builder
itself):
export default function() {
... // returning the builder directly
return {
with: {
amountInEuros(amount) {
...
return this // WRONG! 'this' is bound to the 'with' object
}
}
}
}
The resulting test
With this builder, the first test now reads like the following:
As you can see the component wrapper creation is very declarative if we use this builder strategy. Anyone can easily understand what’s being set in the component. And, if we wanted to add a new prop/data/spy, we’d create a new function inside the with
or spy
objects, which wouldn’t break existing usages of the builder.
This implementation allows us to do some cool tricks, like composing builders with Vuex store builders; but that’s for another post :)