kitak's blog

Kみたいなエンジニアになりたいブログ

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 を対象にテストを実施することが可能です。

最近読んだ漫画

とあるカメラを巡って、一匹狼の登山家と彼を追うジャーナリストが話を動かしていく。
谷口ジローの絵の描き込みがすごい。登山の場面もさることながら、渋谷や新宿の居酒屋が細部まで緻密に描かれていて、酒を飲みたくなる。


蕭何のモデルが明らかに遠藤憲一でウケる。
劉邦のだらしないキャラがギャグっぽい描き方で際立っていて面白い。でも、このペースだと天下を取るまでにどんだけかかるんだろうね...


料理がおいしそうな、のほほん漫画と思いきや、策略・謀略の話が途中から増えてきて、なかなか読み応えのある作品。


地底旅行 1 (ビームコミックス)

地底旅行 1 (ビームコミックス)

気狂いのおじさんに付き従って、地底に探検に行く物語。
19世紀のSF小説が基になっているので「いやいや!」とツッコミを入れたくなるところも多いのだけど、当時わかっている科学的事実でロジックを組み上げている。


1122(1) (モーニング KC)

1122(1) (モーニング KC)

はてなにも匿名で書くからね」というインパクトのあるコマをTwitterで見かけて、買った。
仲がいい夫婦なのだけど、婚外恋愛の上になりたっていて、どうみてもフラジャイルな関係。正直、良い終わり方がまったく想像できない...


七都市物語(2) (ヤングマガジンコミックス)

七都市物語(2) (ヤングマガジンコミックス)

騙そうとするもの、騙されたふりをして騙そうとするもの、都市間の駆け引きが面白い。田中芳樹の物語は、人を喰ったというか、斜に構えた良い味のキャラクターが多いのが良い。

Rails + Hypernova なアプリを Heroku にデプロイする

個人メモ

の続き。

Rails + Hypernova で作った趣味アプリをデプロイすることに。

最近は Firebase を使うことも増えてきたのだけど、Express や Rails で作った API やウェブアプリのデプロイには、Heroku や Now などの Docker に対応している PaaS を使っている。

Docker 対応の PaaS で Rails + Hypernova のアプリをデプロイする際に、 1コンテナ 1サービス のお作法に従うと、Rails と Node でそれぞれ PaaS のアプリケーションを用意して、デプロイしないといけない。 このようにアプリケーションを分割すると、Rails と Node で同じコンポーネントのファイルを使用する事情から、正しく動かすには Rails と Node のデプロイ完了のタイミングを(可能な限り)揃える必要がある。

各ロールでさらに複数のアプリケーションを作っておいて、Blue-Green Deployment のようなことを実現するか、コンポーネントをHypernova に登録する際の名前にバージョンを含めるようにして、過去のバージョンも扱えるようにすればできそうだが、前者はデプロイタスクが煩雑になるし、後者はコードの管理が複雑になるので、趣味アプリでそこまでやる必要性は感じない。 また、Heroku や Now では、アプリケーション同士は内部ネットワークでやりとりできず、外部ネットワークを通す形になるのでレイテンシーも気になる(計測はしていない)。

上記の理由と Wantedly でも Rails のコンテナに Hypernova を同居させてうまくいっているようなので( ref: Rearchitecting Wantedly's Frontend | Wantedly Engineer Blog )、1コンテナで Rails と Hypernova 両方のサービスを動かすことにした。

Rails の対応

Hypernova に限らず Heroku にデプロイするために必要な対応。

Heroku では静的ファイルの配信をアプリケーションサーバーがおこなう必要があるので、config/environments/production.rb に以下を書く。

config.public_file_server.enabled = true

また、Heroku の制約でログを標準出力に書き出す必要があるので、RAILS_LOG_TO_STDOUT 環境変数を設定する。これは設定ファイルにデフォルトで以下のような記述がある。

  if ENV["RAILS_LOG_TO_STDOUT"].present?
    logger           = ActiveSupport::Logger.new(STDOUT)
    logger.formatter = config.log_formatter
    config.logger    = ActiveSupport::TaggedLogging.new(logger)
  end

昔は、rails_12factor gem を入れて上記と同じ対応していたのだけど、最近は、そういった gem を入れる必要がなくなったぽい。

Docker の対応

複数のサービスを動かすために Run multiple services in a container | Docker Documentation を参考に supervisor を使うことにした。

以下のように ruby:2.5.1 のイメージをベースに apt-get で supervisor を入れ、必要なディレクトリを掘り、設定ファイルをコピーして、supervisord を起動する。

FROM ruby2.5.1

#...

RUN apt-get update && apt-get install -y supervisor

# ...

RUN mkdir -p /var/log/supervisor
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf

# ...

CMD ["/usr/bin/supervisord"]

supervisord の設定は以下のようなかんじ。Rails や Hypernova のプロセスの標準入出力を supervisord にリダイレクトするために Dockerで子プロセスからのstdoutをsupervisordにリダイレクトする方法 – 踊る犬.netブログ (旧) を参考にした。

[supervisord]
nodaemon=true
directory=/usr/src/app

[program:rails]
command=/bin/bash -c "rails server -b 0.0.0.0"
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:hypernova]
command=/bin/bash -c "node hypernova.js"
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

