コンポーネントの横断的関心事を処理する方法 Mixins, HOCs, Render Props, Hooks を振り返る

これは何か

複数のコンポーネントが必要とする機能を共有可能にする方法を整理してみました。プロコンとこれから。基本的には 参考 URLs にあるドキュメントの切り貼りです。

キーワード

横断的関心事とは

コンポーネントは React のコード再利用における基本単位です。しかし、いくつかのパターンの中には、これまでのコンポーネントが素直に当てはまらないことがあることに気づいたかもしれません。

https://ja.reactjs.org/docs/higher-order-components.html

具体例として以下のようなコンポーネントを考えてみます。

class CommentList extends React.Component {
  constructor(props) { /* ... */ }
  componentDidMount() {
    // Subscribe to changes
    DataSource.addChangeListener(this.handleChange);
  }
  componentWillUnmount() {
    // Clean up listener
    DataSource.removeChangeListener(this.handleChange);
  }
  handleChange() {
    // Update component state whenever the data source changes
    this.setState({
      comments: DataSource.getComments()
    });
  }
  render() {
    return (
      <div>
        {this.state.comments.map((comment) => (
          <Comment comment={comment} key={comment.id} />
        ))}
      </div>
    );
  }
}
// https://ja.reactjs.org/docs/higher-order-components.html

上のコードは CommentList だけですが、もう片方のコンポーネントも大部分の実装は同じになります。

  • コンポーネントのマウント時に DataSource にイベントリスナを登録
  • リスナの内部で setState をデータソースが変更されるたびに呼び出す
  • コンポーネントのアンマウント時にはイベントリスナを削除

横断的関心事とは、このような複数のコンポーネントに共通しているが1箇所にまとめるのが難しい関心事のことです。横断的関心事のロジックを1つの場所に定義し、複数のコンポーネントで共有可能にするために Mixins, HOCs, Render Props, Hooks といったテクニックが有用なことがあります。

Higher-Order Components

高階関数は「引数として関数をとる」「関数を戻り値として返す」関数です。では高階コンポーネントとはどういうものでしょうか。

具体的には、高階コンポーネントとは、あるコンポーネントを受け取って新規のコンポーネントを返すような関数です

https://ja.reactjs.org/docs/higher-order-components.html

const EnhancedComponent = higherOrderComponent(WrappedComponent);

前述の CommentList コンポーネントを高階コンポーネントを利用してリファクタリングすると以下のようになります。 withSubscription が高階コンポーネントです。

const CommentListWithSubscription = withSubscription(
  CommentList,
  (DataSource) => DataSource.getComments()
);
const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id)
);
// https://ja.reactjs.org/docs/higher-order-components.html
function withSubscription(WrappedComponent, selectData) {
  return class extends React.Component {
    constructor(props) { /* ... */ }
    componentDidMount() {
      DataSource.addChangeListener(this.handleChange);
    }
    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }
    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props)
      });
    }
    render() {
      // ... and renders the wrapped component with the fresh data!
      // Notice that we pass through any additional props
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}
// https://ja.reactjs.org/docs/higher-order-components.html

より具体的な説明:

HOC は入力のコンポーネントを改変したり、振る舞いをコピーするのに継承を利用したりしません。むしろ HOC は元のコンポーネントをコンテナコンポーネント内にラップすることで組み合わせるのです。HOC は副作用のない純関数です。

... 外側にある HOC は渡すデータが使われる方法や理由には関心がありませんし、ラップされたコンポーネントの側はデータがどこからやって来たのかには関心を持ちません。

https://ja.reactjs.org/docs/higher-order-components.html

アンチパターンとして:

コンポーネントの改変を行うような HOC は不完全な抽象化です。つまり、利用する側は他の HOC との競合を避けるため、どのように実装されているかを知っておく必要があるのです。

https://ja.reactjs.org/docs/higher-order-components.html

Vue と Higher-Order Components

高階コンポーネントフレームワークが提供する機能ではなく汎用的な考え方なので、他のフレームワークでも実現可能です。

Render Props

“レンダープロップ (render prop)”という用語は、値が関数である props を使って、コンポーネント間でコードを共有するためのテクニックを指します。

... より具体的に述べると、レンダープロップとは、あるコンポーネントが何をレンダーすべきかを知るために使う関数の props です。

https://ja.reactjs.org/docs/render-props.html

<DataProvider render={data => (
  <h1>Hello {data.target}</h1>
)}/>

具体例として「マウスに追従する猫」を以下の2つにわけて実装してみます。

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        <Mouse render={mouse => (
          <Cat mouse={mouse} />
        )}/>
      </div>
    );
  }
}
// https://ja.reactjs.org/docs/render-props.html
class Cat extends React.Component {
  render() {
    const mouse = this.props.mouse;
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
    );
  }
}
class Mouse extends React.Component {
  constructor(props) { /* ... */ }
  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }
  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        {/*
          Instead of providing a static representation of what <Mouse> renders,
          use the `render` prop to dynamically determine what to render.
        */}
        {this.props.render(this.state)}
      </div>
    );
  }
}
// https://ja.reactjs.org/docs/render-props.html

Vue の Scoped Slots

Higher-Order Components と同様に Rendre Props も機能 (API) ではなく考え方です。それゆえ Vue でも同様な考え方を利用してコンポーネントのロジック共有が可能です。

