【Vue】ライフサイクルフックのテストを書く【TypeScript】

例によって一筋縄ではいかなかったので共有。
#ライフサイクルフックとは？
公式ドキュメントは以下
[Instance Lifecycle Hooks](https://vuejs.org/v2/guide/instance.html#Instance-Lifecycle-Hooks)

#どうやってテストする？
基本は普通のコードとまったく同じ。

- 【準備】
- 【実行】
- 【検証】

が、今回の場合やっかいなのは【実行】。
`created` や `mounted` であれば、`vue-test-utils`でVueコンポーネントをマウントした直後に【検証】すれば何となく事足りてしまうが、`beforeUpdate` や `updated` だとそうはいかない。
記述したライフサイクルフックのロジックを見つけ出し、**明示的に**実行してやる必要がある。

#ライフサイクルフックは何処？
通常のインスタンスメソッドと同じように記述してあるため、インスタンス直下で簡単に見つかりそうなものだがさにあらず。
試しに`wrapper.vm`の下をいくら探しても見つからない。

ならばと[GitHub上で検索](https://github.com/vuejs/vue/search?l=TypeScript&q=beforeUpdate)してみると、何やらそれらしいモノが見つかる。

```typescript:vuejs/vue/types/options.d.ts（抜粋）
  beforeUpdate?(): void;
  updated?(): void;
```
これは要するに、

- `$options`プロパティの下に
- `beforeUpdate` や `updated` が
- `undefined` 若しくは **引数無し・戻り値無しの関数** として存在

という風に読める。

#テスト（その1）～ライフサイクルフック関数の存在確認～
試しに、Vueコンポーネントに何らかライフサイクルフックを記述し、そのライフサイクルフック関数の存在確認テストを書いてみる。

```typescript:任意のVueファイル(抜粋)
@Component
export default class MyComponent extends Vue {
  /* 中略 */
  /* lifecycle hooks */
  private beforeUpdate() {
    /* まだ空っぽで構わない */
  }
```

```typescript:テストコード
  describe('lifecycle hooks', () => {
    beforeEach(() => {
      wrapper = shallowMount(MyComponent);
    });

    describe('beforeUpdate', () => {
      it('should be function', () => {
        expect(wrapper.vm.$options.beforeUpdate).toBeInstanceOf(Function);
      });
    });
  });
```

```console:テスト実行結果（エラー）
    expect(value).toBeInstanceOf(constructor)

    Expected constructor: Function
    Received constructor: Array
    Received value: [[Function beforeUpdate]]

      xx | 
      xx |   it('', () => {
    > xx |     expect(wrapper.vm.$options.beforeUpdate).toBeInstanceOf(Function);
         |                                              ^
      xx |   });
      xx | 
```
ん？ **エラー！？**
`Function` ではなく、`Function beforeUpdate` の配列とな！？

詳細を調べるため、ターミナルからデバッグを起動。
`node_modules\@vue\cli-plugin-unit-jest\README.md` には `runInBand` オプション無しのコマンドが載ってますが、並列で走られたら追っ掛けられる訳ないんでご注意を。

```console:デバッグ起動
# macOS or linux
> node --inspect-brk ./node_modules/.bin/vue-cli-service test:unit --runInBand

# Windows
> node --inspect-brk ./node_modules/@vue/cli-service/bin/vue-cli-service.js test:unit --runInBand
```
`chrome://inspect` を開いて適当にやってれば何とかなるなる。

ブレークポイントを設定し、配列の先頭要素 `wrapper.vm.$options.beforeUpdate[0]` の内容を `.toString()` 等で確認してみると、Vueファイルに書いた `beforeUpdate` で間違いなさそう。

```text:デバッガのWatchで確認
▼Watch
wrapper.vm.$options.beforeUpdate[0]: "beforeUpdate() {}"
```
そんな訳で、どうやら `vuejs/vue/types/options.d.ts` 内の定義が実態と合ってないっぽい。
とはいえ、関数の配列と分かっていれば後はそれを参照できさえすれば良い。

#テスト（その2）～ライフサイクルフック関数を参照する～
まずは配列の先頭要素を、添字を使って参照してみる。
なお、変数名 `handlers` は `node_modules/vue/src/core/instance/lifecycle.js` に倣った。

```typescript:テストコード
  describe('lifecycle hooks', () => {
    let handlers: (() => void)[] | undefined;

    beforeEach(() => {
      wrapper = shallowMount(MyComponent);
      handlers = wrapper.vm.$options.beforeUpdate;
    });

    it('should be function', () => {
      if (handlers && handlers.length > 0) {
        expect(handlers[0]).toBeInstanceOf(Function);
      }
    });
  });
```
まぁ、型が合わないのでいきなりエラー発生。

```text:VSCode上でのエラー
型 '(() => void) | undefined' を型 '(() => void)[] | undefined' に割り当てることはできません。
  Type '() => void' is missing the following properties from type '(() => void)[]': pop, push, concat, join, and 27 more.ts(2322)
```
いずれ直ることを期待しつつ、所詮はテストコードなので当座 `as` 句で凌いでおく。

```typescript:テストコード（型不一致回避）
  describe('lifecycle hooks', () => {
    let handlers: (() => void)[] | undefined;

    beforeEach(() => {
      wrapper = shallowMount(MyComponent);
      handlers = wrapper.vm.$options.beforeUpdate as (() => void)[] | undefined;
    });

    it('should be function', () => {
      if (handlers.length > 0) {
        expect(handlers[0]).toBeInstanceOf(Function);
      }
    });
  });
```
これで記述したライフサイクルフック関数を無事参照できるようになった。

#テスト（その３）～ this コンテキストの扱い～
最後に `this` コンテキストの扱いについて記載しておく。

公式のドキュメント [Options / Lifecycle Hooks](https://vuejs.org/v2/api/#Options-Lifecycle-Hooks) の注意書きにもある通り、
ライフサイクルフック関数内では `this` を自動的に Vueコンポーネントのインスタンスとして設定してくれる。（そのため、ライフサイクルメソッドをアロー関数で記述するべきではない）

しかし、テストコード内ではそういうフレームワークによる作用は無い。自分の手で明示的に `this` コンテキストを指定しなければならない。
テストコード内で Vueコンポーネントのインスタンスを指すのはもちろん `wrapper.vm` なので、`call` や `apply` を用いて指定してやればOK。

```typescript:テストコード（thisコンテキスト指定）
  describe('lifecycle hooks', () => {
    let handlers: (() => void)[] | undefined;

    beforeEach(() => {
      wrapper = shallowMount(MyComponent);
      handlers = wrapper.vm.$options.beforeUpdate as (() => void)[] | undefined;
    });

    it('should update instance property X', () => {
      if (handlers && handlers.length > 0) {
        /* 準備 */
        wrapper.setData({ myProp: false });

        /* 実行(thisコンテキスト指定) */
        handlers[0].call(wrapper.vm);

        /* 検証 */
        expect(wrapper.vm.$data.myProp).toBe(true);
      }
    });
  });
```
以上で、ライフサイクルフックのテストを自在に書けるはず。
めでたしめでたし。