idやclassを使ってテストを書くのは、もはやアンチパターンである

  • 11
    Like
  • 0
    Comment

いきなり結論を書くと、idclassはスタイルのためのものなので、テストでそれを使うのはやめましょう。そして、カスタムデータ属性を使いましょう。

先に断っておくと主にreactについての話で、JSXを前提とします。

はじめに

ご存知の通り、ロジックとスタイルは別物です。
もしスタイルの変更のためにclass名を変えて、ロジックのテストが落ちるなんてことはあってはならないのです。

cssに手を入れたいのだけど、このクラス名は変えていいのか?取り除いていいのか?あれ?スタイルの変更のためにクラス名を変更したらテストが落ちた。テストで取得するクラス名を変えなくては!

全く、これは最悪な状況です。同じクラス名をスタイルでもテストでも使ってしまっているパターンです。ロジックとスタイルが完全に密結合になっています。あまりに、あまりに、脆い。脆弱なテストであります。

さて、問題を共有したところで、実例を踏まえて、これを解決する方法を検討していきましょう。

修正困難なクラス名

以下のようなフォームがあるとしましょう(なお、ニュアンスだけ伝わればいいので、以下のコードそのままでは動作しません)

スクリーンショット 2017-11-19 02.20.07.png

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-test属性

カスタムデータ属性を使います。
これによって、何をテストすべきかが明確になります。

  <form onSubmit={this.handleSubmit}>
    <div className="field">
      <div className="control">
        <input
+         data-test="email"
          type="text"
          className="input"
          placeholder="Email"
        />
      </div>
    </div>

    <div className="field">
      <div className="control">
        <input
+         data-test="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-test="${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-testというテストのための属性を使うことで、何をテストするのか非常に明確になりました。
そして、テストからクラスを扱わないことで、自由にスタイルのためにクラス名を変更したり、また、いつでもCSS in JSに切り替えられます。

ここではclassで取得して、ここではタグで取得して、ここではidで、ここでは...ここでは...ここでは......うんざりです。全くもってうんざりなのです。人により違う書き方。複数のやり方があるというのは。h2をh3と変えるだけで意味もなく落ちるロジックのテスト。そんなテストは開発の邪魔でしかありません。(また、h2とh3に変えたときに落としたいなら、スナップショットテストを利用すべき状況です。クラス名同様タグに依存すべきではありません)

しかし、それは過去の話です。テストのためのカスタムデータ属性を使うことで、綺麗に分離ができます。(なお、htmlの機能の一つでしかないので、上記のようなコンポーネント単位でもE2Eテストでも有効な手法です)

プロダクションビルドではカスタムデータ属性を取り除く

プロダクションにdata-testの属性は残す必要はありません。(まあ残しても問題はないですが)
御存知の通り、JSXはhtmlではなく、JavaScriptのシンタックスの一つに過ぎません。(要らぬツッコミを避けるために言及するとBabel環境を前提とします。ですので何がJavaScriptだとかの議論は不要でありますので別の場所でやって頂けると嬉しいです)
つまり、ASTの操作でいくらでも消すことが可能です。
具体的には、以下のプラグインをproductionビルドで有効にすれば跡形もなく消えます。

babel-plugin-react-remove-properties

{
  "env": {
    "production": {
      "plugins": ["react-remove-prop-types"]
    }
  }
}

In:

class Foo extends React.Component {
  render() {
    return (
      <div className="bar" data-test="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の内部でも使われているので、知らずのうちにあなたのプロジェクトの依存にいるかも知れません。

以下の記事に詳しくまとまっています。

Making your UI tests resilient to change – kentcdodds

ちなみにdata-test属性を取り除くプラグインの作者は、material-uiの作者のoliviertassinariです。

まとめ

data-test属性を使うことで、reactにおけるテストの見通しを上げる方法を紹介しました。
テストを書くのは当然として、メンテしやすく、よりよいテストを書いていきましょう。

もっといい方法やうちではこう書いてる等、何かあればコメント欄、twitterで是非議論しましょう。
また、Reactのテストのベストプラクティスについて何かよい考えがあれば教えて頂けるとうれしいです。

参考

Making your UI tests resilient to change – kentcdodds

+α デモ

ちょっとコードが少なすぎた感じがあるので、入力後、エンターが押されたら画面に反映するデモのテストを簡単に用意しました。テスト対象に、data-testを書くのでDOMの構造が変わってもテストは変わりません。ええ、h2h3にしても、<Title/>というコンポーネントに切り出しても、他のdivでラップしてもCSSinJSにしても変わることはないのです。また、changeInputValueなどのいくつかのユーティリティをいくつか用意してあげるとわかりやすくてよいです。

https://gyazo.com/32196226db3a69d9aff0921b3105cc69

      <div>
        <h2 data-test="title">Welcome to {title}.js!</h2>
        <form onSubmit={this.handleSubmit)}>
          <fieldset>
            <input
              data-test="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-test="${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!')
})