$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

React + Apolloを使ったコンポーネントのテストをする

Reactを使ったコンポーネントのテストのやり方を知らなかったので、やってみた内容をメモ。hatena/go-Intern-Diaryをお試し環境として利用した。結果は https://github.com/shibayu36/go-Intern-Diary/pull/5/files

テスト概要 – Reactを最初の資料としながら色々調査していった。

どのライブラリを使うか

  • Jest: テストランナーやアサーションライブラリ、テスト便利グッズ用途として。Reactの公式でもおすすめされているので利用する。
  • enzyme: Reactコンポーネントをテストするための便利グッズ用途として。
    • 公式では@testing-library/reactがおすすめされている参考ようだが、renderメソッドの返り値でgetByTextとかDOM操作用の便利関数が返ってくるモデルのインターフェースが気に入らなかったのでenzymeにした
  • @apollo/react-testing: Apolloのテスト用の便利グッズとして
  • @testing-library/react: enzymeを使ったとしても、act()などの関数が必要になることがあるため

テストを書くまでの手順

以下の手順で始められる。

  • 必要なpackageをインストールする
  • Jestのセットアップをする
  • テストを書く

必要なpackageをインストールする

レポジトリ内の package.json を参考に。TypeScriptで作っているので型のためのpackageも入れる必要あり。

yarn add -D @apollo/react-testing @testing-library/react enzyme enzyme-adapter-react-16 jest ts-jest

# 型
yarn add -D @types/enzyme @types/enzyme-adapter-react-16 @types/jest

# 使っても使わなくても良いもの
yarn add -D waait

Jestのセットアップをする

jest.config.jsの用意と、enzymeのためにsetupTests.tsを書けばOK。

jest.config.js。TypeScriptをテストで使うために変換の設定と、テスト時の最初に読み込むファイル(setupTests.ts)の設定をしている。

module.exports = {
  preset: "ts-jest",
  verbose: true,
  roots: ["<rootDir>/ui/src"],
  transform: {
    "^.+\\.tsx?$": "ts-jest"
  },
  setupFilesAfterEnv: ["<rootDir>/ui/src/setupTests.ts"],
  globals: {
    "ts-jest": {
      tsConfig: "ui/tsconfig.json"
    }
  }
};

ui/src/setupTests.ts。enzymeのセットアップのために呼びたいコードを書いている。

import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";

configure({ adapter: new Adapter() });

テストを書く

あとは自分の書きたいテストを書くだけ。

ブログ一覧ページのテストを簡単に書いたものは以下の通り。

ui/src/pages/__tests__/diaries.test.tsx

import React from 'react';
import wait from 'waait';
import { mount } from 'enzyme';
import { getMyDiariesQuery, Diaries } from '../diaries';
import { MockedProvider } from '@apollo/react-testing';
import { act } from '@testing-library/react';
import { BrowserRouter as Router } from 'react-router-dom';

describe('<Diaries />', () => {
  it('正常にレンダリングできる', async () => {
    const mocks = [
      {
        request: {
          query: getMyDiariesQuery
        },
        result: {
          data: {
            visitor: {
              id: '1',
              name: 'shibayu36',
              diaries: [
                {
                  id: '2',
                  name: 'shibayu36の日記'
                },
                {
                  id: '3',
                  name: 'shibayu36のブログ'
                }
              ]
            }
          }
        }
      }
    ];

    const wrapper = mount(
      <MockedProvider mocks={mocks} addTypename={false}>
        <Router>
          <Diaries />
        </Router>
      </MockedProvider>
    );

    await act(async () => {
      await wait(0);
    });
    wrapper.update();

    expect(wrapper.find('h1').text()).toBe('shibayu36のダイアリー一覧');

    const diaries = wrapper.find('Link');
    expect(diaries).toHaveLength(3);
    expect(diaries.at(0).prop('to')).toBe('/diaries/2');
    expect(diaries.at(0).find('p').text()).toBe('shibayu36の日記');
    expect(diaries.at(1).prop('to')).toBe('/diaries/3');
    expect(diaries.at(1).find('p').text()).toBe("shibayu36のブログ");
  });
});

ポイントとしては

これでなんとなくテストをするイメージを掴むことができた。

まとめ

今回はReact + Apolloを使ったコンポーネントのテストをする方法について簡単な実践をして、分かったことを書いてみた。さらにいろんなテストを書くためには以下のような資料を読むと良いだろう。

Nodeのイベントループを理解するために遊んだ & Apolloのテストでawait wait(0)するとなぜデータがロードされるか

Apolloを触っていて、テストをするために https://www.apollographql.com/docs/react/development-testing/testing/#testing-final-state を読んでいた。その文章の中で、

  • MockedProviderをrenderした時はloading状態になる
  • データがロードされた最終状態にするにはwaaitみたいなnpm packageを使って、await wait(0)とかしてね
    • It delays until the next "tick" of the event loop, and allows time for that Promise returned from MockedProvider to be fulfilled.

