Nuxt.js で Vuex の結合テストを書く方法を考えた

この間、友人と焼き肉を食べていて、「Nuxt.js で Vuex の結合テスト書くのどうやってますか?」という話になったので、考えてみました。

Vuex の結合テストの定義

この記事では、アクションやミューテーション単体ではなく、 アクションをディスパッチした結果のステートやゲッターが意図通りになっているか確認するテストのことを、Vuex の結合テストと呼びます。

方針

Vuex の結合テストで課題となるのは、テスト対象の Vuex Store インスタンスの生成です。

今回は、(少し強引ですが)Nuxt.js のドキュメントのエンドツーエンドテストのサンプル( 開発ツール - Nuxt.js )をベースに Store インスタンスの生成を Nuxt.js に任せることにしました。

最初、モジュールモードの場合に Nuxt.js が内部でおこなっている Store オプションの組み立て( nuxt.js/store.js at e0cc9a1cc6ffdfc87f944d0669ec1dd8d35130c9 · nuxt/nuxt.js · GitHub )をテストコードで再現しようと思いましたが、込み入った処理なのと、アクションのコードで利用している Nuxt.js のプラグインや Webpack を利用した環境変数などの仕組みの再現も考える必要があり、コストが高いと考えて諦めました。

また、Store インスタンスの生成を Nuxt.js に任せることで、(スタブ・モック化はしますが)実際に動作するコードに限りなく近い対象をテストすることが可能になります。

手順

  • テストを実行するためのコンポーネントを作成する
  • Nuxt コンストラクタのオプションの extendRoutes を用いて、上記のコンポーネントレンダリングするルーティングの設定を追加する
  • テストでスタブ・モック化したい処理、例えば、action 内でおこなっている通信やストレージを扱う処理は、事前に Nuxt.js プラグインにしておき、テストでは必要に応じてスタブ・モックに差し替える
  • renderRoute で上記のルートを呼び出し、テストの関数をコンポーネントfetch フックで実行する

サンプルコード

コードで具体的にみていきます。

ストアのコード

テスト対象の小さいストアのコードです。記事をDBから取得して、ステートにセットします。

export const state = () => ({
  entry: null,
});

export const mutations = {
  setEntry(state, doc) {
    state.entry = doc;
  }
};

export const actions = {
  async fetchEntry({commit}, entryId) {
    const doc = await this.$db.get(`entry_${entryId}`).catch(() => null);
    commit('setEntry', doc);
  }
};

DBのプラグインのコード

DB へのアクセスは、Nuxt.js プラグインによって注入されたオブジェクト(this.$db)を通して行います。データベースは、なんでもよいのですが、このサンプルでは PouchDB ということにします。プラグインの内容を以下に示します。

import PouchDB from 'pouchdb';

export default (_, inject) => {
  const db = new PouchDB('http://localhost:5984/entries');
  inject('db', db);
};

テストに関係するコード

テストの関数を実行するコンポーネント

テストコードから渡された関数を、Store のインスタンスやスタブ・モックオブジェクトに差し替えるための関数(inject)を引数として呼び出すための Page コンポーネントです。 pages 配下に設置するとテスト実行時以外にもアクセスできてしまうので、test ディレクトリ配下に execute-test.vue という名前で保存します。

<template>
  <p>Component for testing</p>
</template>

<script>
export default {
  async fetch(context) {
    const {app, req} = context;
    try {
      await req.test(context, app.$inject);
    } catch (e) {
      throw e;
    }
  }
};
</script>

injector プラグイン

Nuxt.js プラグインで注入されたオブジェクトをテストコードで、スタブ・モックのオブジェクトに差し替えるためのプラグインを用意します。plugins の配下に injector.js という名前で保存します。

export default (_, inject) => {
  inject('inject', inject);
};
ava を使ったテストコード

テストコードです。Nuxt.js のドキュメントのエンドツーエンドテストをベースにしているので、ava をつかっていますが、jest など他のテストフレームワークやテストランナーを使っても問題ありません。
解説するポイントが複数あるので、下で詳しく解説します。

