React はフロントエンド環境で一世を風靡していますが、それにはもっともな理由があります。React を使用すると、データドリブンのユーザーインターフェイスを直感的にビルドできます。また、ユーザーインターフェイスの宣言的コンポーネントを記述し、これらのインターフェイスのレンダリングに必要なデータを提供できます。このデータが変更されるときに、DOM は効率的に更新されます。一方、Redux は、React がインターフェイスをレンダリングするために必要なデータを管理できます。Redux は、フロントエンドアプリケーションのデータを構造化して更新するための予測可能な方法を提供し、React と非常に緊密に連携します。
React と Redux がどのように連携するかを簡単に説明したので、 次に既存の React と Redux アプリケーションのテストを記述する方法を見ていきましょう。次に、CircleCI で継続的インテグレーションを構成し、テストを自動化し、新しいコードを追加するときに既存の機能が損なわれないようにします。
React と Redux の基本的な知識があると、このブログを最大限に活用できます。React を初めて使用される場合、「Intro to React」というチュートリアルを参照して、React の仕組みを確認できます。Reduxドキュメントの「Learn Redux」セクションには、多くの学習用のリソースがあります。Redux のサンプルページでは、サンプルアプリを確認でき、Redux がどのように動作するかを理解できます。
これらの情報についてはいったん置いておいて、本題に入りましょう。
はじめに
この記事では、サンプルアプリとして、Redux ドキュメントの Reddit API サンプルアプリを使用します。このアプリケーションを使用して、実際のアプリにテストを追加する方法をデモします。このような操作は、自分のアプリをビルドするときにも実行します。
テストを追加する前に、アプリが現在どのように機能しているかを確認する必要があります。アプリを、クローンを作成して、ローカルで実行すると、アプリの機能を簡単に確認できます。次のコマンドを実行して、アプリケーションのクローンを作成し、依存関係をインストールして、実行します。
git clone https://github.com/reactjs/redux.git
cd redux/examples/async
git checkout db5f8d1
npm install
npm start
注: git checkout
コマンドは、この記事を記述した時点における最新コミットの redux
リポジトリをチェックアウトします(つまり、db5f8d1
)。この記事をご覧いただくときには、リポジトリにいくつかの変更が加えられている可能性がありますので、このコマンドを実行して、全員が同じスタートポイントから、このブログの内容を確認できるようになります。
アプリが実行されると、次のような画面が表示されます。
アプリケーションの機能の説明
このアプリの主な機能は、Reddit API からこのデータを取得して、選択したサブレディットの現在のヘッドラインを表示することです。これにより、ユーザーは見出しを表示するサブレディットを選択し、見出しを読み込んで画面に表示できます。また、[Refresh(更新)] ボタンをクリックして、現在選択されているサブレディットに表示されるデータを更新できます。
これは単純なアプリのように見えるかもしれませんが、実際のアプリに必要な次のようなすべてのコンポーネントが含まれています。
- APIからデータを取得する
- ユーザーインタラクション
- 同期および非同期アクション
- プレゼンテーションおよびコンテナコンポーネント
Git と GitHub の進捗を追跡する
アプリに変更を加えるため、これらの変更を Git で追跡する必要があります。ここでは、新しいリポジトリを作成します。これにより、redux
リポジトリとは別に変更を追跡できます。以下のコマンドを実行して、async
フォルダに新しい git リポジトリを作成します。
git init
この新しいリポジトリは、async
フォルダの変更のみを追跡し、redux
リポジトリのクローンを作成したときにプルした残りのコードは無視します。この時点で、redux
からコードをインポートしたポイントをマークして、新しい git リポジトリで最初のコミットを行います。
git add .
git commit -m "Import async example from redux"
また、コードをプッシュする新しいリポジトリを GitHub で作成する必要があります。これは、後で CircleCI と統合する必要があるときに役立ちます。では、新しい GitHub リポジトリを作成し、コードをリポジトリにプッシュします。
このガイドは、GitHub に習熟されていない場合に参照できる優れたリソースです。
これで、Reddit API アプリの確認は終了です。ここまでで、アプリの機能について理解し、自分の GitHub アカウントにアプリのコピーを追加しました。では、テストの追加に進みます。
React コンポーネントのテスト
テストの設定
テストを開始するには、次のいくつかのツールが必要になります。
package.json
ファイルを確認すると、test
コマンドがすでに構成されていることがわかります。
このコマンドで指定された余分なフラグである --env=node --passWithNoTests
を最初に削除しましょう。このフラグは、ここでは不要です。
フラグを削除すると、テストコマンドは次のようになります。
"test": "react-scripts test"
react-scripts
には jest
がインストールおよび構成されているため、再インストールする必要はありません。ただし、ここで使用している React のバージョン用に、enzyme
とそのアダプタをインストールしましょう。
npm install --save-dev enzyme enzyme-adapter-react-16
また、アダプタを使用するように enzyme
を構成する必要があります。react-scripts
では、src/setupTests.js
ファイルを使用してテストツールを構成できます。
では、この js ファイルを作成し、次の内容を追加します。
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
このテストでは、スナップショットテストを使用してコンポーネントの変更を追跡します。この手法により、コンポーネントのスナップショットを取得でき、コンポーネントのレンダリングされた出力が変更されたときに、この変更を簡単に検出できます。スナップショットは読み取りすることも可能であるため、コンポーネントが期待される出力をレンダリングしているかを簡単に確認する方法でもあります。
スナップショットを有効にするには、テスト中に enzyme-to-json
パッケージをインストールして、React コンポーネントをスナップショットに変換する必要があります。
npm install --save-dev enzyme-to-json
また、jest
を構成して、このパッケージをスナップショットシリアライザとして使用する必要があります。package.json
で以下を追加してこの設定を行います。
"jest": {
"snapshotSerializers": [
"enzyme-to-json/serializer"
]
}
これで、実際にテストを開始する準備が整いました。
コンポーネントテスト
最初に、App
コンポーネントのテストから記述しましょう。まず、スナップショットテストを追加して、このコンポーネントが、必要とされる出力を、必須の props
を考慮して、レンダリングするかを確認します。
最初に、App
コンポーネントをインポートする必要があります。ただし、App.js
でエクスポートするのはこのコンポーネントの redux-connected バージョンのみです。つまり、デフォルトの connect(mapStateToProps)(App)
のみをエクスポートします。redux
との相互作用をテストするのではなく、コンポーネントのレンダリングをテストしますので、ベースにする App
コンポーネントもエクスポートする必要があります。この操作を行うには、次のスニペットを App.js
に追加します。
export { App };
まとめますが、App.js
ファイルの現在は次のようになります。
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { selectSubreddit, fetchPostsIfNeeded, invalidateSubreddit } from '../actions'
import Picker from '../components/Picker'
import Posts from '../components/Posts'
class App extends Component {
static propTypes = {
selectedSubreddit: PropTypes.string.isRequired,
posts: PropTypes.array.isRequired,
isFetching: PropTypes.bool.isRequired,
lastUpdated: PropTypes.number,
dispatch: PropTypes.func.isRequired
}
componentDidMount() {
const { dispatch, selectedSubreddit } = this.props
dispatch(fetchPostsIfNeeded(selectedSubreddit))
}
componentWillReceiveProps(nextProps) {
if (nextProps.selectedSubreddit !== this.props.selectedSubreddit) {
const { dispatch, selectedSubreddit } = nextProps
dispatch(fetchPostsIfNeeded(selectedSubreddit))
}
}
handleChange = nextSubreddit => {
this.props.dispatch(selectSubreddit(nextSubreddit))
}
handleRefreshClick = e => {
e.preventDefault()
const { dispatch, selectedSubreddit } = this.props
dispatch(invalidateSubreddit(selectedSubreddit))
dispatch(fetchPostsIfNeeded(selectedSubreddit))
}
render() {
const { selectedSubreddit, posts, isFetching, lastUpdated } = this.props
const isEmpty = posts.length === 0
return (
<div>
<Picker value={selectedSubreddit}
onChange={this.handleChange}
options={[ 'reactjs', 'frontend' ]} />
<p>
{lastUpdated &&
<span>
Last updated at {new Date(lastUpdated).toLocaleTimeString()}.
{' '}
</span>
}
{!isFetching &&
<button onClick={this.handleRefreshClick}>
Refresh
</button>
}
</p>
{isEmpty
? (isFetching ? <h2>Loading...</h2> : <h2>Empty.</h2>)
: <div style={{ opacity: isFetching ? 0.5 : 1 }}>
<Posts posts={posts} />
</div>
}
</div>
)
}
}
const mapStateToProps = state => {
const { selectedSubreddit, postsBySubreddit } = state
const {
isFetching,
lastUpdated,
items: posts
} = postsBySubreddit[selectedSubreddit] || {
isFetching: true,
items: []
}
return {
selectedSubreddit,
posts,
isFetching,
lastUpdated
}
}
export default connect(mapStateToProps)(App)
export { App };
慣例により、jest
は __tests__
という名前のフォルダにある、.test.js
で終わる名前のテストファイルを検索します。したがって、コンテナフォルダの下に __tests__
ディレクトリを作成し、その下に App.test.js
ファイルを作成します。
App
がエクスポートされたので、テストファイルに次のように App
をインポートできます。
import { App } from '../App'
Redux
とは別に App
コンポーネントをテストしているため、redux
によって現在提供されているコンポーネントの props
はすべて明示的に提供する必要があります。いくつかのレンダリングテストを追加して、実際にどのように機能するかを見てみましょう。App.test.js
内に、最初のテストを追加します。
import React from 'react'
import { shallow } from 'enzyme'
import toJson from 'enzyme-to-json'
import { App } from '../App'
describe('App', () => {
it('renders without crashing given the required props', () => {
const props = {
isFetching: false,
dispatch: jest.fn(),
selectedSubreddit: 'reactjs',
posts: []
}
const wrapper = shallow(<App {...props} />)
expect(toJson(wrapper)).toMatchSnapshot()
})
})
このテストでは、必要なすべての props
が提供され App
アプリがレンダリングされることを確認します。そのため、実際のアプリで redux
が実行する処理をシミュレートする props
を提供します。 jest
は、テストで実際の関数の代わりに使用できるモック関数を提供します。ここでは、これを使用して dispatch
関数をモックします。この関数は、テストで実際の dispatch
関数の代わりに呼び出されます。
npm test
コマンドを使用して、テストを実行できます。jest
テストランナーが起動し、テストを実行し、テスト実行のサマリーを出力します。次のメッセージも表示されます。
1 snapshot written from 1 test suite.
src/containers/__tests__/__snapshots__/App.test.js.snap
を開くと、コンポーネントのレンダリング出力を示すコンポーネントのスナップショットバージョンが表示されます。
App
のレンダリング動作をテストするためのテストをさらに追加します。最初に、selectedSubreddit
prop が常に Picker
コンポーネントに渡されることを確認するテストを追加します。このテストを既存のテストの直下に追加します。
// Add this import
import Picker from '../../components/Picker'
it('sets the selectedSubreddit prop as the `value` prop on the Picker component', () => {
const props = {
isFetching: false,
dispatch: jest.fn(),
selectedSubreddit: 'reactjs',
posts: []
}
const wrapper = shallow(<App {...props} />)
// Query for the Picker component in the rendered output
const PickerComponent = wrapper.find(Picker)
expect(PickerComponent.props().value).toBe(props.selectedSubreddit)
})
このテストは、ネストされたコンポーネント、つまり Picker
をクエリする場合に、enzyme
を簡単に使用し、正しい props
を使用してレンダリングされることを確認できる方法を示します。enzyme のドキュメントを参照し、enzyme で利用できる各種のテストユーティリティを確認することを強くお勧めします。
次に、いくつかの条件に応じてレンダリングされる要素を確認するための別のテストを追加します。このケースでは、isFetching
プロパティが false
の場合に Refresh ボタンがレンダリングされることを確認します。
it('renders the Refresh button when the isFetching prop is false', () => {
const props = {
isFetching: false,
dispatch: jest.fn(),
selectedSubreddit: 'reactjs',
posts: []
}
const wrapper = shallow(<App {...props} />)
expect(wrapper.find('button').length).toBe(1)
})
最後に、ユーザーインタラクションに関するテストを追加します。Refresh ボタンがクリックされたときに、正しいアクションが実行されることを確認します。
// Add this import
import * as actions from '../../actions';
it('handleRefreshClick dispatches the correct actions', () => {
const props = {
isFetching: false,
dispatch: jest.fn(),
selectedSubreddit: 'reactjs',
posts: []
}
// Mock event to be passed to the handleRefreshClick function
const mockEvent = {
preventDefault: jest.fn()
}
// Mock the actions we expect to be called
actions.invalidateSubreddit = jest.fn();
actions.fetchPostsIfNeeded = jest.fn();
const wrapper = shallow(<App {...props} />)
// Call the function on the component instance, passing the mock event
wrapper.instance().handleRefreshClick(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(props.dispatch.mock.calls.length).toBe(3);
expect(actions.invalidateSubreddit.mock.calls.length).toBe(1);
expect(actions.fetchPostsIfNeeded.mock.calls.length).toBe(2);
})
最初に、actions
をインポートする必要があります。これは、提供される関数のいくつかをモックするために必要です。このテストでは、通常の props
と mockEvent
オブジェクトを提供します。これらを使用して、ボタンがクリックされたときにブラウザから送信されるクリックイベントをシミュレートします。モックされるイベントには preventDefault
プロパティを含める必要があります。これは、handleRefreshClick
関数内で呼び出されることになりますので、関数である必要があります。このプロパティが提供されない場合、プロパティが欠落していること、つまり、e.preventDefault is not a function
のエラーが表示されます。
shallow
を使用してコンポーネントをレンダリングしたら、handleRefreshClick
を手動で呼び出して、モックイベントを渡して、このアプリで関数が呼び出されたときに何が起こるかをシミュレートします。このアプリの次のプロパティを確認します。
event.preventDefault
は、一度呼び出されている。props.dispatch
は、3 回呼び出されている。- lifecycle フックは shallow レンダリング API によって実行されるため、
componentDidMount
で 1 回 handleRefreshClick
関数で 2 回
- lifecycle フックは shallow レンダリング API によって実行されるため、
actions.invalidateSubreddit
は、一度呼び出されている。actions.fetchPostsIfNeeded
は、2 回呼び出されている。componentDidMount
で最初の呼び出しが発生している。handleRefreshClick
で 2 番目の呼び出しが発生している。
componentDidMount
関数呼び出しが正しく行われていることを確認するために、handleRefreshClick
関数呼び出しの直前にこれらの検証を含めることができます。
const wrapper = shallow(<App {...props} />)
// The next assertions are for functions called in componentDidMount
expect(props.dispatch.mock.calls.length).toBe(1);
expect(actions.fetchPostsIfNeeded.mock.calls.length).toBe(1);
wrapper.instance().handleRefreshClick(mockEvent);
//... rest of test omitted for brevity
この時点で、コードの最も困難な部分のテストが完了しています。これらのテストを追加できれば、他のコンポーネント機能のテストも簡単に追加できます。
追加できる可能性のあるテストには次のものがあります。
- たとえば、
isFetching
が true の場合、またはlastUpdated
が提供される場合などのさまざまなレンダリングパスのテスト onChange
関数が呼び出されたときに実行されるべき処理のテストApp
が新しいselectedSubreddit prop
を受け取るときに発生する処理のテスト
Posts
および Picker
コンポーネントに関連するテストは何でしょうか?これらのテストについてもう少し調べて、これらのテストの一部をすべて書き出すことをお勧めします。
Redux 機能のテスト
このセクションでは、アプリケーションの redux
関連部分、特にアクションと reducer に関するテストを追加します。
アクションクリエーターのテスト
アクションクリエーターから始めましょう。このアプリには、プレーンオブジェクトを返す同期アクションクリエーターと、redux-thunk
と組み合わせて使用して、データ取得などの結果をすぐに生成しない非同期操作を可能にする非同期アクションクリエーターがあります。両方をテストする方法について説明します。
参考用に、この src/actions/index.js
ファイルを示します。
export const REQUEST_POSTS = 'REQUEST_POSTS'
export const RECEIVE_POSTS = 'RECEIVE_POSTS'
export const SELECT_SUBREDDIT = 'SELECT_SUBREDDIT'
export const INVALIDATE_SUBREDDIT = 'INVALIDATE_SUBREDDIT'
export const selectSubreddit = subreddit => ({
type: SELECT_SUBREDDIT,
subreddit
})
export const invalidateSubreddit = subreddit => ({
type: INVALIDATE_SUBREDDIT,
subreddit
})
export const requestPosts = subreddit => ({
type: REQUEST_POSTS,
subreddit
})
export const transformResponseBody = (json) => {
return json.data.children.map(child => child.data);
}
export const receivePosts = (subreddit, json) => ({
type: RECEIVE_POSTS,
subreddit,
posts: transformResponseBody(json),
receivedAt: Date.now()
})
const fetchPosts = subreddit => dispatch => {
dispatch(requestPosts(subreddit))
return fetch(`https://www.reddit.com/r/${subreddit}.json`)
.then(response => response.json())
.then(json => dispatch(receivePosts(subreddit, json)))
}
const shouldFetchPosts = (state, subreddit) => {
const posts = state.postsBySubreddit[subreddit]
if (!posts) {
return true
}
if (posts.isFetching) {
return false
}
return posts.didInvalidate
}
export const fetchPostsIfNeeded = subreddit => (dispatch, getState) => {
if (shouldFetchPosts(getState(), subreddit)) {
return dispatch(fetchPosts(subreddit))
}
}
テストを有効にするために必要なファイルを最初に作成しましょう。src/actions/
内に、__tests__
という名前のフォルダを作成し、このフォルダの中に actions.test.js
というファイルを作成します。
同期アクションクリエーターを開始します。同期アクションクリエーターは、データを取得してアクションオブジェクトを返す単純な関数です。必要な引数が指定されると、アクションクリエーターが正しいアクションを返すことを確認する必要があります。これを、subreddit
を引数として受け入れ、アクションを返す selectSubreddit
アクションクリエーターのテストを使用して、この検証を行います。
import * as actions from '../index'
describe('actions', () => {
const subreddit = 'reactjs'
describe('selectSubreddit', () => {
it('should create an action with a given subreddit', () => {
const expectedAction = {
type: actions.SELECT_SUBREDDIT,
subreddit
}
expect(actions.selectSubreddit(subreddit)).toEqual(expectedAction)
})
})
})
通常の同期アクションクリエーターでは、このテストのみで十分です。
では、receivePosts
アクションクリエーターのテストも追加しましょう。このテストにより、非同期アクションクリエーターのテストを行うときの作業が容易になります。現在の関数の内容を以下に示します。
export const receivePosts = (subreddit, json) => ({
type: RECEIVE_POSTS,
subreddit,
posts: json.data.children.map(child => child.data),
receivedAt: Date.now()
})
返されるアクションでは、posts
プロパティが変換されています。json
引数を取り、必要な変換を行う新しい関数呼び出しに、この変換を追加します。したがって、receivePosts
関数の新しいバージョンは次のようになります。また、後のテストでアクセスできるように、新しいヘルパー関数をエクスポートする必要があることに注意してください。
export const transformResponseBody = (json) => {
return json.data.children.map(child => child.data);
}
export const receivePosts = (subreddit, json) => ({
type: RECEIVE_POSTS,
subreddit,
posts: transformResponseBody(json),
receivedAt: Date.now()
})
さらに、返されるアクションには、Date.now()
を返す receivedAt
プロパティが含まれています。このプロパティは、関数が呼び出されるたびに変化するため、このプロパティのテストは、ここではスキップします。たとえば、Date.now
関数をモックし、特定の戻り値を返すように設定するなど、プロパティをテストする方法はありますが、このブログではプロパティの照合についてはスキップします。
必要な作業のスコープを選択しましたので、receivePosts
アクションクリエーターのテストを追加しましょう。
describe('actions', () => {
const subreddit = 'reactjs'
// Add the mockJSON response
const mockJSON = {
data: {
children: [{ data: { title: "Post 1" } }, { data: { title: "Post 2" } }]
}
};
// ... other tests...
describe('receivePosts', () => {
it('should create the expected action', () => {
const expectedAction = {
type: actions.RECEIVE_POSTS,
subreddit,
posts: actions.transformResponseBody(mockJSON),
}
expect(actions.receivePosts(subreddit, mockJSON)).toMatchObject(expectedAction);
})
})
})
toMatchObject
が使用され、返されたアクションオブジェクトのサブセットのみが照合され、receiveAt
キーとの照合は行われないことに注意してください。
残りの同期アクションクリエーターは、いくつかのデータを指定して、同じプロセスでテストし、正しいアクションが返されるかどうかをテストします。
非同期アクションクリエーター、具体的には fetchPosts
アクションクリエーターのテストに進みましょう。
最初に、関数をエクスポートする必要があります。export
をこの関数に追加して、次のようにします。
export const fetchPosts = subreddit => dispatch => {
dispatch(requestPosts(subreddit))
return fetch(`https://www.reddit.com/r/${subreddit}.json`)
.then(response => response.json())
.then(json => dispatch(receivePosts(subreddit, json)))
}
また、いくつかの新しいパッケージをインストールする必要があります。
npm install --save-dev fetch-mock redux-mock-store
fetch-mock
を使用して、fetch
を使用して作成された HTTP リクエストをモックし、redux-mock-store
を使用して、テストで使用するモックストアを作成します。次のようにテストを追加します。
// Add the new imports
import thunk from 'redux-thunk'
import fetchMock from 'fetch-mock'
import configureMockStore from 'redux-mock-store'
const middlewares = [thunk]
const mockStore = configureMockStore(middlewares)
describe('actions', () => {
const subreddit = 'reactjs'
const mockJSON = {
data: {
children: [{ data: { title: "Post 1" } }, { data: { title: "Post 2" } }]
}
};
// ... other tests...
describe("fetchPosts", () => {
afterEach(() => {
// restore fetch() to its native implementation
fetchMock.restore()
})
it("creates REQUEST_POSTS and RECEIVE_POSTS when fetching posts", () => {
// Mock the returned data when we call the Reddit API
fetchMock.getOnce(`https://www.reddit.com/r/${subreddit}.json`, {
body: mockJSON
})
// The sequence of actions we expect to be dispatched
const expectedActions = [
{ type: actions.REQUEST_POSTS },
{
type: actions.RECEIVE_POSTS,
subreddit,
posts: actions.transformResponseBody(mockJSON)
}
]
// Create a store with the provided object as the initial state
const store = mockStore({})
return store.dispatch(actions.fetchPostsIfNeeded(subreddit)).then(() => {
expect(store.getActions()).toMatchObject(expectedActions)
})
})
})
})
redux-thunk
を含む、必要なすべての import から始めます。この場合、実際のストアを構成する必要があります。つまり、ミドルウェアをモックストアにも適用します。
次に、各テストの後に実行される afterEach
関数を使用し、モックの実装が他のテストで使用されないように、元の fetch
実装を復元します。
テストでは、予想されるリクエストをモックすることから始めて、レスポンスボディとして返されるモック body
を提供します。
次に、fetchPosts
を呼び出すときに実行するアクションのシーケンスを定義します。
このシーケンスでは、fetchPosts
がディスパッチされると、REQUEST_POSTS
アクションを生成し、次に要求された subreddit のポストで RECEIVE_POSTS
を生成する必要があります。このため、前のテストと同様に、RECEIVE_POSTS
アクションで receivedAt
プロパティを除外し、前に行ったように、変換したレスポンスボディを posts
キーに追加します。
次に、ストアを作成し、ストアに初期状態を設定し、fetchPosts
をディスパッチします。
最後に、ストアに適用されるアクションのリストは、expectedActions
アレイのシーケンスと一致する必要があることを宣言します。
この時点でテストを再実行すると、すべてが合格することを確認できます。 これで、アクションクリエーターのテストは終了しました。次に reducer をテストする方法を見ていきます。
reducer のテスト reducer は、アプリケーション全体の状態を更新する方法であり、redux の中核部です。reducer のテストにより、ディスパッチされた各アクションが期待どおりに状態を更新することを確認できます。
テストする reducers/index.js
ファイルの内容は次のとおりです。
import { combineReducers } from 'redux'
import {
SELECT_SUBREDDIT, INVALIDATE_SUBREDDIT,
REQUEST_POSTS, RECEIVE_POSTS
} from '../actions'
const selectedSubreddit = (state = 'reactjs', action) => {
switch (action.type) {
case SELECT_SUBREDDIT:
return action.subreddit
default:
return state
}
}
const posts = (state = {
isFetching: false,
didInvalidate: false,
items: []
}, action) => {
switch (action.type) {
case INVALIDATE_SUBREDDIT:
return {
...state,
didInvalidate: true
}
case REQUEST_POSTS:
return {
...state,
isFetching: true,
didInvalidate: false
}
case RECEIVE_POSTS:
return {
...state,
isFetching: false,
didInvalidate: false,
items: action.posts,
lastUpdated: action.receivedAt
}
default:
return state
}
}
const postsBySubreddit = (state = { }, action) => {
switch (action.type) {
case INVALIDATE_SUBREDDIT:
case RECEIVE_POSTS:
case REQUEST_POSTS:
return {
...state,
[action.subreddit]: posts(state[action.subreddit], action)
}
default:
return state
}
}
const rootReducer = combineReducers({
postsBySubreddit,
selectedSubreddit
})
export default rootReducer
reducer ファイルには、2 つの reducer があり、それぞれが状態の独自の部分を管理します。これらは最終的に、combineReducers
を使用して単一のルート reducer にマージされます。このスニペットを reducers/index.js
に追加して、テストをより簡便にするために、個々の reducer 関数をエクスポートします
export { postsBySubreddit, selectedSubreddit }
では次に進みましょう。reduces
の下に __tests__
ディレクトリを作成してから、そのディレクトリ内に reducers.test.js
ファイルを作成します。このディレクトリで、このテストを行います。selectedSubreddit
reducer の方が簡易ですので、この reducer を最初にテストしましょう。
import {
SELECT_SUBREDDIT, INVALIDATE_SUBREDDIT,
REQUEST_POSTS, RECEIVE_POSTS
} from '../../actions'
import { postsBySubreddit, selectedSubreddit } from '../index'
describe('app reducer', () => {
describe('selectedSubreddit', () => {
it('should return the default state', () => {
expect(selectedSubreddit(undefined, {})).toBe('reactjs')
})
it('should update the selectedSubreddit', () => {
const subreddit = 'frontend'
const action = {
type: SELECT_SUBREDDIT,
subreddit
}
expect(selectedSubreddit(undefined, action)).toBe(subreddit)
})
})
})
最初のテストでは、selectedSubreddit
reducer が状態を正しく初期化することを確認します。未定義の状態または空のアクションが指定されると、reactjs
に設定されているデフォルト値を返す必要があります。次のチェックでは、reducer
が有効なアクションオブジェクトを受け取ったときに、状態を正しく更新することを確認します。
次に、postsBySubreddit
reducer に進みます。
describe('postsBySubreddit', () => {
const subreddit = 'frontend'
it('should return the default state', () => {
expect(postsBySubreddit(undefined, {})).toEqual({})
})
it('should handle INVALIDATE_SUBREDDIT', () => {
const action = {
type: INVALIDATE_SUBREDDIT,
subreddit
}
expect(postsBySubreddit({}, action)).toEqual({
[subreddit]: {
isFetching: false,
didInvalidate: true,
items: []
}
})
})
it('should handle REQUEST_POSTS', () => {
const action = {
type: REQUEST_POSTS,
subreddit
}
expect(postsBySubreddit({}, action)).toEqual({
[subreddit]: {
isFetching: true,
didInvalidate: false,
items: []
}
})
})
it('should handle RECEIVE_POSTS', () => {
const posts = ['post 1', 'post 2']
const receivedAt = Date.now()
const action = {
type: RECEIVE_POSTS,
subreddit,
posts,
receivedAt
}
expect(postsBySubreddit({}, action)).toEqual({
[subreddit]: {
isFetching: false,
didInvalidate: false,
items: posts,
lastUpdated: receivedAt
}
})
})
})
状態を正しく初期化されるかどうかをテストすることから始めます。最初のテストで確認したように、この場合、デフォルトの状態は空のオブジェクトです。
残りのアクションのテストは非常に似ており、あるアクションが指定される場合に、reducer が予測される更新された状態を返すことを確認します。subreddit
は返されるオブジェクトのキーとして設定し、ネストされたオブジェクトはreducer で設定したルールに従って更新される必要があります。
reducer に共通することは、特定の入力セット、つまり初期状態とアクションを指定すると、新しい状態が返されることです。返された状態に対してすべての検証を行い、それが期待されるものであることを確認します。
これらのテストでは、一般的なアプリでテストする必要がある React および redux アプリケーションのさまざまな部分について説明しました。
GitHub および CircleCI を使用した継続的インテグレーション
ここまで、React と Redux アプリケーションのさまざまな部分にテストを追加する方法について説明してきました。次に、CircleCI を使用して、継続的インテグレーションを追加する方法を見ていきましょう。継続的インテグレーションにより、コードを変更しても、既存の機能が損なわれないようにできます。既存のブランチに新しいコミットを追加する場合でも、プルリクエストを開いて新しいブランチをメインブランチにマージする場合でも、新しいコードをプッシュするたびにテストが実行されます。これにより、開発プロセスの早期の段階でバグを発見できるようになります。
CircleCI の構成
最初に追加する必要があるのは、CircleCI にアプリケーションのテスト方法を指示するコンフィグファイルです。コンフィグファイルは、ルートフォルダーの
.circleci
ディレクトリに配置し、config.yml
という名前にする必要があります。
このアプリケーションで使用するコンフィグファイルは次のとおりです。
ここで説明する概念のいくつかを見ていきましょう。
- Docker イメージ は、使用する基本の Docker コンテナを指定します。この場合は、Node.js バージョン 8 がインストールされているコンテナになります。以下のすべてのコマンドは、このコンテナイメージのインスタンスで実行されます。
- チェックアウトステップは、作業ディレクトリにソースコードをチェックアウトします。
- CircleCI は依存関係のキャッシュをサポート しているため、この機能を利用して
npm
の依存関係をキャッシュします。 restore_cache
ステップは、以前のビルドで使用可能なキャッシュを復元します。run
ステップでは、npm ci
を使用してプロジェクトの依存関係をインストールします。save_cache
ステップは、npm
依存関係のキャッシュ、具体的には/home/circleci/.npm
フォルダを保存します。これは、npm ci
を使用して依存関係をインストールするときにnpm
キャッシュが格納されるフォルダです。package-lock.json
ファイルのコンテンツのチェックサムを使用するキャッシュを作成します。つまり、このファイルが変更されると、新しいキャッシュが作成されます。- また、CircleCI ではキャッシュはイミュータブルであることに注意してください。つまり、キャッシュが作成されると、その後キャッシュは変更されません。キャッシュを変更するには、新しいキャッシュを完全に作成する必要があります。 キャッシュキー の
v1
部分は、キャッシュを無効にするのに役立ちます。そのため、ここでは、キャッシュを手動で強制的に再作成する必要がある場合、v2
に変更できます。 - 最後のコマンドは、テストを実行する実際のテストコマンドです。
CircleCI のコンフィグ形式の概要と JavaScript プロジェクトで使用可能なすべてのオプションについては、circleci.com/docs/ja/language-javascript/ を参照してください。
CircleCI と GitHub の統合
ここで、前に作成した GitHub リポジトリにすべての変更をプッシュしていることを確認しましょう。次に、CircleCI を統合し、新しい変更を加えたときにコードを継続的にテストできるようにします。
プロジェクトを CircleCI に追加する方法は次のとおりです。
- CircleCI で新しい アカウントを作成 します(作成していない場合)。
- ログインしたら、左上で自分のアカウントが選択されていることを確認してください。
- Add Projects をクリックします。
- 次の画面で、GitHub リポジトリの名前を検索し、その横の Set Up Project をクリックします。
- 次のページで、Next Steps まで下方にスクロールし、Start Building をクリックします。
- CircleCI がテストステップを実行し、すぐに、ビルドが成功することを確認できます。
CI プロセスをセットアップすると、リポジトリに新しいコミットがプッシュされると、テストが実行され、ビルドを破損するような変更が導入されることが防止されます。変更によってテストが失敗する場合、通知が行われ、どのコミットがテストを失敗させたのかを正確に追跡できます。
結論
これで、実際の React と Redux アプリケーションにテストを追加できました。ここで説明した概念を活用して、将来同じようなアプリケーションで作業するときに、成功できることを願っています。
次のリソースは非常に有用であり、React と Redux のテストに関する詳細を参照できます。