React+Reduxアプリケーション テスト戦略
高橋勇人
はじめに
このエントリは全5回を予定する19卒新人ブログリレーの第4回目です。
はじめまして、リクルートテクノロジーズ新卒2年目の高橋 勇人です。
現在は不動産検索サービスSUUMOのフロントエンドエンジニアとして働いています。新卒入社してからの1年間、SUUMOの新機能開発に携わり、物件を地図から探す機能の開発を進めてきました。
SUUMOではこれまでJavaScript+jQueryで開発されてきましたが、SPサイト上で地図による物件探し機能を実現するために、React+Reduxという技術スタックが採用されました。
(※SP=スマートフォン)
長期的な開発がされてきたプロダクトで、これまで使ってきていなかった技術スタックでの開発を推し進めるにあたり、継続的な保守性を向上するための取り組みの一環としてテストの整備が進められました。
本記事では、React+Reduxでの地図機能の開発において採用されたテスト戦略について紹介します。
想定読者
- React+Redux経験者でアプリケーションを構築したことがある人
- テストを書くことでサービスの品質を向上させたいと思っている人
地図検索機能(横断地図)について
今回開発したのは、SUUMOの物件を地図から探す機能です。この機能は2020年6月24日にリリースされました(地図検索機能ページ)。
SUUMOで取り扱っている賃貸・新築マンション・中古マンション・新築一戸建て・中古一戸建て・土地をまとめて地図から探すことができ、検索条件を変更しながら、住みたい場所を起点として物件を見つけることができます。
このように、様々な物件の種別を横断的に探せることから、横断地図と呼ばれています(以下、横断地図)。
SUUMOでは既に同様の機能がPC版に実装されていましたが、今回新たにSP Web上で地図によるスムーズな物件探し体験を実現するため、React+Reduxの技術構成で構築を行いました。
React+Reduxアプリケーションのテスト戦略
Reactについて
ReactはFacebookによって開発が行われているViewライブラリで、コンポーネントベースであることが特徴です。
ReactコンポーネントでインタラクティブなUIを作成していく際には、propsを通じて状態を受け取り、各状態に対応するViewを設計していくことでアプリケーションを作り上げていきます。
1 2 3 4 5 6 7 8 9 10 11 |
import React from 'react' import ReactDOM from 'react-dom' const HelloMessage: React.FC<{ name: string }> = props => ( <div>Hello {props.name}</div> ) ReactDOM.render( <HelloMessage name="SUUMO" />, document.getElementById('hello-example'), ); |
一方でReactは、基本的にはViewのライブラリであり、状態管理が複雑化していくとどのコンポーネントで状態を持っているのか分かりにくく、全体の見通しが悪くなってしまいます。そのため、Reactはよく状態管理を行うライブラリと組み合わせて使われます。
Reduxについて
Reactの状態管理によく使われているライブラリの一つがReduxです。
ReduxはFluxアーキテクチャに加えて、関数型プログラミングの原理に基づいています。Fluxアーキテクチャには単方向データフローというコンセプトがあり、状態変更は必ずActionのDispatchによって行われます。また、Reduxにおいて状態変更を行うReducerは純粋関数によって記述され、副作用処理はMiddlewareに分離されます。
Reduxにおける状態変更のフローは以下の図のようになります。
Reduxの各要素は以下のような役割を持っています。
- Singleton Store:アプリケーション全体のデータを持つ。
- Reducer:Dispatchされたアクションと現在のStateの内容をもとに、Stateを更新する純粋関数。
- Middleware:アクションがDispatchされてからStateの更新を行うまでに処理を挟み込むことができる。非同期処理などの副作用のある処理はMiddlewareに分離する。
- Selector:StateからViewに渡すPropsを生成する。(Redux公式Document)
このような状態変更のデータフローの特性と、Reactコンポーネントの特性をもとにテスト戦略を考えていきます。
テスト戦略
先述したように、Reactコンポーネントは外部からpropsとして受け取った状態に対してのViewを返します。つまり、コンポーネントがローカルState等の内部状態を持たなければ、同じStateをPropsとして受け取ったときに、同じViewになることを保証することができます。
そのため、ReduxのState変更ロジックをテストによって保証することでStateの内容の正しさを担保すれば、Stateに基づいているViewまでを演繹的に保証することができるといえます。
この戦略によってReact+Reduxアプリケーションに対して効果的にテストを行なっていくために重要なのは、以下の2点です。
- コンポーネントが生成するView(HTML)がRedux Stateの中身と一対一で対応する状態を作ること(≒内部に状態を持たないこと)
- アプリケーションの状態変更が必ずActionのDispatchによって行われること(≒Redux以外の部分にロジックを持たないこと)
アプリケーションにおける状態管理・ロジックをRedux側に寄せることで上記の2点を達成しつつ、Reduxに対してテストを拡充していくことによってView→Action→Reducer→Stateの動作を保証してゆくことで、アプリケーション全体のロジック部分をテストによって担保しつつ、ReduxのStateに基づいているViewも演繹的に保証されていくという戦略が効果的です。
上記の2点は完全に達成することは難しいかもしれませんが、できる限り守った上で、そこから外れるコンポーネントの動作については手動での検証を含め、違った形で担保していくこととなります。コンポーネントに対するテストを効果的に行う方法については記事の後半で紹介します。
Reduxの状態変更・ロジックを保証するためには、Reduxにおける一連のフローに対してテストを書いていくこととなります。
テストを行なっていく対象は以下になります。
1.Action Creator
2.Reducer
3.Selector
4.Middleware
5.API通信処理
この戦略におけるテスト対象を示したのが以下の図です。
各要素に対するテストの書き方はReduxの公式Documentにありますが、この記事では横断地図での実例をもとに、どのようなテストを書くべきかについて説明していきます。
テスト例
Action Creator
Action Creatorのテストでは、返すActionの中身が正しいことを担保していきます。
返されるActionの中身をテストすることで、Reducerに渡されるアクションが想定通りになっていることを担保することができます。
また、テストとして書いておくことでActionの設計を変える際にも意図しない変更に気付けたり、複雑なPayloadを受け取るActionの場合にはサンプルコード的な役割も果たす等のメリットがあります。
テストの書き方はRedux公式Documentを参照してください。
Reducer Test
Reducerのテストでは、初期Stateが正しく返されることや、アクションを処理した結果のStateが正しいことを担保していきます。
基本的にはStateの書き換えが正しく行われているかをテストしていけばよいのですが、Reducer内にロジックを持っている場合には、関数に対するテストと同様に正常系・異常系・境界値に対してテストしていきます。
例えば、横断地図では以下のように、マーカー選択された際に対象のマーカーが地図の中央より下にあった時に、UIの表示に被らずマーカーが中央に来るよう、地図の中央位置を変更するロジックを持っていました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// Map Reducer const reducer = (state = initialState, action) => { switch (action.type) { case 'SELECT_MARKER': return { ...state, center: { lat: Math.min(state.center.lat, action.payload.marker.lat), lng: state.center.lng, }, }; default: return state; } }; |
このReducerに対しては、以下のようにテストしていきました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import reducer, { INITIAL_STATE, selectMarker } from '../mapReducer' describe('State: sealectMarker', () => { const marker: Marker = { lng: 100, lat: 100 } const selectMarkerAction = selectMarker(marker) test("returns marker's latitude smaller than state's", () => { const initialCenter = { lat: 101, lng: 101 } const state = { ...INITIAL_STATE, center: initialCenter, } const actual = reducer(state, selectMarkerAction) // latがマーカーの値に置き換わっていることをアサート expect(actual).toEqual({ ...state, center: { lat: marker.lat, lng: initialCenter.lng, }, }) }) test("returns state's latitude", () => { const initialCenter = { lat: 99, lng: 99 } const state = { ...INITIAL_STATE, center: initialCenter, } const actual = reducer(state, selectMarkerAction) // latの値が置き換わっていないことをアサート expect(actual).toEqual({ ...state, center: { ...initialCenter, }, }) }) }) |
このように、Reducerが持っているロジックに対して他にもパターン網羅を意識しながらテストを書いていきました。
Reducerに対してしっかりとしたテストを拡充することで、State変更の挙動の正しさを担保することができます。
State変更の挙動の正しさはテスト戦略上でも重要な部分なので、Reducer内にロジックを持っている場合は特に、しっかりとしたテストを書くことでアプリケーションとしての動作をより確かなものにしていくことができます。
Selector Test
Selectorのテストでは、Stateを与えた時に、期待する形に加工されていることを担保していきます。
Selectorは、Stateの値をもとに計算する処理を行います。Reselectを使ってSelectorを実装することで、計算のメモ化も行うことができるため、パフォーマンス観点でも有効です。
横断地図では、表示される地図のマーカーが多い時に複数のマーカーをまとめる処理を施しています。当初はGoogle Maps APIのMarker Clusterer機能を使っていたのですが、マーカーの数が数十件を超えたあたりからパフォーマンスに甚大な影響が出ていました。
そのAPIでは一度マーカー全部を地図上にレンダリング→レンダリングされた結果をもとにクラスタリング処理→クラスタリングしたマーカーをレンダリングし直す、という流れで処理が行われていたため、マーカーの数に対して指数関数的にパフォーマンスに影響がありました。
そこで、クラスタリングアルゴリズムについては、今回は厳密にクラスタリングする必要はなかったため、大まかなグリッド分割による実装に切り替えることで、O(n) で処理が完了するよう改良しました。
更に、地図にレンダリングされる前にSelectorを使って予めクラスタリングしたものをViewに渡す実装に変更しました。これによって、最初に全部のマーカーをレンダリングすることなくクラスタリングされた状態にできたため、マーカーの数が大きくてもパフォーマンス上問題なく動作させることができました。
このようにSelectorによってパフォーマンスの問題を解決したのですが、自前実装でクラスタリング処理を行なっているため、動作保証をしっかりとやるべき箇所となります。また、クラスタリングの結果が想定通りになっているかはGoogle Mapsにレンダリングされたものを確認しても判別しにくいため、その点でも動作をテストケースによって担保していくべき箇所です。
クラスタリング処理の内容は大幅に簡略化していますが、Selectorの実装は以下のようなイメージです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// Marker Reducer - Selector import { createSelector } from 'reselect' const getMarkers = (state: State): Marker[] => state.markers const getGridSize = (state: State): { horizontal: number vertical: number } => state.gridSize export const clusteredMarkerSelector = createSelector( [getMarkers, getGridSize], ( markers: Marker[], gridSize: { horizontal: number vertical: number }, ): Marker[] => { const grid: Array<Marker[]> = [] // マーカーをクラスターごとにGridに格納 markers.forEach((marker: Marker) => { addToGrid(marker, grid, gridSize) }) // GridごとにマーカーをマージしてGridで一つのマーカーに集約する const clusteredMarkers: Marker[] = grid.map( (markerItems: Marker[]): Marker => merge(markerItems), ) return clusteredMarkers }, ) |
このSelectorに対して、Stateを与えた時に、期待するようにクラスタリングされた値になっていることをテストしていきました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import { clusteredMarkerSelector } from '../markerReducer' describe('Clustering', () => { const testGridSize = { horizontal: 1.0, vertical: 1.0 } const getState = (markers: Marker[]): State => { // 受け取ったmarkersをセットしたテスト用Stateを設定 const state = { markers, gridSize: testGridSize, } ........ return state } test('Cluster two markers', () => { const markers: Marker[] = [ { lat: 1.0, lng: 1.0 }, { lat: 1.5, lng: 1.5 }, ] const state = getState(markers) const expectedMarkers: Marker[] = [{ lat: 1.25, lng: 1.25 }] const clusteredMarkers = clusteredMarkerSelector(state) expect(clusteredMarkers).toEqual(expectedMarkers) }) ........ }) |
この部分はロジックが多いため、実際のテストコードでは、細かいエッジケースについてもカバーするようテストケースを拡充していきました。
クラスタリング処理の結果は、まとめられてGoogle Maps上にレンダリングされた結果が、どの程度正しいのかを確認するのが難しい部分です。そのため、この処理に対してテストケースを手厚く書くことで、実装の担保及びデグレード検知を行なっています。
実際、実装していく中で何度かクラスタリング処理のアルゴリズムを改良していったのですが、意図した挙動になっているか、デグレードが起きていないかをテストによって確認しながら進めることができたため、自信を持ってリファクタリングしていくことができました。
Middleware Test
Middlewareのテストでは、対象のアクションが流れた際にMiddlewareの処理が実行されていること、副作用処理等が正しく動作していることを担保していきます。
Middlewareは副作用処理も含めた様々な処理を受け持つことになります。特に、横断地図ではイベントに紐づいたログ送信のアクションをMiddlewareに分離していたので、ビジネスロジック的にも重要な部分を担っていました。
どの程度テストを書くべきかについては、担保したい内容と重要度によって決めるべきところですが、特に重要な処理であるログ送信に関しては、手厚くテストケースが追加されています。
もう一つ、Middlewareの処理でテストケースが有効に働いた箇所として、時間経過によって分岐する処理があります。
前回アクセスからの経過時間を判定して、それをもとに分岐する処理をMiddlewareに実装していましたが、手動で確認すると経過時間を待ってから確認する方法やアクセス時間の書き換えなどが必要になるので、手間がかかる部分です。
処理の概要としては以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// Restore Middleware import { restoreConditions } from '../modules/restore' const getLastAccessTime = (state: State) => state.lastAccessTime const restoreMiddleware = (): Middleware => { return <D extends Dispatch = Dispatch>({ dispatch, getState, }: MiddlewareAPI<D>) => (next: Dispatch<AnyAction>) => ( action: any, ): Dispatch<AnyAction> => { if (action.type !== INIT_ON_ACCESS) { return next(action) } const lastAccessTime = getLastAccessTime(getState()) const elapsedTime = new Date().getTime() - lastAccessTime // 前回のアクセスから7日以内であればアクションがdispatchされる if (elapsedTime < SEVEN_DAYS) { dispatch(restoreConditions()) } return next(action) } } export default restoreMiddleware() |
このようなMiddlewareに対するテストでは、日付をモックしてやる必要があります。日付の取得をMiddleware内部でやっているため、今回はJestでDateオブジェクトをモックすることでテストしていきました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
import { initOnAccess, restoreConditions } from '../../modules/restore' import restoreMiddleware from '../restoreMiddleware' import { INITIAL_STATE } from '../../reducer' // Middlewareに対してテストするための関数を返却する const create = (state: any = INITIAL_STATE): any => { const store = { dispatch: jest.fn(), getState: jest.fn(() => state), } const next = jest.fn() const invoke = () => (action: AnyAction): any => { restoreMiddleware()(store)(next)(action) } return { store, next, invoke } } describe('restoreMiddleware', () => { beforeAll(() => { // Middleware内で使われるDateオブジェクトをモック const OriginalDate = Date const mockDate = new Date('2020-01-01T12:00Z') // Dateに明示的に値が渡された場合以外の日付をmockedDateに差し替え jest.spyOn(global, 'Date').mockImplementation((arg?: any): any => { return arg ? new OriginalDate(arg) : mockDate }) }) test('dispatch action within 6 days', () => { const sixDaysAgo = new Date('2019-12-26T12:00Z') const state = { ...INITIAL_STATE, lastAccessTime: sixDaysAgo, } const initOnAccessAction = initOnAccess() const { store, next, invoke } = create(state) invoke()(initOnAccessAction) // 次のMiddlewareへとアクションが渡されていることのアサート expect(next).toHaveBeenCalled() // アクションがDispatchされていることのアサート expect(store.dispatch).toHaveBeenCalledWith(restoreConditions()) }) test('does not dispatch action after 7 days', () => { const sevenDaysAgo = new Date('2019-12-25T12:00Z') const state = { ...INITIAL_STATE, lastAccessTime: sevenDaysAgo, } const initOnAccessAction = initOnAccess() const { store, next, invoke } = create(state) invoke()(initOnAccessAction) // 次のMiddlewareへとアクションが渡されていることのアサート expect(next).toHaveBeenCalled() // アクションがDispatchされていないことのアサート expect(store.dispatch).not.toHaveBeenCalledWith(restoreConditions()) }) }) ........ |
経過時間などに紐づいた処理を行う部分は、特に手動での確認に手間がかかる部分なので、Unitテストによってロジックが保証されることの恩恵が大きいところです。
今後ロジックに変更が入った場合でも、デグレードの確認をテストによって行うことができるので、アプリケーションをエンハンスしていく際もテストによる恩恵が受けられます。
API Test
API等の非同期処理アクションのテストでは、実行されるアクションの順番と内容が正しいことを担保していきます。
Middlewareが担う主な副作用処理に、APIへの非同期通信処理があります。
非同期通信処理の実行にはRedux Thunk、Redux-Saga、redux-effects-steps等のMiddlewareを使って実装していくことが多いです。
横断地図では、redux-effects-stepsを使用しました。redux-effects-stepsはアクションの実行順序を制御するもので、Request開始アクション→Fetch処理→Success/Failアクションの流れを定義する形で使用します。
実際のFetch処理についてはredux-effects-stepsライブラリ自体は機能を持っていないため、他のMiddlewareライブラリ等によって実現します。横断地図では、レスポンスからの値をもとにSuccess/Failを決める処理等を行うために、Fetch用のMiddlewareを実装する形としました。
redux-efects-stepsを使用したアクションの定義は以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// Marker Reducer - async action creator import { steps } from 'redux-effects-steps' import actionCreatorFactory from 'typescript-fsa' import fetchMarkerFromAPI from '../middleware/markerFetcherMiddleware' const actionCreator = actionCreatorFactory() export const fetchMarkerRequest = actionCreator('FETCH_MARKER_REQUEST') export const fetchMarkerSuccess = actionCreator<Marker[]>( 'FETCH_MARKER_SUCCESS', ) export const fetchMarkerFail = actionCreator<ErrorType>('FETCH_MARKER_FAIL') export const fetchMarker = (searchConditions: SearchConditions) => { return steps(fetchMarkerRequest(), fetchMarkerFromAPI(searchConditions), [ fetchMarkerSuccess, fetchMarkerFail, ]) } |
fetchMarkerActionsをDispatchすることで一連のFetch処理がredux-effects-stepsのMiddlewareによってハンドリングされます。fetchMarkerFromAPIアクションから返ってきたPromiseがresolveされている場合はSuccess、rejectされている場合はFailが実行されます。
実際にFetch処理を行うMiddlewareは、例えば以下のような実装です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
// Marker Fetcher Middleware import actionCreatorFactory from 'typescript-fsa' const actionCreator = actionCreatorFactory() const FETCH_MARKER_FROM_API = 'FETCH_MARKER_FROM_API' export const fetchMarkerFromAPI = actionCreator<SearchConditions>( FETCH_MARKER_FROM_API, ) export const fetchMakerFunction = async ( searchCondition: SearchCondition, ): Promise<Marker[] | ErrorType> => { // Fetch処理, エラーハンドリング ........ return markers } const markerFetcherMiddleware = (): Middleware => { return () => (next: Dispatch<AnyAction>) => ( action: any, ): Promise<Marker[] | ErrorType> => { if (action.type !== FETCH_MARKER_FROM_API) { return next(action) } const { searchConditions } = action.payload return fetchMarkerFunction(searchConditions) } } export default markerFetcherMiddleware() |
このような非同期通信アクションに対しては、Dispatchされるアクションの実行内容が正しいかをテストしていきます。Fetch処理を行うMiddlewareに対しては別でテストを書くことになります。
テストにおいては通信処理をモックする必要があるため、Jestのモック機能によって関数単位でのモックを行うか、fetch-mockなどを使って通信をモックします。
非同期アクションに対してのテストでは、Fetch処理を行う関数ごとモックしてテストを行いました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
import steps from 'redux-effects-steps' import configureMockStore from 'redux-mock-store' import markerFetcherMiddleware, * as module from '../../middleware/markerFetcherMiddleware' import { fetchMarker, fetchMarkerRequest, fetchMarkerSuccess, fetchMarkerFail, } from '../markerReducer' const mockStore = configureMockStore([markerFetcherMiddleware, steps]) describe('async actions', () => { test('creates FETCH_MARKER_SUCCESS when fetching markers has been done', async () => { const searchConditions: SearchConditions = { type: 'ALL' } const markers: Marker[] = [{ lat: 1, lng: 1 }] jest.spyOn(module, 'fetchMarkerFunction').mockImplementationOnce(() => Promise.resolve(markers), ) const expectedActions = [ fetchMarkerRequest(), fetchMarkerSuccess(markers), ] const store = mockStore({ markers: [] }) await store.dispatch(fetchMarker(searchConditions)) expect(store.getActions()).toEqual(expectedActions) }) ........ }) |
Redux Thunkなどを使用している場合は、Redux公式Documentにもテスト例が紹介されているため、参考にしながらテストを拡充していくと良いと思います。
このように、Reduxにおける一連のフローに対してテストを書いていくことでReduxの状態変更・ロジックを保証していきます。
アプリケーションにおける状態管理・ロジックをRedux側に寄せた上で、Reduxにおける一連のフローに対してテストを拡充していくことによって、アプリケーション全体のロジック部分をテストによって担保しつつ、ReduxのStateに基づいているViewも演繹的に保証することができます。
しかし、Reduxによって保証できるのはあくまでStateに基づく部分のみで、アプリケーションの重要な部分として、Viewやコード全体の整合性などもあります。次の章ではそのようなアプリケーション全体に対してテストしていくための方法を紹介していきます。
アプリケーション全体でのテストについて
ここまでに紹介してきたように、React+Reduxの構成においては、Reduxのテストを書くことでアプリケーション全体のロジック・振る舞いに対するテストをしています。
一方で、コンポーネントの表示やユーザーインタラクションに対するイベントの処理などは別の方法でテストしていく必要があります。
アプリケーションテストの考え方
フロントエンドアプリケーションに対してテストする上では、Viewをどの程度までテストによって保証するべきか決める必要があります。
この考え方の一つにTesting Pyramidがあり、これはテストを走らせた時のスピードとテストを書くコストはトレードオフであるという考えをもとに、書くべきテストの比率を示したものです。
Testing Pyramidでは、コストも安く、実行速度も速いUnit Testが最も多く書くべきテストであることが示されています。
一方でフロントエンドアプリケーションにおいてはテストによって何が保証できているかを考える必要があります。実装の詳細をテストしすぎても(例えばボタンの色が緑色であることをテストしても)テストによって保証できることよりもコストの方が大きくなってしまいます。
そのようなフロントエンド開発におけるテストの課題に対して、Testing Trophy というコンセプトがKent C.Doddsにより提唱されています。
(引用元: https://testingjavascript.com)
Testing Trophyでは、Testing Pyramidにはなかった「Static」の項目が追加されており、それぞれのテストの比率も大きく変わっています。
Testing Trophyには、「自信係数」という考えがあり、上に行くほどそのテストがアプリケーションが正しく動くことへの自信を与えるものである、という点を考慮しています。
Testing PyramidではUnitテストの方がコストも小さく実行速度も速いので、この点だけを考えると、Unitテストを書くという方向性になります。
しかしTesting Trophyの自信係数が示すように、「テストによってどれだけソフトウェアがちゃんと動くことを担保できるのか」という観点が、テストを考える上では重要です。
Trophyの各項目の大きさが書くべきテストの総量を示しており、Kentは自信係数、スピード、コストを考慮した際に最もトレードオフのバランスが良いIntegration Testを書くべきと提唱しています。
これまで挙げてきたReduxテストはUnit Testであるとともに、Integration Testの部分も担っています。Reduxが扱っているのはViewによるイベントが発火した際の状態変更であり、複数のコンポーネントにまたがった変更に対してのテストであるため、Integration Testの役割も内包しています。
そのため、Reducer等に対してテストすることで、複数コンポーネントの結合部分をテストしているのに近い結果が得られます。
ここまで見てきたのはReduxに対してのテストによるアプリケーション動作の担保についてでしたが、実際のアプリケーションとしてテストすべき対象には、UI表示やユーザーインタラクションに対するイベント処理などのView側のテストなどがあります。そしてそれ以外にも静的解析によって実装の正しさの担保を行うことができます。
これらに関しても、スピード、コスト、そして自信係数のトレードオフを考慮しながら、アプリケーション全体のテスト戦略を決めていくのが重要になります。
この章では、Reactコンポーネントに対してのテストを書くのに使えるライブラリと、静的解析のための仕組みを紹介します。
Enzyme, React Testing Library
React コンポーネントにテストを記述するためのライブラリとして Enzyme と React Testing Library があります。
これらはどちらが明確に良いというものではなく、どのようなテストを書きやすいようにデザインされているかという点に違いがあります。
EnzymeはReactコンポーネントの出力についてアサートや操作を行う、いわゆるUnitテスト的な書き方に適しています。一方でReact Testing Libraryは、ユーザーが使う際の操作をコンポーネントに適用していくような形で記述していく、E2Eテスト的な書き方に適しています。
そのため両者は、アプリケーションのViewに対してどのような内容を保証したいかによって使い分けるべきものです。Viewについてもコンポーネントレベルで動作を保証するテストを書きたい場合にはEnzyme、重要な画面やコンポーネントに対してユーザー操作に対する挙動を保証したい場合にはReact Testing Libraryを導入するとよいです。
横断地図では、React Testing LibraryによるE2Eテストを導入しており、ユーザーがランディングした際の導線のテストなどを追加しています。
Storybook
Viewコンポーネントのレンダリング結果を確認しやすくするためのライブラリとしてStorybookがあります。
Storybookはコンポーネントのカタログのように使えますが、propsに渡す値を変えて表示することで、アプリケーションを起動せずとも各状態の時のコンポーネントの表示を確認することができるので便利です。
他のコンポーネントとの相互作用を切り離せる上に、アプリケーションで所定の状態を再現しなくてもViewを確認できるため、コンポーネントのスタイルを実装する際に使うことで実装の助けにもなります。
ViewのUnitテストのような、他のコンポーネントの実装と切り離したViewを確認する用途でも、Storybookを使うことができます。
横断地図でも、Reactコンポーネントを実装する際にStorybookを活用して、画面とのつなぎ込みを行う前から表示を確認・調整したり、各propsに対するViewの内容を確認したりしています。
TypeScript
TypeScriptを使うことで、JavaScriptに対して型を書くことができます。
これまでに挙げた、テストケースを書くようなものとは違いますが、型も実装の正しさを担保する上では非常に有用です。
TypeScriptを導入することによって、各コンポーネント間のPropsの受け渡しやコンポーネントがReduxから受け取るStateの値の想定が実装として正しいかを型によって検査することができます。
型による恩恵は非常に大きく、IDEによるサジェストも充実する上、テストケースなどとは違った側面から、実装の正しさを強力に担保してくれる仕組みとなります。
特にReduxのStateの設計が変更された場合などには、型によって変更が波及している範囲を型チェックによって追うことができるため、設計の変更を行った際に不整合を残してしまうリスクを低減することもできます。
TypeScriptによって担保できる内容はReduxテストとは異なり、型レベルでの整合性です。これにより、主にコンポーネント間の値の受け渡しや、Storeで持っている状態が他の部分の実装と乖離していないか等を、開発中に知ることができます。
型との不整合が起きればIDEによる警告が出るためすぐに実装の間違いに気づくことができ、これを型による一つの自動テストと考えることもできます。
型をしっかりと書くことはコストがかかりますが、単体テストを充実させるのと同等か、それ以上に恩恵を受けることができます。
ESLint / Prettier
ESLintはJavaScriptのLinter、Prettierはコードフォーマッターです。
これもテストケースを書くようなものとは違いますが、適切に運用することで実装ルールを徹底することができたり、記述上のミスを編集中に防ぐことができるため、実装の正しさを支えてくれるものです。
ESLintにはReact用のプラグインのようにReact特有のベストプラクティスを運用するための追加ルールなどもあり、こういった細かいながらチームで統一していきたいようなルールを、機械的に適用していくことができます。
細かい記述の正しさやベストプラクティスは、テストケースや型で保証しにくい部分ですが、ESLint / Prettierを適切に運用することでそれらの部分を補うことができます。
テストを活用した開発の円滑化
横断地図では、Reduxの実装部分には全てテストを書くこと、Reactコンポーネントには基本的にStorybookを書くこと、TypeScriptの型を基本的に書くこと(no implicit anyを適用)をルールとして運用しました。これによって、レビュアーの負担を大幅に減らしながら開発することができました。
これらがないと、レビュアーはPull Requestが挙がってきた時点で書かれているコードが正しく動くのか、また書かれているロジックの挙動が正しいのかをコードを読み解いて確認する必要に迫られます。
特にビジネスロジックは複雑なものであるほど不具合を見逃しやすく、確認自体の負担も大きくなります。
Reduxのテストケースがしっかりと書かれていることで、挙動が正しいかどうかはテストケースの内容から確認できるため、わざわざアプリケーションを起動して落ちそうなケースを再現して確認するという必要がなくなります。
実装が変わった時やリファクタリングを行った時にも、デグレードが起きていないか否かは、テストケースが落ちていないことや型に不整合が起きていないことから確認できます。また、型チェックが行われていることによって、設計を変更した場合にも、受け渡す値に型レベルでの不整合が起きてないかといった内容を検証してレビューする手間も減ります。
Jenkinsを使って、リポジトリにpushされたタイミングでテスト・Lint・型をチェックし、失敗している場合にはSlack通知を流すというフローも運用することで、レビューする前には基本的なチェックが終わった状態になっています。
実装の中で変更を加えた際にデグレードが起きた場合でも、Failしたテストケースの内容や型のアサートの内容を見れば修正対象や修正方針が分かるため、その後の修正も行いやすいです。
TypeScriptの型をしっかりと書いておくことで、テストと合わせて、開発の中でデグレが起きた際にバグが発生するという形ではなく型コンパイル/テストケースの失敗という形で、事前に検知できることになります。
細かい記述スタイルや記述ルールについてはESLint / Prettier / Stylelintを使い、push前にチェック・修正を自動的に行うことにより、細かい記述スタイルに関する指摘・議論を行う必要がなくなります。
このような仕組みによって運用することによって、実装にバグがないかの検証も、型やテストによって補完しながら見ていくことができます。
結果としてレビュアーの負担を減らすことができ、レビューでは実装の方針や設計についての議論に集中しやすくなります。
横断地図での取り組みとその成果
横断地図では、これまでに述べたようなテスト戦略のもと、Redux側の処理部分に対してテストを拡充しながら開発を進めていった結果、テストカバレッジとして90%以上の水準でテストを整備することができました。
カバレッジを目標においていたわけではないのですが、テスト戦略としてReduxを手厚くテストすること、実装の担保としてのテストにすることを目標にしたことで、結果的に高いカバレッジを達成することができました。
その結果として、コード規模2万6千行程(テストコード除く)に対して、テストフェーズにおけるバグ発生件数は、軽微なものも含めると50件程度という形で開発を完了することができました。
その内訳としては以下のような結果でした。
- デザインのずれや表示崩れなど:十数件
- 実装上の不備があったもの:十数件
- 挙動にバグがあったもの:20件弱
このうち、多くはコンポーネント側の挙動に起因しているもので、軽微なものも多く、テスト戦略において重点的に保証していたReduxの実装に起因したバグは3件程度でした。
これは、テスト戦略の狙いとしてはある程度達成していると言えます。結果的に、テストフェーズにおいて重大な手戻りや遅れなども発生せず開発を完了することができました。
横断地図の初期開発においては、テストによってStateを保証することで、Viewを保証していくという方針のもと、Reduxテストを拡充してきました。
今後機能のエンハンスを進めていく上で継続的に開発しやすい仕組みを作るために、E2Eテストの拡充や、Visual Regressionテストなども取り入れることで、アプリケーション全体でのテストによって保証できる範囲を広げてゆき、自信を持ってアプリケーションを変更していけるような仕組みを整えていければと考えています。
最後に
この記事では、横断地図の開発で採用したReact+Reduxアプリケーションにおけるテスト戦略について紹介しました。
Reduxに状態管理を寄せた上でReduxに対してテストを拡充していく方針を取る際に、React Reduxの状態変更によるパフォーマンス影響は気をつける必要があります。
Reactにおけるパフォーマンスチューニングについては辻健人さんによるこちらの記事を参考にしました。
Selectorの章で紹介したGoogleMaps上のマーカーの例を含め、横断地図ではパフォーマンスでボトルネックになっている点を分析して改善を行いながら開発を進めてきました。
そういったパフォーマンスチューニングのための変更を行う際も、テストと型があることによって自信を持ってアプリケーションを変更していくことができました。
テストを書くことはそれ自体もコストがかかる上、どこまでをテストするのかはフロントエンドアプリケーションの難しい部分ではありますが、テストがあることによる恩恵は初期開発に限ったものではなく、エンハンスを続けていく上での資産となると思います。
この記事が、React+Reduxアプリケーションのテストを書く上で少しでも参考になれば幸いです。