感想

業務でテストを書くことは今までも少しあったのですが、その際には「バグを早期発見することで、保守性が上げる」「長期的にはメリットがあるけど、短中期的にはコストがかかる」みたいな認識だったのと、自分自身クライアントワークだったので、そこまでフロントエンドのテストについて学んでいませんでした。

ですが最近は関わるプロジェクトが 5 年 / 10 年と続くようなプロジェクトに変わってきました。
そこで体験したこととして、テストに力を入れているかいないかで開発速度にかなり差がある気がしたのと、「いいテストコードを書くには、いい設計をしないと書けない」ということも学べたので、これを機にテストを学んでみようと思いました。

第六章まではテストについて色々説明が多かったので、軽く自分のメモとして残しています!

本書

https://www.amazon.co.jp/フロントエンド開発のためのテスト入門-今からでも知っておきた-吉井健文/dp/4798178187/ref=tmm_hrd_swatch_0?_encoding=UTF8&qid=&sr=

第一章: テストの目的と障害

テストを書く目的

事業の信頼

  • UI やシステムの障害は、サービスのイメージに直結する
    • それをテストにより未然に防ぐ

健全なコードを維持するため

  • リファクタした際に、他の箇所にも影響が意図せず出てしまうかも。
    • それが影響で、修正を見送ってしまったこともあるのでは??
  • テストコードを日常的に書く習慣がつけば、リファクタリングを行った際は、逐一テストを実行して、今の実装が壊れていないか確認する習慣がつく
  • テストコードがあるという安心感

実装品質に自信を持つため

  • テストコードが書きづらい時は、テスト対象に処理を詰め込みすぎ
  • 例えば UI コンポーネントに、表示分岐、入力バリデーション、非同期処理更新の三つが合体されているよりも、別々の方が責務が分かれていてテストも書きやすい

円滑なコラボレーションのため

  • これは実質ドキュメント代わりになると言っても変わりない気がした
    • 新しい開発者が入った際や、レビュワーに対して、仕様書プラス α の情報を教えられる
      • テストのタイトルと内容から
    • ただし、テストのテストのタイトルと内容が乖離している可能性もあるのでそれは注意

リグレッションを防ぐため

リグレッション(regression)とは、日本語で「回帰」「後戻り」「後退」といった意味を持つ英単語です。 システム開発などのシーンにおいては、修正したバグや不具合が復活したり、ソフトウェアのバージョンアップで機能が低下したりすることを指します。

  • モジュール単体の責務やテストはシンプルになる。
  • しかし、モジュール同士の依存によって、依存先の変更によって、リグレッションが発生しやすい構造ができてしまう。
    • モダンフロントエンドの課題

テストを書くと、時間が節約できる理由

  • 「ある開発者が実装した機能にバグが含まれている」場合の話
  • 機能実装に 16 時間、自動コミットに 4 時間かかる
    • 自動テストをコミットした場合
      • 4 時間のうちに同期バグを発見し、継承することができる
      • この場合かかった時間は 20 時間
    • 自動テストをコミットしなかった場合
      • かかった時間の合計は 16 時間
      • こっちの方が確かに早い
  • テストエンジニアによる手動テストの段階でバグが見つかった。
    • バグ報告のチケットを起票し、開発者に修正依頼をし、バグが修正されているか再検証する
    • いわゆる「手戻り」が発生する
      • この手戻りにはゆうに 4 時間はかかる
    • 自動テストをコミットしていなかったとしても、手戻りの時間を合算すると 20 時間以上はかかる
  • 前者と後者を比較すると、時間の差分はさほどないように見える
  • 資産として、「自動テストコードが残るか否か」に差が出る

テストを全員が書くためには?

  • コードが小さいうちにから方針を示しておくことで、どのように書けばよいかというのが共通認識が生まれる
  • 前例をコミットしておけば、テストに不慣れなメンバーでも、前例を参考にある程度テストが書けるようになる

第二章: テスト手法とテスト戦略

ここでは「フロントエンドのテスト範囲と目的」いついて学びました

