🦜

なぜ私は shallow rendering を決して使わないのか

2023/03/04に公開

数年前、私が React を始めたとき、React コンポーネントのテスト方法を理解しなければ、と考えたときのことを覚えています。私は enzyme の shallow を試してみましたが、React コンポーネントをテストするためにこれを使用しないとすぐに決めました。

私は幾度もこの考えについて表明しましたが、定期的に「なぜ shallow rendering についてそのように感じるのか」、「なぜ React Testing Library は shallow rendering を決してサポートしないのか」と質問されます。

そこで、私が shallow rendering を使用しない理由と、他の誰も使用しない方がよいと思う理由を説明します。以下が、私の主な主張です。

shallow rendering では、コンポーネントの実装をリファクタリングしても、テストが壊れてしまうことがあります。
shallow rendering では、アプリケーションを壊しても、テストではすべて正常に動作していると言えます。

これは私にとって非常に重要なことです。なぜなら、テストが苛立たしいだけでなく、誤った安心感に陥らせてしまうからです。私がテストを書く理由は、自分のアプリケーションが動作する確信を得るためであり、そのためには shallow rendering よりもはるかに優れた方法があります。

shallow rendering とは何か

今回は、この例をテスト対象としてみましょう。

import * as React from 'react'
import { CSSTransition } from 'react-transition-group'

function Fade({ children, ...props }) {
  return (
    <CSSTransition {...props} timeout={1000} className="fade">
      {children}
    </CSSTransition>
  )
}

class HiddenMessage extends React.Component {
  static defaultProps = { initialShow: false }
  state = { show: this.props.initialShow }
  toggle = () => {
    this.setState(({ show }) => ({ show: !show }))
  }
  render() {
    return (
      <div>
        <button onClick={this.toggle}>Toggle</button>
        <Fade in={this.state.show}>
          <div>Hello world</div>
        </Fade>
      </div>
    )
  }
}

export { HiddenMessage }

以下は、 enzyme による shallow rendering を使用するテストの例です。

import * as React from 'react'
import Enzyme, { shallow } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import { HiddenMessage } from '../hidden-message'

Enzyme.configure({ adapter: new Adapter() })

test('shallow', () => {
  const wrapper = shallow(<HiddenMessage initialShow={true} />)
  expect(wrapper.find('Fade').props()).toEqual({
    in: true,
    children: <div>Hello world</div>,
  })
  wrapper.find('button').simulate('click')
  expect(wrapper.find('Fade').props()).toEqual({
    in: false,
    children: <div>Hello world</div>,
  })
})

shallow rendering を理解するために、 enzyme レンダリングした構造をログ出力する console.log(wrapper.debug()) を追加します。その結果:

<div>
  <button onClick={[Function]}>Toggle</button>
  <Fade in={true}>
    <div>Hello world</div>
  </Fade>
</div>

Fade がレンダリングしている CSSTransition が実際には表示されていないことにお気づきでしょう。これは、実際にコンポーネントをレンダリングして Fade コンポーネントを呼び出すのではなく、shallow rendering しているコンポーネントが作成する React 要素に適用される props を shallow が参照しているだけだからです。実際、HiddenMessage コンポーネントの render メソッドを取り出して、それが返すものを console.log すると、次が得られます。

{
  "type": "div",
  "props": {
    "children": [
      {
        "type": "button",
        "props": {
          "onClick": [Function],
          "children": "Toggle"
        }
      },
      {
        "type": [Function: Fade],
        "props": {
          "in": true,
          "children": {
            "type": "div",
            "props": {
              "children": "Hello world"
            }
          }
        }
      }
    ]
  }
}

見覚えはありますか?つまり、すべての shallow rendering は、与えられたコンポーネントの render メソッドの結果(React 要素になる - JSX とは何ですか? を参照)を受け取り、この JavaScript オブジェクトをトラバースするためのユーティリティを備えた wrapper オブジェクトを提供するものです。

ということは、ライフサイクルメソッドを実行せず(React 要素を扱うだけだから)、DOM 要素と実際にやりとりもできず(実際には何もレンダリングされないから)、カスタムコンポーネント(Fade コンポーネントなど)が返す React 要素を実際に取得することもありません。

人々はなぜ shallow rendering を使うのか

私が早い段階から shallow rendering を使わないと決めたのは、 shallow rendering のようなトレードオフを強いられない、もっとよい方法があると考えたからです。私は最近、shallow rendering を使う理由を 教えてほしいとお願いしました

ここではその回答から、shallow rendering を使っている理由をいくつか紹介します。

  1. ...React コンポーネントでメソッドを呼び出すため
  2. ...テスト対象の各コンポーネントの子コンポーネントを、テストごとに何百回も、何千回とレンダリングするのは無駄のように思えます...
  3. 実際のユニットテスト用。構成されたコンポーネントをテストすると、新しい依存関係が発生し、ユニット自体は意図したとおりに動作していても、エラーが発生する可能性があります。

