Material-UI と Downshift のスナップショットテストで属性値の差分が出るのを解消する
最近 React のテストを書き始めて、Jest の toMatchSnapshot
便利だなと感じながら使っています。
テスト結果をいちいちテストコード内に頑張って組み立てる必要がなくて楽ですし、
出力結果の差分ってコードの変更の結果としてもとても理解しやすいんですよね。
ロジックを追うより出力を見た方が何をしてるか一目瞭然な場合も多いです。
さてこのスナップショットテスト、便利なんですが、時々変な差分が出たり、一つのテストの失敗が別のテストにも波及して見えることがあったりします。class
とか aria-xxxx
とかその辺の属性値が微妙にブレたりするんですよね。JavaScript のライブラリによくあるパターンだと思います。
原因としては、モジュール内にIDを裁判するためのカウンタ変数など、モジュール内 State とも言えるような変数が存在して、テストが一箇所失敗するとカウンタがずれて後続のテストのスナップショットに波及する、と言った感じだと思います。
Issue として見かける問題なので、著名なライブラリであれば、モジュール内変数を操作するためのテスト用ユーティリティが提供されている場合があります。されていない場合はちょっと辛いですね。諦めて差分を甘受するかIssue/PRを送るかの二択になりそうです。
今回は Material UI と Downshift で出くわした属性値のブレと、そのブレを解消した記録です。動作するコードは github に置いておきました。
TL;DL
コードが長くなるので先に要所だけ抜き出しておきます。
Material UI の makeStyles の採番をなくす
クラス名の生成関数をインデックス番号が振られないように新しく定義して Provide してやります。
import { StylesProvider } from '@material-ui/styles' import { GenerateId } from 'jss' const generateTestClassName: GenerateId = (rule, sheet) => { return `${sheet!.options.classNamePrefix}-${rule.key}` } const provideTestStyles = (el: React.ReactElement) => ( <StylesProvider generateClassName={generateTestClassName} children={el} /> ) // `createElement` 時に以下のように使ったり provideTestStyles( <> <MySelectInput items={items} itemToString={itemToString} /> <MyOtherComponent /> </> )
テスト用と割り切って !.
を使っています。
テスト用の Provider をメソッドにするかコンポーネントにするかはお好みでどうぞ。
Downshift の採番をテストごとにリセットする
こちらはテストごとに採番をリセットする方法をとります。
import { resetIdCounter } from 'downshift' beforeEach(resetIdCounter)
以上です。
参考
- https://github.com/downshift-js/downshift#resetidcounter
- SSR 用って書いてあるので、あんまりテストで使ったりはしないのかもしれません...
- https://github.com/mui-org/material-ui/issues/9492#issuecomment-368205258
- そのまま使わせてもらいました :)
順を追って
採番される対象
今回スナップショットで差分が出る場所は以下の通りです。
- Material UI:
makeStyles
で生成したクラス名MySelectInput-root-23
の23
- Downshift:
aria-labelledby="downshift-0-label"
の0
テスト対象のコンポーネント
テスト対象となるコンポーネントです。
よくあるリストから選択するタイプの Material-UI + Downshift な入力コンポーネント MySelectInput
です。
import React from 'react' import Downshift from 'downshift' import { Paper, MenuItem, TextField, makeStyles } from '@material-ui/core' interface Props<Item> { items: Item[] itemToString: (item: Item | null) => string onChange?: (item: Item | null) => void } const useStyles = makeStyles( { root: {}, menu: {}, }, { name: 'MySelectInput' }, ) export default function MySelectInput<Item>(props: Props<Item>) { const { items, ...downshiftProps } = props const classes = useStyles() return ( <Downshift {...downshiftProps}> {({ isOpen, getInputProps, getMenuProps, getItemProps, itemToString, inputValue, highlightedIndex, }) => { const itemsToUse = inputValue ? items.filter(e => itemToString(e).includes(inputValue)) : items return ( <div className={classes.root}> <TextField {...getInputProps()} fullWidth /> {isOpen && ( <div className={classes.menu} {...getMenuProps()}> <Paper open={isOpen} {...getMenuProps()}> {itemsToUse.map((item, index) => ( <MenuItem key={index} children={itemToString(item)} selected={index === highlightedIndex} {...getItemProps({ item, index })} /> ))} </Paper> </div> )} </div> ) }} </Downshift> ) }
そして makeStyles
の採番をずらすために、もう一つコンポーネントが必要だったので、
適当に MyOtherComponent
を定義します。
import React from 'react' import { makeStyles } from '@material-ui/core' const useStyles = makeStyles( { root: {} }, { name: 'MyOtherComponent' }, ) export default function MyOtherComponent() { const classes = useStyles({}) return <div className={classes.root} /> }
テスト
採番に対して何も対策していない元々のテストコードです。 ReactDOM をそのまま利用した素朴なテストです。
import React from 'react' import ReactDOM from 'react-dom' import MySelectInput from './MySelectInput' import MyOtherComponent from './MyOtherComponent' // Common Props // ------------ interface SimpleItem { id: number name: string } const items: SimpleItem[] = [ { id: 1, name: 'item-1' }, { id: 2, name: 'item-2' }, ] const itemToString = (item: SimpleItem | null) => (item ? item.name : '') // Test Container // -------------- let container: HTMLDivElement beforeEach(() => { document.body.innerHTML = '' container = document.createElement('div') document.body.appendChild(container) }) // Tests // ----- describe('MySelectInput with MyOtherComponent', () => { it('renders without crush', async () => { // throw new Error('Something occurred!') ReactDOM.render( <> <MySelectInput items={items} itemToString={itemToString} /> <MyOtherComponent /> </> , container) expect(container).toMatchSnapshot() }) it('renders without crush in reverse order', async () => { ReactDOM.render( <> <MyOtherComponent /> <MySelectInput items={items} itemToString={itemToString} /> </> , container) expect(container).toMatchSnapshot() }) })
ポイントとしては MySelectInput
と MyOtherComponent
の順序を逆にして二回テストを行っているところです。普段はこんなテストはしませんが、採番ズレを再現するために手軽な方法をとっています。
一度このテストを実行してスナップショットを保存してから、
上記のコードの throw new Error('Something occurred!')
のコメントアウトを外すと初めのテストがレンダリング前に失敗になり、二番目のテストのスナップショットに
- aria-labelledby="downshift-1-label" - class="MySelectInput-root-1" + aria-labelledby="downshift-0-label" + class="MySelectInput-root-2"
のような差分がちらほらと見られるはずです。
コンポーネントの初回の評価順が逆になったことによる class
のインデックス番号のズレと、Downshift
の評価回数が減ったことによる aria
の属性値のズレが発生しています。
冒頭に書いた変更を行えばこのズレが解消されるはずです。最後に修正後のテストコード全体を書いておきます。
import React from 'react' import ReactDOM from 'react-dom' import { resetIdCounter } from 'downshift' import { StylesProvider } from '@material-ui/styles' import { GenerateId } from 'jss' import MySelectInput from './MySelectInput' import MyOtherComponent from './MyOtherComponent' // Common Props // ------------ interface SimpleItem { id: number name: string } const items: SimpleItem[] = [ { id: 1, name: 'item-1' }, { id: 2, name: 'item-2' }, ] const itemToString = (item: SimpleItem | null) => (item ? item.name : '') // Test Container // -------------- let container: HTMLDivElement beforeEach(() => { document.body.innerHTML = '' container = document.createElement('div') document.body.appendChild(container) // Reset Downshift module-internal state resetIdCounter() }) // Test Styles // ----------- // Generate class name without rule index const generateTestClassName: GenerateId = (rule, sheet) => { return `${sheet!.options.classNamePrefix}-${rule.key}` } const provideTestStyles = (el: React.ReactElement) => ( <StylesProvider generateClassName={generateTestClassName} children={el} /> ) // Tests // ----- describe('MySelectInput with MyOtherComponent', () => { it('renders without crush', async () => { // throw new Error('Something occurred!') ReactDOM.render(provideTestStyles( <> <MySelectInput items={items} itemToString={itemToString} /> <MyOtherComponent /> </>, ), container) expect(container).toMatchSnapshot() }) it('renders without crush in reverse order', async () => { ReactDOM.render(provideTestStyles( <> <MyOtherComponent /> <MySelectInput items={items} itemToString={itemToString} /> </>, ), container) expect(container).toMatchSnapshot() }) })
こういう風にテストコードはは太っていくんですね。