通化させたい振る舞いをもつコンポーネントに何をレンダーさせるか動的に決定させる方法として Vue の場合 Scoped Slots を利用することができます。

In case you are wondering what’s the equivalent pattern in Vue, it’s called scoped slots (and if using JSX it works the same as React)

https://twitter.com/youyuxi/status/912422434300710912

以下は VeeValidate の ValidationProvider を利用した実装例です。

<ValidationProvider name="name" rules="required" v-slot="{ errors }">
  <input v-model="name">
  <p>{{ errors[0] }}</p>
</ValidationProvider>

Render Props と Higher-Order Components

ミックスインの問題を解決させる方法として HOCs が広まったようですが、HOCs にもいくつかの問題があったようです。以下は Reneder Props でそのあたりも解決できるよ、という話。

This technique avoids all of the problems we had with mixins and HOCs:

  • Indirection. We don’t have to wonder where our state or props are coming from. We can see them in the render prop’s argument list.
  • Naming collisions. There is no automatic merging of property names, so there is no chance for a naming collision.

...

Additionally, the composition model here is *dynamic*!

Use a Render Prop! - componentDidBlog

Mixins

コンポーネントのロジック共有のテクニックとしてミックスインがどうして利用されなくなったのかでしょうか。

以前に横断的関心事を処理する方法としてミックスインをお勧めしました。私たちはその後にミックスインはそれが持つ価値以上の問題を引き起こすことに気づきました。ミックスインから離れる理由と、既存のコンポーネントを移行する方法についてはこちらの詳細な記事を読んでください

https://ja.reactjs.org/docs/higher-order-components.html

上の引用にもある Mixins Considered Harmful – React Blog がよくまとまっていました。私が利用していたのは Vue のミックスインですが、ほぼ共感できる内容でした。以下は記事内の "Why Mixins are Broken" より:

  • Mixins introduce implicit dependencies
    • 暗黙的な依存関係が生まれてしまう。安全にコードを変更することが難しくなるし、新しいメンバーが理解するハードルもあがる。
    • コンポーネントがミックスインのメソッドを呼び出すこともあれば、ミックスインがコンポーネントのメソッドを呼び出すこともある。
    • コンポーネントとは違いミックスインの間には階層構造がなくフラットな関係なため、データの流れや依存関係がどのようになっているのか把握するのが難しくなる。
  • Mixins cause name clashes
    • 例えば適用させる複数のミックスインが同じメソッド名を持つ場合
  • Mixins cause snowballing complexity

Mixins => HOCs => Render Props => Hooks

フックは React 16.8 から追加された React の API です。フックを導入した動機が フックの導入 – React に書かれています。

ステートフルなロジックをコンポーネント間で再利用するのは難しい

... React をしばらく使った事があれば、この問題を解決するためのレンダープロップ高階コンポーネントといったパターンをご存じかもしれません。しかしこれらのパターンを使おうとするとコンポーネントの再構成が必要であり、面倒なうえにコードを追うのが難しくなります。典型的な React アプリを React DevTools で見てみると、おそらくプロバイダやらコンシューマやら高階コンポーネントやらレンダープロップやら、その他諸々の抽象化が多層に積み重なった『ラッパー地獄』を見ることになるでしょう。... React にはステートフルなロジックを共有するためのよりよい基本機能が必要なのです。

フックを使えば、ステートを持ったロジックをコンポーネントから抽出して、単独でテストしたり、また再利用したりすることができます。フックを使えば、ステートを持ったロジックを、コンポーネントの階層構造を変えることがなく再利用できるのです。このため、多数のコンポーネント間で、あるいはコミュニティ全体で、フックを共有することが簡単になります。

フックの導入 – React

Render Props や HOCs との関係についても書かれています。

フックはレンダープロップや高階コンポーネントを置き換えるものですか?

レンダープロップや高階コンポーネントは、ひとつの子だけをレンダーすることがよくあります。フックはこのようなユースケースを実現するより簡単な手段だと考えています。これらのパターンには引き続き利用すべき場面があります(例えば、バーチャルスクローラーコンポーネントrenderItem プロパティを持つでしょうし、コンテナコンポーネントは自分自身の DOM 構造を有しているでしょう)。とはいえ大抵の場合ではフックで十分であり、フックがツリーのネストを減らすのに役立つでしょう。

フックに関するよくある質問 – React

ロジック共有のためにコンポーネントを利用する必要なんてないんですよ。

Vue Composition API

Vue に React Hooks のような関数ベースのロジック合成 API は追加されるのでしょうか。

( ´・ω) (´・ω・) (・ω・`) (ω・` ) ネー

Composition API RFC | Vue Composition API:

ここで詳しい情報を確認できるようです。

Motivation

Logic Reuse & Code Organization

...

Better Type Inference

...

Code Organization

...

But as mentioned in the Motivations section, we believe the Composition API actually leads to better organized code, particularly in complex components. Here we will try to explain why.

What is "Organized Code"?

...

export default {
  setup() { // ...
  }
}

function useCurrentFolderData(networkState) { // ...
}

function useFolderNavigation({ networkState, currentFolderData }) { // ...
}

function useFavoriteFolder(currentFolderData) { // ...
}

function useHiddenFolders() { // ...
}

function useCreateFolder(openFolder) { // ...
}

