きみはねこみたいなにゃんにゃんなまほう

ねこもスクリプトをかくなり

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)

以上です。

参考

順を追って

採番される対象

今回スナップショットで差分が出る場所は以下の通りです。

  • Material UI: makeStyles で生成したクラス名
    • MySelectInput-root-2323
  • Downshift:
    • aria-labelledby="downshift-0-label"0

テスト対象のコンポーネント

テスト対象となるコンポーネントです。

f:id:lightbulbcat:20190815031020p:plain

よくあるリストから選択するタイプの 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()
  })
})

ポイントとしては MySelectInputMyOtherComponent の順序を逆にして二回テストを行っているところです。普段はこんなテストはしませんが、採番ズレを再現するために手軽な方法をとっています。

一度このテストを実行してスナップショットを保存してから、 上記のコードの 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()
  })
})

こういう風にテストコードはは太っていくんですね。