Vue.jsの非同期動作テストで用いる$nextTickとsetTimeoutの違い
Vue Test Utilsのドキュメントには非同期動作のテストに関するガイドがある。$nextTick
かsetTimeout
を使いましょうということが書かれている。
このドキュメントにもある通り、仕組みとしてはPromiseのコールバック関数が実行されたあとに
$nextTick
あるいはsetTimeout
のコールバック関数が実行されるため、期待する動作になる。
$nextTick
とsetTimeout
の動作としてはほぼ同等であり、今回のようにアサーションでコンポーネントのdataをチェックする場合は、$nextTick
とsetTimeout
のどちらを使っても問題ない。
しかし、場合によっては$nextTick
を使ったパターンとsetTimeout
を使ったパターンで動作に違いが生じることがある。
例として以下のようなAPIから取得したデータを描画するだけのシンプルなコンポーネントをテストする。
<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>
テストコードは以下のようになる。
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
なぜ$nextTick
とsetTimeout
で結果に差異が生じるのか?
その原因は$nextTick
での処理によるものである。
そもそもsetTimeout
のコールバック関数がPromiseのコールバック関数より後に実行されるのは、
ここにも書かれている通り、JavaScriptのイベントループにおいて用いるタスクキューにタスクとマイクロタスクの2種類があるためである。
イベントループはタスクキューの中にタスクとマイクロタスクがある場合にマイクロタスクを先に処理する。
setTimeout
はタスク、PromiseはマイクロタスクなのでPromiseのコールバック関数が処理された後に、setTimeout
のコールバック関数が処理される。
では$nextTick
はどうだろうか?
v2.6.4のVue.jsのソースコードを読んで、$nextTick
の処理を紐解いていくと実態はマイクロタスクであることがわかる。
// 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
である。
参考にした記事等
Discussion