React メジャーなパッケージを使った開発とテストのチュートリアル

2022/07/18に公開

有名どころのReact QueryReact Hook FormRedux Toolkitを使ってシンプルなアプリを作る。

APIへのリクエストに関する状態管理をReact Queryで、フォームの入力状態の管理をReact Hook Formで、アプリの状態管理(ユーザーのログイン状態など)をRedux Toolkitで行う。APIクライアントにはaxiosを使用する。また、テストではReact Testing LibraryMSW(Mock Service Worker)を使用する。

react18.2、react-query3.39、react-redux8.0で検証

ログイン画面とユーザーの一覧を表示する画面の2つをもつアプリを作る。また、ユーザー一覧はログインしていること、ログイン画面はログインしていないことという条件の表示制御をもうける。

プロジェクトの作成

create-react-appReduxTypeScriptのテンプレートを指定してプロジェクトを作る。

$ npx create-react-app react-app --template redux-typescript

ルーティング

/にアクセスされたらHomeコンポーネントを/loginではLoginコンポーネントを表示するようにする。

src/App.tsx
import { Router } from "./Router";

const App = () => {
  return <Router />;
};

export default App;

ルーティングは別ファイルとする。

src/Router.tsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Login from "./pages/Login";

const Router = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/login" element={<Login />} />
      </Routes>
    </BrowserRouter>
  );
};

export default Router;

Homeコンポーネント。ここにユーザーの一覧とログアウトボタンをおいていく。

src/pages/Home.tsx
const Home = () => {
  return <h1>Users</h1>;
};

export default Home;

Loginコンポーネント。ここにメールアドレスとパスワードの入力フォームと送信ボタンをおいていく。

src/pages/Login.tsx
const Login = () => {
  return <h1>Login</h1>;
};

export default Login;

React Queryでデータを取得する

RestなAPIからユーザーの一覧を取得して画面に表示する。

axiosReact Queryのインストール

$ npm install axios
$ npm install react-query

ユーザーの一覧が返ってくるAPIへのリクエストを実装する。

src/services/api.ts
import axios from "axios";

export const client = axios.create();
export const API_URL = "https://example.com/api";
client.defaults.baseURL = API_URL;

export interface User {
  id: number;
  email: string;
}

export const fetchUsers = async (): Promise<User[]> => {
  const res = await client.get<User[]>("/users");
  return res.data;
}

App以下のコンポーネントでReact Queryを使えるようにする。

src/App.tsx
import { QueryClient, QueryClientProvider } from "react-query";

const queryClient = new QueryClient();

const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <Router />
    </QueryClientProvider>
  );
};

export default App;

コンポーネントから呼び出す。useQueryを使うことでisLoadingisErrorなどのリクエストの状態を簡単に扱える。

src/pages/Home.tsx
import { useQuery } from "react-query";
import { fetchUsers } from "../services/user";

const Home = () => {
  const { isLoading, isError, data: users } = useQuery("users", fetchUsers);

  if (isLoading) return <>Loading</>;
  if (isError) return <>Error</>;

  return (
    <ul>
      {users?.map((user) => (
        <li key={user.id}>
          {user.id}:{user.email}
        </li>
      ))}
    </ul>
  );
};

export default Home;

React Hook Formでログインフォームを作る

React Hook Formのインストール

$ npm install react-hook-form

useFormを使って入力フォームを作る。入力値の管理、バリデーションの管理がReact Hook Formで行われる。フォームに値が入力されたら送信ボタンを有効にする。また、バリデーションに成功して送信ボタンが押されたらhandleSubmitで指定した関数が呼ばれる。

src/pages/Login.tsx
import { useForm } from "react-hook-form";

type FormState = {
  email: string;
  password: string;
};

