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 コンポーネントのテスト

テストの設定

テストを開始するには、次のいくつかのツールが必要になります。

  • Jest - テストランナー
  • Enzyme - 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 をインポートする必要があります。これは、提供される関数のいくつかをモックするために必要です。このテストでは、通常の propsmockEvent オブジェクトを提供します。これらを使用して、ボタンがクリックされたときにブラウザから送信されるクリックイベントをシミュレートします。モックされるイベントには preventDefault プロパティを含める必要があります。これは、handleRefreshClick 関数内で呼び出されることになりますので、関数である必要があります。このプロパティが提供されない場合、プロパティが欠落していること、つまり、e.preventDefault is not a function のエラーが表示されます。

shallow を使用してコンポーネントをレンダリングしたら、handleRefreshClick を手動で呼び出して、モックイベントを渡して、このアプリで関数が呼び出されたときに何が起こるかをシミュレートします。このアプリの次のプロパティを確認します。

  • event.preventDefault は、一度呼び出されている。
  • props.dispatch は、3 回呼び出されている。
  • 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 という名前にする必要があります。

このアプリケーションで使用するコンフィグファイルは次のとおりです。

version: 2
jobs:
  build:
    working_directory: ~/redux-async
    docker:
      - image: circleci/node:8
    steps:
      - checkout
      - restore_cache:
          key: npm-cache-v1-{{ checksum "package-lock.json" }}
      - run:
          name: Install Dependencies
          command: npm ci
      - save_cache:
          key: npm-cache-v1-{{ checksum "package-lock.json" }}
          paths:
            - /home/circleci/.npm
      - run:
          name: Run Tests
          command: npm test

ここで説明する概念のいくつかを見ていきましょう。

  • 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 のテストに関する詳細を参照できます。


Dominic Motuka 氏は Andela 社の DevOps エンジニアであり、コンフィグ管理、CI/CD、DevOps プロセスを活用した、AWS および GCP の本番環境へのデプロイのサポート、自動化、最適化について 4 年以上の実務経験があります。

さんの他の投稿を読む Dominic Motuka