Logic Extraction and Reuse

The Composition API is extremely flexible when it comes to extracting and reusing logic across components.

...

import { ref, onMounted, onUnmounted } from 'vue'

export function useMousePosition() {
  const x = ref(0)
  const y = ref(0)
  function update(e) {
    x.value = e.pageX
    y.value = e.pageY
  }
  onMounted(() => {
    window.addEventListener('mousemove', update)
  })
  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })
  return { x, y }
}

export default {
  setup() {
    const { x, y } = useMousePosition()
    // other logic...
    return { x, y }
  }
}

( ^ω^)

参考 URLs

ESLint の設定 (Nuxt TypeScript)

Nuxt で開発する時の ESLint の設定ファイルです。Nuxt のバージョンは 2.10 です。

前提

構文チェックに ESLint, コードフォーマットには Prettier を利用します。eslint-plugin-prettier を利用して ESLint 経由で Prettier を動かします。

参考:
Integrating with Linters · Prettier

設定ファイル

npm i -D @nuxtjs/eslint-config-typescript eslint eslint-config-prettier eslint-plugin-nuxt eslint-plugin-prettier prettier
// .eslintrc.js
module.exports = {
  root: true,
  extends: [
    'eslint:recommended',
    '@nuxtjs/typescript',
    'plugin:nuxt/recommended',
    'plugin:vue/strongly-recommended',
    'plugin:prettier/recommended',
    'prettier/vue'
  ],
  rules: {
    // Add rules
  }
}

ESLint の設定ファイルを読む

ざっくりです。

基本知識

rules に設定されたルールがチェックされます。

module.exports = {
  rules: {
    semi: ['error']
  }
}

ESLint のルール以外の構文チェックを設定したい場合 plugins を追加します。プラグインの名前から eslint-plugin- を省略することができます。eslint-plugin-vue なら vue で設定可能です。

module.exports = {
  plugins; ['vue'],
  rules: {
    'vue/html-self-closing': ['error']
  }
}

参考:
https://eslint.org/docs/user-guide/configuring#configuring-rules
https://eslint.org/docs/user-guide/configuring#configuring-plugins

実際に適用されるルールを確認

ESLint には --print-config path::String というコマンドが用意されています。これを利用することで、最終的にどのような設定が適用されるのか確認ができます。

node_modules/.bin/eslint --print-config pages/index.vue
{
  "env": {
    "browser": true,
    "node": true,
    "es6": true,
    "jest/globals": true
  },
  "globals": {
    "document": false,
    "navigator": false,
    "window": false
  },
  "parser": "<PATH_TO_PROJECT>/node_modules/vue-eslint-parser/index.js",
  "parserOptions": {
    "ecmaVersion": 2018,
    "sourceType": "module",
    "ecmaFeatures": {
      "jsx": true
    },
    "parser": "@typescript-eslint/parser"
  },
  "plugins": [
    "standard",
    "promise",
    "node",
    "import",
    "unicorn",
    "jest",
    "@typescript-eslint",
    "nuxt",
    "vue",
    "prettier"
  ],
  "rules": {
    "vue/html-self-closing": [
      0
    ],
    "vue/array-bracket-spacing": [
      "off"
    ],
    // ... (数が多いので省略)
  },
  "settings": {
    "import/resolver": {
      "node": {
        "extensions": [
          ".js",
          ".mjs"
        ]
      }
    }
  }
}

.eslintrc.js にはなかった pluginsrules の設定が追加されているのが確認できると思います。

設定ファイルを拡張する extends

extends を利用すると既存の設定ファイルを再利用することができます。npm から設定ファイルをインストールして利用することも可能です。

参考:
https://eslint.org/docs/2.0.0/user-guide/configuring#extending-configuration-files

命名規則

plugins と同様に extends も名前を省略して設定することができます。eslint-config- が省略可能です。
プラグイン内の設定ファイルを利用する場合は plugin: を先頭につけます。名前の先頭の eslint-plugin- はここでも省略可能です

最初の設定ファイルを省略なしで記述すると以下のようになります。

extends: [
  'eslint:recommended',
  '@nuxtjs/eslint-config-typescript',
  'plugin:eslint-plugin-nuxt/recommended',
  'plugin:eslint-plugin-vue/strongly-recommended',
  'plugin:eslint-plugin-prettier/recommended',
  'eslint-config-prettier/vue'
],

命名規則がわかると利用されているパッケージ名がわかるので確認しやすくなると思います。 (この記事で言いたいことはこれくらいかもしれない)

@nuxtjs/eslint-config-typescript

試しに1つ、今回利用している設定ファイルを確認してみます。

eslint-config/packages/eslint-config-typescript at master · nuxt/eslint-config · GitHub

module.exports = {
  extends: [
    '@nuxtjs'
  ],
  plugins: ['@typescript-eslint'],
  parserOptions: {
    parser: '@typescript-eslint/parser'
  },
  rules: {
    '@typescript-eslint/no-unused-vars': ['error', { args: 'all', argsIgnorePattern: '^_' }]
  }
}

内部で @nuxtjs/eslint-config, @typescript-eslint/eslint-plugin, @typescript-eslint/parser を利用しているのがわかります。

いつ ESLint を走らせるのか

(ここの説明は雑です)

手動で