import test from 'ava';
import sinon from 'sinon';
import { Nuxt, Builder } from 'nuxt';
import { resolve } from 'path';

let nuxt = null;

test.before('Init Nuxt.js', async t => {
  const rootDir = resolve(__dirname, '..');
  let config = {};
  try { config = require(resolve(rootDir, 'nuxt.config.js')) } catch (e) {};
  config.rootDir = rootDir; // project folder
  config.dev = false; // production build

  config.env = config.env || {};
  // テスト実行時か判断するための環境変数を設定する
  config.env.testEnabled = true;

  config.router = config.router || {};
  config.router = {
    ...config.router,
    extendRoutes (routes, resolve) {
      // テストを実行するためのルートを追加する
      routes.push({
        name: 'execute-test',
        path: '/test/execute',
        component: resolve(__dirname, 'execute-test.vue')
      });
    }
  };

  config.plugins = config.plugins || [];
  // プラグインで注入されたオブジェクトを差し替えるためのプラグイン
  config.plugins.push('~/plugins/injector.js');

  nuxt = new Nuxt(config);
  await new Builder(nuxt).build();
  nuxt.listen(4000, 'localhost');
});

test('プラグインで注入されたオブジェクトの一部のメソッドをスタブにする', async t => {
  const test = async (context, _) => {
    // DBへのアクセスをスタブ化
    const db = context.app.$db;
    const get = sinon.stub(db, 'get')
    get.returns(Promise.resolve({
      _id: "entry_kitak",
      _rev: "1-c507349e68734f039c71a5050a3cac67",
      content: "Hello!",
      title: "kitak",
    }));

    // actionのdispatchとstateの確認
    await context.store.dispatch('fetchEntry', 'kitak');
    t.is(context.store.state.entry.content, 'Hello!');
  };
  await nuxt.renderRoute('/test/execute', {
    req: {
      test,
    },
  });
});

test('プラグインで注入されたオブジェクトをスタブ・モックオブジェクトで注入しなおす', async t => {
  const test = async (context, inject) => {
    // DBへのアクセスをスタブ化
    const db = {
      get() {},
    };
    sinon.stub(db, 'get').callsFake(function () {
      return Promise.resolve({
        _id: "entry_kitak",
        _rev: "1-c507349e68734f039c71a5050a3cac67",
        content: "こんにちは!",
        title: "kitak",
      });
    });
    inject('db', db);

    // actionのdispatchとstateの確認
    await context.store.dispatch('fetchEntry', 'kitak');
    t.is(context.store.state.entry.content, 'こんにちは!');
  };
  await nuxt.renderRoute('/test/execute', {
    req: {
      test,
    },
  });
});

// Nuxt サーバーをクローズする
test.after('Closing server', t => {
  nuxt.close();
});

テストコードの解説と考慮すべきこと

設定オプションの変更

before フックでテスト対象のアプリケーションのビルドとサーバーの起動をおこなっています。ポイントはテストのために Nuxt コンストラクタの設定オプションを変更することです。

まず、環境変数testEnabled というプロパティを追加しています。

config.env = config.env || {};
// テスト実行時か判断するための環境変数を設定する
config.env.testEnabled = true;

今回は利用していませんが、テスト時に実行したくないコード、例えば、Vuex のプラグインでストレージに書き込んだり、他のサーバーにイベントを送信するといった処理を回避するために利用します。

2つ目がテストを実行するページコンポーネントにアクセスするためのルートの追加です。

config.router = config.router || {};
config.router = {
  ...config.router,
  extendRoutes (routes, resolve) {
    // テストを実行するためのルートを追加する
    routes.push({
      name: 'execute-test',
      path: '/test/execute',
      component: resolve(__dirname, 'execute-test.vue')
    });
  }
};

最後がプラグインで注入したオブジェクトをスタブ・モックのオブジェクトに差し替えるためのインジェクタープラグインの追加です。

config.plugins = config.plugins || [];
// プラグインで注入されたオブジェクトを注入し直すためのプラグイン
config.plugins.push('~/plugins/injector.js');

