Feedforce Developer Blog

フィードフォース開発者ブログ

ReactのContextをDI Containerとして使う

JSON色付け係の小飼 id:kogainotdan です。

今回はJSON色付け係として、テスタブルなJSON色付け手法について書きたいと思います。
Reactのみの話です。

3行で

  • windowを触るとテストが辛い
  • ReactのContextを使うとDI出来る
  • DI出来るとテストが簡単

課題

JSON色付け、と言うかGUI開発に限らず外部環境への依存性を持ったソフトウェアコンポーネントはテスト容易性を失いがちです。
中でもWebアプリケーションのフロントエンドでは、HTTPリクエストやTimer関連の処理、Client Side Storageへのアクセス、認可(OAuth)処理などがソフトウェアコンポーネントに含まれているために、テストが難しくなるということはよくあることだと思います。
(もちろんJestのTime Mocksなど、グローバルな環境を書き換えることで解決することもありますが)

こういったソフトウェアコンポーネントがなぜテストするのが難しいかというと、テストが実装に依存している構造に原因があると考えられます。
例えばlocalStorageへ何かしらデータを保存するコンポーネントがあった時に、素朴に実装するとlocalStorageがどのような振る舞いをするかは、(当然ですが)実装コードの中で決まります。

コードで言うと以下のような感じです。
(以降、全体的にJestっぽいテストコードが続きますが、お使いのテストランナーに併せて雰囲気で読み替えて下さい :pray: )

// useLocalStorage.js
export const useLocalStorage = key => ({
  getItem: () => localStorage.getItem(key),
  setItem: value => localStorage.setItem(key, value)
});

// useLocalStorage.test.js
import { renderHook } from "@testing-library/react-hooks";
import { useLocalStorage } from "./useLocalStorage";

test("can save", () => {
  const { result } = renderHook(() => useLocalStorage("my-salary"));
  result.current.setItem(100);
  // `localStorageが書き換わったこと`はどのようにテストしたらいいのか?
  expect(localStorage.getItem("my-salary")).toBe(100);
});

上の例では、素朴にlocalStorageそのものを使ってテストしています。
例えばテストが並行・並列に実行される時、あるいはブラウザではなくNode.js環境で実行される時(しかもjs-domの実装がブラウザに追いついていない時)、そもそもグローバルオブジェクトではない外部環境とのやり取りを持っている時など、テストしづらくなるシチュエーションは多岐に渡ります。

上述の通り、こういった問題をテストが実装に依存している構造にあると考えてみると、実装がテストに依存する構造、より一般化して実装が上位レイヤーに依存する構造を取ることによって、テスト容易性を獲得出来ると考えられます。

いわゆる依存関係逆転の原則(もしくは依存性注入)ですが、ReactのContext APIを用いることで、他のDIライブラリを導入することなくDIを実現出来そうです。

DI ContainerとしてのReactのContext

さて、DIを使ってコンポーネントを実装していくにあたって、注入される依存性を都度書くのは煩雑です。
例えば最初のコード例を拡張すると、

// useLocalStorage.js
// テストだけでなく、このコンポーネントを使うコードは全てlocalStorageを明示的に注入する責務が生じる
export const useLocalStorage = (key: string, ls: LocalStorage) => ({
  getItem: () => ls.getItem(key),
  setItem: value => ls.setItem(key, value)
});

// useLocalStorage.test.js
import { renderHook } from "@testing-library/react-hooks";
import { useLocalStorage } from "./useLocalStorage";

test("can save", () => {
  const ls = {
    getItem: jest.fn(),
    setItem: jest.fn(),
  };
  const { result } = renderHook(() => useLocalStorage("my-salary", ls));
  result.current.setItem(100);
  // テストは簡単になる
  expect(ls.setItem.mock.calls).toEqual([["my-salary", 100]]);
});

依存関係は逆転されテスト自体は簡単になるものの、このコンポーネントを使うコードは全てLocalStorageを明示的に注入する責務が生じてしまい、煩雑です。
もちろんデフォルト引数を使って省略することは出来ますが、

export const useLocalStorage = (key: string, ls?: LocalStorage = localStorage) => ({

依存するものが増えてきた場合にうっとうしいことになりそうですし、デフォルト引数を使った関数設計を使いたいケースにもややこしいことになりそうです。
そこで、DI Containerを用いた自動DIが出来ると嬉しい、ということになります。

そういった時に優秀なDIライブラリを探しに行くのも良いのですが、実は多くのReactユーザが、既にDI ContainerとDIを用いたテストを既に行っている、ということを指摘させて下さい。

もしReactと一緒にreact-reduxも使っていて、redux-mock-storeを使ってテストをしているとしたら、本稿でご紹介したい手法を既に実践してるのです。
公式ドキュメントに案内されているので、そういった方は一定数いるものと思います)

DI ContainerとしてのReactのContext(react-reduxの場合)

まずはおさらいです。
react-reduxを用いてreduxパターンを実現する時、以下のようなコードを書くと思います。

// ./Root.js
import { createStore } from "redux";
import { Provider, useStore, useDipsatch } from "react-redux";

const reducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    default:
      return state
  }
};

const store = createStore(reducer);

export const Root = () => {
  const state = useStore();
  const dispatch = useDipsatch();
  return (
    <div onClick={() => dispatch({ type: "INCREMENT" })}>
      {`Hello!, ${state}`}
    </div>
  );
};

// ./App.js
import { Root } from './Root';

