React Testing Libraryの適切な使い方 【全文翻訳+補足】

Reactコンポーネントのテストツールとして主流なReact Testing Libraryに用意されているクエリ(メソッド)の公式ドキュメントを全文翻訳しポイントをまとめました。

コマリブル
コマリブル

要素の選択にどのクエリを使うか分からなくて困るワン。

ツカレパンダ
ツカレパンダ

読みにくいテストコードは後で見返すのが疲れる。

シバセンセー
シバセンセー

クエリの使い方を押さえると効果的にTesting Libraryを使える

React Testing Libraryのドキュメントは親切ですが、特徴や設計思想を知らないといピンとこないところもあります。

Testing Libraryの勘所をピンポイントで理解したい人は、特にクエリ優先度の項目を読むと良いと思います。

豊富な補足とともに意訳していくので、一緒に読んでいきましょう。

概要

React Testing Libraryのクエリとは、ページ上の要素を見つけるために提供されているメソッド群です。クエリにはget, find, queryキーワードで区別される3種類の異なるタイプがあり、それらの違いは以下の2点です。

  • 要素を発見できなかった時に、エラーを発生させるかどうか。
  • クエリ(メソッド)の返り値としてPromiseを返したり、retry(再試行)を行ったりするかどうか。

ページ上のどんな要素を選択しようとしているかによって、適切なクエリは異なります。ページ上の要素に最もアクセスしやすいようなセマンティックなクエリの利用法に関しては、クエリ優先度ガイドをご覧ください。

重要ポイント:セマンティックなクエリ

セマンティッククエリとは、WAI-ARIAに準拠した要素選択を行えるクエリです。

セマンティック(Semantic)には英語で、「形式に捉われない、意味論の」といった意味があります。

RTLでは、ページ上の要素を考える時、要素を実装しているHTML要素が何かということに捉われず、その要素がユーザーにとってどんな役割を果たすかを中心にテストしたいと考えています。

ページ上のHTML要素のユーザーにとっての役割を決めている基準の一つが、WAI-ARIAです。RTLでは、WAI-ARIAで定められた役割(HTMLのrole)で要素選択するクエリを標準として提供し、セマンティッククエリと呼称しているのです。

要素を選択したら、イベントAPIまたはuser-eventライブラリを利用してユーザーイベントを発生させ、ユーザーインタラクションをシミュレートできます。加えて、Jestとjest-domライブラリで要素の挙動に対する例外を設定して、テストを行います。

Testing Libraryのクエリと協調して動作するヘルパーメソッドがあります。

要素の表示・非表示は、ユーザーアクションへの応答して(非同期的に)行われることがあります。このような場合、waitForクエリfindByクエリといった非同期APIを使用して、awaitでDOM要素の変化を(同期的に)待つことができます。

特定の要素の(直下の)子要素を指定したい時には、withinが利用できます。

また、各クエリはオプションをつけて呼び出すことができ、必要に応じて再試行までのタイムアウトやデフォルトのテストIDを設定できます。

使用例

{"code":"import { render, screen } from '@testing-library\/react';\ntest('should show login form', () => {\n  render(<Login \/>);\n  const input = screen.getByLabelText('Username');\n  \/\/ Events and assertions...\n});","language":"jsx","id":16,"filename":""}

クエリの種類

  • 1つの要素を選択するクエリ
    • getBy... : クエリに一致したDOMノードを返します。もし一致した要素が無かった場合または複数見つかった場合は、エラーメッセージを返します。(複数要素を取得されることを期待するテストでは、getAllByを代わりに使用してください。)
    • queryBy... : クエリに一致したDOMノードを返します。もし一致した要素が無かった場合、nullを返します。返り値のnullは、指定されたパターンに一致する要素が無いことをテストしたいときに有用です。また複数の要素が見つかった場合にはエラーを返します。(複数要素が取得されることを期待するテストでは、queryAllByを代わりに使用してください。)
    • findBy... : クエリに一致するDOMノードがあるかどうかについてのPromiseを返します。findByのデフォルトのタイムアウトは1000ミリ秒です。(デフォルトでは)1000ミリ秒経過後に、指定されたパターンに一致する要素が無かった場合または複数の要素が見つかった場合、Promiserejectで解決されます。(複数要素が取得されることを期待するテストでは、findAllByを代わりに使用してください。)
  • 複数の要素を選択するクエリ
    • getAllBy... : クエリに一致したDOMノードの配列を返します。もし一致した要素が1つも無かった場合、エラーメッセージを返します。
    • queryAllBy... : クエリに一致したDOMノードの配列を返します。もし一致した要素が無かった場合、空配列[]を返します。
    • findAllBy... : クエリに一致するDOMノードが(複数個も含めて)あるかどうかについてのPromiseを返します。findAllByのデフォルトのタイムアウトは1000ミリ秒です。(デフォルトでは)1000ミリ秒経過後に、指定されたパターンに一致する要素が1つも無かった場合にPromiserejectで解決されます。
      • findByメソッドはgetBy*クエリとwaitForメソッドの組み合わせです。findByメソッドは、最後の引数としてwaitForのオプションを受け取ることができます。
        使用例:await screen.findByText('text', queryOptions, waitForOptions)

