Edited at

自作アプリにJest+enzyme導入

🔰初心者です

前回導入編を紹介したので、更にdispatchした時やTextFieldに入力された時のバリデーションテスト等々を、自分用の備忘録がてら紹介していこうと思います。

(…まだまだエラーに躓きっ放しでございます!😭😭😭)


■ TextFieldに入力した文字が280文字を超えたらエラーを表示させる

今実装しているのが、「280文字を超えたらエラーを表示させる」テキストエリアです。下のように、Material-UIのTextFieldで用意されているerrorhelperTextのプロパティを使ってエラーを表示させるようにしました。

import TextField from '@material-ui/core/TextField'

class Form extends React.Component {
state = {
status: '',
statusError: null,
}
handleChange = event => {
const text = event.target.value
this.setState({ status: text }, () => {
this.validateStatus()
})
}
validateStatus = () => {
this.setState({
statusError:
this.state.status.length > 280
? '文字数は280文字以内に収めてください'
: null,
})
}

render() {
return (
<div>
<TextField
label="Let's Tweet!"
placeholder="280文字以内で入力してください"
margin="none"
variant="filled"
rows="5"
multiline
fullWidth
onChange={this.handleChange}
value={this.state.status}
error={this.state.statusError ? true : false}
helperText={
this.state.statusError ? this.state.statusError : null
}
/>
</div>
)
}
}

TextFieldhandleChangeを設置して、stateにあるstatusに入れています。this.setState()の引数に280文字以上超えた時のバリデーションを用意しました。ユーザーがリアルタイムで入力してエラーが表示されるようにしています。

ちゃんと280文字超えたらerrortrueになってくれるか、テストしていきます。下はテストです。Jestとenzymeを使っています!

ちなみにこの

import { storeFactory } from '@app/testUtils/testUtils'

は前回記事で書いたファイルです。→記事

import * as React from 'react'

import { Provider } from 'react-redux'
import { storeFactory } from '@app/testUtils/testUtils'
import { createShallow, createMount } from '@material-ui/core/test-utils'
import Form from '@app/components/Form'
import TextField from '@material-ui/core/TextField'

const shallow = createShallow()
const mount = createMount()

const setup = (state = {}) => {
const store = storeFactory(state)
const wrapper = mount(
<Provider store={store}>
<Tweet />
</Provider>
)
return wrapper
}

describe('<Tweet />', () => {
it('Textareaが280文字の時、エラーメッセージは表示されない', () => {
const wrapper = setup()
wrapper
.find(TextField)
.find('textarea')
.simulate('change', {
target: {
value:
'あなたは翌日もしその紹介人というのの日にしましたい。はなはだ今朝をお話し人はとうとう同じ意見たありかもをしているんがは相違炙っございでて、一応には防ぐませたたまし。モーニングをするでしょのもちょうど将来を無論ないなます。いやしくも岡田さんへ開始権力まだ存在へ怒らです平気その責任皆か運動のというご意見ありましだでして、その同年はそれか文芸先生をできるば、嘉納さんののへ道の誰がもっと小ごろごろとさので私仕立をご合点の教えようにもうご始末をふらしますならから、はなはだいよいよ講演を買い占めるですからかかるたのに倒さなた。なおそれでお自我にするつもりもいっそ好き',
},
})

expect(wrapper.find(TextField).props().error).toBe(false)
})

it('Textareaが281文字の時、エラーメッセージを表示する', () => {
const wrapper = setup()
wrapper
.find(TextField)
.find('textarea')
.simulate('change', {
target: {
value:
'あなたは翌日もしその紹介人というのの日にしましたい。はなはだ今朝をお話し人はとうとう同じ意見たありかもをしているんがは相違炙っございでて、一応には防ぐませたたまし。モーニングをするでしょのもちょうど将来を無論ないなます。いやしくも岡田さんへ開始権力まだ存在へ怒らです平気その責任皆か運動のというご意見ありましだでして、その同年はそれか文芸先生をできるば、嘉納さんののへ道の誰がもっと小ごろごろとさので私仕立をご合点の教えようにもうご始末をふらしますならから、はなはだいよいよ講演を買い占めるですからかかるたのに倒さなた。なおそれでお自我にするつもりもいっそ好きと',
},
})

expect(wrapper.find(TextField).props().error).toBe(true)
})
})

wrapper.find(TextField)を繋げ、さらにその中にあるtextareaを探します。(今回はTextFieldmultilineを入れてテキストエリアにしています!テキストフォームの場合は.find(input)です。)

