makeStylesを利用してスタイリングするとスナップショットテストで痛い目をみる
環境
React
Material UI
TypeScript
Storyshots
事象
@emotion/styled から @material-ui/core/makeStyles に乗り換え後、2つ目のコンポーネントを作成していた時の話。
コンポーネントを作成し終えてスナップショットテストを行ったところ、修正を加えていない1つ目のコンポーネントがエラーになった。なお、スタイルも別で定義しており、お互いに依存関係は無い。
スナップショットを確認したところ、1つ目のコンポーネントのクラス名が変わっていたことが原因であった。
理由
Material-UIのCSS生成および、CSSクラス名の生成方法によってスナップショットエラーとなっていた。
Material-UIは同じスタイリングを利用していたとしても、割り当てられるコンポーネント・要素ごとに異なるCSSクラス名を生成する。(Emotionはスタイルごとに一意なCSSクラス名を生成するため、今までは発生しなかった)
例えば以下のようなスタイルをmakeStylesで定義する。
code:TypeScript
import { makeStyles, createStyles } from '@material-ui/core/styles';
const useStyle = makeStyles((theme: Theme) =>
createStyles({
root: {
color: theme.palette.primary.main,
},
})
);
このスタイルを利用してコンポーネントを定義する。
code:TypeScript
type Props = { messages: string[] };
const Component: React.FC<Props> = (props) => {
const classes = useStyles();
return (
<div>
{ props.messages.map(msg => <p className={classes.root}>{msg}</p>) }
</div>
)
};
例えば props.messages に5件分の文字列を渡すと、5つのpタグがレンダリングされる。
この時、レンダリングされたpタグに割り当てられているCSSクラス名は すべて異なる。
code:HTML
<div>
<p class="makeStyles-root-1">Message</p>
<p class="makeStyles-root-2">Message</p>
<p class="makeStyles-root-3">Message</p>
<p class="makeStyles-root-4">Message</p>
<p class="makeStyles-root-5">Message</p>
</div>
生成されたCSSクラス名を見れば分かる通り、フォーマットは
code:txt
${prefix}-${ruleName}-${suffix}
となっている。
prefix はDefaultだとmakeStylesで、ruleName には対象のスタイルのプロパティが指定される。
問題なのが最後のsuffixで、こいつは固定ではなく「全体から何番目に割り当てられたか」を示すインデックス値となる。
つまり、先程定義した Component よりも先に別のコンポーネントが読み込まれると、Component に割り当たるCSSクラス名は変化する。
これが原因でスナップショットテストが失敗してしまう。
解決方法
厳密には解決していないのだけど、とりあえずCSSクラス名のフォーマットを変更することで回避。
具体的にはMaterial-UIが生成するCSSクラス名のサフィックスを「CSSのハッシュ値」に変更した。
じゃあどうやって「CSSのハッシュ値」に変更するかであるが、これはゴリ押し。
既存のCSSクラス名を生成する処理を丸っとコピーしてきて、suffixの生成処理を少し変更した。
code:createGenerateClassName.js
import hash from '@emotion/hash'; // 追加する
// 2行追加
const hasSymbol = typeof Symbol === 'function' && Symbol.for;
export default (hasSymbol ? Symbol.for('mui.nested') : '__THEME_NESTED__');
...
// 65行目あたりから
if (process.env.NODE_ENV === 'production') {
return ${seedPrefix}${productionPrefix}${ruleCounter};
}
// 元:const suffix = ${rule.key}-${ruleCounter};
const styleHash = hash(JSON.stringfy(rule.style));
const suffix = ${rule.key}-${styleHash}
// Help with debuggability.
if (styleSheet.options.classNamePrefix) {
return ${seedPrefix}${styleSheet.options.classNamePrefix}-${suffix};
}
return ${seedPrefix}${suffix};
};
このような感じで、スタイルからハッシュ値を求めた。
なお、これはコピー元の「createGenerateClassName.js」と同階層に「createGenerateClassNameHash.js」が存在し、先頭の @emotion/hashはそこから拝借したもの。
上記はスナップショットテスト時にのみ適用させるようにしている。
code:.storybook/preview.js
import createGenerateClassName from './createGenerateClassName';
const generateClassName = prosess.env.NODE_ENV === 'test' ? createGenerateClassName() : void 0;
addDecorate(storyFc =>
<StyleProvider generateClassName={generateClassName}>{storyFc()}</StyleProvider>
);
こんな感じで NODE_ENV が 'test'の場合のみ、独自のジェネレータをStyleProviderに渡している。
備考
本件は既知のようで、GitHubにissueが上がっていた。
一応、解決策は記載されていて、単に「suffixを排除して、スナップショットエラーが出ないようにしたい」のであればそれで良い。(あと、スナップショット時にしか適用させないようにしないと、Storybook上でレンダリングさせるときも死ぬので注意) 個人的には「スタイルの変更により、どのコンポーネントに影響が出るか」も知りたいので、suffixはハッシュ値を選択した。
なお、このスナップショットに関する議論は執筆時(2020/03/07)でも行われている様子。