M

変更に強いUIテストの実現

Oct 05, 2022

これは、Kent C. Dodds 氏のブログ記事であるMaking your UI tests resilient to changeを日本語訳してみたものです。

誤訳などあればIssueや PR を頂けると幸いです。


あなたは開発者で壊れたログイン体験をリリースしたくないので、そうならないことを確認するためにいくつかテストを書くものとします。 そのようなフォームの例を見てみましょう:

const form = (
  <form onSubmit={handleSubmit}>
    <div>
      <label htmlFor="username">Username</label>
      <input id="username" className="username-field" />
    </div>
    <div>
      <label htmlFor="password">Password</label>
      <input id="password" type="password" className="password-field" />
    </div>
    <div>
      <button type="submit" className="btn">
        Login
      </button>
    </div>
  </form>
);

このフォームをテストするとしたら、ユーザー名とパスワードの入力欄を埋めてフォームを送信したいでしょう。それを適切に行うには、フォームをレンダリングしてドキュメントを検索してそれらの node を見つけて操作する必要があります。そのために以下のようなことを試みるかもしれません。

const usernameField = rootNode.querySelector('.username-field');
const passwordField = rootNode.querySelector('.password-field');
const submitButton = rootNode.querySelector('.btn');

ここで問題が生じます。 もう 1 つ別のボタンを追加するとしたらどうなるでしょう? ”Login”ボタンの前に”Sign up”ボタンを追加するとしたら?

const form = (
  <form onSubmit={handleSubmit}>
    <div>
      <label htmlFor="username">Username</label>
      <input id="username" className="username-field" />
    </div>
    <div>
      <label htmlFor="password">Password</label>
      <input id="password" type="password" className="password-field" />
    </div>
    <div>
      <button type="submit" className="btn">
        Sign up
      </button>
      <button type="submit" className="btn">
        Login
      </button>
    </div>
  </form>
);

おっと、これではテストが壊れてしまいます。ただ、修正することは簡単ですよね?

// change this:
const submitButton = rootNode.querySelector('.btn');
// to this:
const submitButton = rootNode.querySelectorAll('.btn')[1];

これでもう大丈夫ですね!それでは、フォームのスタイリングに CSS-in-JS を使い始めることになり username-field と password-field のクラス名がもはや不要になったら、それらを削除するべきでしょうか?それともテストで利用しているのでそのままにするべきでしょうか?うーん。。。。🤔

それでは変化に強いセレクタをどう書くのでしょう?

”テストがソフトウェアの使われ方に似てくるほど、より信頼性が高まる”ことを考えると、どのようなクラス名でもユーザが気にしないことを考えるのは賢明です。

では、チームに手動テスターがいて、そのテスターにページをテストしてもらうための手順を書いているとしましょう。その手順には何が書かれていますか?

  1. username-fieldというクラス名の要素を取得します
  2. ...

”待ってください”とテスターは言います。”username-fieldというクラス名の要素をどうやって見つけるんですか?”と。

”ああ、devtools を開いて、それから...”

”でもユーザーはそうしないでしょう。usernameというラベルのついた入力欄を見つければいいんじゃないでしょうか?”

”ああ、そうですね、いい考えですね。”

Testing Libraryが持つクエリの理由はここにあります。このクエリはユーザが見つけるのと同じようにして要素を見つけることの助けとなります。 roleラベルのテキストプレースホルダのテキストテキストのコンテンツ表示されている値alt のテキストタイトルtest IDなどによって要素を探すことができます。

実はこれは推奨の順番です。これらのアプローチにトレードオフがあることも確かですが、このクエリを用いて手動テスターに向けて手順を書くとしたら、以下のようになると思います。

  1. usernameとラベルつけれらた入力欄に偽の名前を入力します
  2. passwordとラベルつけられた入力欄に偽のパスワードを入力します
  3. sign inというテキストのボタンをクリックします
const usernameField = rootNode.getByRole('textbox', { name: /username/i });
const passwordField = rootNode.getByLabelText('password');
const submitButton = rootNode.getByRole('button', { name: /sign in/i });

そうすると、可能な限りソフトウェアが利用されるのと同じようにテストすることにつながります。テストからより多くの価値を得られるでしょう。

data-testidクエリはどうしたんでしょうか?

他のどのクエリでも要素を選択できないことが時としてあります。そのような場合にdata-testidを利用することが推奨されます(ただ、最初に適切なrole属性の指定漏れなどないかを確かめる必要があります)。

この状況に直面した多くの人はgetByClassNameクエリを含まない理由を不思議に思います。クラス名をセレクタに利用することを私が嫌う理由は、通常、クラス名をスタイリングのためのものと考えるからです。 そのため、それ以外の目的で多くのクラス名を追加し始めると、そのクラス名が何のためにあるのかも、いつそのクラス名を削除できるのかも一層分からなくなります。

また、すでにスタイリングのために使っているクラス名をただ再利用しようとしても上述のボタンのような問題に直面します。 そして、”リファクタリングや機能追加の度にテストを修正しなければならないのであれば、テストが脆いことの表れとなります”。問題の核心はテストとソースコードの関係があまりにも暗黙的であることです。両者の関係をより明示的にすることでこの問題を克服できます。

もし選択しようとしている要素にメタデータを追加できるのであれば、問題を解決できます。聞いてください!実はこのための API が存在します!それはdata-*属性です!例えば:

function UsernameDisplay({ user }) {
  return <strong data-testid="username">{user.username}</strong>;
}

そしてテストはこうなります:

const usernameEl = getByTestId('username');

これはE2E テストにも最適です。なので、そちらでも使用することをお勧めします。 ただし、このような属性を本番環境に公開することに懸念があると伝えてきた人もいます。もしあなたもそうなら、本当にそれが実際のところ問題かどうか考えてみてください(なぜなら正直言ってあなたが考えているほど大きな問題ではないでしょうから)。 それでも本当にそうしたいのであれば、babel-plugin-react-remove-propertiesでそのような属性を取り除いてコンパイルすることができます。

結論

ソフトウェアが使われるのと同じようにアプリケーションをテストすると、変更に強いだけではなく、より多くの価値を提供してくれることに気づくでしょう。 これに関してより学びたければ、私のブログ記事であるTesting Implementation Detailsをお勧めします。

この記事がお役に立てば幸いです。幸運を祈ります!