実装の詳細をテストすることについて

2022/09/22に公開約11,100字

これは Kent C. Dodds による記事 Testing Implementation Details の翻訳です

(当時の皆と同じように)私が enzyme を使っていた頃、私は enzyme の特定の API を慎重に扱っていました。shallow rendering は完全に避けinstance()state()find('ComponentName')使ったことさえ ありません。

また、他の人の pull request のコードレビューの時には、なぜこれらの API を避けるべきか何度も何度も説明してきました。それは、これらの API がコンポーネントの実装の詳細をテストできてしまうからです。よく 「実装の詳細」とは何を意味するのか問われてきました。私はこれを、それが何であるかテストするのが難しいことであるという意味で用いています。 なぜ実装の詳細をテストしてしまわないようにこれらの API を制限する必要があるのでしょうか?

なぜ実装の詳細をテストするのは悪いのか?

実装の詳細をテストすべきでない明確な理由が 2 つあります。実装の詳細をテストしているテストは、

  1. アプリケーションコードをリファクタリングしただけなのに失敗する可能性があります False negatives
  2. アプリケーションコードが正常に動作していないときに失敗しない場合があります False positives

以下のシンプルなアコーディオンコンポーネントを例に、各理由について順番に見ていきましょう

// accordion.js
import * as React from 'react';
import AccordionContents from './accordion-contents';

class Accordion extends React.Component {
  state = { openIndex: 0 };
  setOpenIndex = (openIndex) => this.setState({ openIndex });
  render() {
    const { openIndex } = this.state;
    return (
      <div>
        {this.props.items.map((item, index) => (
          <>
            <button onClick={() => this.setOpenIndex(index)}>
              {item.title}
            </button>
            {index === openIndex ? (
              <AccordionContents>{item.contents}</AccordionContents>
            ) : null}
          </>
        ))}
      </div>
    );
  }
}

export default Accordion;

なぜ例に function component (+ hooks) ではなく class component を使うのか不思議に思うでしょう。まだ読むのをやめないで!これは鋭い発見です(enzyme を知っている人は既に予想できているかもしれませんが)。

そして、こちらは実装の詳細をテストしているテストです。

// __tests__/accordion.enzyme.js
import * as React from 'react';
// なぜ shallow を使わないのか疑問の方は
// こちらを読んでください https://kcd.im/shallow
import Enzyme, { mount } from 'enzyme';
import EnzymeAdapter from 'enzyme-adapter-react-16';
import Accordion from '../accordion';

// enzyme の react adapter をセットアップします
Enzyme.configure({ adapter: new EnzymeAdapter() });

test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount(<Accordion items={[]} />);
  expect(wrapper.state('openIndex')).toBe(0);
  wrapper.instance().setOpenIndex(1);
  expect(wrapper.state('openIndex')).toBe(1);
});

test('Accordion renders AccordionContents with the item contents', () => {
  const hats = { title: 'Favorite Hats', contents: 'Fedoras are classy' };
  const footware = {
    title: 'Favorite Footware',
    contents: 'Flipflops are the best',
  };
  const wrapper = mount(<Accordion items={[hats, footware]} />);
  expect(wrapper.find('AccordionContents').props().children).toBe(
    hats.contents
  );
});

自身のコードベースでこのようなテストを見たことがある(あるいは書いたことがある)人は手をあげてください (🙌)

OK、ではこれらのテストによってどのような問題が起きるか見ていきましょう

リファクタリング時の false negatives

テスト(特に UI テスト)を不愉快だと思う人は驚くほど多いです。それはなぜでしょうか?様々な理由がありますが、私が何度も聞いたことがある大きい理由の一つは、テストのお世話をするために多くの時間が費やされるからというものです。「コードを変えるたびにテストが失敗する!」これは生産性の大きな足枷です!私たちのテストがどうしてこのイライラする問題を引き起こすのか見てみましょう。

複数のアコーディオン項目を一度に開けるようにするためにアコーディオンをリファクタリングしているとします。リファクタリングとは 実装のみを 変更し、既存の動作を全く変えないことです。そのため、動作を変えないように 実装を 変えてみましょう。

複数のアコーディオン要素を一度に開けるように、内部ステートを openIndex から openIndexes へ変更します。