テキストエリアが入力されたイベントはenzymeで用意されているsimulateを使っています。

最後にTextFieldのerrorプロパティはwrapper.find(TextField).props().errorのように指定してあげます。この記述で正しく動作することを確認できました!

▼ エラー解消してくれた記事

Testing with Jest and Enzyme in React — Part 3 (Best Practices when testing with Jest and Enzyme)

見ればわかるテストなので笑、Submitした時dispatchされるかテストしていきます!


■ Submitした時、dispatchされることを確認する

上記でエラーメッセージが正しく表示されることを確認できました!

お次は、バリーデーションに引っかかっている状態の時はSubmitされては困るので、それもテストします。

- 0字の時はSubmitされない

- 1字以上280字以内の時、Submitされる

- 280字を超えたらSubmitされない

今回は、Submitされた時this.props.postTweetStatusが動作するように書いています。

const setup = (state = {}) => {

const props = {
postTweetStatus: jest.fn()
}
const store = storeFactory(state)
const wrapper = mount(
<Provider store={store}>
<Tweet {...props} />
</Provider>
)
return { wrapper, props }
}

describe('dispatchの動作テスト', () => {
it('0文字の時dispatchされない', () => {
const { wrapper, props } = setup()
const submitButton = wrapper
.find(`[data-test="submitButton"]`)
.at(0)
.find('button')
expect(props.postTweetStatus).not.toHaveBeenCalled()
})

it('0字以上280文字以内の時正しくdispatchされる', () => {
const { wrapper, props } = setup()
const submitButton = wrapper
.find(`[data-test="submitButton"]`)
.at(0)
.find('button')
wrapper.setState({ status: 'test' })

submitButton.simulate('click', () => {
const { wrapper, props } = setup()
const submitButton = wrapper
.find(`[data-test="submitButton"]`)
.at(0)
.find('button')
expect(props.postTweetStatus).toHaveBeenCalled()
})
})

it('281字以上の時dispatchされない', () => {
const { wrapper, props } = setup()
const submitButton = wrapper
.find(`[data-test="submitButton"]`)
.at(0)
.find('button')
wrapper.setState({ status: 'あなたは翌日もしその紹介人というのの日にしましたい。はなはだ今朝をお話し人はとうとう同じ意見たありかもをしているんがは相違炙っございでて、一応には防ぐませたたまし。モーニングをするでしょのもちょうど将来を無論ないなます。いやしくも岡田さんへ開始権力まだ存在へ怒らです平気その責任皆か運動のというご意見ありましだでして、その同年はそれか文芸先生をできるば、嘉納さんののへ道の誰がもっと小ごろごろとさので私仕立をご合点の教えようにもうご始末をふらしますならから、はなはだいよいよ講演を買い占めるですからかかるたのに倒さなた。なおそれでお自我にするつもりもいっそ好きと' })

submitButton.simulate('click', () => {
expect(props.postTweetStatus).not.toHaveBeenCalled()
})
})
})

setup()のところにActionで書いている関数をjest.fn()で設定してあげます。

それをwrapperのコンポーネントタグで{...props}のように渡してあげて前準備は完成です!

const props = {

postTweetStatus: jest.fn()
}

wrapper.setState({ ... })でwrapperのローカルステートを更新しています。

ダミーテキストは適当に入れちゃいましたがもっと細かくできるかも(?👀)

そのあとにsimulateでクリックさせた上で予想している動作を書いてあげます。

submitButton.simulate('click', () => {

// テスト
})

参考にしたYoutube → Mocking Axios in Jest + Testing Async Functions

テスト結果はオールパス!!😭🎉

ここに行き着くまでもかなり時間掛かっています…!


■ 記事を取得後、記事のlength分ループして記事が表示されている

今までstoreを使う必要があったのでmountで対応していましたが、このテストでは上手くいきませんでした…

mountでテストしようとすると、読み込んでいる子コンポーネントの値が取れないとエラーが表示されてしまうのです…!😭💦 なので今回はshallowの方を使い、逆にコンポーネント側をテストに合わせて少しだけ作り変えています。

記事一覧で実装したテストは3点です。

- 新規登録した時や取得する記事が0件の時、デフォルト表示用に用意したdivタグの表示を確認

- ローディング時はローディングだけのdivが表示

- 記事取得後、記事のlengthと表示のループ回数が一緒