概要表

クエリの種類0個マッチ1個マッチ2個以上マッチRetry(Async/Await)
1つの要素の検索
getBy...エラー一致した要素エラー無し
queryBy...null一致した要素エラー無し
findBy...エラー一致した要素エラー有り
複数要素の検索
getAllBy...エラー一致要素の配列一致要素の配列無し
queryAllBy...空配列[]一致要素の配列一致要素の配列無し
findAllBy...エラー一致要素の配列一致要素の配列有り
クエリの種類の分類表

クエリ優先度

React Testing Libraryの基本理念に基づいて、テストはページやコンポーネントに対するユーザーのインタラクションを可能な限り模擬したものでなくてはなりません。この観点から、私たちは使用すべきクエリに以下の優先順位を定めています。

  • あらゆる人が利用可能なクエリ:読み上げ機能などの支援技術を利用する視覚障がい者も含めた、全てのユーザーに共通した体験を反映するクエリ
    •  getByRole
        
       このメソッドは、アクセシビリティツリーに現れる全ての要素を選択できます。nameオプションを使用すれば、アクセシブル名で要素を絞り込みできます。
       このクエリは、最も使用優先度が高いクエリです。getByRoleで取得できない要素はあまりありません(もし取得できないのであれば、アクセシビリティの観点からUIの実装に不備がある恐れがあります)。
       nameオプションを使用した典型的な使用例です:getByRole('button', {name: /submit/i})getByRoleに指定するrole属性はMDNの対応表を確認してください。

    •  getByLabelText
       
       このメソッドは、フォーム内の要素を選択するのに適しています。ユーザーがwebフォームを操作するとき、ユーザーは要素のラベルテキストで入力フィールドを見つけます。
       getByLabelTextはこの振る舞いを模倣しますので、フォーム内の要素をテストしたい時このメソッドは最優先されるべきです。

    •  getByPlaceHolderText

       前提として、プレースホルダーはラベルの代わりにはなりません。しかしながら、プレースホルダー以外のものが利用できない場合に限りこのメソッドを利用してください。

    •  getByText

       フォームの外では、ユーザーが要素を見つける主要な手がかりはテキストコンテンツです。このメソッドは、div,span,p要素などのユーザーインタラクションの無い要素を選択するのに適しています

    •  getByDisplayValue

       フォーム要素のvalueの値を指定して要素を指定するメソッドです。
  • セマンティッククエリ:上記以外で、HTML5またはARIA準拠のセレクターで要素を指定します。
    •  getByAltText

       img, area, input要素などにalt属性が指定されている場合、alt属性をこのメソッドで指定して要素の選択に利用できます。

    •  getByTitle

       title属性で要素を選択できるメソッドです。しかし、title属性はスクリーンリーダーで必ずしもサポートされておらず、視覚障害のないユーザーにもデフォルトで表示されないことに留意してください。
  • (開発者が設定した)テストIDによる検索クエリ
    •  getByTestId

       HTML要素のrole属性やテキストで要素の選択ができず、開発者が設定したカスタム属性(data-*の形の属性など)が唯一の手段のとき、このメソッドを利用してください。例えば、テキストが動的に変化するページの場合です。

クエリの使用法

DOMテストを行うライブラリで利用できる基本的なクエリには、第一引数として、(コンポーネントをラップするための)コンテナ要素を渡す必要があります。

ただし、Testing Libraryに含まれるほとんどのフレームワークは、単にレンダーするだけで、コンポーネントをコンテナ要素でラップする仕様になっています。つまり、コンポーネントのレンダー時にマニュアル的にコンテナ要素を作成し、クエリに渡す必要はありません。

加えて、document.bodyを指定したいときには、下記の使用例のようにscreenを利用できます。