eslint --ext .ts,.vue, .
eslint --ext .ts,.vue --fix .

コミット時

huskypre-commit hook に lint-staged を設定することで、変更したファイルのみを ESLint の対象にする。

ファイル保存時

VSCode を利用している場合

{ 
  "eslint.autoFixOnSave": true,
  "eslint.validate": [
    {
      "language": "vue",
      "autoFix": true
    },
    {
      "language": "typescript",
      "autoFix": true
    },
    {
      "language": "javascript",
      "autoFix": true
    }
  ],
  "editor.formatOnSave": false
}

保存時に ESLint を実行させます。必要に応じて "editor.formatOnSave": false, のように既存処理が邪魔をしない設定も追加してください。

Date と Timezone

ちょっとしたメモです。でてくるサンプルコードは JavaScript です。

new Intl.DateTimeFormat('ja-JP', { dateStyle: 'long', timeStyle: 'long' }).format(new Date(-2587712400000))
// => "1888年1月1日 0:00:00 GMT+9"
new Intl.DateTimeFormat('ja-JP', { dateStyle: 'long', timeStyle: 'long' }).format(new Date(-2587712400001))
// => "1888年1月1日 0:18:58 GMT+9:18:59"
import { DateTime, Settings } from "luxon";

Settings.defaultZoneName = 'Asia/Tokyo';

/**
 * 生成
 */
console.log(DateTime.fromISO("2019-10-05T14:00:00").valueOf());
// => 1570251600000
console.log(DateTime.fromISO("2019-10-05T14:00:00", { zone: "America/Los_Angeles" }).valueOf());
// => 1570309200000

const tokyo = DateTime.fromISO("2019-10-05T14:00:00");

/**
 * 取得, 変更
 */
console.log(tokyo.hour);
// => 14
console.log(tokyo.setZone("America/Los_Angeles").hour);
// =>22
console.log(tokyo.startOf('day')); // `startOf("day")` で「日」の開始に変更
console.log(tokyo.startOf('day').valueOf());
// => 2019-10-05T00:00:00.000+09:00
// => 1570201200000
console.log(tokyo.setZone("America/Los_Angeles").startOf('day'));
console.log(tokyo.setZone("America/Los_Angeles").startOf('day').valueOf());
// => 2019-10-04T00:00:00.000-07:00
// => 1570172400000

/**
 * 表示
 */
console.log(tokyo.toLocaleString(DateTime.DATETIME_SHORT));
// => 10/5/2019, 2:00 PM
console.log(tokyo.setZone("America/Los_Angeles").toLocaleString(DateTime.DATETIME_SHORT));
// => 10/4/2019, 10:00 PM

Nuxt のモジュールをテストする

以下のような構成のモジュールをつくったとして、テストをどのように書くことができるか整理します。

.
├── README.md
├── __tests__
│   ├── fixtures
│   │   ├── nuxt.config.js
│   │   └── pages
│   │       └── index.vue
│   └── index.js
├── jest.config.js
├── lib
│   ├── module.js
│   └── plugin.template.js
├── package-lock.json
└── package.json

モジュールは「Google Analytics を設定するプラグインを提供するモジュール」として話を進めます。

モジュール本体

実際の機能を提供しているファイルは lib/module.js, lib/plugin.template.js です。

// module.js
const path = require('path');

module.exports = async function Module(moduleOptions) {
  const options = {
    // Default values
    id: undefined,
      
    ...this.options['ga'], // options via nuxt.config.js
    ...moduleOptions,
  };

  this.addPlugin({
    src: path.resolve(__dirname, 'plugin.template.js'),
    fileName: 'ga.js',
    options,
    ssr: false,
  });
};
// plugin.template.js
export default ({ app }) => {
  const moduleOptions = <%= serialize(options) %>;
  if (!id) {
    return;
  }
  // Load analytics.js
  (function(i, s, o, g, r, a, m) {
    // ...
  })(
    window,
    document,
    'script',
    'https://www.google-analytics.com/analytics.js',
    'ga'
  );

  // Create a tracker
  ga('create', id, 'auto');

  app.router.afterEach(to => {
    // Update the tracker
    ga('set', 'page', to.fullPath);
    // Send a pageview hit
    ga('send', 'pageview');
  });
};

詳細な説明は省きますが、モジュールオプションか nuxt.config.js のトップレベルのオプションを利用して GA のトラッキング ID を設定することが可能です。トラッキング ID が設定されない場合は GA のスクリプトがロードされないようになっています。

テストの方針

Nuxt のモジュールは Nuxt がビルドされる時に呼び出されます。そのため、モジュール関連のテストをするには Nuxt をプログラムから利用する必要があります(*)。

今回はテスト用に nuxt.config.jspages/index.vue を用意し、テストコード内では Nuxt インスタンスを作成 → ビルド → サーバ起動 を行い、実行結果が期待したものになるか確認をします。

(*) すべてのケースでこのように Nuxt をプログラムから利用する必要はないと思っています。自分が作成したロジックだけをテストするのであれば、別の方法も有効かもしれません。今回のテストも、プラグインのテストだけなら plugin.template.js を lodash template でコンパイルするという選択もあります。Nuxt をビルドするテストは時間がかかるという点も忘れない方がいいです。

(WIP) テストファイル

(記事は途中ですが、以下コードは問題なく動作します。現段階だと説明がわかりにくいだけです。)

