Stop writing nextTick in your component tests

Posted 1 minute ago by Jessica Sachs

Your tests are 3x longer than they need to be, because you're awaiting the component's render loop. Over. And over... And over... And sometimes you still end up with test flake. *BUT* all is not lost...

Goals

  1. Write concise component tests

  2. Avoid writing await nextTick() at all costs 😈

  3. Build a helper function to retry assertions until they pass

The TLDR

If you read blog posts the way I do, you might wanna just skip to the implementation.

Bart Simpson at the chalkboard writing I will not cut corners
"I will not cut corners" - Bart Simpson

The Problem

We're going to create simple component and a test for that component. We have to use $nextTick to test the component's business logic, which clutters the test.

Our Component: Stepper.vue

Let's go into an example component.

We have a numerical stepper with two buttons, and a piece of reactive data to represent the value.

a photo of the described stepper
Stepper.vue

When you click the decrease button, it decrements value by 1. Conversely, the increase button will increment the value by 1. Our goal is to ensure that the buttons correctly modify the value when they're clicked. To have the most confidence that this is working, we'll mount the component, and then validate that the {{ value }} is correct after clicking the stepper buttons.

Here's the source code for Stepper.vue*

<template>
  <div>
    <button @click="value--" data-testid="decrease">Decrease</button>
    <span>{{ value }}</span>
    <button @click="value++" data-testid="increase">Increase</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
export let value = ref(0)
</script>

* The <script setup> syntax is still experimental, but it's possible to use it right now in Vue 3. Here's the RFC with a basic example

Our Test: Stepper.spec.js

So, it's time for our test. Let's scaffold up a quick empty spec and go through it, bit by bit.

import { mount } from '@vue/test-utils'
import Stepper from './Stepper'

describe('Stepper', () => {
  it('can be incremented and decremented', () => {
    const wrapper = mount(Stepper) // The start of any component test
  })
})

From here, we'll quickly click some buttons and validate some values and....

const wrapper = mount(Stepper) 
wrapper.find('[data-testid=increase]').trigger('click')
expect(wrapper.text()).toContain(1)

.... it fails, gloriously 🔥

Why does this test fail?

This test fails because Vue is batching updates to the DOM and the updates haven't been applied yet! Component Testing veterans might remember that Vue's render loop, like most frameworks', uses a concept called Microtasks and that to fix this test, you need to await this render loop, using async/await somewhere in the test.

In Vue 2, we had a method called $nextTick. It's located at wrapper.vm.$nextTick.

This was the Vue 2 solution for how to await the render loop, and it will still work, but a more modern approach is to either:

// Careful, only certain methods can be awaited!
await wrapper.trigger('click')

or use the global nextTick

import { nextTick } from 'vue'
// ...
await nextTick()

Either one of these will allow you to await one tick of the next render loop. If you're still confused, I suggest you read the Vue Test Utils Async guide for a deeper understanding.

Now, let's fix the test.

import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import Stepper from './Stepper'

describe('Stepper', () => {
  it('can be incremented and decremented', async () => {
    const wrapper = mount(Stepper)
    wrapper.find('[data-testid=increase]').trigger('click')
    await nextTick() // <- Await the render loop
    expect(wrapper.text()).toContain(1)
  })
})

Boom.

Done.

It seems like nextTick isn't all that bad... Was this example too simple to demonstrate the pain of nextTick? Let's make the example more complex. (read: uglier)

// ... 
describe('Stepper', () => {
  it('can be incremented and decremented', async () => {
    const wrapper = mount(Stepper)

    wrapper.find('[data-testid=increase]').trigger('click')
    await nextTick()
    expect(wrapper.text()).toContain(1)

    wrapper.find('[data-testid=decrease]').trigger('click')
    await nextTick()
    expect(wrapper.text()).toContain(0)
  })
})

It's a third larger than it needs to be. For larger components, this gets really gross, really quickly... and sometimes it just doesn't work because you need to await for some internal async behavior that isn't part of the render loop.

