いきなり結論を書くと、id
やclass
はスタイルのためのものなので、テストでそれを使うのはやめましょう。そして、カスタムデータ属性を使いましょう。(id
やclass
はスタイルのためだけではないという意見はごもっともです!しかし、主にとしてスタイルに使われるということでご了承頂いて以下の駄文に付き合って頂けると幸いです🙇)
先に断っておくと主にreactについての話で、JSXを前提とします。(手法はReactに限りませんが理由は後述)
2020/03/23 追記
この記事は1年以上前に書かれた記事なのでテストフレームワークとしてenzymeを使っていますが、現時点ではTesting Libraryの使用をオススメします。data-testid
に対応するクエリを備えています。
React Testing Library · Testing Library
はじめに
ご存知の通り、ロジックとスタイルは別物です。
もしスタイルの変更のためにclass名を変えて、ロジックのテストが落ちるなんてことはあってはならないのです。
cssに手を入れたいのだけど、このクラス名は変えていいのか?取り除いていいのか?あれ?スタイルの変更のためにクラス名を変更したらテストが落ちた。テストで取得するクラス名を変えなくては!
全く、これは最悪な状況です。同じクラス名をスタイルでもテストでも使ってしまっているパターンです。ロジックとスタイルが完全に密結合になっています。あまりに、あまりに、脆い。脆弱なテストであります。
さて、問題を共有したところで、実例を踏まえて、これを解決する方法を検討していきましょう。
修正困難なクラス名
以下のようなフォームがあるとしましょう(なお、ニュアンスだけ伝わればいいので、以下のコードそのままでは動作しません)
const Form = (
<form onSubmit={this.handleSubmit}>
<div className="field">
<div className="control">
<input type="text" className="input email-field" placeholder="Email" />
</div>
</div>
<div className="field">
<div className="control">
<input
type="password"
className="input password-field"
placeholder="Password"
/>
</div>
</div>
<div className="field is-grouped">
<p className="control">
<button type="submit" className="button is-primary">
Sign in
</button>
</p>
</div>
</form>
)
enzymeでテストするには以下のようにfind
を使って要素を特定していきます。ここでは、クラス名を用いて要素を特定します。
この時点では、問題はありません。
import { mount } from 'enzyme'
test('Sign in', () => {
const wrapper = mount(<Login />)
const emailField = wrapper.find('.email-field')
const passwordField = wrapper.find('.password-field')
const submit = wrapper.find('.button')
// ...
})
さて、ここで、新しくSign Up
ボタンを追加したいとしましょう。
<p className="control">
<button type="submit" className="button is-primary">
Sign Up
</button>
</p>
<p className="control">
<button type="submit" className="button">
Sign in
</button>
</p>
only meant to be run on a single node. 2 found instead.
.button
が2つになりNodeを特定できず、テストが落ちました。
では、以下のように順番に要素を取得しますか?
const signIn = wrapper.find('.button').at(0)
const signUp = wrapper.find('.button').at(1)
まさかです。ちなみにすでにバグがあります。signInとsignUpが逆です。ここで、テストのためにclassNameにsign-btn
でも加えますか?ですが、それに対してスタイルが当たってしまったらもうがんじがらめです。cssやクラス名を容易に変更することは出来なくなりました。新しいCSSのアーキテクチャを導入することもスタイルとテストが密結合なので難しく、そしてCSS in JS
に移行したくとも、スタイルのためのclassNameがどれで、テストのためのclassNameがどれか判断するのは困難です。
この問題は、ソースコードとテストの関係があまりに暗黙的に過ぎるのです。
この関係を明快にすることで、問題を解決しましょう。
data-testid属性
カスタムデータ属性を使います。
これによって、何をテストすべきかが明確になります。
<form onSubmit={this.handleSubmit}>
<div className="field">
<div className="control">
<input
+ data-testid="email"
type="text"
className="input"
placeholder="Email"
/>
</div>
</div>
<div className="field">
<div className="control">
<input
+ data-testid="password"
type="password"
className="input"
placeholder="Password"
/>
</div>
</div>
<div className="field is-grouped">
<p className="control">
+ <button type="submit" className="button" data-test="sign-in">
Sign in
</button>
</p>
<p className="control">
+ <button type="submit" className="button is-primary" data-test="sign-up">
Sign Up
</button>
</p>
</div>
</form>
どうテストを書けばいいのかが非常にスッキリします。
また、sel
というユーティリティ関数を用意しましょう。
const sel = id => `[data-tesidt="${id}"]`
const emailField = wrapper.find(sel('email'))
const passwordField = wrapper.find(sel('password'))
const signIn = wrapper.find(sel('sign-in'))
const signUp = wrapper.find(sel('sign-up'))
🎉🎉🎉
data-testid
というテストのための属性を使うことで、何をテストするのか非常に明確になりました。
そして、テストからクラスを扱わないことで、自由にスタイルのためにクラス名を変更したり、また、いつでもCSS in JS
に切り替えられます。
ここではclassで取得して、ここではタグで取得して、ここではidで、ここでは...ここでは...ここでは......うんざりです。全くもってうんざりなのです。人により違う書き方。複数のやり方があるというのは。h2をh3と変えるだけで意味もなく落ちるロジックのテスト。そんなテストは開発の邪魔でしかありません。(また、h2とh3に変えたときに落としたいなら、スナップショットテストを利用すべき状況です。クラス名同様タグに依存すべきではありません)
しかし、それは過去の話です。テストのためのカスタムデータ属性を使うことで、綺麗に分離ができます。(なお、htmlの機能の一つでしかないので、上記のようなコンポーネント単位でもE2Eテストでも有効な手法です)
プロダクションビルドではカスタムデータ属性を取り除く
プロダクションにdata-testid
の属性は残す必要はありません。(まあ残しても問題はないですが)
御存知の通り、JSXはhtmlではなく、JavaScriptのシンタックスの一つに過ぎません。(要らぬツッコミを避けるために言及するとBabel環境を前提とします。ですので何がJavaScriptだとかの議論は不要でありますので別の場所でやって頂けると嬉しいです)
つまり、ASTの操作でいくらでも消すことが可能です。
具体的には、以下のプラグインをproductionビルドで有効にすれば跡形もなく消えます。
babel-plugin-react-remove-properties
{
"env": {
"production": {
"plugins": ["react-remove-properties"]
}
}
}
In:
class Foo extends React.Component {
render() {
return (
<div className="bar" data-testid="thisIsASelectorForSelenium">
Hello Wold!
</div>
);
}
}
Out:
class Foo extends React.Component {
render() {
return (
<div className="bar">
Hello Wold!
</div>
);
}
}
誰が使っているか?
この方法を推奨しているのがpaypalのエンジニアであり、tc39のメンバーでもあるkentcdaddsです。
最も勢いのあるCSSinJSのライブラリの一つであるglamorousの作者でもあります。glamorousはstorybookの内部でも使われているので、知らずのうちにあなたのプロジェクトの依存にいるかも知れません。
なぜidを使わないか、listなどの複数要素はどうするか等含め、以下の記事に詳しくまとまっています。
Making your UI tests resilient to change – kentcdodds
ちなみにdata-testid
属性を取り除くプラグインの作者は、material-uiの作者のoliviertassinariです。
まとめ
data-testid
属性を使うことで、reactにおけるテストの見通しを上げる方法を紹介しました。
テストを書くのは当然として、メンテしやすく、よりよいテストを書いていきましょう。
もっといい方法やうちではこう書いてる等、何かあればコメント欄、twitterで是非議論しましょう。
また、Reactのテストのベストプラクティスについて何かよい考えがあれば教えて頂けるとうれしいです。
mountを使ったらそれはインテグレーションテストという認識。とゆうか、テストのベストプラクティスが知りたい pic.twitter.com/1iPNE06rkb
— あかめ@無職.js (@akameco) November 18, 2017
参考
Making your UI tests resilient to change – kentcdodds
Babel Pluginを使ってdate-testidを自動で付加する
ツイッターやはてブのコメントの反映
ツイッターやはてブの意見を反映してより中身のある記事にしたいので反応を見かけ次第記事に反映していきます。
🤔 こういう動機に至らせてしまうReactがそもそも開発プラットフォームとしては微妙な気がしている
いいえ。それは誤解です。この問題はReactに限りません。どのようなフロントエンド環境でも同じ問題は起こります。むしろ、JSXによる属性の削除が簡単な分、利点と言えるでしょう。
🤔 idやclassはスタイルのためだけではない
HTMLのidもclassもある文書の中で固有の物、既存の要素とは別の分類でmarkupをしたい時に使うものでCSSでもそれらがセレクタに使用できるというだけである。文書全体の趣旨には全く文句はないが、idやclassはスタイルのためだけの物ではない
— KIMATA RobertHisasi (@robert_KIMATA) 2017年11月19日
全く仰るとおりです。ですが、そこまで言及すると、無駄に説明が伸びてしまうので、この引用をもって、代わりとさせてください。
🤔 うちはjs用のclass名の命名規則があるので、デザインを変えてもそのclass名は変わらない
チームで共通のルールがあれば、それでよいと思います。ただ、もしご覧になっていたらコメントして頂きたいのですが、プロダクションではそのクラス名は残していますか?
取り除くためのプラグインを作りました🎉
const hello = () => (
<div className="test-a hello test-b">hello</div>
)
↓ ↓ ↓ ↓ ↓ ↓
const hello = () => (
<div className="hello">hello</div>;
)
😇 テスト書いてない
+α デモ
ちょっとコードが少なすぎた感じがあるので、入力後、エンターが押されたら画面に反映するデモのテストを簡単に用意しました。テスト対象に、data-testid
を書くのでDOMの構造が変わってもテストは変わりません。ええ、h2
をh3
にしても、<Title/>
というコンポーネントに切り出しても、他のdiv
でラップしてもCSSinJS
にしても変わることはないのです。また、changeInputValue
などのいくつかのユーティリティをいくつか用意してあげるとわかりやすくてよいです。
<div>
<h2 data-testid="title">Welcome to {title}.js!</h2>
<form onSubmit={this.handleSubmit)}>
<fieldset>
<input
data-testid="name"
placeholder="name"
type="text"
value={name}
onChange={e => this.setState({ name: e.target.value })}
onKeyUp={this.watchForEnter}
/>
</fieldset>
</form>
</div>
import * as React from 'react'
import { mount } from 'enzyme'
import Component from '.'
function sel(id) {
return `[data-testid="${id}"]`
}
function changeInputValue(input, value) {
input.simulate('change', { target: { value } })
}
function keyUpInput(input, key) {
input.simulate('keyup', { key })
}
test('エンターが押されたとき、nameフィールドに入力された文字列をタイトルに設定する', () => {
const wrapper = mount(<Component />)
const nameField = wrapper.find(sel('name'))
changeInputValue(nameField, '無職')
keyUpInput(nameField, 'Enter')
expect(wrapper.find(sel('title')).text()).toBe('Welcome to 無職.js!')
})