// __tests__/index.js
const { Nuxt, Builder } = require('nuxt');

process.env.PORT = process.env.PORT || 3000;

const config = require('./fixtures/nuxt.config');
const url = path => `http://localhost:${process.env.PORT}${path}`;

// Nuxt のビルドには時間がかかるので jest のデフォルトタイムを変更
// https://jestjs.io/docs/ja/jest-object#jestsettimeouttimeout
jest.setTimeout(60000);

describe('ga module', () => {
  let nuxt;
  let addTemplate;

  /**
   * テスト用に用意した `nuxt.config` を利用して Nuxt インスタンスを生成
   * ビルドが完了したらサーバを起動
   */
  const initNuxt = async config => {
    if (nuxt) {
      await clearNuxt();
    }

    // https://ja.nuxtjs.org/api/nuxt#nuxt-%E3%81%AE%E3%82%B3%E3%83%B3%E3%82%B9%E3%83%88%E3%83%A9%E3%82%AF%E3%82%BF
    nuxt = new Nuxt(config);
    addTemplate = nuxt.moduleContainer.addTemplate = jest.fn(
      nuxt.moduleContainer.addTemplate
    );
    await new Builder(nuxt).build();

    // https://github.com/nuxt/nuxt.js/blob/8786ff731767425bb0c1c72af6d8a8895155acf5/packages/server/src/server.js#L225
    await nuxt.server.listen(process.env.PORT);
  };

  /**
   * Nuxt サーバーをクローズする
   */
  const clearNuxt = async () => {
    await nuxt.close();
  };

  /**
   * addTemplate (jest のモック関数) を利用してテンプレートオプションを取得
   */
  const getTemplateOptions = () => {
    const call = addTemplate.mock.calls.find(args =>
      args[0].src.includes('plugin.template.js')
    );
    return call && call[0] && call[0].options;
  };
 
  afterEach(async () => {
    await clearNuxt();
  });
 
  test('installs ga script and sets trackingId with Nuxt options', async () => {
    // トップレベルオプションに `ga` 設定を追加
    await initNuxt({
      ...config,
      ga: {
        id: 'WITH_NUXT_OPTIONS',
      },
    });

    // https://ja.nuxtjs.org/api/nuxt-render-and-get-window
    // > Nuxt.js アプリケーションの URL を渡して window を取得します。
    // 
    // `__tests__/fixtures/pages/index.vue` がレンダーされます
    const window = await nuxt.renderAndGetWindow(url('/'));

    expect(window.ga).toBeDefined();
    expect(getTemplateOptions().id).toBe('WITH_NUXT_OPTIONS');
  }); 
});
// __tests__/fixtures/nuxt.config.js
const { resolve } = require('path');

module.exports = {
  srcDir: __dirname,
  
  // https://ja.nuxtjs.org/api/configuration-dev
  // 
  // https://github.com/nuxt/nuxt.js/blob/8786ff731767425bb0c1c72af6d8a8895155acf5/packages/builder/src/builder.js#L57
  // dev が true だと監視状態になります。無効にしておいたほうがよさそうです。
  dev: false,

  // @@ はエイリアスで `rootDir` を指しています。
  // https://ja.nuxtjs.org/guide/directory-structure/#%E3%82%A8%E3%82%A4%E3%83%AA%E3%82%A2%E3%82%B9-%E5%88%A5%E5%90%8D-
  modules: ['@@'],
};

参考

コンポーネント単体テストについてつらつらと

書きます。

何をテストするのか

f:id:ryotah:20181203134621p:plain

コンポーネント単体テストで何をテストするのか。

基本的に、考え方は関数のテストと同じだと思います。何かが入力されると結果が出力される。テストでは、その結果が正しいかを確認します。「入力」と「出力」にフォーカスすることが大切です。

一般的なヒント | Vue Test Utils では以下のように説明しています。

コンポーネントのパブリックインターフェイスを検証するテストを作成し、内部をブラックボックスとして扱うことをお勧めします。単一のテストケースでは、コンポーネントに提供された入力(ユーザーのやり取りやプロパティの変更)によって、期待される出力(結果の描画またはカスタムイベントの出力)が行われることが示されます。

このように考えれば、テストの方針も立てやすくなります。結果、何をテストしているのか理解しやすくなりますし、コードもシンプルになります。内部実装をリファクタしたとしても、テストが壊れることはないです。

なぜ書くのか

なぜテストをするのですか | Vue Test Utils から引用。

単体テストの利点:

  • コンポーネントがどう動作すべきかのドキュメントを提供します
  • 過度な手動テストの時間を節約します
  • 新しい機能におけるバグを減らします
  • 設計を改良します
  • リファクタリングを容易にします

(ここに「フレームワークへの理解が深まる」も追加できると思います。)

単体テストのすべきこと:

  • 実行が早いこと
  • 理解しやすいこと
  • 一つの作業だけをテストすること

テストが書きにくいな思ったら、もしかしたら、コンポーネントの設計に問題があるかもしれません。例えば、あるコンポーネントに複数の役割を持たせていたり、必要以上に大きく依存関係が複雑になってたり。

テストを書くことで既存コードに改良の余地があることに気づく、そういったケースもあるかと思います。

入力と出力とは何か

