アプリケーションにセットされたユーザー情報を読み取って表示する


この記事は他の記事の続きのため単独で読んでもあまり価値がないかもしれません。

前回の記事

React で Clean にレイヤリングしながら TDD で実装進めるためのアーキテクチャ模索

一連のシリーズ

React のアーキテクチャを考える

リポジトリ

https://github.com/Hidekazoo/react-practice

作成する構成のイメージ

この記事ではユーザー情報を表示する Component および関連する関数などの実装を進めます。

UserInfo Component はユーザー情報を表示する Component です。この Component は表示するユーザー情報は外部ではなくアプリケーション内の Context に設定されたものを利用します。

画像はあくまでイメージなので、作りながら必要なものは足し、いらないものは除いていきます。画像右側から順番に作成していくことにします。

UserInfo Component のテストの作成

まずはテストを作成します。UserInfo Component がまだ存在しないのでテストを通すために必要な interface も考えておきます。

// UserInfo.test.tsx
import { render, screen } from "@testing-library/react";

describe("UserInfo", () => {
  test("ユーザー情報が表示される", () => {
    const user = {
      id: "test",
      name: "test user",
    };
    render(<UserInfo user={user} />);
    expect(screen.getByTestId("user-info")).toBeInTheDocument();
    expect(screen.getByTestId("user-id")).toHaveTextContent(user.id);
    expect(screen.getByTestId("user-name")).toHaveTextContent(user.name);
  });
});

こんな感じで書きました。元々、userId を受け取って UserInfo 内部であれこれするつもりでしたが見通しが悪くなりそうだったので user 情報を受け取って表示することにしました。

テストのエラーを解消するために UserInfo Component と interface の作成を行います。

import { FC } from "react";

interface IUserInfoProps {
  user: {
    id: String;
    name: String;
  };
}
export const UserInfo: FC<IUserInfoProps> = ({ user }) => {
  return null;
};

npm run test でテストが実行されて正しく落ちるはずです。

テストが通るように UserInfo を修正します。

export const UserInfo: FC<IUserInfoProps> = ({ user }) => {
  return (
    <div data-testid="user-info">
      <div data-testid="user-id">{user.id}</div>
      <div data-testid="user-name">{user.name}</div>
    </div>
  );
};

テストが通れば UserInfo の実装は完了です

UserInfo に user 情報を渡す Component

最初のイメージだと UserInfo は useUserInfo からデータを取得する感じになっていました。このイメージ通りに、props ではなく useUserInfo からデータを取得するように UserInfon のテストを修正して実装を修正しても良いと思います。

ただ、useUserInfo に UserInfo が直接依存すると、テストに useUserInfo のモックを作る必要があり、useUserInfo の interface が変わると常に UserInfo のテスト修正も必要となります。

UserInfo の責務はユーザー情報が渡されたときに想定した内容を出力することで、データがどこから来るかは重要ではありません。また、hook 経由より props でデータを受け取る方がテストが書きやすく壊れにくいです。useUserInfo と UserInfo の間に 1 つ Component を挟みます。

// UserInfoContainer.tsx
import { UserInfo } from "./UserInfo";
export const UserInfoContainer = () => {
  // const user = useUserInfo();
  const user = {
    id: "dummy",
    name: "dummy",
  };

  return <UserInfo user={user} />;
};

このコンテナはユニットテストできなくはないですがモックが増えて大変になりそうなのと、書くとしても E2E レイヤーで担保する方が良さそうなので実装を書きました。一旦、他の実装に影響がないようにダミーです。

useUserInfo の作成 

ユーザー情報を取得する関数の作成をします。ここからのレイヤーは React への依存を減らすかどうかで設計のやり方が変わると感じており、ここから hooks にする必要はあるか?など悩んだ部分です。最終的には このレイヤーを hooks にするか、React の依存を無くした通常の関数にするかなど実装の詳細はさておき、結合時の安全のために prefix に use をつけて hooks とすることにしました。

安全に使いたいためのトレードオフ

これから実装していく関数は、下位のレイヤーとは interface に依存する形で実装します。なのでこの関数自体に state など React 固有の機能が必要なかった場合は、 hooks を表す use という命名規則 を利用するのは下位レイヤーのことを知りすぎているとも言えます。

一方、React では API 通信や state を操作したいとき、state や別のカスタム hooks を使えるととても便利です。つまり、これから作成するユーザーデータを取得する関数は、データ取得部分は interface に依存して実装自体は知らないものの、インジェクトされる関数はカスタム hooks を利用する可能性があります。そうなると呼び出し元の関数には hooks の制約がかかることになります。

hooks には関数のトップレベルでのみ呼び出せるというルールがあります。

フックのルール

下位レイヤーで hooks が使われる可能性があるなら、呼び出し元も hooks にすることで、これより下位のレイヤーの関数で hooks を許容していることを明示したいという意図があります。また、こうすることで eslint などエディタ上で React のルール違反の確認がしやすくなります。一方、使用できるのが React の関数 Component に限られ、hooks なのでこのレイヤーが React に強く依存する制約もできます。

より良い回避方法がありそうな気はしますが、今思いつく範囲ではこの方が良いと思ったのでカスタム hooks にすることにしました。hooks を縛ると一気に実装が面倒になるのもあります。

良り良い方法が思いついたら後でリファクタすれば良いので進めます。