const Login = () => {
  const { register, handleSubmit, formState } = useForm<FormState>({ mode: "onChange" });

  return (
    <>
      <form onSubmit={handleSubmit((values) => console.log(values))}>
        <div>
          <input placeholder="Email" {...register("email", { required: "required" })} />
          <span>{formState.errors.email && formState.errors.email.message}</span>
        </div>
        <div>
          <input placeholder="Password" {...register("password", { required: "required" })} />
          <span>{formState.errors.password && formState.errors.password.message}</span>
        </div>
        <input type="submit" value="Login" disabled={!formState.isDirty || !formState.isValid} />
      </form>
    </>
  );
};

export default Login;

ログインAPIへのリクエストを実装する。ログインに成功すると認証用のトークンがレスポンスに含まれることとする。

src/services/api.ts
・・・

export interface Auth {
  token: string;
}

export const login = async (email: string, password: string): Promise<Auth> => {
  const res = await client.post<Auth>("/login", { email, password });
  return res.data;
};

handleSubmitに指定した関数が呼ばれた部分でAPIへのリクエストを行う。useMutationを使うことでisLoadingisErrorなどのリクエストの状態を簡単に扱える。

src/pages/Login.tsx
import { useMutation } from "react-query";
import { login } from "../services/user";

・・・

const Login = () => {
    ・・・
  const { mutate, isError } = useMutation((state: FormState) => login(state.email, state.password), {
    onSuccess: (data) => {
      console.log(data); // { token: "foo" }
    },
  });

  return (
    <>
      {isError && <>ログインできませんでした</>}
      <form onSubmit={handleSubmit((values) => mutate(values))}>
        ・・・
      </form>
    </>
  );
};

export default Login;

Redux Toolkitでログイン状態を管理する

ドキュメントを参考にReduxのセットアップ。後でテストを書くため、ここではWriting Testsの項を参考にしている。

src/app/store.ts
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import type { PreloadedState } from "@reduxjs/toolkit";

import authReducer from "../features/auth/authSlice";

const rootReducer = combineReducers({
  auth: authReducer,
});

export const setupStore = (preloadedState?: PreloadedState<RootState>) => {
  return configureStore({
    reducer: rootReducer,
    preloadedState,
  });
};
export const store = setupStore();
export type RootState = ReturnType<typeof rootReducer>;
export type AppStore = ReturnType<typeof setupStore>;
export type AppDispatch = AppStore["dispatch"];
src/app/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

認証用のトークンをストアで管理する。また、ログイン成功時に認証トークンをセットするsetTokenとログアウト時に認証トークンをクリアするclearTokenを実装する。

src/features/auth/authSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";

interface AuthState {
  token: string | null;
}

const initialState: AuthState = {
  token: null,
};

export const authSlice = createSlice({
  name: "auth",
  initialState,
  reducers: {
    setToken: (state, action: PayloadAction<string>) => {
      state.token = action.payload;
    },
    clearToken: (state) => {
      state.token = null;
    },
  },
});

export const { setToken, clearToken } = authSlice.actions;
export const selectToken = (state: RootState) => state.auth.token;
export default authSlice.reducer;

コンポーネントからログイン状態を変更する

ログインに成功したら上で作ったsetTokenを呼んでアプリに反映する。

src/pages/Login.tsx
import { useAppDispatch } from "../app/hooks";
import { setToken } from "../features/auth/authSlice";

・・・

const Login = () => {
  const dispatch = useAppDispatch();
  const { mutate, isError } = useMutation((state: FormState) => login(state.email, state.password), {
    onSuccess: (data) => {
      dispatch(setToken(data.token));
    },
  });
  ・・・
  return (
      ・・・
    <form onSubmit={handleSubmit((values) => mutate(values))}>
      ・・・
  );
}

Homeコンポーネントにログアウトボタンを追加して、上で作ったclearTokenを呼んでアプリに反映する。

src/pages/Home.tsx
import { useAppDispatch } from "../app/hooks";
import { clearToken } from "../features/auth/authSlice";

const Home = () => {
  const dispatch = useAppDispatch();
  ・・・

  return (
    <>
      <button onClick={(e) => dispatch(clearToken())}>Logout</button>
      ・・・
    </>
  );
};

ログイン状態によってアクセス可能なページを制御する

/はログインしていること、/loginは未ログインであることをページにアクセスできる条件とする。ここではストアの値を参照する。