最初は、テストケースごとにスタブやモックに差し替える Nuxt.js プラグインを用意して、アプリケーションをビルドしようかと考えていましたが、ビルドに10~20秒程度時間がかかるので、ビルドは before フックでの一回のみにし、汎用的なインジェクタープラグインを用意することにしました。

Nuxt.js プラグインで注入されたオブジェクトのスタブ・モック化

今回のアプローチでは、Nuxt.js がビルドしたコードをテスト対象にしているので、proxyquire などの import 文にフックさせてスタブ・モックさせるライブラリは利用できません。
DB や ストレージにアクセスしたり、環境に応じて処理を分岐するコードを、 積極的に Nuxt.js プラグインにして、スタブ・モック化を図ります。
スタブのためのライブラリとして、今回、Sinon.js を使いました。

サンプルコードでは、ふたつテストケースがありますが、プラグインで注入されたDBにアクセスするオブジェクトをどうスタブするかが異なります。

1つ目はメソッドレベルでスタブに変更し、2つ目はスタブオブジェクトを作成し、injector プラグインで追加された関数を利用して、注入し直して差し替えています。
どちらの方法を使うかは状況に応じて、使い分けると良いでしょう。

スタブの後始末とテストの並列化について

今回のサンプルコードでは、スタブの後始末をおこなっていませんが、これは Nuxt.js がリクエスト毎にコンテキストを生成するためです。今回のテストに関係する箇所でいうと、リクエスト毎にプラグインの関数の呼び出し(dbオブジェクトの生成)と Store インスタンスの生成が行われます。
これはテストを実行する上で非常に有用な性質です。後始末の記述を不要にする他に、テストの並列実行を可能にします。

ただし、プラグインで、関数の外側で生成したオブジェクトを変数にキャッシュして、再利用しているような場合は、リクエストのコンテキストにまたがって同じ参照を共有するので、後始末が必要になります。
例えば、今回の DB のプラグインでは以下のようなコードが関数の外側で生成したオブジェクトを変数にキャッシュして再利用しているコードです。

import PouchDB from 'pouchdb';

const db = new PouchDB('http://localhost:5984/keywords');

export default (_, inject) => {
  inject('db', db);
};

後始末は、1つ目のテストケースの場合は db.get.restore() を呼び出し、 2つ目のテストケースの場合はオリジナルのオブジェクトを取っておいて終了時に注入し直します。※

const originalDB = context.app.$db;

// スタブ化やアクションのディスパッチなど
// ...

inject('db', originalDB);

また、ava はデフォルトでテストを並列で実行しますが、リクエストのコンテキストにまたがって同じ参照を共有している場合、スタブの処理が競合する可能性があります。この場合、テストは直列で実行する必要があるので注意が必要です。 ava で直列にテストを実行するには --serial オプションを指定します。

ava --serial

※ この処理を毎回書くのが大変な場合は injector を改良することをおすすめします。注入用の関数をラップした関数を用意し、その関数でオリジナルのオブジェクトを保存する処理を追加したり、保存しておいたオブジェクトに戻す関数を提供してもよいかもしれません。

アクションのディスパッチとステートの確認

ここはコードに書かれている通りなので、特に解説はおこないません。

テストの実行

テストを実行するパスに対してrenderRouteを呼び出します。

await nuxt.renderRoute('/test/execute', {
  req: {
    test,
  },
});

テストの関数は、reqオブジェクトのパラメータとして、コンポーネントではコンテキストから参照するようにします。

async fetch(context) {
  const {app, req} = context;
  try {
    await req.test(context, app.$inject);
  } catch (e) {
    throw e;
  }
}

まとめ

今回は Nuxt.js のドキュメントのエンドツーエンドテストのサンプルをベースに、Vuex の結合テストを記述・実行する方法を紹介しました。 Nuxt.js のプラグインの仕組みを使って、必要な箇所をスタブ・モック化しつつ、Nuxt.js がインスタンス化した Store を対象にテストを実施することが可能です。