テストの範囲

  • Web アプリケーションテストコードは、さまざまなモジュールを組み合わせて実装する
    例えば一つの機能を提供するためには、次のようなモジュール(システム)が必要

    1. ライブラリが提供する関数
    2. ロジックを伴う関数
    3. UI を表現する関数
    4. Web API クライアント
    5. API サーバー
    6. DB サーバー
  • フロントエンドの自走テストを書くとき、この 1 ~ 6 の内、「どこからどこまでの範囲をカバーしたテストであるか」を意識する必要がある

  • それらは大体次の四つに分類される

    1. 静的解析
    2. 単体テスト
    3. 結合テスト
    4. E2E テスト

静的解析

  • 隣接するモジュールの関連携の不整合」に着目したテスト
  • TypeScriptESLint による静的解析
  • 一つ一つのモジュールの内部検証だけでなく、2 と 3、3 と 4 の間などの検証

単体テスト

  • モジュール単体が提供する機能」に着目したテスト
  • 2 のみ、3 のみなどの検証
  • 独立した検証が行えるため、アプリケーションか同時には滅多に発生しないケース(コーナーケース)にの検証に向いている。
    • コーナーケースに限って、処理を中断した方が良いと判断されることがある
    • それが「どういった条件で例外をスローするべきか」という検討に、単体テストは役立つ
      • 「このような条件になり得ないか?」
      • 「なり得るならどう処理すべきか」
      • といった検討を重ねることにより、コードの考慮漏れに気付ける

結合テスト

  • モジュールをつなげることで提供できる機能」に着目したテスト
  • 1 ~ 4 まで、2 ~ 3 などの検証
    • 例:
      1. セレクトボックスを操作する
      2. URL の検索クエリーが変化する
      3. 検索クエリーの変化により、データ取得 API が呼ばれる
      4. 一覧表示内容が更新される
    • 「セレクトボックスを操作する」という一つのインタラクションで、最終的に「一覧表示内容が更新される」という処理までが実装される
    • ① を実行したら ④ が実行されるというテストが、この機能に着目した結合テスト
  • 範囲が広いほどテスト対象を効率よくカバーすることができるが、相対的にざっくりとした検証になる傾向がある

E2E テスト (End to End テスト)

  • ヘッドレスブラウザ + UI オートメーションで実施するテスト
    • 外部ストレージや連携するサブシステムを含むテスト
  • 入力内容に応じて保存された値が更新されるので、外部を跨いだ機能はもちろん、外部連携が正常に機能しているかを検証できる。
  • 3 ~ 6 を通す、最も広範囲な結合テストともいえる
  • アプリケーション稼働状況に忠実なテスト

テストの目的

テストタイプは検証目的に応じて設定され、テストタイプごとに適したテストツールが存在する
ツール単体で実現するものもあれば、組み合わせることで実現するものもある

機能テスト(インタラクションテスト)

  • 開発対象の機能に不具合がないかを検証するのがこのテスト
  • Web フロントエンドにおける開発対象機能の大部分は、UI コンポーネントの操作(インタラクション)が起点となる
    • そのため、インタラクション = 機能テストになることが多い
  • React などのライブラリで実装された UI コンポーネントにおいては、ブラウザなしでもインタラクションテストが実行できる環境が整っている
    • 「仮想ブラウザ環境」でテストを実行しているため
  • 本物のブラウザ API を使用することが重要なテストの場合は、ヘッドレスブラウザ + UI オートメーションを使用して自動テストを書く
    • スクロールやセッションストレージなどの機能は仮想ブラウザにおいて不十分
    • 例:
      • 最下部までスクロールすると、新しいデータロードされる
      • セッションストレージに保存した値が復元される

非機能テスト(アクセシビリティテスト)

  • 心身特性に隔てのない製品を提供できているかという検証が「アクセシビリティテスト
    • 例:
      • チェックボックスとしてチェックできる
      • エラーレスポンンスが表示された場合、エラー文言が読み上げ対象としてレンダリングされる
      • 表示している画面で、アクセシビリティ違反がないか調べる

リグレッションテスト

  • 特定観点から、前後の差分を検出して、想定外の不具合が発生していないかを検証するテストが「リグレッションテスト
  • Web フロントエンドにおける開発対象の大部分が見た目(ビジュアル)を持つ UI コンポーネントであることから、「ビジュアルリグレッションテスト」が重要視される
  • 例:
    • ボタンの見た目に、リグレッションがない
    • メニューバーを開いた状態に、リグレッションがない
    • 表示された画面に、リグレッションがない