もっと多くの回答がありましたが、shallow rendering を使用する主な論拠をまとめると、このようになります。では、それぞれについて取り上げてみましょう。

react コンポーネントでメソッドを呼び出す

このようなテストを見たり書いたりしたことはないでしょうか?

test('toggle toggles the state of show', () => {
  const wrapper = shallow(<HiddenMessage initialShow={true} />)
  expect(wrapper.state().show).toBe(true) // initialized properly
  wrapper.instance().toggle()
  wrapper.update()
  expect(wrapper.state().show).toBe(false) // toggled
})

これは shallow rendering を使用する素晴らしい理由ですが、実にお粗末なテスト方法です。考慮すべき 2 つの非常に重要な点があります。

  1. このテストは、本番環境でコンポーネントを破壊するミスがあった場合、失敗しますか?
  2. このテストは、そのコンポーネントの完全な後方互換性のあるリファクタリングが行われた場合、機能し続けますか?

この種のテストは、その両方の考慮すべき事項に反しています。

  1. ボタンの onClickthis.toggle ではなく this.tgogle にミスタイプできました。私のテストは動作し続けますが、私のコンポーネントは壊れています。
  2. toggle の名前を handleButtonClick に変更できました(そして対応する onClick の参照を更新しました)。これはリファクタリングであるにもかかわらず、私のテストは失敗します。

この種のテストがこのような配慮に欠けるのは、無関係な実装の詳細をテストしているためです。ユーザーは 内部では何と呼ばれているか なんてちっとも気にしていません。

実際、このテストでは show 状態が false のときにメッセージが適切に隠され、show 状態が true のときに表示されるかどうかさえ検証されていません。つまり、このテストは私たちを破損から守るための素晴らしい仕事をしないだけでなく、薄っぺらで、そもそもそのコンポーネントが存在する理由を実際にテストしていないのです。

まとめると、もしあなたのテストが instance()state() を使っているのなら、 ユーザーが知るはずのないこと、あるいは気にすることさえないことをテストしていることになります。

...それは無駄のように思える...

shallow rendering は、react コンポーネントをテストする他のどの形式よりも高速であるという事実を回避できません。確かに react コンポーネントを mount するよりもずっと速いです。しかし、それは数ミリ秒の話です。それは積み重なるものですが、テストが終わるまでに数秒から数分余分に待つ代わりに、テストが実際にユーザーに出荷するときアプリケーションが動作するという確信を与えてくれるなら、私は喜んで待ちます。

実際のユニットテスト用

これは非常によくある誤解です:「react コンポーネントをユニットテストするには、他のコンポーネントがレンダリングされないように shallow rendering を使用する必要があります」