テスト

useUserInfo のテストを書きます。ユーザー情報を返してくれる関数の interface を UserInputPort に定義し、その関数を呼び出しているかを確認、そしてその関数が返した userInfo を返しているかを確認します。

import { renderHook } from "@testing-library/react";
const USER_ID = "test";
const USER_NAME = "test name";

describe("useUserInfo", () => {
  test("ユーザー情報を取得する", async () => {
    const userInfo = {
      id: USER_ID,
      name: USER_NAME,
    };
    const UserInputPortMock: jest.Mocked<UserInputPort> = {
      findUserInfo: jest.fn().mockResolvedValue(userInfo),
    };
    const { result } = renderHook(() => useUserInfo(UserInputPortMock));

    await waitFor(() => expect(result.current.user?.id).toEqual(USER_ID));
    expect(UserInputPortMock.findUserInfo).toHaveBeenCalledTimes(1);
    expect(result.current.user).toEqual({
      ...userInfo,
    });
  });
});

テストを動かすために最低限の実装をします。

// User.ts
export interface UserInfo {
  id: String;
  name: String;
}

// useUserInfon.tsx
export const useUserInfo = (userInputPort: UserInputPort) => {};

// userInputPort.tsx
export interface UserInputPort {
  findUserInfo: () => Promise<UserInfo>;
}

テストが想定通りに落ちていることを確認したら useUserInfo の実装をします。

// useUserInfo.tsx
export const useUserInfo = (userInputPort: UserInputPort) => {
  const [user, setUser] = useState<UserInfo>({ id: "", name: "" });

  useEffect(() => {
    const findUserInfoBy = async () => {
      const userInfo = await userInputPort.findUserInfo();
      setUser({
        id: userInfo.id,
        name: userInfo.name,
      });
    };

    findUserInfoBy();
  }, [userInputPort]);

  return {
    user,
  };
};

テストが通れば次に進みます。

UserInputPort の実装

useUserInfo は UserInputPort の findUserInfo の interface に依存しており実態はまだありません。実際には DI して使えるようにするために interface を満たした関数の実装を行います。関数名は UserGateway とします。

UserGateway のテストを書く

設計上ユーザー情報は UserContext から取得することにしています。なので、UserGateway の findUserInfo 関数は UserContext に依存します。

ここもテストから書き始めるのが良いとは思いますが、シンタックスが効かずに辛そうなので一旦 UserContext を作成します。

UserContext の作成

UserContext は user 情報を持っている想定です。その interface を書きます。

// UserContext.tsx
import React from "react";
export interface UserInfo {
  id: string;
  name: string;
}
interface IUserContext {
  user: UserInfo;
}
export const UserContext = React.createContext<IUserContext | null>(null);

必要なものが増えたら後から増やすとして一旦 import はできるようになったはずです。

UserGateway のテスト

import { renderHook } from "@testing-library/react";
import { UserContext } from "./UserContext";
import { useUserGateway } from "./useUserGateway";

const { Provider } = UserContext;
const USER_ID = "test";
const USER_NAME = "test name";

describe("UserGateway", () => {
  test("findUserInfo", async () => {
    const userInfo = {
      id: USER_ID,
      name: USER_NAME,
    };
    const wrapper = ({ children }: any) => (
      <Provider
        value={{
          user: userInfo,
        }}
      >
        {children}
      </Provider>
    );

    const { result } = renderHook(() => useUserGateway(), { wrapper });
    const actual = await result.current.findUserInfo();
    expect(actual).toEqual({
      ...userInfo,
    });
  });
});

Context を利用するためにテストのセットアップで UserContext の Provider を使います。テストが通るように useUserGateway を実装します。

export const useUserGateway = (): UserInputPort => {
  const context = useContext(UserContext);

  const findUserInfo = async () => {
    const user = context!.user;
    const userInfo: UserInfo = {
      id: user.id,
      name: user.name,
    };
    return userInfo;
  };

  return {
    findUserInfo,
  };
};

テストが通れば完了です。

UserContainer の修正

これで一通り処理が書けたのでダミーになっていた UserContainer を修正します。

export const UserInfoContainer: FC = () => {
  const userGateway = useUserGateway();
  const { user } = useUserInfo(userGateway);

  return <UserInfo user={user} />;
};

エラーはなくなりました。

画面で利用できるようにする

UserInfoContainer を利用するために UserContext から UserProvider を作成します。

// UserContext.tsx
export const UserContext = React.createContext<IUserContext | null>(null);
const { Provider } = UserContext;

export const UserProvider = ({ children }: { children: React.ReactNode }) => {
  const user = {
    id: "dummy userId",
    name: "dummy userName",
  };
  return <Provider value={{ user }}>{children}</Provider>;
};

user 情報をセットする方の処理は書いていないので固定値を返すようにします。これで画面で利用できる準備ができました。

// App.tsx
import "./App.css";
import { UserInfoContainer } from "./components/user/UserContainer";
import { UserProvider } from "./components/user/UserContext";

function App() {
  return (
    <div className="App">
      <UserProvider>
        <UserInfoContainer />
      </UserProvider>
    </div>
  );
}

export default App;

画面は…

想定通り!

次はユーザー情報を外部から取得するところ

ユーザー情報の取得部分ができたので次の記事ではユーザー情報を外部 API から取得し、アプリケーションにセットする実装を進めます。