悪習を繰り返そうとしたときにフフッと笑う

最近、試しているやつ。何かの本か記事で読んだ気がするのだけど、思い出せない。

タイトルの通りなのだけど、自分が悪い習慣をおこなおうとしたときにフフッと笑うようにすると、その行動を抑制できる(ような気がする)。

例えば、自分の場合だと数分おきに自然とスマホに手が伸びてしまうことがあるのだけど、そういうときにフフッと笑う。そうすると、行動をおこそうとしている自分を客観的に見ているもうひとりの自分が「お前ってやつは、しょうがないやつだなぁ」と言っている気がして、伸ばしかけた手を引っ込めることができる。

脳には、身体行動を先におこなうと、それに見合った感情が形成されるというクセがある。例えば、とりあえず口角をあげると幸せな気持ちになる、といったものがよく知られている。このクセを利用して、フフッと笑うという身体行動をとりあえずおこなうことで、悪習を抑制する感情を形成させるというわけである。

とはいえ、傍から見たら、スマホに手を伸ばしかけた人間がフフッと笑っているのは気味が悪いので、さりげなくやるのがポイントだと思う。

Heroku Container Registry で Rails アプリをデプロイするときにハマったこと

個人用メモ。

久しぶりに Heroku を触ったら、Dockerfile のコマンドに一部制限はあるものの手元で作ったイメージを動かすことができるようになっているらしく、おお、となった。buildpack を扱うのを避け続けてきた人生でした。

Hypernova の検証のために、Heroku Container Registry を使って Rails アプリのデプロイをやってみたが、アプリケーションエラーが出る。heroku logs コマンドでログを眺めるがよく分からない。heroku run rails console も試すが、コケる。スタックトレースで、pg が入っていないのが原因だと分かる。

多分、Rails アプリの Docker 化をやったことがある人は知っているか、一度はハマったことがあると思うのだけど、手元で叩いた --without production.bundle/config に記録されたのを COPY でまるごと移してしまっていたのが原因だった。 .dockerignore.bundle を追加して解決した。

あと、本筋と関係のないところで、最初に git push でアプリをデプロイして、諸々のアドオンを導入した後に、Heroku Container Registry プラグインを使うようにしたのだけど、アドオンや環境変数の設定はそのまま引き継がれるようだった。

Hypernova で Vue.js のコンポーネントを Rails でレンダリングする

個人メモ。

Rearchitecting Wantedly's Frontend | Wantedly Engineer Blog の記事を読んで、副業先でも使えるかもと思い、Hypernova を触っていた。

Hypernova を使う個人的なモチベーション

リリースまで至った、それなりの規模の SPA ではないブラウザナビゲーションで遷移する Rails アプリを想定する。リッチな UI の実現と保守性の観点から JavaScript フレームワークの導入をおこなう。導入自体は Webpacker のような仕組みで簡単にできるようになったが、ビューのロジックがあちこちに存在するという課題が生まれる。ロジックとは、erb や haml などのテンプレートに書く(サーバーサイドで一度決定したら変わることはないという意味で)静的なロジックと、JavaScript フレームワークが担うUI操作などのイベントに応じて実行される動的なロジックのふたつである。
これらのファイル、あるいはテンプレートの仕様を行き来し( erb, haml <-> react, vue, angular )、頭を切り替えながら実装するのは個人的にはけっこうキツい。

