Nuxt.js + Composition API + Vuex + jest で Composable のテストをする

📆October 28, 2021🔖 NuxtJSJest

Vue Composition API のリリースにより、Composable ディレクトリ内に useXxx という名称のファイルを作成することで Vue の SFC ファイルから状態とロジックを切り離しやすくなりました。これにより、Vue コンポーネントのマウントを行うことなく(vue-test-utils を使用することなく)TypeScript ファイルのテストとしてテストを実行することができるようになりました。

今回の記事では、Composable の単体テストを実装する上で出てきた課題と、それの解決策についての覚書を残したいと思います。(もしかしたらそのうちこのあたりをまるっと解消してくれる test-utils が登場するかもしれないので、出てきたら教えて下さい)同じようなテスト指針でテストを実行してる方の参考になれば幸いです。

既存のアーキテクチャとテストのモチベーション

  • Nuxt.js v2 に、Nuxt Composition API を導入済み
  • 状態管理はほぼ全て Vuex で行う
  • 非同期通信は Vuex の Action 内に限定している
  • Composable 内から Store への接続を行う(dispath / getter)
  • アーキテクチャの概念図
    アーキテクチャの概念図

    このようなアーキテクチャにすることで、Vue ファイルからは Vuex の存在や非同期通信を意識することなく、Composable 経由で useXxx でほしいデータを取得したり、また、form からの submit を Composable 経由で行ったりすることができるようになります。

    余談ですが、実装を分業する場合にもけっこうメリットが大きくて、ロジック部分の実装担当者と UI 部分の実装担当者で開発フローが疎になるのも良いなと思っています。

    テストのモチベーションとして、Page 層を他の実装者にお願いする関係上、Composable 以下のレイヤの実装をする際の動作の担保としてテストが必要だったというのがあります。僕たちは少人数で開発しているという背景もあり Page 層の単体テストは行っておらず、見た目部分は Cypress を使った e2e テストにお任せしています。そのため、なるべく Page レイヤーには見た目以外の責務を持たせず、ビジネスロジックはなるべく Composable に寄せる方針でアプリケーション開発を進めています。

    長くなりましたが、ここから本題です。

    Composable 内で useStore を使うとき

    普通にテストを実行して、 useStore() を呼び出してしまうとThis must be called within a setup function というエラーが出てテストが実行できません。これは useStore が setup 関数内でしか呼び出せないという制約があるためです。

    回避方法はいくつかあるのですが、今回は Jest の spyOn 機能を使って、useStore() をモックします。spy 化されたメソッドは mockReturnValue というメソッドを呼ぶことで、返り値を上書きすることができます。返り値にモジュール化された Store を定義してやることで、Store の振る舞いを持たせることができます。

    import * as s from '@/store/storeModuleA';
    import * as ca from '@nuxtjs/composition-api';
    
    /* 色々省略 */
    
    const spy = jest.spyOn(ca, 'useStore');
    const store = new Vuex.Store(cloneDeep(s));
    spy.mockReturnValue(store);

    Nuxt Composition API を Jest で使う

    普通に実行するとまたエラーが出ます。You need to add @nuxtjs/composition-api to your buildModules in order to use it. See https://composition-api.nuxtjs.org/getting-started/setup. これは、NuxtJS で Composition API を使用する際に、nuxt.configbuildModules オプションに Composition API を追記していて、Nuxt アプリケーションの起動時にそれが読み込まれるようになっているのですが、Jest 実行時には Nuxt アプリケーションを起動しないので、Composition API が存在していないよと怒られているものです。

    回避策として、jest.config.js に以下のように Composition API のエントリポイントを指定してやることで、Jest でも Composition API が読み込めるようになります。

      moduleNameMapper: {
        '^@/(.*)$': '<rootDir>/$1',
        '^~/(.*)$': '<rootDir>/$1',
        '^vue$': 'vue/dist/vue.common.js',
        // '@nuxtjs/composition-api': '@nuxtjs/composition-api/lib/entrypoint.js',
      },

    (参考)https://itoka.hatenadiary.com/entry/2021/05/09/180523

    dispatch / getter の引数にモジュール名あり / なしをうまく吸収する

    もっと良い回避策があればここは教えてもらいたいところなのですが、ひとまずこうやって動かしたということでシェア。

    Nuxt.js の Vuex の仕様で、モジュールモードというものがあり、Store をファイル単位で分割できるオプションが存在します。たとえば store/todos.ts というファイルを作成すれば、特に実装者は何も意識することなく、このモジュールの Store を使用することができるようになります。

    Vue.js ファイル、もしくは Composable ファイルからこのモジュールにアクセスするには、たとえば dispatch の場合だと、以下のように モジュール名/アクション名 というアクセス方法になります。

    // Composition API 内での Store へのアクセス
    
    const store = useStore();
    store.dispatch('todos/addTodo', todo);

    しかし、さきほどの説明で Jest 内でモジュール単体を指定して Store を初期化しているため、テストファイル内での dispatch のアクセスでは、 アクション名 だけで Store にアクセスすることができてしまいます。

    // 今回の設計での Jest 実行時の Store へのアクセス
    store.dispatch('addTodo', todo);

    この差分をうまくテスト側で吸収してやらないと、うまくテストが実行できなかったため、またここも spyOn を使って、dispatch の第一引数をモック化することにしました。

    // Composition API 側
    export const getActionName = () => 'todo/addTodo';
    await store.dispatch(getActionName(), todo);
    
    // テスト側
    const spy = jest.spyOn(c, 'getActionName');
    spy.mockReturnValue('addTodo');

    うまく動かせましたが、正直ここはイマイチ感あるので、もっと良いやり方あったら教えて下さい。(そもそも Jest で Vuex の Store の初期化がモジュール全体でできれば良いのですが、やり方が分からなかった)