LoginSignup
10
7

More than 3 years have passed since last update.

TypeScriptでRedux~そしてユニットテストへ~

Posted at

 ドラゴンクエストⅢみたいなタイトルになってしまいましたが,わりと内容は真面目です。最近,仕事で本格的にReactとReduxを使う機会があったので,お手本構成プロジェクトを自分なりに作ってみました。備忘録がてらQiitaにも内容を残しておきたいと思います。今回の目的は以下の通りです。

  • TypeScriptでReactとReduxを扱う
  • (テストを考慮して)ReactとReduxは疎結合にする→Container/Presenter構成
  • 非同期処理も考慮する→Redux Thunk導入
  • ユニットテストを書く

何はなくともcreate-react-app

 手抜き目的でボイラープレートを使います。だから,いつまでたってもwebpackが書けない。

npm install -g create-react-app

 TypeScriptモードでプロジェクトを生成しておきます。めちゃ便利。

create-react-app sample --typescript

生成されるコードをクラス仕様に変更

 ボイラープレートが生成するコードは,クラス仕様になっていないので変更しておきます。


interface AppProperty {}
interface AppState {}

/**
 * 生まれ変わったAppコンポーネント
 */
export default class App<AppState, AppProperty> extends React.Component {
  render() {
    return (
      <div className="App">
      </div>
    )
  }
}

Reduxを導入

 Reduxを導入します。ActionCreatorやReducerを作るのが面倒なので補助ライブラリも入れておきたいですね。

  • typescript-fsa → ActionCreatorを簡単に生成してくださる素晴らしい方
  • typescript-fsa-reducers → Reducerを簡単に作成してくださる素晴らしい方
npm i -S react-redux redux typescript-fsa typescript-fsa-reducers

 あと,忘れずにRedux用のTypeScript型ファイルも入れておきます。

npm i -D @types/react-redux

redux thunkを導入

 非同期処理もやっていくのでthunkもこのタイミングで導入します。TypeScriptの型も忘れずに!

npm i -S redux-thunk
npm i -D @types/redux-thunk

Actionの作成

普通のAction

 まずはObjectを返却する普通のActionです。

import { actionCreatorFactory } from 'typescript-fsa';

interface AdviceInfo {
  message: string;
}

const actionCreator = actionCreatorFactory();
//普通のAction
export const fetchAdvice = actionCreator<AdviceInfo>('FETCH_ADVICE');

 typescript-fsaのおかげで簡潔に書けますね。

非同期処理のAction

 続いて非同期パターン。redux-thunkを使うので関数で書いていきます。今回はAxiosで格言?をランダムで返してくれるOpenなAPIを呼んでみました。
ちなみに,middleware(redux-thunk)の適用を忘れると「Actionはオブジェクトしか返せねぇよ!!!(怒)」というエラーが出るので気をつけましょう。

//非同期処理を行うAction
export const fetchAdviceAsync = () => {
  return async (dispatch: Dispatch<Action>, getState: () => AppState) => {
    try {
      const response = await axios.get('https://api.adviceslip.com/advice');
      const result: AdviceInfo = {
        message: response.data.slip.advice,
      };
      dispatch(fetchAdvice(result));
    } catch {
      //勇者よ,忘れず例外処理をやるのです
      //例外通知用の同期Actionを作るのもオススメです
    }
  };
};

 AppStateはStoreで定義します。念の為。

Reducerの作成

 今度はActionを受けてStateの操作を行うReducerを作っていきます。typescript-fsa-reducersが大活躍です。

import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { fetchAdvice } from '../actions/adviceAction';

/**
 * ReducerのState型
 */
export interface AdviceState {
  message: string;
}

/**
 * ReducerのState初期値
 */
const initialState: AdviceState = {
  message: '',
};

/**
 * Actionを受け取った時のState操作
 */
export const adviceReducer = reducerWithInitialState(initialState).case(
  fetchAdvice,
  (state, payload) => {
    return {
      ...state,
      message: payload.message,
    };
  }
);

Storeの作成

 ここは普通……ですね! Thunkをmiddlewareとして適用するのを忘れずに!

/**
 * アプリケーション全体のStore定義
 */