const App = () => (
  <Provider store={store}>
    <Root />
  </Provider>
);

だいぶ単純な例ですが、意味するところは伝わったと信じます。

ここでRoot componentをテストする時にはどうするでしょうか。
redux-mock-storeを用いて、以下のようにテストを書くのではないでしょうか?

import { Provider } from "react-redux";
import configureStore from "redux-mock-store";
import { render, fireEvent } from '@testing-library/react';
import { Root } from './Root';

test("can increment", () => {
  const store = configureStore()();
  const { getByRole } = render(
    <Provider store={store}>
      <Root />
    </Provider>
  );
  fireEvent.click(getByRole("div"));

  expect(store.getActions()).toEqual([{
    type: "INCREMENT"
  }]);
});

Providerを使って、アプリケーションの実行環境とテストの実行環境で、別々のStore(Root componentの依存しているデータ構造)を注入していることが見て取れます。
それによって、アプリケーションの実行時に使われるデータ構造(この場合はReduxのStore)の実装に依存せず、テストが実行出来ていることが分かります。

別の言い方をすると、Root componentがどのように振る舞うかを、テストコード・もしくはApp componentが決定している、とも言えるでしょう。
少なくともこの時点で、依存関係の逆転は出来ていそうですね。

ではこのProviderは何者なのか。
ソースコードを見てみると、

  const Context = context || ReactReduxContext

  return <Context.Provider value={contextValue}>{children}</Context.Provider>

react-reduxのProviderは、ReactReduxContextとの中継役に過ぎないことが分かります。
ではReactReduxContext何をしているかと言うと、

import React from 'react'

export const ReactReduxContext = React.createContext(null)

export default ReactReduxContext

これも単にcreateContextという関数を呼び出しているだけです。
こいつがProviderというComponentを含んだ、何かしらのオブジェクトを返しているわけですね。

そこでReactのドキュメントを確認してみると、

Creates a Context object. When React renders a component that subscribes to this Context object it will read the current context value from the closest matching Provider above it in the tree.

it will read the current context value from the closest matching Provider above it in the tree.

Componentがrenderされた時、Component treeの直上からProvide(r)されてきた値(context value)を読み込める、という風に理解できます。
更に、

The defaultValue argument is only used when a component does not have a matching Provider above it in the tree.

react-reduxでは使っていませんでしたが、createContextに値を渡して呼び出すと、それがContextのデフォルト値として用いられる、ということが分かります。

つまり、

  • 任意の種類の値を格納するための空間を生成出来る
  • その空間にはデフォルト値を格納しておける
  • その空間から取り出した値を使っていると、(Component treeに属していれば)上層のComponentから、任意の値を注入出来る

という性質が見て取れます。

この性質を持っていることで、ReactのContextをDI Containerとして成り立たせる事が出来る、というのが本稿の主張するところです。
更に、redux-mock-storeを使ったテストをしているのであれば、既にそのようにContextを使っている、と言えるのではないでしょうか。

DI ContainerとしてのReactのContext(実践)

では最初の例に戻って、実際にどのようにReactのContextをDI Containerとして活用していけるのかを見ていきましょう。
まずContextを作って、Contextを経由してlocalStorageにアクセスするようにします。

+ export const LocalStorageContext = React.createContext(localStorage);
+
export const useLocalStorage = key => {
+   const ls = useContext(LocalStorageContext);
  return ({
+    getItem: () => ls.getItem(key),
-    getItem: () => localStorage.getItem(key),
+    setItem: value => ls.setItem(key, value)
-    setItem: value => localStorage.setItem(key, value)
  });
};

次に作成したContextのProviderを用いて、テスト環境においてlocalStorageのように振る舞うオブジェクトを注入します。

import { renderHook } from "@testing-library/react-hooks";
+ import { useLocalStorage, LocalStorageContext } from "./useLocalStorage";
- import { useLocalStorage } from "./useLocalStorage";

test("can save", () => {
+   const ls = {
+     getItem: jest.fn(),
+     setItem: jest.fn(),
+   };
+  const wrapper = ({ children }) => (
+      <LocalStorageContext.Provider value={ls}>
+        {children}
+      </LocalStorageContext.Provider>
+    );
+  const { result } = renderHook(() => useLocalStorage("my-salary"), { wrapper });
-  const { result } = renderHook(() => useLocalStorage("my-salary"));
  result.current.setItem(100);
  // `localStorageが書き換わったこと`はどのようにテストしたらいいのか?
+  expect(ls.setItem.mock.calls).toEqual([["my-salary", 100]]);
-  expect(localStorage.getItem("my-salary")).toBe(100);
});

これでuseLocalStorageから、localStorageへの直接の依存を排除し、その上層のコードから注入することが出来るようになりました。

今回はlocalStorageに限ってご説明しましたが、これ以外にもURLパラメータや認可(OAuth)フロー、View componentなど、実際のプロダクトで現れ得る様々なシチュエーションで応用出来ます。

まとめ

というわけで、ReactのContextはDI Containerなのではないかというお話でした。
ググってみると、同じ意見の人はそこそこいますね

React Has Built-In Dependency Injectionなんかは、私の主張したかったことそのものズバリのタイトルです。
Build Inというのはこの見方のメリットの一つですね。

もしプロダクトコードでどのように活用しているか見たい方は、Feedforceの転職ドラフトにぜひラブコールお願いします。
(コードを見に来るだけでも歓迎です)

それでは。