src/Router.tsx
import { BrowserRouter, Routes, Route, Navigate, Outlet } from "react-router-dom";
import Home from "./pages/Home";
import Login from "./pages/Login";
import { useAppSelector } from "./app/hooks";
import { selectToken } from "./features/auth/authSlice";

export const Router = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route element={<PrivateRoute />}>
          <Route path="/" element={<Home />} />
        </Route>
        <Route element={<PublicRoute />}>
          <Route path="/login" element={<Login />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
};

const PublicRoute = () => {
  const currentToken = useAppSelector(selectToken);
  if (currentToken) {
    return <Navigate to="/" />;
  }
  return <Outlet />;
};

const PrivateRoute = () => {
  const currentToken = useAppSelector(selectToken);
  if (!currentToken) {
    return <Navigate to="/login" />;
  }
  return <Outlet />;
};

APIのリクエストヘッダーで認証トークンを指定する

axiosのインターセプターでストアに認証トークンがセットされていればリクエストヘッダーに追加する。

src/services/api.ts
・・・
import { RootState, store } from "../app/store";

export const client = axios.create();

・・・

client.interceptors.request.use((request) => {
  if (!request.headers) {
    return request;
  }
  let token = (store.getState() as RootState).auth.token;
  if (token) {
    request.headers!.auth = token;
  }
  return request;
});

APIのレスポンスが認証エラーなら認証トークンを削除する

axiosのインターセプターでAPIから認証エラーが返されたら認証トークンをクリアしてアプリに反映する。

src/services/api.ts
・・・
import { RootState, store } from "../app/store";
import { clearToken } from "../features/auth/authSlice";

export const client = axios.create();

・・・

client.interceptors.response.use((response) => {
  if (response.status === 403) {
    store.dispatch(clearToken());
  }
  return response;
});

テストの準備

Reduxのドキュメントを参考にテストに必要なコンポーネントをレンダリングする関数を用意する。また、テスト実行時にストアの初期値が指定できる。

src/utils/test-utils.tsx
import React, { PropsWithChildren } from "react";
import { render } from "@testing-library/react";
import type { RenderOptions } from "@testing-library/react";
import { configureStore } from "@reduxjs/toolkit";
import type { PreloadedState } from "@reduxjs/toolkit";
import { Provider } from "react-redux";
import { QueryClient, QueryClientProvider } from "react-query";

import type { AppStore, RootState } from "../app/store";
import authReducer from "../features/auth/authSlice";

interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
  preloadedState?: PreloadedState<RootState>;
  store?: AppStore;
}

export function renderWithProviders(
  ui: React.ReactElement,
  {
    preloadedState = { auth: { token: null } },
    store = configureStore({ reducer: { auth: authReducer }, preloadedState }),
    ...renderOptions
  }: ExtendedRenderOptions = {}
) {
  function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
    const queryClient = new QueryClient();
    return (
        <Provider store={store}>
          <QueryClientProvider client={queryClient}>
          {children}
          </QueryClientProvider>
        </Provider>
    );
  }

  return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
}

また、MSWをインストールしておく。

$ npm install msw --save-dev

ログイン画面のテスト

フォームに値がセットされていない初期状態ではログインボタンが無効になっていることをテストする。

src/pages/Login.test.tsx
import { screen } from "@testing-library/react";
import Login from "./Login";
import { setupServer } from "msw/node";
import { renderWithProviders } from "../utils/test-utils";

describe("ログイン画面", () => {
  it("ログインボタンの初期状態", () => {
    renderWithProviders(<Login />);
    expect(screen.getByRole("button")).toHaveAttribute("disabled");
  });
});

ログイン成功時のテスト。APIへリクエストをMSWでモックする。ログイン成功後、ストアにAPIから返された認証トークンがセットされることをテストする。また、afterEachで各テストごとにコンポーネントのアンマウントとMSWのリセットを行う。

