Vue 3 Testing Framework from Scratch


This article is available as a screencast!

UI Libraries like Vue and React and their Testing libraries, Vue Test Utils and Enzyme, can seem like “magic” sometimes. To remove this magic and see exactly how a test framework works, let’s build a simple unit testing framework for Vue.js 3, from scratch. Will will see that testing frameworks are just simple wrappers that provide an abstraction around basic usual DOM APIs.

This article is available as a screencast!

We will also learn to write Vue components using render functions, instead of single file components (.vue files). This will simplify the setup for this article. The focus of this article is on the concepts behind a unit testing framework, not the specific technologies we are using.

What are we building?

We will write a testing framework that implements a subset of Vue Test Utils that will allow us to write the following test:

const wrapper = mount(Component)
expect(wrapper.find('#root').text()).toBe(''Count is 0')

wrapper.find('button').trigger('click')
expect(wrapper.find('#root').text()).toBe(''Count is 1')

This has a whole bunch of useful functionality that can accomplish quite a lot - finding elements, triggering events, and asserting content is rendered correctly.

Getting Started

This article requires jest and the latest alpha for Vue. Install them with yarn add vue@3.0.0-alpha.4 jest. All the content for this article will be in an entire script, index.spec.js.

Next, we will define the App component using a render function. A render function is composed from 1 or more h functions, which can be used to built DOM structures. h has a few different signatures, taking between between 1 and 3 arguments. For the sake of simplicity, we will use the implementation of h with three arguments: h(TAG, props, content), where props is an object of properties, and content is either text content or an array of more h functions. It’s probably easier to see this as an example than explain it using text:

const App = {
  render() {
    return h(
      'div',
      {},
      [h('div', { id: 'root' }, 'Count is ?')]
    )
  }
}

This will build the follow DOM structure:

<div>
  <div id="root>Count is ?</div>
</div>

Now let’s attempt it mount it. Vue 3 uses createApp to render an app:

test('renders an app', async () => {
  const app = createApp(App).mount('#app')
})

Running this with yarn jest gives us an error: [Vue warn]: Failed to mount app: mount target selector returned null.. Makes sense… there is no <div id="app" /> to mount the app. Jest runs in a DOM like environment using a library called jsdom, so we can go ahead and create an element for the app to mount on:

test('renders an app', async () => {
  const el = document.createElement('div')
  el.id = 'app'
  document.body.appendChild(el)
  const app = createApp(App).mount('#app')

  console.log(document.body.outerHTML)
})

Adding in the console.log and running the test again gives us <body><div id="app"><div><div id="root">Count is ?</div></div></div></body>. The app has successfully mounted!

Creating Increment

We still haven’t got a whole lot to test. Let’s build a simple Increment component, that emits a increment event when it is clicked. The parent (App) will respond to this by incrementing a count by 1. First, update App:

const App = {
  setup() {
    return {
      count: ref(0)
    }
  },

  render() {
    return h(
      'div',
      {},
      [
        h('div', { id: 'root' }, `Count is ${this.count}`),
        h(Increment, { onIncrement: () => this.count++ })
      ]
    )
  }
}

When rendering a DOM element, h takes the tag name as a string. For a custom component, however, it takes the actual component as an object. This will compile to the following:

<div>
  <div id="root>Count is ?</div>
  <Increment @increment="count++" />
</div>

Of course the test is failing now - let’s create the Increment component.

Creating the Increment Component

We are about to see cool feature of Vue 3’s setup function in the Increment component - it can return a render function!

const Increment = {
  setup(props, context) {
    return () => h(
      'button',
      {
        onClick: () => context.emit('increment')
      },
      'Increment'
    )
  }
}

The way you handle emitting events in Vue 3 components using the setup function is by calling context.emit. This compiles the following:

<button @click="emit('increment')">Increment</button>

The errors are gone - let’s make some actual assertions now we have an application work with.

Writing the Assertions

The test we will write is kind obvious, based on the component we just wrote:

  • assert count is 0
  • find and click the button
  • assert count is 1

We will refactor this into the nice API described at the beginning soon. For now, let’s get something working, then work backwards from there.

test('renders an app', async () => {
  const el = document.createElement('div')
  el.id = 'app'
  document.body.appendChild(el)
  const app = createApp(App).mount('#app')

  expect(document.querySelector('#root').textContent).toBe('Count is 0')
  // click the button
  await nextTick()
  expect(document.querySelector('#root').textContent).toBe('Count is 1')
})

The first assertion passes, which makes sense. The second one fails. Let’s see how to dispatch a click event.

Dispatching DOM Events

The DOM exposes quite a few low level utilities to create and dispatch events. In day to day use, you don’t see these often, but they are what drives frameworks like Vue and React. You can create and dispatch a click event as follows:

const evt = document.createEvent('Event')
evt.initEvent('click')
<DOM ELEMENT>.dispatchEvent(evt)

You can learn more about these methods on MDN. We can implement this in our tests as follows:

test('renders an app', async () => {
  const el = document.createElement('div')
  el.id = 'app'
  document.body.appendChild(el)
  const app = createApp(App).mount('#app')

  expect(document.querySelector('#root').textContent).toBe('Count is 0')
  const evt = document.createEvent('Event')
  evt.initEvent('click')
  document.querySelector('button').dispatchEvent(evt)
  // click the button
  await nextTick()
  expect(document.querySelector('#root').textContent).toBe('Count is 1')
})

It now passes! This test is pretty difficult to read and understand - let’s work backwards towards to the API described in the beginning.

Creating the mount method

The first part of the API we will implement is mount. It takes a single argument, the component to mount - in this case, App. It will contain all the logic to render the Vue app:

function mount(Compoment) {
  const el = document.createElement('div')
  el.id = 'app'
  document.body.appendChild(el)
  const app = createApp(App).mount('#app')
  return app
}

test('renders an app', async () => {
  mount(App)

  // ...
})

Still passing. The next part is to implement a wrapper around the raw app which is returned from mount. The reason for this is we want to implement methods like wrapper#find and wrapper#trigger - it’s not ideal to attach those to the app object.

Implementing VueWrapper

We will define two types of wrapper. VueWrapper, which wraps Vue components, and DOMWrapper, which wraps raw DOM elements. Let’s start with VueWrapper and a find function.

class VueWrapper {
  constructor(vm) {
    this.vm = vm
  }

  find(selector) {
    return this.vm.$el.querySelector(selector)
  }
}

We save the vm on the class instance for use functions such as find. Find is fairly trivial to implement. Next, we use VueWrapper in mount:

function mount(Compoment) {
  const el = document.createElement('div')
  el.id = 'app'
  document.body.appendChild(el)
  const app = createApp(App).mount('#app')
  return new VueWrapper(app)
}

Next, we can simplify our first assertion using the new find method:

test('renders an app', async () => {
  const wrapper = mount(App)
  expect(wrapper.find('#root').textContent).toBe('Count is 0')
})

Now the test is a lot more readable. We can do better - let’s implement DOMWrapper and trigger.

Implementing DOMWrapper

DOMWrapper is similar to VueWrapper. trigger is more or less the logic we wrote earlier.

class DOMWrapper {
  constructor(element) {
    this.element = element
  }

  trigger(evtString) {
    const evt = document.createEvent('Event')
    evt.initEvent(evtString)
    this.element.dispatchEvent(evt)
  }
}

We now need to make a change to VueWrapper#find to return a DOMWrapper.

class VueWrapper {
  constructor(vm) {
    this.vm = vm
  }

  find(selector) {
    return new DOMWrapper(this.vm.$el.querySelector(selector))
  }
}

Now that find returns a DOMWrapper instead of a HTMLElement, we can no longer do wrapper.find('#root').textContent - we need to call textContent on the element property of DOMWrapper. This is not ideal, and the solution is to implement a text method on the DOMWrapper - an exercise for the reader.

Now we have trigger and DOMWrapper implemented, our test looks like this:

test('renders an app', async () => {
  const wrapper = mount(App)
  expect(wrapper.find('#root').element.textContent).toBe('Count is 0')

  wrapper.find('button').trigger('click')
  // click the button
  await nextTick()
  expect(wrapper.find('#root').element.textContent).toBe('Count is 1')
})

Looking good.

Discussion

There is a more valuable lesson here than how to build a simple test framework. We demonstrated that testing frameworks are simply abstractions on the existing tooling - there is nothing you can do with a testing framework you could not have accomplished with regular DOM APIs. The benefit of a framework is to make your testing more readable and maintainable.

Conclusion and Improvements

Some improvements that can be made are:

  • implementing DOMWrapper#text, so we do not need to do find('...').element.textContent
  • return nextTick fromtrigger, so you can doawait wrapper.find(‘…’).trigger(‘..’)

These are good exercises for the reader.


Register your email to get occasional emails about new screencasts and courses!
Thanks for registering!