LoginSignup
18
21

More than 5 years have passed since last update.

Reactテストパターン - すべてがgになる

Last updated at Posted at 2016-04-01

タイトル前回と揃えてみた。gはGREEN(test ok)のg。(^^;

常々かねがね、どうやるのが良いんだろ?と、思ってましたがenzymeというテストフレームワークを見つけてゴニョゴニョやっていたら割といい感じに作れそうな気がしてきたのでその方法を記しておきます。

対象アプリケーション

こんな感じで作ってます。

約1年前に書いたものですが、実際にこのやり方でひとつプロダクトを作りきった感想としてはそんなに外してなかったな、と。
現在もほぼこのままの方法で開発しています。

ざっとまとめると

  • 通信はjQuery#ajax
  • APIクラスはサーバのEndpointをキックしてPromiseを返すだけ
  • ServiceクラスはAPIをコールして必要な情報がそろったらEventをEmitするだけ
    • Serviceクラスのメソッドに返り値はない
    • 分岐も交えて複数のAPIをコールして最終的にひとつのEventをEmitすることもある
  • Componentクラスは必要に応じてServiceクラスのメソッドを実行し、その結果はEventとして受け取る

という感じです。
APIを一個追加するたびにAPI、EventConstants、Serviceと3箇所も修正が必要なのは面倒と感じる時もありますが、やはりメリットの方が大きいと感じています。

以前に書いたところ以外のメリットとしては、

  • 完全にワンパターンなので新規に参加するメンバーのキャッチアップが容易

という点も大きいです。(Giveryでは定期的に海外インターンが入れ替わり入ってきます。)

また、今回テストを作ってみたことでさらに多くのメリットがあることを発見しました。

Enzymeとは

airbnbが作っているReactのテストフレームワークです。
InitialCommitが2015年8月なので比較的新しいフレームワークと言えますが、すでにStarは4200を超えています。

導入として読むなら次の二つがオススメ。

前者はミニマムのサンプルリポジトリが参考になります。
後者は導入に関してこれだけで間に合うレベルの日本語記事です。

How it works

How to useは上記で間に合っていると思うので、ここでは個人的にとても感心したEnzymeがどのように動くのかについて紹介します。
まず、Enzymeをmochaで実行する場合のコマンドは以下のようになります。(Enzyme関連のファイルはすべてenzymeディレクトリに置いている想定です。)

$ mocha enzyme/setup.js **/*.js

このコマンドの意味がわかるでしょうか?
これは、

  • 最初にsetup.jsを実行して必要な初期化処理を行い、
  • その後にサブディレクトリにあるテストを実行している

のです。
僕はそこそこ長くmochaを使ってますが、この発想はなかったのでとても新鮮でした。

そして実際にsetup.jsでやっている内容はmochaのexampleで確認できます。(exampleではファイル名の先頭に「.」がついてますが、好みで自分の手元ではつけていません。)

やっていることは大きく2つ

1. babel-registerをrequireすることで、これ以降に読み込むファイルをbabelで変換する

これによりテストの中で、<Foo/>のようなReact Syntaxが使えるようになります。

2. JSDOMをセットアップし、NodeJSのglobalでブラウザ空間をエミュレートする

これによりReactの中でDOMを操作する処理が動作するようになります。

Reactユニットテストする際に問題となる部分が見事に解決されています。すばらしい!

実のところ、ここさえわかっていればReactテストは勝ったも同然です。

初期化処理を追加する

setup.jsは必ずテストの先頭で動くので、必要な初期化処理があった場合はそこに追加することができます。

うちの場合は日英の言語対応のためのメッセージファイルがサーバーサイドで生成されているため、初期化時にそれを取得したいという要件がありました。
通信は非同期処理なので普通に考えるとちょっと面倒なんですが、setup.jsはmochaのテストでもあるのでdescribeとitを使って一番最初に実行するテストとして
同期的に実装することができます。

var request = require("request"); 
var MSG     = require("../js/services/messages.service.js");

describe("Initialize", () => {
  var host    = "http://localhost:9000";

  it("Load MSG", (done) => {
    request(host + "/i18n/en/messages.json", (error, response, body) => {
      assert.notOk(error);
      assert.equal(response.statusCode, 200);

      MSG.setup(JSON.parse(body), "en");
      done();
    });
  });
});

これによって以降のテストではMSGは初期化済みで使えます。

HTTP通信を差し替える

APIの実装にはjQuery#ajaxを使っていました。

JDOMの導入によって、jQuery#ajaxも一応動くんですが、cookieのハンドリングがうまくできません。(originへの通信ではなくなるため)
そのため、ログイン不要の単発APIはそのままでも動きますが、ログインしてその後に呼び出すようなAPIは認証エラーになります。

この問題もsetup.jsの中でAPIの通信処理を差し替えることで解決しました。

var $       = require("jquery");
var request = require("request");
var API     = require("../js/api/API.js");

function execByRequest(method, path, data) {
  var d = $.Deferred();
  var params = {
    method: method,
    uri: this.endpoint + path,
    json: true,
    jar: true
  };
  if (data) {
    if (method === "GET" || method === "DELETE") {
      params.qs = data;
    } else {
      params.form = data;
    }
  }
  request(params, (err, res, body) => {
    if (err) {
      d.reject(err);
      return;
    }
    if (res.statusCode >= 200 && res.statusCode < 300) {
      d.resolve(body);
    } else {
      d.reject(res);
    }
  });
  return d.promise();
}

describe("Initialize", () => {
  var host    = "http://localhost:9000";

  it("Setup API", () => {
    API.endpoint = host;
    API.__proto__.exec = execByRequest;
  });
});

上記はかなり簡略化した実装ですが、やっていることはわかると思います。
jQueryの代わりにNodeのrequestパッケージを使って通信しているだけです。

通信の方法が変わってもテストしたいのはそこではないので別に問題はありません。
__proto__を使ったインスタンス生成後のメソッドの差し替えは一般的には行儀がよくないとされていますが、テストの際には覚えておくと色々なところで応用の効く技だと思います。

サンプル1

以下は

  • 組織のユーザー一覧画面
    • ログインが必要
  • 画面が表示されたらcomponentDidMountで一覧取得のAPIを実行
  • 一覧取得のEventが発生したらそれを表示する
  • queryによって検索条件を指定できる

というコンポーネントのテストの例です。
実際に動いているコードからザクザク編集して作ったので、微妙に間違ってるかもしれませんが概要はわかると思います。

Utility.js

class Utility {
  signin(username, password, done) {
    //Signin ユーティリティ
    // - username - ユーザ名
    // - password - パスワード
    // - done - mochaの非同期処理完了時のコールバック関数
    //AuthServiceのメソッドを実行してその結果AUTH_SIGNINイベントが発生すればサインイン成功
    EventService.once(Events.AUTH_SIGNIN, () => {
      done();
    });
    AuthService.signin(username, password);
  }

  signout(done) {
    //同じく Signout ユーティリティ
    EventService.once(Events.AUTH_SIGNOUT, () => {
      done();
    });
    AuthService.signout();
  }

  once(eventName, func) {
    //EventServiceに一回限りのリスナを登録する
    //中でsetTimoutしているのは同一イベントに複数のリスナが登録されている場合に実行順序を最後にするため
    EventService.once(eventName, () => {
      setTimeout(func, 0);
    });
  }

  optionsWithRouter() {
    //React-Routerを使っている場合のoptionを返すメソッド
    //後述するのでここでは空とする
  }
}
module.exports = new Utility();

MemberList.test.js

import React     from 'react';
import {assert}  from "chai";
import { mount } from 'enzyme';

import Utility    from "../utility.js";
import MemberList from "...";
import Events     from "../../js/constants/event.constant";

const params = {
  orgname: "org1"
};

describe("OrgMemberList", () => {

  before(done => {
    //本当はここにデータの初期化処理が入るが今回は省略
    Utility.signin("user1", "password", done);
  });

  after(done => Utility.signout(done));

  describe("with real API, with no query", () => {
    var wrapper;

    before(() => {
      const query = {};
      wrapper = mount(<MemberList params={params} query={query}/>, Utility.optionsWithRouter());
    });

    after(() => wrapper.unmount());

    it("should have 4 rows", (done) => {
      function onList() {
        assert.equal(wrapper.find(".code-company-table").find("tbody").find("tr").length, 4);
        done();
      }
      Utility.once(Events.MEMBER_LIST, onList);
    });
  });

  describe("with real API, with query 'search=Taro'", () => {
    var wrapper;

    before(() => {
      const query = {
        search: "Taro"
      };
      wrapper = mount(<MemberList params={params} query={query}/>, Utility.optionsWithRouter());
    });

    after(() => wrapper.unmount());

    it("should have 2 rows", (done) => {
      function onList() {
        assert.equal(wrapper.find(".code-company-table").find("tbody").find("tr").length, 2);
        done();
      }
      Utility.once(Events.MEMBER_LIST, onList);
    });
  });
});

なんとなくやっていることはわかるでしょうか?

Utilityはテスト全体で使うユーティリティクラスです。
before/afterで使用するsignin/signoutなどのメソッドが含まれています。
optionsWithRouterは後で説明するので今は無視してください。

MemberList.test.jsでのポイントは

  • トップレベルのbeforeでsigninして以降の認証が必要なAPIの実行を可能にしている
  • unmountをafterで実行したいため各テストごとに 1 describe, 1 it としている
  • 通常は暗黙的に渡されるparamsやqueryもコンポーネントの属性として明示的に渡している
  • テストしたいイベントが発生した時点で実際の処理を記述しその最後でdone()を実行してテストを終了する
    • ここではテーブルの行数を数えている

といったところでしょうか。
EnzymeによってjQueryライクなSelectorがコンポーネントに適用できるので割と直感的にテストを書くことができます。

正直ここはまだ試行錯誤の段階でもっとこなれた書き方がある気がしていますが。。。。実はあんまりこういうテストを書くことはないんじゃないかと思ってます。(^^;;;

HTTPを無効化してEventをエミュレートする

さて、ここまでサーバとの通信を前提にその結果をテストする方法を説明してきましたが、普通クライアント側のテストでサーバとの通信が必要なものはユニットテストとは言わないですよね。(爆)

やりはじめたら楽しくなっちゃったので、興味本位で通信前提のテストを作ってみましたが、ユニットテストと言うならばやはりサーバなしでコンポーネント単体でテストしたいわけです。

イベントアーキテクチャでは、これもここまでの応用で簡単にできます。

function execByRequest(method, path, data) {
  var d = $.Deferred();
  if (this.disabled) {
    return d.promise();
  }
  ...
}

class Utility {

  disableAPI() {
    API.disabled = true;
  }

  enableAPI() {
    API.disabled = false;
  }
}

上のコードでは先に作ったexecByRequestメソッド内で、this.disabledが設定されている場合は何もせずにPromiseを返しています。
こうすることによって、どのAPIを実行しても何も実行されずEventも発生しなくなります。

代わりに自分でEventServiceにEventをemitすることによってEventの発生をエミュレートすることができます。
(ちなみに上のPromiseはメモリリークになると思いますが、テストなのでとりあえず無視してます。問題になるようだったらリークの発生しない似非Promiseに書き換えるつもりです。)

サンプル2

describe("MemberList with disabled API", () => {
  before(() => Utility.disableAPI());
  after(() => Utility.enableAPI());

  var listData = [{
    id: 1,
    name: "hoge"
    ...
  }, {
    id: 2,
    name: "fuga"
    ...
  }];

  it("should have 2 rows", () => {
    const query = {};
    const wrapper = mount(<MemberList params={params} query={query}/>, Utility.optionsWithRouter());
    EventService.emit(Events.MEMBER_LIST, listData);
    try {
      assert.equal(wrapper.find(".code-company-table").find("tbody").find("tr").length, 2);
    } finally {
      wrapper.unmount();
    }
  });
});

テスト対象のコンポーネントは先の例と同じですが、componentDidMountの中で実行されているメンバー一覧取得のAPI実行はdisableされているので何も起きません。
代わりに自分でMEMBER_LISTイベントをemitすることでAPIの実行を代替しています。

こちらの例ではtry/finallyの中でunmountしていますが、最終的にmountしたコンポーネントがunmountされていればどのようなコードであっても構いません。
unmountし忘れるとコンポーネントの中でイベントリスナが生き続けるので意図しない動作が発生する可能性があります。

この場合本来ログインが必要な画面であっても、ログインなしでいきなりテストできますし、テストデータの加工もその場で好きに出来ます。
また、execメソッドの作り方によっては一部のAPIだけdisableにして置き換えるということも可能です。

Routerを差し替える

最後にReact-Routerを使っている場合のテスト方法について書いておきます。

ちなみにreact-routerのバージョンは0.13.xです。
react-router v1.xが出た時に「互換性がねー。。。(--」と思いながら見送ってたらいつの間にかv2がリリースされてますね。。。(--
あんまり置き換える動機もないのでどうしたもんでしょうね?

閑話休題

EnzymeでRouterに依存したコンポーネントをそのままmountしようとするとエラーが発生します。
Routerに依存したコンポーネントとは

  • Navigation、またはStateをmixinしている
  • Linkコンポーネントを使用している

コンポーネントのことです。
これらのコンポーネントではRouter依存の処理を行う際に、

  • コンポーネントのcontextからrouterオブジェクトを取得し、
  • そのメソッドを実行する

ために、contextにrouterが設定されていないとエラーになるわけです。

逆に言うとどうにかしてcontextにrouterを設定してしまえばエラーは回避できます。
Enzymeではmountの第2引数のオプションでcontextを設定することができます。

その実装がこれです。

class Utility {
  optionsWithRouter() {
    function empty() {}
    var options = {
      context: {}
    };
    var router = function() {};
    router.transitionTo = empty;
    router.makeHref = empty;
    router.isActive = empty;
    router.makePath = empty;
    router.replaceWith = empty;
    router.goBack = empty;
    router.getCurrentPath = empty;
    router.getCurrentRoutes = empty;
    router.getCurrentPathname = empty;
    router.getCurrentParams = empty;
    router.getCurrentQuery = empty;
    router.isActive = empty;
    router.getRouteAtDepth = empty;
    router.setRouteComponentAtDepth = empty;

    options.context.router = router;
    return options;
  }
}

このダミーRouterではすべてのメソッドが何もしていないので、当然Linkは正しく生成されないしNavigation#transitionToなんかも動作しませんが、その部分がテスト対象外ならこれで十分なはずです。(多分)

Routerのv1, v2がどういう実装なのかは知りませんが、基本的な考え方は同じだと思います。

まとめ

以上、

  • Reactテストの導入方法
  • 通信がある場合のテスト方法
  • 通信を無視したテスト方法
  • ReactRouterのテストでの扱い方

について書いてきました。
これだけの材料があれば、ある程度テストを書き慣れた人ならば大抵のReactコンポーネントのテストを作ることができるんじゃないかと思います。

あとは、実際に誰かがテストを書いてくれるのを待つだけです。

18
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
21