class Accordion extends React.Component {
-  state = {openIndex: 0}
-  setOpenIndex = openIndex => this.setState({openIndex})
+  state = {openIndexes: [0]}
+  setOpenIndex = openIndex => this.setState({openIndexes: [openIndex]})
  render() {
-    const {openIndex} = this.state
+    const {openIndexes} = this.state
    return (
      <div>
        {this.props.items.map((item, index) => (
          <>
            <button onClick={() => this.setOpenIndex(index)}>
              {item.title}
            </button>
-            {index === openIndex ? (
+            {openIndexes.includes(index) ? (
              <AccordionContents>{item.contents}</AccordionContents>
            ) : null}
          </>
        ))}
      </div>
    )
  }
}

アプリを開いて動作を確認し、全てが適切に動いていることを確認しました。素晴らしい!これで、後で複数のアコーディオンを開く機能をコンポーネントに追加するのはカンタンでしょう!ではテストしてみます。💥 ドーン 💥 なんとテストは壊れています。どれが失敗しているのでしょうか? setOpenIndex sets the open index state properly です。

エラーメッセージにはこう書かれています。

expect(received).toBe(expected)

Expected value to be (using ===):
  0
Received:
  undefined

このテストの失敗は、重要な問題について警告していますか? いいえ! 今、コンポーネントはちゃんと動作しています。

これが false negative と呼ばれるものです。 アプリケーションコードが壊れたのではなく、テストがおかしいせいでテストが失敗したことを意味します。正直、これよりイライラするテストの失敗は考えられません。さて、気を取り直してテストを直しましょう。

test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount(<Accordion items={[]} />);
-  expect(wrapper.state('openIndex')).toEqual(0);
+  expect(wrapper.state('openIndexes')).toEqual([0]);
  wrapper.instance().setOpenIndex(1);
-  expect(wrapper.state('openIndex')).toEqual(1);
+  expect(wrapper.state('openIndexes')).toEqual([1]);
});

結論: 実装の詳細をテストしているテストは、リファクタリング時に false negative を与える場合があります。これによりテストが、コードをみているだけで壊れるように感じるほど、脆くてイライラするものになるかもしれません。

False positives

では、同僚がアコーディオンコンポーネントに取り組んでいて以下のコードを見つけたとしましょう。

<button onClick={() => this.setOpenIndex(index)}>{item.title}</button>

彼はすぐに早すぎる最適化をしたい気持ちが出てきてこう思うでしょう「よし!render の中のインラインアロー関数は パフォーマンスが良くないから、消してしまおう! 多分動くし、そこだけチャチャっと直してテストしてみよう」

<button onClick={this.setOpenIndex}>{item.title}</button>

Cool。テストをしてみると… ✅✅ 素晴らしい! 彼はブラウザーで確認せずにコミットしました。テストが通っているから問題ないですよね? コミットは無関係な何千行も変更がある PR に混ぜられ、当然ながら見逃されました。プロダクション環境でアコーディオンは壊れました。ナンシーは、来年の 2 月にソルトレイクで開催されるウィキッドを見るためのチケットを見ることができません(訳者注: 「ナンシーは〜」は訳に自信がありません)。ナンシーは泣いており、あなたのチームはひどい気分です。

何が悪かったのでしょうか? setOpenIndex が呼ばれたらステートが変化すること、そして アコーディオンの内容が正常に表示されることは確認しましたよね!? Yes その通り! しかし、問題は button が setOpenIndex に正しく紐づけられているかテストしていなかったことです。

これが false positive と呼ばれるものです。 これはテストが成功したが、本来は失敗しているべきだったという意味です! 再発防止するにはどうしたらいいでしょうか? ボタンをクリックしたらステートが正しく更新されるか検証するテストを追加する必要があります。そして、この間違いを繰り返さないようにコードカバレッジを常に 100%にする必要があります。Oh, さらに他の人がこれらの実装の詳細のテストを助長する Enzyme の API を使わないようにする ESLint プラグインをたくさん作らなければ!

…しかしとても面倒です…Ugh, false positive と false negative にウンザリしてるだけです。テストを全く書かないほうが良いかもしれませんね。テストを全部消してしまいましょう!! 何もしなくても勝手にベストプラクティスで書けてしまうツールがあればいいんですが…… いえ、そのようなツールは 実在します!

実装の詳細をテストしないテスト

実装の詳細をテストしないように使う API を制限しつつ Enzyme ですべてのテストを書き直すこともできますが、代わりに実装の詳細をテストすることがそもそも難しい React Testing Library を使おうと思います。React Testing Library をみてみましょう!

// __tests__/accordion.rtl.js
import '@testing-library/jest-dom/extend-expect';
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Accordion from '../accordion';

test('can open accordion items to see the contents', () => {
  const hats = { title: 'Favorite Hats', contents: 'Fedoras are classy' };
  const footware = {
    title: 'Favorite Footware',
    contents: 'Flipflops are the best',
  };
  render(<Accordion items={[hats, footware]} />);

  expect(screen.getByText(hats.contents)).toBeInTheDocument();
  expect(screen.queryByText(footware.contents)).not.toBeInTheDocument();

  userEvent.click(screen.getByText(footware.title));

  expect(screen.getByText(footware.contents)).toBeInTheDocument();
  expect(screen.queryByText(hats.contents)).not.toBeInTheDocument();
});

Sweet! 1 つのテストですべての動作をうまく検証できています。また、このテストは内部ステートの名前が openIndexopenIndexestacosAreTasty🌮 のどれかに関わらず成功します。Nice! false negative を除くことができました! また、クリックハンドラーが正しく紐づけられていなかった場合、テストは失敗します。Sweet! false positive も除くことができました! また、どの API を使うべきでないかといったベストプラクティスを覚えておく必要もありません。ただ React Testing Library を普通に使うだけで、アコーディオンがユーザーが望む通りに動いていると自信を持てるテストが手に入ります。

