Vue.jsの非同期動作テストで用いる$nextTickとsetTimeoutの違い

2021/08/11に公開

Vue Test Utilsのドキュメントには非同期動作のテストに関するガイドがある。
https://vue-test-utils.vuejs.org/ja/guides/testing-async-components.html
要するに非同期処理を含むコンポーネントをテストする際には、通常はPromiseがresolveする前にアサーションが実行されるため、$nextTicksetTimeoutを使いましょうということが書かれている。

このドキュメントにもある通り、仕組みとしてはPromiseのコールバック関数が実行されたあとに
$nextTickあるいはsetTimeoutのコールバック関数が実行されるため、期待する動作になる。

$nextTicksetTimeoutの動作としてはほぼ同等であり、今回のようにアサーションでコンポーネントのdataをチェックする場合は、$nextTicksetTimeoutのどちらを使っても問題ない。
しかし、場合によっては$nextTickを使ったパターンとsetTimeoutを使ったパターンで動作に違いが生じることがある。

例として以下のようなAPIから取得したデータを描画するだけのシンプルなコンポーネントをテストする。

Example.vue
<template>
  <div>{{ name }}</div>
</template>
<script>
import axios from 'axios'

export default {
  data: function() {
    return {
      name: ''
    }
  },
  created() {
    axios
      .get('https://jsonplaceholder.typicode.com/users/1')
      .then(res => {
        this.name = res.data.name
      })
  }
}
</script>

テストコードは以下のようになる。

Example.spec.js
import { shallowMount } from '@vue/test-utils'
import Example from '@/components/Example.vue'

jest.mock('axios', () => {
  return {
    get: () =>
      new Promise(resolve => {
        resolve({ data: { name: 'Leanne Graham' } })
      })
  }
})

describe('Example.vue', () => {
  it('$nextTick', done => {
    const wrapper = shallowMount(Example)
    wrapper.vm.$nextTick(() => {
      expect(wrapper.text()).toEqual('Leanne Graham')
      done()
    })
  })
  it('setTimeout', done => {
    const wrapper = shallowMount(Example)
    setTimeout(() => {
      expect(wrapper.text()).toEqual('Leanne Graham')
      done()
    })
  })
})

ここで大事なのはVue Test Utilのドキュメントでの例と違い、DOMに描画されているかをテストしていることである。

このテストは両方とも成功しそうだが、実際にはsetTimeoutのパターンのみ成功で、$nextTickのパターンは失敗する。

  FAIL  tests/unit/Example.spec.js (5.426s)
  Example.vue
    ✕ $nextTick (5011ms)
    ✓ setTimeout (4ms)

  ● Example.vue › $nextTick

    : Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.Error:

      12 |
      13 | describe('Example.vue', () => {
    > 14 |   it('$nextTick', done => {
         |   ^
      15 |     const wrapper = shallowMount(Example)
      16 |     wrapper.vm.$nextTick(() => {
      17 |       expect(wrapper.text()).toEqual('Leanne Graham')

      at new Spec (node_modules/jest-jasmine2/build/jasmine/Spec.js:116:22)
      at Suite.<anonymous> (tests/unit/Example.spec.js:14:3)
      at Object.<anonymous> (tests/unit/Example.spec.js:13:1)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        5.849s

なぜ$nextTicksetTimeoutで結果に差異が生じるのか?
その原因は$nextTickでの処理によるものである。

そもそもsetTimeoutのコールバック関数がPromiseのコールバック関数より後に実行されるのは、
ここにも書かれている通り、JavaScriptのイベントループにおいて用いるタスクキューにタスクとマイクロタスクの2種類があるためである。
イベントループはタスクキューの中にタスクとマイクロタスクがある場合にマイクロタスクを先に処理する。

setTimeoutはタスク、PromiseはマイクロタスクなのでPromiseのコールバック関数が処理された後に、setTimeoutのコールバック関数が処理される。

では$nextTickはどうだろうか?
v2.6.4のVue.jsのソースコードを読んで、$nextTickの処理を紐解いていくと実態はマイクロタスクであることがわかる。
https://github.com/vuejs/vue/blob/612fb89547711cacb030a3893a0065b785802860/src/core/util/next-tick.js

// The nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // In problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true

そう、つまりsetTimeoutはタスク、$nextTickはマイクロタスクであるため、それぞれのコールバック関数が処理されるタイミングが違ってしまうのである。

ちなみに$nextTickのタスクキューにまつわる実装の歴史を探ると、v2.4時代は今のようにマイクロタスクであったが、v2.5ではタスクを使っていたりと、紆余曲折あってこのような形になっていることがわかる。

まとめると以下のように処理が行われていると思われる。

setTimeout

コンポーネントのdataを更新

DOMの更新

setTimeoutのコールバック関数の処理(テストのアサーション)

$nextTick

コンポーネントのdataを更新

$nextTickのコールバック関数の処理(テストのアサーション)

DOMの更新

結論としては、非同期動作テストでsetTimeout$nextTickのどちらを使うか迷った場合は前者を使ったほうが良さそうである。
また、もう一つの選択肢であり、ドキュメントでも紹介されているflush-primisesも内部の実装はただのsetTimeoutである。

参考にした記事等

https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
https://aloerina01.github.io/blog/2018-09-27-1

Discussion