shallow rendering が他のコンポーネントをレンダリングしないのは事実です(上で示したとおり)。これの何が問題かというと、他のコンポーネントをレンダリングしないので、粗っぽすぎる(<訳注>原文:it's way too heavy handed)ということです。選択の余地はありません。

shallow rendering では、サードパーティのコンポーネントがレンダリングされないだけでなく、ファイル内のコンポーネントさえもレンダリングされません。

たとえば、上記の <Fade /> コンポーネントは <HiddenMessage /> コンポーネントの実装の詳細ですが、 shallow rendering では <Fade /> がレンダリングされないため、そのコンポーネントを変更するとアプリケーションが壊れる可能性があります。にもかかわらず、テストは壊れません。これは私にとって大きな問題であり、実装の詳細をテストすべき証(<訳注>原文:evidence)でもあります。

加えて、shallow rendering なしの react コンポーネントのユニットテストも間違いなく可能です。Jest mocking を使って <CSSTransition /> コンポーネントをモックアウトする、そのようなテストの例(React Testing Library を使いますが、enzyme でもできます)を、最後のセクションでチェックしてみてください。

私は普段、サードパーティのコンポーネントさえも 100% モックすることに反対していることを付け加えておきます。サードパーティのコンポーネントをモックすることの論拠としてよく耳にするのは、「構成されたコンポーネントをテストすると、ユニット自体は意図したとおりに動作しているにもかかわらず、エラーを引き起こす可能性のある新しい依存関係が導入される」というものです。

しかし、テストのポイントは「アプリケーションが動作する確信を得るため」ではないでしょうか?アプリが壊れているときに、ユニットが動くかどうかなんて誰が気にするでしょうか?私は、自分が使っているサードパーティーのコンポーネントが、自分のユースケースを壊すかどうかを絶対に知りたいです。彼らのテストベース全体を書き直すつもりはありませんが、彼らのコンポーネントをモックアウトしないことでユースケースを簡単にテストできるのであれば 、それを行うことでより高い信頼性を獲得しませんか?

さらにさらに、私は統合テストをより重視することに賛成していることも付け加えておきます。そうすると、単純なコンポーネントのユニットテストは少なくなり、結局はコンポーネントのエッジケース(モックし放題)だけをユニットテストする必要があります。このような状況であっても、どのコンポーネントがモックされ、どのコンポーネントがレンダリングされているかをフルマウントとモックを明示することで、より信頼性と保守性の高いテストベースになると思います。

shallow rendering を使わない

私は React Testing Library の指針となる原則を大いに信じています:

"テストがソフトウェアの使用方法に似るほどに、信頼性が高まる - ケント C.ドッズ 👋"

これこそが、そもそも私がこのライブラリを書いた動機です。この記事の補足として、React Testing Library に「ユーザーにとって不可能なことをするための方法が少ない」ことに触れておきたいと思います。以下は React Testing Library が(そのままでは)できないことのリストです。

  1. shallow rendering
  2. 静的レンダリング(enzyme の render 関数と同様)
  3. enzyme のほとんどのメソッドは、コンポーネントのクラスやその displayName で検索する機能を含む要素をクエリ(find)します(繰り返しますが、ユーザーはあなたのコンポーネントが何と呼ばれているかを気にしないし、あなたのテストもそうするべきではありません)。注:React Testing Library は、コンポーネントのアクセシビリティと、より保守性の高いテストを促進する方法で、要素のクエリをサポートしています。
  4. コンポーネントインスタンスの取得(enzyme の instance 同様)
  5. コンポーネントの props の取得と設定(props()
  6. コンポーネントの状態の取得と設定(state()

これらはコンポーネントのユーザができないことなので、テストもそうするべきではありません。以下は <HiddenMessage /> コンポーネントのテストですが、これはユーザーがコンポーネントを使用する方法によく似ています。さらに、<CSSTransition /> を適切に使用しているかどうかも確認できます(shallow rendering の例ではできなかったことです)。

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { CSSTransition } from 'react-transition-group'

import { HiddenMessage } from '../hidden-message'

// NOTE: you do NOT have to do this in every test.
// Learn more about Jest's __mocks__ directory:
// https://jestjs.io/docs/en/manual-mocks
jest.mock('react-transition-group', () => {
  return {
    CSSTransition: jest.fn(({ children, in: show }) => (show ? children : null)),
  }
})

test('you can mock things with jest.mock', () => {
  render(<HiddenMessage initialShow={true} />)
  const toggleButton = screen.getByText(/toggle/i)

  const context = expect.any(Object)
  const children = expect.any(Object)
  const defaultProps = { children, timeout: 1000, className: 'fade' }

  expect(CSSTransition).toHaveBeenCalledWith({ in: true, ...defaultProps }, context)
  expect(screen.getByText(/hello world/i)).not.toBeNull()

  CSSTransition.mockClear()

  userEvent.click(toggleButton)

  expect(screen.queryByText(/hello world/i)).toBeNull()
  expect(CSSTransition).toHaveBeenCalledWith({ in: false, ...defaultProps }, context)
})

まとめ

数週間前、DevTipsWithKent(私が YouTube で毎日配信しているライブストリーム)で「Migrating from shallow rendering react components to explicit component mocks」をライブ配信しました。その中で私は、上記で説明した shallow rendering の落とし穴と、代わりに jest mocking を使用する方法をいくつか実演しました。

これがお役に立てば幸いです!私たちは皆、ユーザーに素晴らしい体験を提供するためにベストを尽くしてます。その努力が報われることを祈っています。!

追伸

とある人の提案:

小さな独立したコンポーネントをテストするには、shallow wrapper が適しています。適切なシリアライザーを使用することで、明確で理解しやすいスナップショットを作成することができます。

私は react でスナップショットテストを使うことはほとんどありませんし、shallow で使うこともないでしょう。それは実装の詳細のためのレシピです。スナップショット全体が実装の詳細に過ぎません(リファクタリングで常に変化するコンポーネント名やプロップ名でいっぱいです)。あなたがコンポーネントを触るたびに失敗し、スナップショットの git diff は、あなたがコンポーネントを変更したときのものとほぼ同じになるでしょう。

常に変更されるため、スナップショットの更新の変更について不注意になります。つまり、基本的に無価値なのです(大抵、テストがないよりも悪いです。なぜなら、あなたがカバーされていないときにカバーされていると思わせるし、テストがあるために適切なテストを書かないようになるからです)。

でも、スナップショットは便利だと思います。このことについては、別のブログ記事をご覧ください。

効果的なスナップショットテスト

I hope that helps!

ロンラン Tech Zenn

Discussion