結局「実装の詳細」とは?

私が考えたとてもシンプルな定義がこれです。

  • 実装の詳細とは、あなたのコードのユーザーが一般的に使ったり、見たりどころか、知ることすらないもののことです。

さて、おそらくみなさんが最初に考える質問にお答えしましょう。「このコードのユーザーとは誰か」。まず、ブラウザーでコンポーネントを操作するエンドユーザーは明らかにユーザーです。エンドユーザーはレンダリングされたボタンやコンテンツを見たり触ったりします。しかし、開発者もまたユーザーです。開発者はアコーディオンを props を使ってレンダリングします。まとめると、一般に React コンポーネントにはエンドユーザーと開発者という2種類のユーザーがいます。エンドユーザーと開発者はアプリケーションコードが考慮すべき「ユーザー」です。

Great。では、ユーザーが使ったり見たり知っている箇所とはコードのどこなのでしょうか?エンドユーザーは render メソッドでレンダリングしたものを触ったり見たりします。開発者はコンポーネントに渡した props を触ったり見たりします。そのため、一般的に渡した props とレンダリング結果のみをテストで扱うべきです。

これはちょうど React Testing Library のテストが行っていることです。fake props を入れたアコーディオンコンポーネントを React Testing Library へ渡して、ユーザーが見ている通りのコンテンツをチェックしたり(あるいはコンテンツが表示されてないことを確認したり)、レンダリングされたボタンをクリックしたりして、レンダリング結果を操作します。

Enzyme のテストについて考えてみましょう。Enzyme では、 openIndex ステートを参照していました。ユーザーは直接的にこれを気にしていません。そのステートの名称が何であるかを知りませんし、open index がプリミティブ値として保持されているか、配列として保持されているかを知りませんし、はっきりいって気にしていません。setOpenIndex メソッドについても特に知りませんし気にしていません。しかし、Enzyme のテストはこの2つの実装の詳細について知っています。

これが Enzyme のテストが false negative になりがちな要因です。 エンドユーザーや開発者と異なる使い方でテストを書いているため、アプリケーションコードが考慮すべき3番目のユーザーが出現します: テストのことです! はっきりいって、テストは誰も気にしていないユーザーです。アプリケーションコードがテストを考慮しなければならないのは望んでいません。完全に時間の無駄です。テスト自身のために書かれたテストは要りません。自動テストはアプリケーションコードがプロダクション環境のユーザーにとって正常に動いているかを検証すべきです。

テストがソフトウェアの使われ方に似ているほど、テストの信頼性が高まります
 — 私

より詳しくは: Avoid the Test User.

hooks について

enzyme は未だに hooks に関して多くの問題を抱えています。これまで見てきたように、実装の詳細をテストしていると、実装を変更した際にテストに大きく影響します。これは大きな爆弾です。なぜなら、たとえば class component を function component + hooks へ移行している時、作業によってどこか壊していないかを確認するのにテストが役に立たないからです。

一方 React Testing Library はどうでしょうか?これはうまく行きます。末尾の codesandbox のリンクで動作を確認してください。React Testing Library で書いたテストを実行するのが好きです。

Implementation detail free and refactor friendly.

happy goats

Conclusion

さて、もう実装の詳細をテストしないようにするにはどうしたらいいかお分かりですよね?より良いツールを使うことはいい出発点です。ここで何をテストすべきかどうやって調べれば良いかお教えします。このやり方に従えば、テストする時の正しいマインドセットを持ち、自然と実装の詳細を避けるのに役立つでしょう:

  1. コードベースのテストしていない部分は、壊れたときに最悪の事態を招きますか? (アプリの会計プロセスについて考えてみます)
  2. コードをいくつかの単位に分割しましょう ("checkout"ボタンをクリックすると /checkout にカート内の商品リストを伴ったリクエストが送られる)
  3. そのコードを見て誰が「ユーザー」であるか考えましょう (開発者は会計フォームをレンダリングし、エンドユーザーはフォームのボタンをクリックします)
  4. 手動でコードが壊れていないかテストするために、インストラクション一覧を書き出しましょう。 (カートの中をフェイクデータにしてフォームをレンダリングして、checkout ボタンを押したらモックした /checkout API が正しいデータを伴って呼び出されているか確認してください。また、モックした API が成功レスポンスを返した時に成功メッセージが画面に出ることも確認してください)
  5. インストラクション一覧をもとに自動テストを作りましょう

これらが役立つことを願っています! もしテスト手法を次のレベルへ進めたい場合、 TestingJavaScript.com🏆 の Pro license を取得することを強くお勧めします。

Good luck!

P.S. もし記事中のコードの内容を確認したい場合、こちらに codesandbox があります.

P.S.P.S. 練習問題としてこちらを置いておきます。もし AccordionContents コンポーネントの名前を変更したら、2番目の Enzyme のテストで何が起こるでしょうか?

Discussion

ログインするとコメントできます