f:id:ryotah:20181203134642p:plain

具体的に「入力」と「出力」にはどのようなものがあるのでしょうか。一般的な SPA の Web アプリケーションを例にして考えてみます。

入力:

  • プロパティ(親コンポーネントから渡される)
    • Vue なら props, Angular なら @Input に相当するもの
  • DOM
    • ユーザーによる UI 操作により発生
    • クリックアクションやフォームの入力など
  • Route
    • URL が変更することにより発生
    • Vue なら this.$route, Angular なら ActivatedRoute
  • Store
    • Flux, Redux のような状態管理を導入している場合、Store の変更もコンポーネントへの入力となる
    • Vue (Vuex) なら this.$store.state (あるいは this.$store.getters), Angular (ngrx) なら this.store.pipe(select(...) など

出力:

  • イベント(親コンポーネントへ渡す)
    • Vue なら emit, Angular なら @Output に相当するもの
  • DOM
    • 表示されたテキスト, 要素の On/Off, スタイル, 属性 など
  • Route
    • Router へのアクション
  • Store
    • Vue (Vuex) なら this.$store.{dispatch|commit}, Angular (ngrx) なら this.store.dispatch など

これ以外にもあるとは思いますが、大体このようなものではないでしょうか。

テストを書く前に調べること

初めてさわるフレームワークコンポーネント単体テストを書く場合、上にあげたような「入力」をシュミレートする方法と「出力」を検証する方法を知っておくと、順調にテストが書けると思います。

あとは非同期の扱いや、テストダブル(スタブ・モック・スパイなど)の扱いについて知っておくといいかと。(これらに関しては、利用するテストフレームワーク側の機能を利用することも多いです。)

一般的なコンポーネントのテストシナリオ

Angular ドキュメントには Component Test Scenarios というセクションがあり、一般的なテストシナリオの実装方法について解説しています。「一般的なテストシナリオにはどんなものがあるのか」について知るという点で、Angular ユーザー以外にも参考になると思います。

以下は抜粋です。

Vue 単体テストのサンプル

テストシナリオを踏まえて、Vue の単体テストで必要そうなケースをいくつかピックアップしたのがこちらです。(若干中途半端ではありますが)

propsemit のテスト

describe("Button", () => {
  it(`should emit a 'click' event`, () => {
    const wrapper = shallowMount(Button, {
      propsData: {
        text: "text"
      }
    });
    wrapper.find("button").trigger("click");
    expect(wrapper.emitted().click).toBeTruthy();
  });
});

NuxtLink (RouterLink) をスタブします。

describe("Breadcrumbs", () => {
  it("should have nuxt-link correspond to props", () => {
    const breadcrumbs = [{ label: "Foo", path: "foo" }];
    const wrapper = shallowMount(Breadcrumbs, {
      propsData: {
        breadcrumbs
      },
      stubs: {
        NuxtLink: RouterLinkStub
      }
    });
    expect(wrapper.find(RouterLinkStub).props().to).toBe(breadcrumbs[0].path);
  });
});

Router を利用しているコンポーネントのテスト

createLocalVue を利用してグローバルの Vue クラスを汚染しないローカルの Vue クラスで VueRouter を利用します。

import { createLocalVue, shallowMount } from "@vue/test-utils";
import VueRouter from "vue-router";

describe("Menu", () => {
  it(`should have '-current' class if the path is same as the current route`, () => {
    const localVue = createLocalVue();
    localVue.use(VueRouter);
    const router = new VueRouter();
    router.push("/foo");
    const wrapper = shallowMount(Menu, {
      localVue,
      router,
      propsData: {
        menus: [
          { label: "foo", value: "/foo" },
          { label: "bar", value: "/bar" }
        ]
      },
      stubs: {
        NuxtLink: RouterLinkStub
      }
    });
    const fooLink = wrapper.findAll(RouterLinkStub).at(0);
    const barLink = wrapper.findAll(RouterLinkStub).at(1);
    expect(fooLink.classes()).toContain("-current");
    expect(barLink.classes()).not.toContain("-current");
  });
});

内部で他のコンポーネントを利用しているケース

shallowMount() を利用すると、子コンポーネントはスタブされたコンポーネントになります。

メモ:

Nuxt のページコンポーネントのテスト

Nuxt の機能と、テスト対象のコンポーネントの機能をわけて考えることが大切です。

以下のテストでは「fetch 時に適切なアクションがディスパッチされているか」と「適切な middleware が登録されているか」についてだけテストをしています。初回アクセス時に fetch が呼ばれているか、パラメータ変更時にも fetch が呼ばれるか、許可されたユーザーのみアクセスできるか、などのテストはしていません。(それは Nuxt のテストや登録されているミドルウェア自体のテストになるから)

describe("FooPage", () => {
  it("dispatches FetchFoo before rendering the page", () => {
    const wrapper = shallowMount(FooPage);
    const store = {
      dispatch: jest.fn()
    };
    const fooId = "asdf";
    const route = fooRoute(fooId);
    const context: any = {
      store,
      route
    };
    // simulate the nuxt fetch hook
    wrapper.vm.$options.fetch(context);
    expect(context.store.dispatch).toHaveBeenCalledWith(FetchFoo({ fooId }));
  });
  it(`needs 'requireAuth' middleware`, () => {
    const wrapper = shallowMount(FooPage);
    expect(wrapper.vm.$options.middleware).toContain("requireAuth");
  });
});
const fooRoute = fooId => ({ params: { fooId } });

ミューテーション単体のテスト

// from https://vuex.vuejs.org/ja/guide/testing.html
describe("mutations", () => {
  it("INCREMENT", () => {
    // ステートのモック
    const state = { count: 0 };
    // ミューテーションを適用する
    increment(state);
    // 結果を検証する
    expect(state.count).to.equal(1);
  });
});

アクション単体のテスト

アクションは外部の API を呼び出す場合が多いです。そのため、モック関数を作成したり、非同期処理をうまく扱う必要があります。

import { FetchFoo, ReceiveFoo, ResetShopStaffs } from "./types";
import { actions } from "../actions";

describe("actions", () => {
  let store;
  beforeEach(() => {
    // inject plugins
    store = {
      $api: {
        request: jest.fn()
      }
    };
  });
  it("FetchFoo request Foo and then commit ReceiveFoo", async () => {
    // fake data
    const fakeRequest = "request";
    const fakeResponse = "response";

    // mock functions
    const commit = jest.fn();
    store.$api.request.mockReturnValue(fakeResponse);

    // dispach
    const action = FetchFoo(fakeRequest);
    actions[action.type].bind(store)({ commit }, action);

    // request
    expect(store.$api.request).toHaveBeenCalledWith(fakeRequest);

    await resolvePromises();

    expect(commit).toHaveBeenCalledWith(ReceiveFoo(fakeResponse));
  });
});

export const resolvePromises = () =>
  new Promise(resolve => setImmediate(resolve));

ストアのテスト(アクション、ミューテーション など全てを含む)

// from https://vue-test-utils.vuejs.org/ja/guides/using-with-vuex.html
import { createLocalVue } from "@vue/test-utils";
import Vuex from "vuex";
import storeConfig from "./store-config";
import { cloneDeep } from "lodash";

test("increments count value when increment is commited", () => {
  const localVue = createLocalVue();
  localVue.use(Vuex);
  const store = new Vuex.Store(cloneDeep(storeConfig));
  expect(store.state.count).toBe(0);
  store.commit("increment");
  expect(store.state.count).toBe(1);
});

test("updates evenOrOdd getter when increment is commited", () => {
  const localVue = createLocalVue();
  localVue.use(Vuex);
  const store = new Vuex.Store(cloneDeep(storeConfig));
  expect(store.getters.evenOrOdd).toBe("even");
  store.commit("increment");
  expect(store.getters.evenOrOdd).toBe("odd");
});

補足

グローバルプロパティをスタブする:

const $route = { path: "http://www.example-path.com" };
const wrapper = shallowMount(Foo, {
  mocks: {
    $route
  }
});

https://vue-test-utils.vuejs.org/ja/api/options.html#mocks

まとまらない何か

テストに関係する Tips とかメモとか。

flush-promises (setImmediate) を利用して非同期を解決させる

Vue でグローバル登録されてるフィルタが存在する場合

import Vue from "vue";
import * as filters from "./util/filters";

// We would extract this to a function that would be reused by both app.js and jest-setup but,
// we didn't want to change original production code
Object.keys(filters).forEach(key => {
  Vue.filter(key, filters[key]);
});

How do we test these globally registered filters? https://github.com/agualis/vue-hackernews-2.0/blob/master/src/jest-setup.js

同年、同月、同日にテストすることを願わん

momentjs - How do I set a mock date in Jest? - Stack Overflow

Date.now = jest.fn(() => 1487076708000); //14.02.2017`

Jest のカスタムマッチャ

const myMockFn = jest.fn()...;

// The mock function was called at least once
expect(mockFunc).toBeCalled();

上記のようなカスタムマッチャは .mock プロパティを検査する方法の糖衣構文

https://jestjs.io/docs/en/mock-functions

jest.spyOn(object, methodName)

  • jest.fn と同様の関数を作成する
  • 引数に与えられた object[methodName] へのコールも実装

https://jestjs.io/docs/ja/jest-object#jestspyonobject-methodname

参考 URLs

Vue SSR 環境構築メモ

Vue SSR ガイド, vuejs/vue-hackernews-2.0 を参考に環境構築を試してみた時のメモです。

サーバサイドで Vue をコンパイルする

https://github.com/ryotah/vue-ssr/compare/step-0...step-1-render-to-string

やっていること:

  • vue-server-renderer からレンダラーを作成
  • リクエスト毎に vue インスタンスを生成
  • HTML に描画して返信

コード抜粋:

server.js

const Vue = require("vue");
const server = require("express")();
// ステップ 1: レンダラを作成
const renderer = require("vue-server-renderer").createRenderer({
  template: require("fs").readFileSync("./index.template.html", "utf-8")
});

server.get("*", (req, res) => {
  // ステップ 2: Vue インスタンスを作成
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>The visited URL is: {{ url }}</div>`
  });

  // ステップ 3: Vue インスタンスを HTML に描画
  renderer
    .renderToString(app)
    .then(html => res.end(html))
    .catch(err => {
      res.status(500).end("Internal Server Error");
    });
});

server.listen(8080);

参考:

クライアントとサーバのコードをわける (Webpack)

https://github.com/ryotah/vue-ssr/compare/step-1-render-to-string...step-2-webpack

やっていること:

  • クライアントとサーバー両方をバンドルするために webpack を導入
  • entry-client.js, entry-server.js, app.js を用意
    • entry-*.jsapp.js (アプリケーションのユニバーサルエントリー) を読み込む

コード抜粋:

entry-client.js

import { createApp } from "./app";

export default context => {
  const { app } = createApp(context);
  return app;
};

server.js

// ...
const createApp = require("./dist/entry").default;
server.get("*", (req, res) => {
  // ステップ 2: Vue インスタンスを作成
  const app = createApp({ url: req.url });

  // ステップ 3: Vue インスタンスを HTML に描画
  // ...
});

server.listen(8080);

webpack.config.js

module.exports = {
  entry: "./entry-server.js",
  output: {
    filename: "entry.js",
    path: path.resolve(__dirname, "dist"),
    libraryTarget: "commonjs2"
  }
  // ...
};

参考:

f:id:ryotah:20181127023444p:plain

ルーティング

https://github.com/ryotah/vue-ssr/compare/step-2-webpack...step-3-router

やっていること:

  • vue-router を導入
  • entry-server.js にルーティングロジックを実装

コード抜粋:

entry-server.js

import { createApp } from "./app";

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router } = createApp();

    // set server-side router's location
    router.push(context.url);

    // wait until router has resolved possible async components and hooks
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();
      if (!matchedComponents.length) {
        reject({ code: 404 });
      }
      resolve(app);
    }, reject);
  });
};

参考:

プリフェッチを実現

https://github.com/ryotah/vue-ssr/compare/step-3-router...step-4-store

やっていること:

  • サーバサイドとクライアントサイドで同じデータを利用可能にするために Vuex を導入
  • ルートコンポーネントにプリフェッチ用メソッド asyncData を実装
  • entry-client.js にサーバからのデータを受け取る store.replaceState(window.__INITIAL_STATE__) を追加

コード抜粋:

entry-server.js

// ...
router.onReady(() => {
  const matchedComponents = router.getMatchedComponents();
  // ...

  // call `asyncData()` on all matched route components
  Promise.all(
    matchedComponents.map(
      ({ asyncData }) =>
        asyncData &&
        asyncData({
          store,
          route: router.currentRoute
        })
    )
  )
    .then(() => {
      // すべてのプリフェッチのフックが解決されると、ストアには、
      // アプリケーションを描画するために必要とされる状態が入っています。
      // 状態を context に付随させ、`template` オプションがレンダラに利用されると、
      // 状態は自動的にシリアライズされ、HTML 内に `window.__INITIAL_STATE__` として埋め込まれます
      context.state = store.state;
      resolve(app);
    })
    .catch(reject);
}, reject);
// ...

参考:

サーバとクライアント、それぞれをビルドするために個別の Webpack 設定ファイルを用意

https://github.com/ryotah/vue-ssr/compare/step-4-store...step-5-hydration

バンドルレンダラ

https://github.com/ryotah/vue-ssr/compare/step-5-hydration...step-6-bundle-renderer

やっていること:

  • バンドルレンダラを利用
  • JSON 形式でサーバサイドのコードをバンドル

参考:

クライアントマニフェストを生成

https://github.com/ryotah/vue-ssr/compare/step-6-bundle-renderer...step-7-client-manifest

参考:

ビルド設定をもうちょい

https://github.com/ryotah/vue-ssr/compare/step-7-client-manifest...step-8-build

やっていること:

  • webpack-dev-middleware を導入
  • コードに変更があったら、サーバとクライアント両方をビルドするように設定

コード抜粋:

server.js

const path = require('path');
const express = require('express');
const MemoryFS = require('memory-fs');
const app = express();
const webpack = require('webpack');

let clientManifest;
let serverBundle;
let renderer;

function update() {
  if (!clientManifest || !serverBundle) {
    return;
  }
  // create a renderer
  const { createBundleRenderer } = require('vue-server-renderer');
  const template = require('fs').readFileSync('./index.template.html', 'utf-8');
  renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false, // recommended
    template,
    clientManifest,
  });
}

// dev middleware
const clientConfig = require('./build/webpack.client.config.js');
const clientCompiler = webpack({
  ...clientConfig,
  // TODO:
  mode: 'development',
});
const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
  publicPath: clientConfig.output.publicPath,
});
app.use(devMiddleware);
clientCompiler.plugin('done', stats => {
  try {
    clientManifest = JSON.parse(
      devMiddleware.fileSystem.readFileSync(
        path.join(clientConfig.output.path, 'vue-ssr-client-manifest.json'),
        'utf-8'
      )
    );
  } catch (e) {
    console.log(e);
  }
  update();
});

// watch and update server renderer
const serverConfig = require('./build/webpack.server.config.js');
const serverCompiler = webpack({
  ...serverConfig,
  // TODO:
  mode: 'development',
});
const mfs = new MemoryFS();
serverCompiler.outputFileSystem = mfs;
serverCompiler.watch({}, (err, stats) => {
  try {
    serverBundle = JSON.parse(
      mfs.readFileSync(
        path.join(clientConfig.output.path, 'vue-ssr-server-bundle.json'),
        'utf-8'
      )
    );
  } catch (e) {
    console.log(e);
  }
  update();
});

// ...