と書かれていて、この文章の意味がわからなかったので、Nodeのイベントループを知るためにちょっとだけ遊んだ。あまり詳しくないので内容が正しいか不明だし、むしろ間違ってる可能性の方が高そうなのでもし間違っていたら教えて下さい...

参考資料

以下2つが非常に参考になった。

Promiseを使って実行順がどうなるか見る

参考資料を前提に以下のサンプルコードを実行して、logがどの順に出力されるか観察する。

// 初めからresolveしているPromiseの実行順
async function func1 () {
  let promise1 = Promise.resolve('promise1');
  promise1.then(function(value) {
    console.log(value);
  });

  console.log('bottom');
}

// 途中でsetTimeoutでresolveするPromiseをawaitする
async function func2 () {
  let promise1 = Promise.resolve('promise1');
  promise1.then(function(value) {
    console.log(value);
  });
  let promise2 = new Promise(resolve => setTimeout(() => resolve('promise2')));
  promise2.then(function(value) {
    console.log(value);
  });
  await promise2;

  console.log('bottom');
}

// timeoutが先
async function func3 () {
  let promise1 = new Promise(resolve => setTimeout(() => resolve('promise1')));
  promise1.then(function(value) {
    console.log(value);
  });

  let promise2 = Promise.resolve('promise2');
  promise2.then(function(value) {
    console.log(value);
  });
  await promise2;

  console.log('bottom');
}

// func2のpromise2のthenをawaitの後に書く
async function func4 () {
  let promise1 = Promise.resolve('promise1');
  promise1.then(function(value) {
    console.log(value);
  });
  let promise2 = new Promise(resolve => setTimeout(() => resolve('promise2')));
  await promise2;
  promise2.then(function(value) {
    console.log(value);
  });

  console.log('bottom');
}


(async function main () {
  console.log('======= func1 =======');
  await func1();
  // bottom -> promise1

  console.log('======= func2 =======');
  await func2();
  // promise1 -> promise2 -> bottom

  console.log('======= func3 =======');
  await func3();
  // promise2 -> bottom -> promise1

  console.log('======= func4 =======');
  await func4();
  // promise1 -> bottom -> promise2
})();

まずfunc1。これはbottom -> promise1の順でログが出力される。初めからPromise.resolveしていたとしてもthenの処理が即座に実行されるわけではなく、microTaskQueueというところに入れられているため、現在の処理が終わった後(bottomが出力された後)にpromise1が出力されると考えられる。

次にfunc2。これはpromise1 -> promise2 -> bottomの順でログが出力される。promise2がsetTimeoutでPromiseをresolveしているため、Timers Phaseまで待たないと次に進めない。つまり次のように処理が実行されていると考えられる(勘で言っていて自信がまったくない、Queueの状態を出力できれば正しくわかりそうだけど...)。

  • promise1のthenがmicroTaskQueueに入れられる
  • promise2の作成処理で、Expired timers / intervals queue に、resolveする関数が入れられる
  • promise2のthenがmicroTaskQueueに入れられる
  • promise2でawaitするのでTimers Phaseでresolveされるまでそこでブロック
  • promise1は既にresolveされているので、Timers Phaseを待つことなく、現在のPhase(microTaskQueueの処理Phase)で処理される
    • ※promise1がログ出力
  • Timers Phaseに来るとsetTimeoutの処理が実行されて、promise2がresolveされ、microTaskQueueのpromise2.thenが実行可能になる
  • Timers Phase終了後、microTaskQueueを見てpromise2.thenのコールバックが実行可能なので、処理される
    • ※promise2がログ出力
  • awaitを過ぎたのでfunc2の処理が再開
    • ※bottomがログ出力


次にfunc3。これはpromise2 -> bottom -> promise1の順でログが出力される。これはおそらく以下のようになっている?

  • promise1のthenがmicroTaskQueueに入れられる(が、setTimeoutでresolveされるので、Timers Phaseの処理が終わらないとこのタスクは実行できない)
  • promise2のthenがmicroTaskQueueに入れられる
  • await promise2では、既にpromise2はresolveされているのでthenコールバックが実行される
    • ※promise2がログ出力
  • func3のメイン処理が再開
    • ※bottomがログ出力
  • Timers Phaseに到達し、promise1がresolveされる
  • Timers Phase終了後、microTaskQueueを見てpromise1.thenのコールバックが処理される
    • ※promise1がログ出力


面白いのはfunc4。func2の処理をちょっと変えて、awaitの後にthenを書いただけだが、これはpromise1 -> bottom -> promise2と出力される(func2ではpromise1 -> promise2 -> bottom)。これはasync/awaitをPromiseの構文に直してみると分かるかも?

(これであってるのか...?)

function func4 () {
  return new Promise(resolve => {
    let promise1 = Promise.resolve('promise1');
    promise1.then(function(value) {
      console.log(value);
    });
    let promise2 = new Promise(resolve => setTimeout(() => resolve('promise2')));
    promise2.then(function(value) {
      promise2.then(function(value) {
        console.log(value);
      });

      console.log('bottom');

      resolve()
    });
  });
}

