@nuxt/composition-apiでcomposition functionを単体テストする

## 概要

Composition APIを用いて作成したVuex的なGlobal Stateを単体テストしようと思って色々調べた結果を小ネタとしてQiitaに共有しておきます。

[今回のTipsが反映されたアプリケーションをGitHubにあげています](https://github.com/piteroni/todo-nuxt-vue3)。

ちなみに今回はComposition APIのライブラリはNuxt.jsを使っているので、`@vue/composition-api`ではなく、`@nuxt/composition-api`を使っていますが、本記事の内容は`@vue/composition-api`にも適応できる考え方だと思っています。

またテスト周りのツールは`Jest + @vue/test-utils`になります。

## composition functionを単体テストする

そもそもComposition APIで提供されている`computed`, `watch`, `onMounted`などの機能は`setup`コンポーネントオプション内で呼ばれることを想定しており[^1]、`@nuxt/composition-api`で提供される`useContext`も同様の思想で設計されています。


>Composition API の使用を開始するには、初めに実際に使用できる場所が必要です。Vue コンポーネントでは、この場所を setup と呼びます。
> ...
> オプション API に比べて Composition API の機能を完全にするには、ライフサイクルフックを setup の中に登録する必要があります。
>
> https://v3.ja.vuejs.org/guide/composition-api-introduction.html より引用

なので例えば以下の様なcomposition functionをテストしたい場合は、`useCounter`をテストケースから直接呼び出すのではなく、`setup`コンポーネントオプション内で呼び出す必要があります。

```counter.ts
import { computed, reactive } from "@nuxtjs/composition-api"

interface State {
  count: number
}

export function useCounter() {
  const state: State = reactive({ count: 0 })
  const increment = (state: State) => () => state.count++

  return {
    count: computed(() => state.count),
    increment: increment(state)
  }
}
```

しかし愚直に書くと、テストケースごとにVueコンポーネントのマウント処理を書いていく必要があり、手間はかかるし、テストコードの可読性も下がるし、結構しんどいところはあります。

```counter.test.ts
import { mount } from "@vue/test-utils"
import { useCounter } from "./counter.ts"

describe("Counter", () => {
  it("increment", () => {
    mount(
      Vue.extend({
        setup() {
          const { count, increment } = useCounter()
          increment()
          expect(count.value).toBe(1)            

          return {}
        },
        template: "<div></div>"
      }
    )
  })
})
```

なので少し面倒ですが、以下の様なヘルパーを定義することによって比較的スマートにテストコードを書くことができます。

```testing.ts
export const createSetupScope = (localVue: typeof Vue, done: jest.DoneCallback, mocks = {}) => {
  return (test: () => (unknown | Promise<unknown>)) => {
    const stub = Vue.extend({
      setup() {
        const callable = test as any

        const result = callable.constructor.name === "AsyncFunction"
          ? callable()
          : new Promise((resolve, reject) => { try { resolve(callable()) } catch (e) { reject(e) } })

        result.then(() => done()).catch((e: any) => done(e))

        return {}
      },
      template: "<div></div>"
    })

    mount(stub, {
      localVue,
      mocks
    })
  }
}
```

```counter.test.ts
import { createLocalVue } from "@vue/test-utils"
import { useCounter } from "./counter.ts"
import { createSetupScope } from "./testing.ts"

const localVue = createLocalVue()

describe("Counter", () => {
  it("increment", done => {
    const setup = createSetupScope(localVue, done)

    setup(() => {
      const { count, increment } = useCounter()
      increment()
      expect(count.value).toBe(1)  
    })
  })
})
```

一応の注意点としては俯瞰してみると`expect`が実行されるのは、`setup`コンポーネントオプションの中で実行されるので、通常のテストとは違い非同期でテストが実行されるということです。

つまり`it`のコールバックの処理が最後まで到達しても、テストが完了していないことを意味します。

しかし幸いにもJestには非同期で実行されるテストに対応するために、`it`のコールバックの第一引数に`DoneCallback`が渡されるように設計されています。

非同期で実行されるテストにおいて、ユーザー側で`DoneCallback`を任意のタイミングで呼び出すことで、`it`の処理が終了したあとでも非同期な処理の実行が完了することを待つことができます。

今回の場合、そういった背景から個人的にはComposition APIを使った実装コードのテストをする場合、`it`のコールバックに渡された`DoneCallback`を`createSetupScope`に渡すようにしていこうかなとは思っています。

[^1]: といいつつも実は`@nuxtjs/composition-api: ^0.24.6`時点では記載されているテストコードは`setup`コンポーネントオプションの中で実行せずとも成功しますので、今後はもしかすると、MVVMでいうところのModelを表現するcomposition functionを作る分には、`setup`コンポーネントオプション外で提供されるAPIが実行できる世界が実現するかもしれません。