テスト戦略モデル

  • 忠実生が高いテストを常に行いたいが、その分メンテナンス工数がかかったり、実行時間がかかったりする
    • テスト用の DB サーバーを用意する
    • 連携する外部システムのレスポンスを全て待つ必要がある

などもある

  • 「コスト配分」をどのようにして設計して最適化を行うかが重要

アイスクリームコーン型、テストピラミット型

  • E2E に一番重きを置く(アイスクリームコーン型)、
  • 単体テストに一番重きを置く(テストピラミット型)
  • アイスクリームコーン型はアンチと言われており、テストピラミット型が理想とされている

テスティングトロフィー型

  • Testing Library の作者である Kent C. Dodds が提唱している
  • フロントエンドが提供する機能は、ユーザー操作(インタラクション)を起点に提供される。
    そのため、ユーザー操作を起点とした結合テストを充実させることこそが、より良いテスト戦略になるという意図が込められている

第三章: はじめての単体テスト

テストの構成要素

テストの構成要素

  • test(テストタイトル, テスト関数)

    • 例:

      • test("1 + 2 は 3")
    • 第二引数のテスト関数にはアサーションを書く

      • アサーション: 検証値が期待値通りであるという検証を行う文
      test("1 + 2 は 3", () => {
        expect(検証値).toBe(期待値);
      });
      

テストグループの作成

  • 関連するいくつかのテストをグループリングしたい場合、describe関数が使える

闘値と例外処理

例外のスローを検証するテスト

  • 一例として、「範囲外の値を与えた場合、例外がスローされること」を検証するテストを書きたい
  • 例外が発生する関数 + toThorwを使う

https://jestjs.io/docs/expect#tothrowerror

用途別のマッチャー

真偽値の検証

  • 「真」= toBeTruthy
  • 「偽」= toBeFalsy
  • null = toBeNull
  • undefined = toBeUndefined

数値の検証

  • 等しい = toBe or toEqual

非同期処理のテスト

非同期処理のテストを書く場合に意識すること

  • 非同期処理を含むテストは、テスト関数を async 関数で書く
  • .resolves.rejects をふくすアサーションは await する
  • try…catch 分による例外スローを検証する場合、expext.assertionsを書く

第四章: モック

モックを使用する目的

  • テストは実際の実行環境と同じ状況に近づけることで、より忠実生の高いものになる
  • しかし、実行に時間がかかるものや環境構築が大変なものがある
    • Web API で取得したデータを扱う
    • Web API はネットワークエラーなどが原因で「失敗」することがある
    • そのため、「成功した場合」だけでなく「失敗した場合」も、テストが必要となってしまう
  • 必ず失敗するテストを、Web API 向けに書くのは良くない
  • 外部サービスの Web API だった場合、テスト向けの実装をすることはできない
  • テストしたい対象は Web API そのものではなく、「取得したデータに関連する処理」
    • Web API サーバーはテスト実行環境に必ずしも存在する必要はない
    • こういったケースで「取得したデータの代用品」として「モック(テストダブル)」を使う、

モックの用語整理

  • スタブ」「スパイ」: モック(テストダブル)をそれぞれの目的に応じて分類したオブジェクトのこと

スタブの目的

  • 「代用」を行うこと
    • 依存コンポーネントの代用品
    • 定められた値を返却するもの
    • テスト対象に「入力」を与えるためのもの
  • テスト対象が依存しているコンポーネントに、何らかの不都合がある場合に使用する
    • Web API に依存しているテスト対象を検証するとき
      • 「Web API からこんな値が返ってきた場合、このように動作する」というテストでスタブを使用する
      • テスト対象がスタブにアクセスすると、スタブは定められた値を返却する

スパイの目的

  • 「記録」を行うこと
    • 関数やメソッドの呼び出し回数を記録するオブジェクト
    • 呼び出された回数、実行時引数を記録するためのもの
    • テスト対象からの「出力」を確認するためのもの
  • テスト対象から外側に向けた出力の検証に利用する
    • 例えば、関数引数のコールバック
    • 関数が実行された「回数」「実行時引数」を記録しているので、意図通りの呼び出しが行われたかを検証で切る