Wouldn't it be awesome if you could auto-magically wait for the render loop and any other async behavior to finish up instead of failing your tests? That's what we're gonna build.

But, first... let's talk about existing solutions.

  1. Cypress's Retryability - at Cypress, we retry assertions by default in something we dub retry-ability*. So, that's great for E2E tests, but how can we implement something similar inside of Jest or Mocha?

  2. Testing Library's waitFor - this is essentially what we want, but it'd be nice if we knew how to implement it outside of the context of @testing-library 😄

Understanding Expectations

Let's consider the humble expect. When an expectation or an assertion fails, it throws an error. If you retry the assertion or expectation until it passes, with a delay in-between, you can create your very own, tiny, retry-ability function for use in Jest, Mocha, or wherever. The premise is you try/catch with a setTimeout, an interval, and a max number of retries.

Remember: these are real timeouts that take actual time, not the faked out ones on jest.setTimeout... so if you retry for minutes, your tests will retry for minutes until they time out. Failing fast is usually what you want.

The Retry Usage

Going back to Goal #1, what we want is something that's bearable and terse.

Let's define our API. We'll need to wrap the entire assertion so that we can try it again and again, so it isn't stale. Caching wrapper.text() outside of the expect isn't going to cause it to retry the subject under test (the text output).

Here's the desired API, which looks pretty identical to Testing Library's waitFor:

await retry(() => expect(wrapper.text()).toContain(0))

// or with optional timeout + interval
await retry(() => expect(wrapper.text()).toContain(0), { interval: 50, timeout: 5000 }) // millisecond units

Again, we want to call wrapper.text() instead of caching text as a variable. I know this can be a bit more verbose.

The Retry Implementation

Let's write a retry function, using the principles we outlined earlier: a try/catch, a timeout, a quick interval, and some Promise magick to make Jest's async/await happy 😇

// Somewhere in your test runner's execution -- usually in a `setup.js` file*

export const retry = (assertion, { interval = 20, timeout = 1000 } = {}) => {
  return new Promise((resolve, reject) => {
    const startTime = Date.now();

    const tryAgain = () => {
      setTimeout(() => {
        try {
          resolve(assertion());
        } catch (err) {
          Date.now() - startTime > timeout ? reject(err) : tryAgain();
        }
      }, interval);
    };

    tryAgain();
  });
};

global.retry = retry; // or window, if you're in a browser

* See the Jest docs for how to configure setup files.

Easy, breezy. Well, kinda. The Promise + setTimeout juggling is a bit gnarly... but that's the implementation. I find the usage to be an improvement on the current status quo.

Wrapping up

Here's the final code, such that it'll work if you copy/paste it... regardless of any setup files. It works with Vue 2 and Vue 3. I inlined the component definition for portability.

import { mount } from '@vue/test-utils'

const Stepper = {
  template: `
  <div>
    <button @click="value--" data-testid="decrease">Decrease</button>
    <span>{{ value }}</span>
    <button @click="value++" data-testid="increase">Increase</button>
  </div>
  `,
  data() {
    return { value: 0 }
  }
}

export const retry = (assertion, { interval = 20, timeout = 1000 } = {}) => {
  return new Promise((resolve, reject) => {
    const startTime = Date.now();

    const tryAgain = () => {
      setTimeout(() => {
        try {
          resolve(assertion());
        } catch (err) {
          Date.now() - startTime > timeout ? reject(err) : tryAgain();
        }
      }, interval);
    };

    tryAgain();
  });
};

describe('Stepper', () => {
  it('can be incremented and decremented', async () => {
    const wrapper = mount(Stepper)

    wrapper.find('[data-testid=increase]').trigger('click')
    await retry(() => expect(wrapper.text()).toContain(1))

    wrapper.find('[data-testid=decrease]').trigger('click')
    await retry(() => expect(wrapper.text()).toContain(0))
  })
})

I hope this was informative and gives you more flexibility in your tests. Find me on Twitter if you'd like to chat.

Happy Testing!

-- Jess