クエリの主な引数は文字列型、正規表現、または関数です。また、DOM要素のノードを解析する方法を調整するためのオプションも利用できます。詳細はTextMatchの項目を参照してください。

React, Vue, Angularのようなフレームワーク、または(単なる)HTMLコードでレンダーできる、以下のようなDOM要素が与えられたとします。

{"code":"<body>\n  <div id = \"app\">\n    <label for=\"username-input\">Username<\/label>\n    <input id=\"username-input\" \/>\n  <\/div>\n<\/body>","language":"jsx","id":16,"filename":""}

所定の要素を検索するために、以下のようにクエリを利用できます(今回は、byLabelTextを利用しました)。

{"filename":"sample.test.js","code":"import {screen, getByLabelText} from '@testing-library\/dom';\n\/\/ With screen:\nconst inputNode1 = screen.getByLabelText('Username');\n\/\/ Without screen, you need to provide a container:\nconst container = document.querySelector('#app');\nconst inputNode2 = getByLabelText(container, 'Username');","language":"javascript","id":1}

クエリのオプション

javascriptのオブジェクトの形で、クエリにオプションを渡すことができます。クエリの種類に応じた利用可能なオプションは、各クエリのドキュメントを参照してください。例えば、byRole APIのドキュメントなどです。

screen

DOMテストを行うライブラリ群に含まれる全てのクエリは、第一引数に(コンポーネントをラップするための)コンテナ要素を取ります。

document.body全体の指定はあまりにありふれているので、DOMテストを行うライブラリはクエリ群の他に、document.bodyに事前にバインドされたscreenオブジェクトをexportしています。

screenオブジェクトには全てのメソッドを使用することができます。

下記がscreenの使用例です。

{"filename":"Native","code":"import {screen} from '@testing-library\/dom';\ndocument.body.innerHTML = `\n  <label for=\"example\">Example<\/label>\n  <input id=\"example\" \/>\n`\nconst  exampleInput = screen.getByLabelText('Example');","language":"jsx","id":16}
{"filename":"React","code":"import {render, screen} from '@testing-library\/react';\nrender(\n  <div>\n    <label htmlFor=\"example\">Example<\/label>\n    <nput id=\"example\" \/>\n    <\/div>,\n);\nconst exampleInput = screen.getByLabelText('Example');","language":"jsx","id":16}
{"filename":"Angular","code":"import {render, screen} from '@testing-library\/angular';\nawait render(`\n  <div>\n    <label for=\"example\">Example<\/label>\n    <input id=\"example\" \/>\n  <\/div>\n`);\nconst exampleInput = screen.getByLabelText('Example');","language":"jsx","id":16}
{"filename":"Cypress","code":"cy.findByLabelText('Example').should('exist');","language":"javascript","id":1}

screenを利用するためには、グローバルなDOM環境が必要です。
Jestを使用してテストしている場合、testEnvironment変数をjsdomに設定してください。
もし、HTMLのscriptタグも含めてテストを実施したい場合、scriptタグがHTMLのbodyタグの直後にあることを確実にしてください。実例はここにあります。

TextMatch

ほとんどのAPIは、TextMatchを引数として取ることができます。TextMatchとは文字列型、正規表現、またはシグネチャ関数のいずれかです。

例:(content?: string, element?: Element | null) => boolean型で、一致要素があればtrueを、なければfalseを返す。

TextMatchの使用例

次のHTMLが与えられたとします。

{"filename":"sample","code":"<div>Hello World<\/div>","language":"html","id":3}

上記のdiv要素にマッチするコード:

{"filename":"sample.test.js","code":"\/\/ Matching a string:\nscreen.getByText('Hello World') \/\/ full string match\nscreen.getByText('llo Worl', {exact: false}) \/\/ substring match\nscreen.getByText('hello world', {exact: false}) \/\/ ignore case\n\/\/ Matching a regex:\nscreen.getByText(\/World\/) \/\/ substring match\nscreen.getByText(\/world\/i) \/\/ substring match, ignore case\nscreen.getByText(\/^hello world$\/i) \/\/ full string match, ignore case\nscreen.getByText(\/Hello W?oRlD\/i) \/\/ substring match, ignore case, searches for \"hello world\" or \"hello orld\"\n\/\/ Matching with a custom function:\nscreen.getByText((content, element) => content.startsWith('Hello'))","language":"javascript","id":1}

div要素にマッチしないコード:

{"code":"\/\/ full string does not match\nscreen.getByText('Goodbye World')\n\/\/ case-sensitive regex with different case\nscreen.getByText(\/hello world\/)\n\/\/ function looking for a span when it's actually a div:\nscreen.getByText((content, element) => {\n    return element.tagName.toLowerCase() === 'span' && content.startsWith('Hello')\n})","language":"javascript","id":1,"filename":""}

マッチングの精度

TextMatchを引数としてとるクエリ群は、最後の引数として、テキストマッチングの精度に影響するオプションをオブジェクトの形で受け取ります。

  • exact:デフォルト値はtrueです。文字列全体への完全一致をテストし、大文字小文字を区別します。falseに設定された時は、部分一致をテストし、大文字小文字を区別しないようになります。
    • 正規表現または関数引数とともに使用されるとき、設定は無効化されます。
    • 多くの場合、{ exact: false }のようにマニュアル的にexact値をfalseに設定するよりも、正規表現を用いた方がより柔軟なあいまい検索を行うことができるため、正規表現を用いた設定が好まれます。
  • normalizer:デフォルトのテキスト正規化の挙動を上書きするためのオプションです。デフォルトの挙動などについては正規化の項目を参照してください。

正規化

DOM要素のテキストにマッチングを行う前に、DOMテストを行うライブラリは自動的に正規化を行います。デフォルトでは、先頭と末尾にあるスペースを文字列から削除し、文字列中の2つ以上連続したスペースは1つに変換します

もしデフォルトの正規化を行わないか、代わりに別の正規化(例えば、Unicode制御文字の削除)を行いたい場合、オプションとしてnormalizer関数を指定することができます。normalizer関数は、文字列を引数にとり、正規化された文字列を返すような関数です。

注記:getDefaultNormalizer

デフォルトの正規化を呼び出すgetDefaultNormalizerメソッドがあります。

デフォルトの正規化を上書きしたい時、normalizerに指定する関数を0から書くこともできますが、getDefaultNormalizerで呼び出した関数の一部を上書きして作ることもできます。

getDefaultNormalizerは、オブジェクトの形でオプションを引数に取り、オプションにより以下の正規化をするかどうか選択できます。

  • trim:デフォルト値はtrueです。文字列の先頭と末尾のスペースを削除します。
  • collapseWhitespace:デフォルト値はtrueです。文字列中の連続したスペースを1つに変換します。

正規化の使用例

先頭と末尾のスペースを削除しない文字列マッチを行うコード:

{"filename":"sample","code":"screen.getByText('text', {\n  normalizer: getDefaultNormalizer({trim: false}),\n})","language":"javascript","id":1}

次の使用例は、以下のような正規化を行うコードです:

  • 先頭と末尾のスペースを削除しない
  • Unicode制御文字を削除する
  • その他の挙動は、デフォルトの正規化と同様にする
{"filename":"sample","code":"screen.getByText('text', {\n  normalizer: str =>\n    getDefaultNormalizer({trim: false})(str).replace(\/[\\u200E-\\u200F]*\/g, ''),\n})","language":"javascript","id":1}

マニュアルクエリ

Testing Libraryが提供するクエリに加えて、要素の選択に通常のquerySelector DOM APIを用いることもできます。

ただし、classidによる要素の選択は非推奨であり、(他の方法で選択できない場合の)避難ハッチであることを留意してください。classidはユーザーからは見えないからです。

必要であればtestidを使用し、セマンティックでないクエリを仕方なく使用している意図を明確にすることで、HTML 要素とクエリの間に安定的な取り決めを確立するべきです。

{"filename":"sample","code":"\/\/ @testing-library\/react\nconst {container} = render()\nconst foo = container.querySelector('[data-foo=\"bar\"]')","language":"javascript","id":1}

ブラウザ拡張機能

Testing Libraryのクエリの使用方法について、まだお困りですか?

Testing Playgroundという、とても便利なChrome拡張機能があります。この拡張機能は、要素を選択する最適なクエリを書く補助をしてくれます。

ブラウザーの開発者向け機能の中でHTML要素の階層を表示して調べることができ、それらの要素をどのように選択するべきか示してくれるため、良いテストコードの書き方を身につける助けになるでしょう。

プレイグラウンド

もしこれらのクエリにより慣れ親しみたいなら、testing-playground.comで試すことができます。Testing Playgroundは様々なクエリをHTMLに試せるインタラクティブな仮想環境で、これまでに説明した要素選択のルールに対する視覚的なフィードバックを得ることができます。

参考サイト

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA


error: Content is protected !!