src/pages/Login.test.tsx
・・・
import { screen, cleanup } from "@testing-library/react";
import { rest } from "msw";
import { setupServer } from "msw/node";
import userEvent from "@testing-library/user-event";
import { API_URL } from "../services/api";
import { setupStore } from "../app/store";

const server = setupServer();

beforeAll(() => server.listen());

afterEach(() => {
  server.resetHandlers();
  cleanup();
});

afterAll(() => server.close());

describe("ログイン画面", () => {
  ・・・
  it("ログイン成功", async () => {
    server.use(rest.post(API_URL + "/login", (req, res, ctx) => res(ctx.status(200), ctx.json({ token: "foo" }))));
    let store = setupStore();
    renderWithProviders(<Login />, { store });
    const email = screen.getByPlaceholderText("Email");
    await userEvent.type(email, "foo@example.com");
    const password = screen.getByPlaceholderText("Password");
    await userEvent.type(password, "password");
    await userEvent.click(screen.getByRole("button"));
    expect("foo").toEqual(store.getState().auth.token);
  });
});

ログイン失敗時のテスト。テストコードからエラーメッセージを表示する要素にアクセスできるようにするためにdata-testidで適当な名前をつけておく。

src/pages/Login.tsx
・・・
const Login = () => {
    ・・・
  const { mutate, isError } = useMutation((state: FormState) => ・・・

  return (
    <>
      {isError && <span data-testid="error">ログインできませんでした</span>}
      ・・・
    </>
  );
};

export default Login;

APIリクエスト後、画面にエラーメッセージが表示されることをテストする。

src/pages/Login.test.tsx
・・・
describe("ログイン画面", () => {
  ・・・
  it("ログイン失敗", async () => {
    server.use(rest.post(API_URL + "/login", (req, res, ctx) => res(ctx.status(422))));
    renderWithProviders(<Login />);
    const email = screen.getByPlaceholderText("Email");
    await userEvent.type(email, "foo@example.com");
    const password = screen.getByPlaceholderText("Password");
    await userEvent.type(password, "password");
    await userEvent.click(screen.getByRole("button"));
    expect(await screen.findByTestId("error")).toHaveTextContent("ログインできませんでした");
  });
});

一覧画面のテスト

APIから返されたデータが画面に表示されていることをテストする。

src/pages/Home.test.tsx
import { screen, cleanup } from "@testing-library/react";
import { rest } from "msw";
import { setupServer } from "msw/node";
import Home from "./Home";
import { renderWithProviders } from "../utils/test-utils";
import { API_URL } from "../services/api";

const server = setupServer();

const data = [
  { id: 1, email: "foo@example.com" },
  { id: 2, email: "bar@example.com" },
  { id: 3, email: "baz@example.com" },
];

beforeAll(() => {
  server.listen();
  server.use(rest.get(API_URL + "/users", (req, res, ctx) => res(ctx.status(200), ctx.json({ data }))));
});

afterEach(() => {
  server.resetHandlers();
  cleanup();
});

afterAll(() => server.close());

describe("ホーム画面", () => {
  it("ユーザー一覧の表示", async () => {
    renderWithProviders(<Home />);
    const items = (await screen.findAllByRole("listitem")).map((x) => x.textContent);
    const expected = data.map((x) => x.id + ":" + x.email);
    expect(items).toEqual(expected);
  });
});

ログアウトのテスト。認証トークンをセットしたストアで初期化した状態でログアウトするとストアの認証トークンがリセットされることをテストする。

src/pages/Home.test.tsx
import { setupStore } from "../app/store";
import userEvent from "@testing-library/user-event";
・・・
describe("ホーム画面", () => {
  ・・・
  it("ログアウト", async () => {
    let store = setupStore({ auth: { token: "foo" } });
    renderWithProviders(<Home />, { store });
    await userEvent.click(await screen.findByRole("button"));
    expect(store.getState().auth.token).toBeNull();
  });
});

おわりに

今回作ったサンプルアプリ
https://github.com/nrikiji/react-useful-packages-example

Next.jsで同じようなことをしたサンプルアプリ
https://github.com/nrikiji/nextjs-useful-packages-example

Discussion