詳細を省きますが、コンポーネントの方はこのように実装しました。


ローディング

propsで渡ってくるtimeline.loadingがtrueの時、<CircularProgress />を表示させています。タグにはテスト用のタグ、data-test="loadingView"を付与しました。


記事が0件の時(例えば初回ログイン時の時など)

props.timeline.homeTimeline.lengthの記事がイコール0の時、data-test="firstSignUpView"を付与してあるdivタグを表示させます。


エラー

timeline.errorがtrueの時、data-test="errorView"を付与したdivを表示させます。


記事取得成功

data-test="timlineView"を付与したdivを表示するようにしています。

export class TimelineLists extends React.Component {

render() {
const { timeline } = this.props

if (timeline.loading) {
return <CircularProgress data-test="loadingView" />
} else if (timeline.homeTimeline.length === 0) {
return (
<div data-test="firstSignUpView">
<p>早速記事を投稿してみましょう!</p>
<Button>記事を投稿する</Button>
</div>
)
} else if (timeline.error) {
return (
<div data-test="errorView">
取得に失敗しました
<Button>再取得する</Button>
</div>
)
}
return (
<div data-test="timlineView">
{timeline.homeTimeline.map(article => {
return (
<div key={article.id} data-test="articleView">
<TimelineArticleMedia article={article} />
</div>
)
})}
</div>
)
}
}
export default TimelineLists

結構省いてしまいましたが、Reduxを使っているのでconnectも指定しているし、ページ遷移用のボタンもあったので、react-router-domも読み込ませています。

テストがしやすいように、classの前にexportを付けました。

export class TimelineLists extends React.Component {

実際のテストがこちらです。

import * as React from 'react'

import { createShallow, createMount } from '@material-ui/core/test-utils'
import { TimelineLists } from '@app/components/TimelineLists'

const shallow = createShallow()
const mount = createMount()

const initialProps = {
timeline: {
homeTimeline: [],
loading: false,
error: null
},
}

const setup = (props = initialProps) => {
const wrapper = shallow(<TimelineLists {...props} />)
return wrapper
}

describe('<TimelineLists />', () => {
describe('各ステータス表示テスト', () => {
it('新規登録時または記事0件の時、"firstSignUpView"が表示される', () => {
const wrapper = setup()
expect(wrapper.find(`[data-test="firstSignUpView"]`).exists()).toBe(true)
})

it('ローディング時"loadingViewが表示される', () => {
const props = {
timeline: {
homeTimeline: [{ id: 1 }, { id: 2 }, { id: 3 }],
loading: true,
error: null,
initialTimelineLoad: false
},
}
const wrapper = setup({}, props)

expect(wrapper.find(`[data-test="loadingView"]`).exists()).toBe(true)
})

it('記事一覧取得成功時、記事のlengthと同じループ処理がされる', () => {
const props = {
timeline: {
homeTimeline: [{ id: 1 }, { id: 2 }, { id: 3 }],
loading: false,
error: null,
initialTimelineLoad: false,
},
}
const wrapper = setup({}, props)

expect(wrapper.find(`[data-test="articleView"]`)).toHaveLength(
props.timeline.homeTimeline.length
)
})
})
})

テストしたい状態をconst props = {}の中で再現し、setup()関数の引数に渡しています。

it(() => {})それぞれのpropsを変えてテストしています。

今回は存在の確認だったので、enzymeが用意しているexists()のtrue / falseで確認しています!

長くなったので、今回はここで締めることにします。次回もテストについて!💻

【やってみて】

絶っっ対他に方法がある…と確信しています。

冗長的だし、より複雑なテストをするとなると今のコードでは太刀打ち出来ないかも👀

もっと深掘りしていきます。

【終わりに】

今携わっている案件で、バグの対応を依頼されました。

エラーを見ると共通ヘッダーコンポーネントでエラーが出ている様子…

ほぼ全コンポーネントでヘッダー読み込ませているので、例えば1箇所変更してもどこがどう変わっちゃうのか分かりません…!

スナップショットテストを導入したら、その話をQiitaに載せようと思います。

話は脱線しますが、このQiita記事を書いている間に、VSCodeのテーマを変更しました♫

今のテーマは「Dracula Official」!!!🧛‍♂️

ピンクが可愛い!!!!

(こちらの記事を参考にVSCodeのプラグインを入れたり消したりしました。The Ultimate VSCode Setup for Front End/JS/React

エラー画面を見ても盛り下がらない工夫必要w