React メジャーなパッケージを使った開発とテストのチュートリアル
有名どころのReact Query、React Hook Form、Redux Toolkitを使ってシンプルなアプリを作る。
APIへのリクエストに関する状態管理をReact Query
で、フォームの入力状態の管理をReact Hook Form
で、アプリの状態管理(ユーザーのログイン状態など)をRedux Toolkit
で行う。APIクライアントにはaxios
を使用する。また、テストではReact Testing LibraryとMSW(Mock Service Worker)を使用する。
react18.2、react-query3.39、react-redux8.0で検証
ログイン画面とユーザーの一覧を表示する画面の2つをもつアプリを作る。また、ユーザー一覧はログインしていること、ログイン画面はログインしていないことという条件の表示制御をもうける。
プロジェクトの作成
create-react-app
でRedux
とTypeScript
のテンプレートを指定してプロジェクトを作る。
$ npx create-react-app react-app --template redux-typescript
ルーティング
/
にアクセスされたらHomeコンポーネントを/login
ではLoginコンポーネントを表示するようにする。
import { Router } from "./Router";
const App = () => {
return <Router />;
};
export default App;
ルーティングは別ファイルとする。
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コンポーネント。ここにユーザーの一覧とログアウトボタンをおいていく。
const Home = () => {
return <h1>Users</h1>;
};
export default Home;
Loginコンポーネント。ここにメールアドレスとパスワードの入力フォームと送信ボタンをおいていく。
const Login = () => {
return <h1>Login</h1>;
};
export default Login;
React Queryでデータを取得する
RestなAPIからユーザーの一覧を取得して画面に表示する。
axios
とReact Query
のインストール
$ npm install axios
$ npm install react-query
ユーザーの一覧が返ってくるAPIへのリクエストを実装する。
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
を使えるようにする。
import { QueryClient, QueryClientProvider } from "react-query";
const queryClient = new QueryClient();
const App = () => {
return (
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
);
};
export default App;
コンポーネントから呼び出す。useQuery
を使うことでisLoading
やisError
などのリクエストの状態を簡単に扱える。
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
で指定した関数が呼ばれる。
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へのリクエストを実装する。ログインに成功すると認証用のトークンがレスポンスに含まれることとする。
・・・
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
を使うことでisLoading
やisError
などのリクエストの状態を簡単に扱える。
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
の項を参考にしている。
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"];
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
を実装する。
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
を呼んでアプリに反映する。
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
を呼んでアプリに反映する。
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
は未ログインであることをページにアクセスできる条件とする。ここではストアの値を参照する。
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のインターセプターでストアに認証トークンがセットされていればリクエストヘッダーに追加する。
・・・
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から認証エラーが返されたら認証トークンをクリアしてアプリに反映する。
・・・
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のドキュメントを参考にテストに必要なコンポーネントをレンダリングする関数を用意する。また、テスト実行時にストアの初期値が指定できる。
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
ログイン画面のテスト
フォームに値がセットされていない初期状態ではログインボタンが無効になっていることをテストする。
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のリセットを行う。
・・・
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
で適当な名前をつけておく。
・・・
const Login = () => {
・・・
const { mutate, isError } = useMutation((state: FormState) => ・・・
return (
<>
{isError && <span data-testid="error">ログインできませんでした</span>}
・・・
</>
);
};
export default Login;
APIリクエスト後、画面にエラーメッセージが表示されることをテストする。
・・・
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から返されたデータが画面に表示されていることをテストする。
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);
});
});
ログアウトのテスト。認証トークンをセットしたストアで初期化した状態でログアウトするとストアの認証トークンがリセットされることをテストする。
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();
});
});
おわりに
今回作ったサンプルアプリ
Next.jsで同じようなことをしたサンプルアプリ
Discussion