export interface AppState {
  advice: AdviceState;
}

const store = createStore(
  combineReducers<AppState>({
    advice: adviceReducer,
  }),
  applyMiddleware(thunk)
);

export default store;

index.tsxの修正

 ReduxとReactを連携させます。

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

Containerの作成

 ReduxのStoreとView(App.tsx)を紐付けるContainerを作成します。ContainerはView単位に作成していきます。面倒ですが,これもテストをしやすくするためです。

/**
 * StateとPropertyをBindするよ
 */
const mapStateToProps = (state: AppState, props: AppProperty) => {
  return {
    message: state.advice.message,
  };
};

/**
 * ActionとPropertyをBindするよ
 */
const mapDispatchToProps = (dispatch: any, props: AppProperty) => {
  return {
    fetchAdvice: () => {
      dispatch(fetchAdviceAsync());
    },
  };
};

const AppContainer = connect(
  mapStateToProps,
  mapDispatchToProps
)(App);

export default AppContainer;

App.tsxを微修正

 ContainerとAppはProperty経由でやり取りするので,AppのProperty定義(interface)をそれに合わせて修正します。

/**
 * Containerと連携するよ
 */
interface AppProperty {
  message?: string;
  fetchAdvice?: () => void;
}

interface AppState {}

/**
 * 生まれ変わったAppコンポーネント
 */
export default class App<AppState, AppProperty> extends React.Component {
  render() {
    return (
      <div className="App">
        <h1>{this.props.message}</h1>
        <button onClick="{this.props.fetchAdvice}">押せ力の限り</push>
      </div>
    )
  }
}

 各属性をOptional型(nullかもしれない)にしておかないとContainerでエラーが出ます。

const AppContainer = connect(
  mapStateToProps,
  mapDispatchToProps
)(App);

 ここでPropertyに何も渡さずにAppを使うからです。

index.tsxを微修正

 Containerを作ったことでAppを直接指定する必要がなくなりました。

ReactDOM.render(
  <Provider store={store}>
    <AppContainer />
  </Provider>,
  document.getElementById('root')
);

一旦動かしてみる

 アプリ自体はこれで完成です。

npm run start

 一旦動かしてみましょう。

ユニットテストを書く

 非同期Actionのテストコードを書くところでハマったので,その部分だけ補足的に書いておきます。テスト対象は↓のこいつ。

//非同期処理を行うAction
export const fetchAdviceAsync = () => {
  return async (dispatch: Dispatch<Action>, getState: () => AppState) => {
    try {
      const response = await axios.get('https://api.adviceslip.com/advice');
      const result: AdviceInfo = {
        message: response.data.slip.advice,
      };
      dispatch(fetchAdvice(result));
    } catch {
      //勇者よ,忘れず例外処理をやるのです
      //例外通知用の同期Actionを作るのもオススメです
    }
  };
};

redux-mock-storeの導入

 ActionはStoreに依存しているため,依存関係を排除したプレーンなテストを実施したいところです。そのためにredux-mock-storeを使います。

npm i -D redux-mock-store

Actionのテストコードを実装

 redux-mock-storeを使ってモック用Storeを作成します。その際にDispatchの型をredux thunk準拠(ThunkDispatch)にしておくことが重要です。この一手間を省くと型エラーが発生してActionを呼べなくなります

describe('AdviceAction', () => {
  it('非同期処理', async () => {
    //この一手間が明暗を分ける
    type DispatchExts = ThunkDispatch<AppState, void, AnyAction>;
    const mockStore = configureStore<AppState, DispatchExts>([thunk]);
    const store = mockStore();
    await store.dispatch(fetchAdviceAsync());
    expect(store.getActions()[0]).toEqual({
      payload: {
        message: 'mocked',
      },
      type: 'FETCH_ADVICE',
    });
  });
});

オマケ: Axiosのモック

 jestを使えばAxiosのモックも出来るので,よりベターです。

jest.mock('axios');
import axios from 'axios';
(axios.get as any).mockResolvedValue({ data: { slip: { advice: 'mocked' } } });

describe('AdviceAction', () => {
  it('非同期処理', async () => {
    //以下略
10
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
7