モックモジュールを使ったスタブ

  • jest.mock をテストファイル冒頭で実行すると、対象モジュールの置き換え準備が実施される
  • 本来の実装を使う場合は、jest.requireActual() を使う

Web API のモック基礎

  • fetchers/index.ts を代用品に置き換える宣言
import * as Fetchers from "../fetchers";

jest.mock("../fetchers");
  • jest.spyOn(object, methodName)

    • これは TypeScript と親和性が高いそう
    • 第一引数で指定した fetcher の中にあるメソッドを、string で指定できる
      • その際にはstring リテラルになる

https://jestjs.io/docs/jest-object#jestspyonobject-methodname

  • mockResolvedValueOnce
    • TypeScript の型が効く

https://jestjs.io/docs/mock-function-api#mockfnmockresolvedvalueoncevalue

Web API のモック生成関数

  • 「モック生成関数」をここでは使用する
    • テストで必要なセットアップを、必要最低限のパラメーターで切り替え可能にしたユーティリティ関数
function mockGetMyArticles(status = 200) {
  if (status > 299) {
    return jest.spyOn(Fetchers, "getMyArticles").mockRejectedValueOnce(httpError);
  }
  return jest.spyOn(Fetchers, "getMyArticles").mockResolvedValueOnce(getMyArticlesData);
}

モック関数を使ったスパイ

  • スパイ: 「テスト対象にどのような入出力が生じたか?」を記録するオブジェクト

実行されたことの検証

  • jest.fnを使ってモック関数を作成する
    • 作成したモック関数は、テストコードで関数として使用できる
    • マッチャーのtoBeCalledを持って検証することで、実行されたか否かが判定できる

https://jestjs.io/docs/mock-function-api#jestfnimplementation

実行された回数の検証

  • toHaveBeenCalledTimes

https://jestjs.io/ja/docs/expect#tohavebeencalledtimesnumber

第五章: UI コンポーネントテスト

UI コンポーネントの基礎知識

  • Web フロントエンドにおける開発対象の大部分は UI コンポーネント
    • 表示のみを司るもの
    • 複雑なロジックを含むものもある

MPA と SPA の違い

  • 従来の Web アプリケーション構築: 「ページのリクエスト単位」に基づき、ユーザーと対話するアプローチが一般的
    • 複数の HTML ページと、HTTP リクエストで構築される Web アプリケーションは、MPA(Multi Page Application)と呼ばれる
    • MPA は、SPA(Single Page Application)と対比されることがある
  • SPA は一枚の HTML ページ上に、Web アプリケーションコンテンツを展開する。
    • Web サーバーがレスポンスした初回ページの HTML を軸とし、ユーザー層によって HTML を部分的に書き換える
    • この部分的に書き換える単位こそが UI コンポーネント

UI コンポーネントのテスト

  • 最小単位の UI コンポーネントはボタンなどが該当する

  • 小さな UI コンポーネントを組み合わせて中粒度の UI コンポーネントを構築する

  • 最終的にはページを表す UI ができあがる

  • もし何かの考慮漏れによって、中粒度の UI コンポーネントが壊れてしまったら

    • 運が悪ければページが壊れてしまう
    • アプリケーションが機能しなくなる
    • UI コンポーネントにテストが必要なのこのため
  • UI コンポーネントに求められる基本機能

    • データを表示すること
    • ユーザー操作内容を伝播すること
    • 関連する Web API を繋ぐこと
    • データを動的に書き換えること

必要なライブラリのインストール

  • jest-environment-jsdom

https://www.npmjs.com/package/jest-environment-jsdom

  • @testing-library/jest-dom

https://testing-library.com/docs/ecosystem-jest-dom/

  • @testing-library/react

https://testing-library.com/docs/react-testing-library/intro/

  • @testing-library/user-event