JavaScript フレームワークに静的なロジックを担わせることもできるが、SEOや初期表示の観点から、どこまでをクライアントサイドでレンダリングするか、という問題が出てくる。 理想を言えば、JavaScriptコンポーネントのコードを Railshaml などのテンプレートエンジンと同様に扱えるとよい。Hypernova を使えば、これを実現することができる。

Node.js プロセスの面倒をみるという運用の手間が増えるが、エラーハンドリングの仕組みがよくできているので、最悪 Node.js のプロセスが落ちても、クライアントサイドのレンダリングにうまくフォールバックしてくれる。

Hypernova を Vue.js で使う

Hypernova、React に限らず SSR ができる JavaScript フレームワークならば何でも扱えそうな造りに見えつつ、Vue.js のコンポーネントを扱う話をあまり見かけない。こういう issueがあるということは多分やろうとしている人はいるんだろう、というのと、hypernova-react のコードを見たらファイルが一枚だけあって、これと同じことをするラッパーを用意すればよさそうだな、と思って書いた。こんなかんじ。

import Vue from 'vue'
import hypernova, { serialize, load } from 'hypernova';

export const renderVue = (name, component) => {
    const Component = Vue.extend(component);
    return hypernova({
        server() {
            return (props) => {
                const { createRenderer } = __non_webpack_require__('vue-server-renderer');
                const renderer = createRenderer();
                const vm = new Component({ propsData: props });
                return new Promise((resolve, reject) => {
                    renderer.renderToString(vm, (err, contents) => {
                        if (err) {
                            console.error(err);
                            reject(err);
                            return;
                        }
                        resolve(serialize(name, contents, props));
                    });
                });
            };
        },
        client() {
            const payloads = load(name);
            if (payloads) {
                payloads.forEach((payload) => {
                    const { node, data } = payload;
                    const vm = new Component({ propsData: data });
                    vm.$mount(node);
                });
            }
            return component;
        }
    });
};

サーバーとクライアント両方で、Webpack でビルドする前提。Webpack 依存のコード(__non_webpack_require__)が入っているのがイケてない。Webpack の設定でクライアントのビルドの場合は vue-server-renderer を除外してもよさそう。ここらへんの問題が解決できたら、hypernova-vueとして npm に公開したい。

使う時は、Vue コンポーネントのオプションオブジェクトを先のファイルで export している renderVue 関数でラップする。後は、Hypernova の設定で、name に応じて、ラップしたコンポーネントを返すようにする。

import {renderVue} from './hypernova-vue';
import Like from './Like.vue';

export default renderVue('like', Like);

Rails のテンプレートからコンポーネントを呼び出す場合は、以下のようになる。ヘルパー名が react 決め打ちなのがアレだけれども alias か自前で render_vue_component ヘルパーを定義すればいいと思う。

<%= render_react_component('like', liked: false) %>

nuxt-community/typescript-template から生成したプロジェクトに express の API server を組み込む

個人メモ。

GitHub - nuxt-community/typescript-template: Typescript starter with Nuxt.js から生成したプロジェクトで色々開発をしていて、途中で JSON を返す API Server を組み込みたくなった(別リポジトリにしたり、実行環境を分けるのも面倒だった)。

API: Nuxt(options) - Nuxt.js にあるように、Nuxt は nuxt コマンドを通さずに、プログラムからサーバーを起動することができる。また、インスタンスの render プロパティを express の middleware として登録することができる。
問題は API Server の TypeScript のコードのコンパイルだよなーと思って、色々探していたら、自分が求めていることを全部やっているテンプレートを見つけた。

github.com

backpack という Node.js 用の諸々入りのビルドツールを使っている。いったん、このテンプレートの backpack 周辺と server ディレクトリのコードを参考にして目的を達成した。

多分、TypeScript → JavaScriptコンパイルだけできればよいので、開発時は ts-node でサーバーを起動して、本番は tscコンパイルしたコードを動かすだけでよいので、backpack 使わなくてもいいんじゃないかな、という気もする。