こうすると、console.log('bottom')が出力されるthenが先にmicroTaskQueueに積まれ、その後にpromise2が出力されるthenがmicroTaskQueueに積まれるので、bottom -> promise2になっていそうに見える。

なぜテスト時にawait wait(0)とするとApolloのデータがロードされるか

上記で色々実験したが、Timers Phaseのことが理解できていれば、なぜテスト時にawait wait(0)とするとApolloのデータがロードされるかが分かる。

まず、MockedProviderに渡されるMockLinkのrequestメソッドが、setTimeout(..., 0)でcompleteさせるObservableを返している(この辺)。このため、requestメソッドが呼ばれた瞬間はデータがロードされず、次のTimers Phaseが訪れたときにデータがロードされるようになる。

また、waaitパッケージのwait関数は単純にsetTimeoutでresolveするPromiseを返しているだけである(参考)。つまりawait wait(0)のやっていることはawait new Promise(resolve => setTimeout(resolve, 0));というだけ。

これらから

  • MockedProviderを使うと、Timers PhaseのキューにApolloのクエリのデータをロードするコールバックが登録される
  • await wait(0)で、Timers Phaseにresolveするだけのコールバックが登録され、awaitでresolveを待つ
  • キューはFIFOなので、先にApolloのクエリのデータをロードするコールバックが実行される
  • await wait(0)のためのresolve()が呼ばれ、awaitを抜ける

となり、await wait(0)が終わった頃にはデータがロードされているということだろう。


このような仕組みなので、setTimeoutを使わずに、単純にresolveされたPromiseを待ったり、setImmediateでresolveされるPromiseを待ったりするだけではロードされている保証がなさそう。

単純にresolveされたPromiseを待つ

const promise = Promise.resolve('resolved');
await promise;

setImmediateでresolveされるPromiseを待つ

await new Promise(resolve => setImmediate(resolve));

まとめ

今回はApolloのテストの挙動を理解するために、Nodeのイベントループの仕組みについて調べて、少し遊んでみた。実証するレベルまでは今回は出来ず、内容が正しいかは保証できないので、もし間違っているところがあったら教えて下さい...

参考

React Hooksを学んだ

React学習メモ - $shibayu36->blog;にてReactを学習したので、続いてReact Hooksを学んだ。Reactのドキュメントはわかりやすくて良い...

以下メモ書き。

全体を通して感じたこと

  • 基本React Hooksの方が非常に見通しが良くなりそうだけど、今後クラス型コンポーネントを使う意味って何かあるのかな?
  • useEffectの副作用関数とクリーンナップ関数は毎回呼ばれるので、パフォーマンス問題に気をつけよう。基本は第二引数指定し、関係する変数の更新があったときのみ実行するようにしたら良い
  • ルールがいくつかあるので、linterは必ず使ったほうが良さそう

各ドキュメントごとのメモ

https://ja.reactjs.org/docs/hooks-overview.html

  • useStateはいままでsetStateで使っていたようなものを、useEffectは今までcomponentDidMountやcomponentDidUpdate、componentWillUnmountで処理していた内容を書く
  • hookは関数のトップレベルで
  • hookの適切な利用のためのlinterもある
  • useStateなどを、用いてカスタムフックを定義可能。ただし状態は別のものが利用される

https://ja.reactjs.org/docs/hooks-effect.html

  • useEffectの中の副作用関数は毎回のレンダー後に呼ばれると考えたら良い
  • setEffectでクリーンナップ処理を関数を返せば、毎回のレンダー後に前回の副作用をクリーンナップしてくれる。なぜコンポーネントアンマウント時だけでなく毎回のレンダー後なのかは、副作用処理で参照しているpropなどのデータがコンポーネント表示中に変更されてしまったときに、バグってしまうため
  • useEffectの第二引数に配列を渡すことで、それらの値が変わってない時は副作用を起こさないようにできる
    • もしマウント時とアンマウント時のみに実行したいなら、第二引数を空配列にすると良い
  • lintあるの便利〜
    • eslint-plugin-react-hooks パッケージの exhaustive-deps ルールを有効にすることをお勧めします。これは依存の配列が正しく記述されていない場合に警告し、修正を提案します。

  • 以下サンプルの例
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    // Specify how to clean up after this effect:
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

フックのルール – React

  • eslint-plugin-react-hooksを使っておいたらルール守れる
  • サンプルにある以下の設定で良さげ
{
  “plugins”: [
    // …
    “react-hooks”
  ],
  “rules”: {
    // …
    “react-hooks/rules-of-hooks”: “error”, // Checks rules of Hooks
    “react-hooks/exhaustive-deps”: “warn” // Checks effect dependencies
  }
}
  • useStateの呼び出しと、stateの対応関係を知るのはフックが呼ばれる順番に依存するため、条件文のなかでuseStateを使うと死ぬ
    • 条件があるならuseStateなどの中に入れれば良い

カスタムフックの作成 – React

  • フックのルールの自動チェックのためにも、カスタムフックは必ずuseで始めよう