https://github.com/testing-library/user-event

  • 役割

    • UI コンポーネントをレンダリングする
    • レンダリングした要素から、任意の子要素を取得する
    • レンダリングした要素に、インタラクションを与える
  • 基本原則として、「テストがソフトウェアの使用方法に似ている」ことを推奨している

    • クリック/マウスオーバー/キーボード入力など
    • Web アプリケーションを操作するのと同じようなテストを書くことを推奨している

インタラクティブな UI コンポーネントテスト

  • fieldset要素は、暗黙のロールとして gruop ロールを持つ。
    • legend要素は、fieldset 要素の子要素として使用するもの
      • グループのタイトルをつけるための要素
    • legend 要素があることで、暗黙的にこのグループのアクセシブルネームが決まっている、ということがテストで検証できる
  • 同じ見た目でもdivは良くない
    • ロールを持たないため、アクセシビリティツリー上ではひとまとまりのグループとして認識できない
    • つまりテストを書くときも、このグループ(Agreement コンポーネント)をひとまりのグループとして特定することが困難
    • このように、UI コンポーネントのテストを書くことで、アクセシビリティへ配慮する機会が増える
type Props = {
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
};

export const Agreement = ({ onChange }: Props) => {
  return (
    <fieldset>
      <legend>利用規約の同意</legend>
      <label>
        <input type="checkbox" onChange={onChange} />
        当サービスの<a href="/terms">利用規約</a>を確認し、これに同意します
      </label>
    </fieldset>
  );
};
  • aria-labelledby 属性に<h2> 要素の ID を指定することで、アクセシブルネームとして引用させることができる

  • HTML の id 属性は、ドキュメント内で一意である必要がある

    • 重複しないように管理することが難しい値

    • React 18 で追加されたフックの useId が使える

      useId – React

    • アクセシビリティ観点で必要な id 値の自動生成、自動管理に便利

  • アクセシブルネームを与えることで、<form>要素は form ロールが適用される。

    • アクセシブルネームがない場合は、ロールを持たない
import { useId, useState } from "react";
import { Agreement } from "./Agreement";
import { InputAccount } from "./InputAccount";

export const Form = () => {
  const [checked, setChecked] = useState(false);
  const headingId = useId();

  return (
    <form aria-labelledby={headingId}>
      <h2 id={headingId}>新規アカウント登録</h2>
      <InputAccount />
      <Agreement
        onChange={(event) => {
          setChecked(event.currentTarget.checked);
        }}
      />
      <div>
        <button disabled={!checked}>サインアップ</button>
      </div>
    </form>
  );
};

UI コンポーネントのスナップショットテスト

  • UI コンポーネントに予期せぬリグレッションテストが発生していないかの検証として、スナップショットテストが活用できる
  • スナップショットテストを実行すると、ある時点のレンダリング結果を HTML 文字列として外部ファイルに保存できる

第六章: カバレッジレポートの読み方

カバレッジレポートの概要

  • テスティングフレームワークには「テスト実行によって対象コードのどのくらいの範囲が実行されたか」を計測し、レポートを出力する機能がある
    • このレポートを、「カバレッジレポート」という
    • jest にも標準で備わっている
  • npx jest --coverage
    • npx jest [ファイル名] --coverage

カバレッジレポートの構成

  • Stmts

    • 命令網羅率
    • テスト対象ファイルに含まれる「すべてのステートメント(命令)」が少なくても一回実行されたか
  • Branch

    • 分岐網羅率
    • テスト対象ファイルに含まれる「全ての条件分岐」が少なくとも一回通過したか、を示す分数。
    • if 分や case 分、三項演算子の分岐が対象
    • 重要な網羅率水準であり、条件分岐に対してテストが書かれていないことを発見することに役立つ
  • Funcs

    • 関数網羅率
    • テスト対象に含まれる「すべての関数」が少なくとも一回呼び出されたかを示す分数
    • プロジェクトで利用されていないが、export されている関数を発見するのに役立つ
  • Lines

    • 行網羅率
    • テスト対象ファイルに含まれている「すべての行」を少なくとも 1 回通過したか、を示す分数

参考

  • 本書の中で非同期処理についてオススメしていた記事

https://jsprimer.net/basic/async/

  • Testing Library
    • UI コンポーネントのテスト用ライブラリ

https://testing-library.com/