テスト
コードベースが拡大するにつれて、予期しない小さなエラーやエッジケースが、より大きな障害へと連鎖的に発展することがあります。バグはユーザーエクスペリエンスの低下につながり、最終的にはビジネス上の損失につながります。脆弱なプログラミングを防ぐ一つの方法は、コードを世に出す前にテストすることです。
このガイドでは、静的解析からエンドツーエンドテストまで、アプリが期待どおりに動作することを確認するための様々な自動化された方法について説明します。
なぜテストするのか
私たちは人間であり、人間は過ちを犯します。テストは、これらの過ちを発見し、コードが機能していることを検証するのに役立つため重要です。おそらくさらに重要なことは、テストによって、新しい機能を追加したり、既存の機能をリファクタリングしたり、プロジェクトの主要な依存関係をアップグレードしたりしても、将来にわたってコードが機能し続けることを保証できることです。
テストには、あなたが思っている以上の価値があります。コードのバグを修正する最善の方法の一つは、そのバグを明らかにする失敗するテストを書くことです。そして、バグを修正してテストを再実行し、合格すれば、そのバグが修正され、二度とコードベースに再導入されないことを意味します。
テストは、チームに新しく加わる人々のためのドキュメントとしても機能します。コードベースを初めて見る人にとって、テストを読むことは、既存のコードがどのように機能するかを理解するのに役立ちます。
最後に、自動テストが増えれば、手動のQA(品質保証)に費やす時間が減り、貴重な時間を解放できます。
静的解析
コードの品質を向上させるための最初のステップは、静的解析ツールの使用を開始することです。静的解析は、コードを実行することなく、記述中にコードのエラーをチェックします。
- リンター(Linter)は、未使用のコードなどの一般的なエラーを検出したり、落とし穴を避けたり、タブの代わりにスペースを使用する(または設定によってはその逆)といったスタイルガイド違反にフラグを立てたりするためにコードを分析します。
- 型チェックは、関数に渡している構成要素が、その関数が受け入れるように設計されたものと一致することを確認し、例えば、数値を期待するカウント関数に文字列を渡すことを防ぎます。
React Nativeには、リンティング用のESLintと型チェック用のTypeScriptという2つのツールが、初期設定済みで付属しています。
テスト可能なコードを書く
テストを始めるには、まずテスト可能なコードを書く必要があります。航空機の製造プロセスを考えてみましょう。あるモデルが初めて離陸して、その複雑なシステムがすべてうまく連携して機能することを示す前に、個々の部品が安全で正しく機能することを保証するためにテストされます。例えば、翼は極端な負荷をかけて曲げることでテストされ、エンジン部品はその耐久性がテストされ、フロントガラスは模擬的な鳥の衝突に対してテストされます。
ソフトウェアも同様です。プログラム全体を多数のコード行を持つ巨大な1つのファイルに書く代わりに、複数の小さなモジュールにコードを書き、組み立てられた全体をテストするよりも徹底的にテストできるようにします。このように、テスト可能なコードを書くことは、クリーンでモジュール化されたコードを書くことと密接に関連しています。
アプリをよりテストしやすくするために、まずアプリのビュー部分(Reactコンポーネント)を、ビジネスロジックとアプリの状態(Redux、MobX、またはその他のソリューションを使用しているかどうかにかかわらず)から分離することから始めましょう。これにより、Reactコンポーネントに依存すべきではないビジネスロジックのテストを、主にアプリのUIをレンダリングする役割を持つコンポーネント自体から独立させることができます。
理論的には、すべてのロジックとデータ取得をコンポーネントから移動させることさえ可能です。これにより、コンポーネントはレンダリング専用になります。状態はコンポーネントから完全に独立します。アプリのロジックは、Reactコンポーネントがなくてもまったく機能するでしょう!
他の学習リソースで、テスト可能なコードというトピックについてさらに探求することをお勧めします。
テストを書く
テスト可能なコードを書いた後、いよいよ実際のテストを書く時です!React Nativeのデフォルトテンプレートには、Jestテストフレームワークが同梱されています。これには、この環境に合わせて調整されたプリセットが含まれているため、設定やモックをすぐにいじることなく生産性を上げることができます。モックについては後ほど詳しく説明します。Jestを使用して、このガイドで紹介されているすべての種類のテストを書くことができます。
テスト駆動開発を行う場合は、実際には最初にテストを書きます!そうすれば、コードのテスト可能性は保証されます。
テストの構造化
テストは短く、理想的には1つのことだけをテストすべきです。Jestで書かれた単体テストの例から始めましょう。
it('given a date in the past, colorForDueDate() returns red', () => {
expect(colorForDueDate('2000-10-20')).toBe('red');
});
it
関数に渡される文字列によってテストが記述されます。何がテストされているかが明確になるように、説明を丁寧に書きましょう。以下の点を網羅するように最善を尽くしてください。
- Given(前提) - ある前提条件
- When(実行) - テスト対象の関数によって実行されるアクション
- Then(結果) - 期待される結果
これはAAA(Arrange, Act, Assert)としても知られています。
Jestはテストを構造化するのに役立つdescribe
関数を提供しています。describe
を使用して、1つの機能に属するすべてのテストをグループ化します。必要であれば、describeはネストすることもできます。他に一般的に使用する関数には、テスト対象のオブジェクトを設定するために使用できるbeforeEach
やbeforeAll
があります。JestのAPIリファレンスで詳細をお読みください。
テストに多くのステップや期待値がある場合は、複数の小さなテストに分割したいと思うかもしれません。また、テストが互いに完全に独立していることを確認してください。スイート内の各テストは、他のテストを先に実行することなく単独で実行可能でなければなりません。逆に、すべてのテストをまとめて実行する場合、最初のテストが2番目のテストの出力に影響を与えてはなりません。
最後に、開発者として私たちは、コードがうまく機能し、クラッシュしないことを好みます。テストでは、これはしばしば逆です。失敗したテストは良いことだと考えてください!テストが失敗すると、何かが正しくないことを意味していることがよくあります。これにより、問題がユーザーに影響を与える前に修正する機会が得られます。
単体テスト
単体テストは、個々の関数やクラスなど、コードの最小単位を対象とします。
テスト対象のオブジェクトに依存関係がある場合、次の段落で説明するように、それらをモックアウトする必要がしばしばあります。
単体テストの素晴らしい点は、書くのも実行するのも速いことです。そのため、作業中にテストがパスしているかどうかのフィードバックを素早く得ることができます。Jestには、編集中のコードに関連するテストを継続的に実行するオプションさえあります:ウォッチモード。
モック
テスト対象のオブジェクトに外部依存関係がある場合、「モックアウト」したくなることがあります。「モック」とは、コードの依存関係の一部を独自の実装に置き換えることです。
一般的に、テストで実際のオブジェクトを使用する方がモックを使用するよりも優れていますが、それが不可能な状況もあります。例えば、JSの単体テストがJavaやObjective-Cで書かれたネイティブモジュールに依存している場合です。
あなたが自分の街の現在の天気を表示するアプリを書いていて、天気情報を提供してくれる何らかの外部サービスや他の依存関係を使用していると想像してください。サービスが雨が降っていると伝えた場合、雨雲の画像を表示したいでしょう。テストでそのサービスを呼び出したくはありません。なぜなら:
- (ネットワークリクエストが関わるため)テストが遅くなったり不安定になったりする可能性がある
- テストを実行するたびにサービスが異なるデータを返す可能性がある
- 本当にテストを実行する必要があるときに、サードパーティのサービスがオフラインになる可能性がある!
そのため、サービスのモック実装を提供することで、何千行ものコードやインターネットに接続された温度計を効果的に置き換えることができます!
Jestは、関数レベルからモジュールレベルのモックまで、モックをサポートしています。
結合テスト
より大きなソフトウェアシステムを書くとき、その個々の部分は互いに相互作用する必要があります。単体テストでは、あなたのユニットが別のユニットに依存している場合、依存関係をモックアウトし、偽のものに置き換えることがあります。
結合テストでは、実際の個々のユニットが(アプリ内と同じように)組み合わされ、それらの連携が期待どおりに機能することを確認するために一緒にテストされます。これはモックがここで発生しないという意味ではありません。モックは依然として必要になりますが(たとえば、天気サービスとの通信をモックするため)、単体テストよりもはるかに少ないです。
結合テストが何を意味するかについての用語は、必ずしも一貫しているわけではないことに注意してください。また、単体テストと結合テストの境界線が常に明確であるとは限りません。このガイドでは、テストが以下のいずれかに該当する場合、「結合テスト」に分類されます。
- 上記のようにアプリの複数のモジュールを組み合わせる
- 外部システムを使用する
- 他のアプリケーション(天気サービスAPIなど)へのネットワーク呼び出しを行う
- 何らかの種類のファイルまたはデータベースのI/Oを行う
コンポーネントテスト
Reactコンポーネントはアプリのレンダリングを担当し、ユーザーはその出力と直接対話します。アプリのビジネスロジックのテストカバレッジが高く、正しい場合でも、コンポーネントテストがなければ、壊れたUIをユーザーに提供してしまう可能性があります。コンポーネントテストは単体テストと結合テストの両方に分類される可能性がありますが、React Nativeの中核部分であるため、ここでは別々に説明します。
Reactコンポーネントをテストする場合、テストしたいことは2つあります。
- インタラクション:ユーザーが操作したときにコンポーネントが正しく動作することを確認するため(例:ユーザーがボタンを押したとき)
- レンダリング:Reactが使用するコンポーネントのレンダリング出力が正しいことを確認するため(例:UI内でのボタンの外観と配置)
例えば、onPress
リスナーを持つボタンがある場合、ボタンが正しく表示されることと、ボタンをタップしたときにコンポーネントが正しく処理することの両方をテストしたいでしょう。
これらのテストに役立ついくつかのライブラリがあります。
- React Native Testing Libraryは、Reactのテストレンダラーの上に構築されており、次の段落で説明する
fireEvent
およびquery
APIを追加します。 - [非推奨] ReactのTest Rendererは、そのコアと並行して開発され、DOMやネイティブモバイル環境に依存せずに、Reactコンポーネントを純粋なJavaScriptオブジェクトにレンダリングするために使用できるReactレンダラーを提供します。
コンポーネントテストは、Node.js環境で実行されるJavaScriptのテストに過ぎません。これらは、React Nativeコンポーネントを支えるiOS、Android、またはその他のプラットフォームのコードを一切考慮しません。したがって、ユーザーにとってすべてが機能するという100%の信頼を与えることはできません。iOSまたはAndroidのコードにバグがある場合、それらはそれを見つけることができません。
ユーザーインタラクションのテスト
UIをレンダリングするだけでなく、コンポーネントはTextInput
のonChangeText
やButton
のonPress
のようなイベントを処理します。また、他の関数やイベントコールバックを含むこともあります。次の例を考えてみましょう。
function GroceryShoppingList() {
const [groceryItem, setGroceryItem] = useState('');
const [items, setItems] = useState<string[]>([]);
const addNewItemToShoppingList = useCallback(() => {
setItems([groceryItem, ...items]);
setGroceryItem('');
}, [groceryItem, items]);
return (
<>
<TextInput
value={groceryItem}
placeholder="Enter grocery item"
onChangeText={text => setGroceryItem(text)}
/>
<Button
title="Add the item to list"
onPress={addNewItemToShoppingList}
/>
{items.map(item => (
<Text key={item}>{item}</Text>
))}
</>
);
}
ユーザーインタラクションをテストするときは、ユーザーの視点からコンポーネントをテストします。ページには何が表示されていますか?操作すると何が変わりますか?
経験則として、ユーザーが見たり聞いたりできるものを使用することを好みます。
- レンダリングされたテキストやアクセシビリティヘルパーを使用してアサーションを行う
逆に、以下のことは避けるべきです。
- コンポーネントのpropsやstateに対するアサーションを行うこと
- testIDクエリ
propsやstateのような実装の詳細をテストすることは避けてください。そのようなテストは機能しますが、ユーザーがコンポーネントとどのように対話するかに重点を置いておらず、リファクタリング(例えば、何かを改名したり、クラスコンポーネントをフックを使用して書き換えたりする場合)によって壊れやすい傾向があります。
Reactのクラスコンポーネントは、内部の状態、props、イベントハンドラなどの実装の詳細をテストする傾向が特にあります。実装の詳細のテストを避けるために、フックを使用した関数コンポーネントを使用することを推奨します。これにより、コンポーネントの内部に依存することがより難しくなります。
React Native Testing Libraryのようなコンポーネントテストライブラリは、提供されるAPIを慎重に選択することで、ユーザー中心のテストの作成を容易にします。次の例では、ユーザーがコンポーネントと対話するのをシミュレートするfireEvent
メソッドのchangeText
とpress
、およびレンダリングされた出力から一致するText
ノードを見つけるクエリ関数getAllByText
を使用しています。
test('given empty GroceryShoppingList, user can add an item to it', () => {
const {getByPlaceholderText, getByText, getAllByText} = render(
<GroceryShoppingList />,
);
fireEvent.changeText(
getByPlaceholderText('Enter grocery item'),
'banana',
);
fireEvent.press(getByText('Add the item to list'));
const bananaElements = getAllByText('banana');
expect(bananaElements).toHaveLength(1); // expect 'banana' to be on the list
});
この例は、関数を呼び出したときに状態がどのように変化するかをテストするものではありません。ユーザーがTextInput
内のテキストを変更し、Button
を押したときに何が起こるかをテストしています!
レンダリングされた出力のテスト
スナップショットテストは、Jestによって可能になる高度なテストの一種です。これは非常に強力で低レベルのツールなので、使用する際には特別な注意が必要です。
「コンポーネントスナップショット」とは、Jestに組み込まれたカスタムのReactシリアライザによって作成されるJSXのような文字列です。このシリアライザにより、JestはReactコンポーネントツリーを人間が読める文字列に変換できます。言い換えれば、コンポーネントスナップショットは、テスト実行中に生成されたコンポーネントのレンダリング出力のテキスト表現です。それは次のようになります。
<Text
style={
Object {
"fontSize": 20,
"textAlign": "center",
}
}>
Welcome to React Native!
</Text>
スナップショットテストでは、通常、最初にコンポーネントを実装し、その後スナップショットテストを実行します。スナップショットテストはスナップショットを作成し、それをリポジトリ内のファイルに参照スナップショットとして保存します。そのファイルはコミットされ、コードレビュー中にチェックされます。将来のコンポーネントのレンダリング出力への変更は、そのスナップショットを変更し、テストが失敗する原因となります。その後、テストをパスさせるためには、保存されている参照スナップショットを更新する必要があります。その変更は再びコミットされ、レビューされる必要があります。
スナップショットにはいくつかの弱点があります。
- 開発者やレビュー担当者にとって、スナップショットの変更が意図したものなのか、それともバグの証拠なのかを見分けるのが難しい場合があります。特に大きなスナップショットはすぐに理解しにくくなり、その付加価値は低くなります。
- スナップショットが作成された時点で、たとえレンダリングされた出力が実際には間違っていたとしても、それは正しいと見なされます。
- スナップショットが失敗したとき、変更が期待通りかどうかを適切に調査せずに、
--updateSnapshot
jestオプションを使用して更新したくなる誘惑に駆られます。したがって、ある程度の開発者の規律が必要です。
スナップショット自体は、コンポーネントのレンダリングロジックが正しいことを保証するものではありません。それらは予期せぬ変更から保護し、テスト対象のReactツリー内のコンポーネントが期待されるprops(スタイルなど)を受け取っていることを確認するのに優れているにすぎません。
小さなスナップショットのみを使用することをお勧めします(no-large-snapshots
ルールを参照)。2つのReactコンポーネントの状態間の変更をテストしたい場合は、snapshot-diff
を使用してください。疑問がある場合は、前の段落で説明したような明示的な期待値を優先してください。
エンドツーエンドテスト
エンドツーエンド(E2E)テストでは、ユーザーの視点から、デバイス(またはシミュレータ/エミュレータ)上でアプリが期待どおりに動作していることを検証します。
これは、アプリをリリース構成でビルドし、それに対してテストを実行することによって行われます。E2Eテストでは、もはやReactコンポーネント、React Native API、Reduxストア、またはビジネスロジックについて考えることはありません。それはE2Eテストの目的ではなく、E2Eテスト中にはアクセスすることさえできません。
代わりに、E2Eテストライブラリを使用すると、アプリの画面上の要素を見つけて制御することができます。例えば、実際のユーザーと同じようにボタンを実際にタップしたり、TextInput
にテキストを挿入したりできます。その後、特定の要素がアプリの画面に存在するかどうか、表示されているかどうか、どのようなテキストを含んでいるかなどについてアサーションを行うことができます。
E2Eテストは、アプリの一部が機能しているという最も高い信頼性を与えてくれます。トレードオフには以下のものが含まれます。
- 他の種類のテストと比較して、作成に時間がかかる
- 実行が遅い
- フレイキー(flakiness)になりやすい(「フレイキー」なテストとは、コードに変更がないのにランダムに成功したり失敗したりするテストのこと)
認証フロー、コア機能、支払いなど、アプリの重要な部分をE2Eテストでカバーするようにしてください。アプリの重要でない部分には、より高速なJSテストを使用してください。テストを追加すればするほど信頼性は高まりますが、同時に、それらの維持と実行に費やす時間も増えます。トレードオフを考慮し、自分にとって何が最善かを決定してください。
いくつかのE2Eテストツールが利用可能です。React Nativeコミュニティでは、DetoxがReact Nativeアプリ向けに調整されているため、人気のあるフレームワークです。iOSおよびAndroidアプリの分野で人気のある他のライブラリには、AppiumやMaestroがあります。
まとめ
このガイドをお読みいただき、何かを学んでいただけたことを願っています。アプリをテストする方法はたくさんあります。最初はどれを使えばよいか決めるのが難しいかもしれません。しかし、あなたの素晴らしいReact Nativeアプリにテストを追加し始めれば、すべてが意味をなしてくると信じています。何を待っているのですか?カバレッジを上げましょう!
リンク
このガイドは元々Vojtech Novakによって執筆され、全文が寄稿されました。