サバンナ便り ~ソフトウェア開発の荒野を生き抜く~

第9回自動テストの実行結果
~意思決定と行動を促す情報としての役割~

WEB+DB PRESS休刊に伴い、今回からWeb上で連載を継続させていただくことになりました。今後とも何卒よろしくお願いします。さて、あらためて本連載の最近の連載のテーマを振り返りますと、それは「信頼性の高い実行結果に短い時間で到達する自動テスト群を組み上げ、ソフトウェアの成長を持続可能なものにする」となります。今回はそのなかから「実行結果」に光を当てます。

多くのテスティングフレームワークには実行結果の出力フォーマットを変更するオプションやプラグイン機構があり、自動テストはその実行結果を様々なフォーマットで出力します。それらテストの実行結果は「情報」であり、情報の役割とは意思決定と行動を促すことです。テストの実行結果が促す行動とはデプロイ、マージ、コードの修正などです。今回は、そのようなテスト実行結果出力の種類と目的についてまとめます。

信号機としてのテスト出力

意思決定から行動へつなげる最初の判断材料は、実行した自動テストがすべて成功したかどうかです。自動テスト群がすべて成功した場合、次の行動として、プルリクエストやブランチのマージ、ステージング環境や本番環境へのデプロイなどに進みます。

このため、自動テストの出力にまず求められるのは、実行したテスト群がすべて成功したのか、それとも失敗したテストがあったのかを明確に伝えることです。実行結果が全件成功であったかどうかがすぐにわかると、実行結果全体を詳細に見なくとも、次の行動を判断しやすくなります。このような信号機としての役割には、情報の受け手が人間の場合は色が、コンピュータの場合は終了コードがよく使われます。

人間はテスト実行結果の色で判断します。多くのテスティングフレームワークでは失敗を赤、成功を緑でプログラマに結果を伝えます。実行結果のサマリー部分が緑色で表示されていれば成功、赤色で表示されていれば失敗と瞬時に判断できます。

コンピュータはコマンドの終了ステータスコードで判断します。多くのテスティングフレームワークは自動テストのコマンド成功時に0、失敗時には1以上を返します。結果の成否がコンピュータに判断しやすく、後続のコマンドにUNIXのパイプ等でつなぎやすくなります。

問題箇所の特定のためのテスト出力

失敗した自動テストがあるときの次の行動は、問題箇所の特定と修復です。このため、失敗時の出力に求められるのは、迅速に問題箇所の特定と原因究明を行い、修復に向かうための情報源となることです。知りたいのは「何が」⁠どこで」⁠どのように」失敗したかです。それらがテスト結果からすぐにわかれば、問題のさらなる絞り込みやデバッグ作業などを経由することなく、コードの修正に迅速に着手できます。

何が失敗したか

「何が失敗したか」とは、どのテストケースが失敗したかということです。自動テストの世界ではテストケースはテスト用の関数やメソッドとして書きますので、どのテスト関数/テストメソッドが失敗したのかがすぐにわかれば次の行動に進めます。

「何が失敗したか」の絞り込みやすさは、自動テストのファイル名、テスト関数/メソッド名、テストコードの構造などを工夫することで改善されます。詳しくは連載の第7回 テストコードの認知負荷 ~テストの名前、構造、情報量を工夫する~を参考にしてください。

どこで失敗したか

「どこで失敗したか」とは、プロダクトコード(テスト対象のコード)のどの場所が原因で自動テストが失敗したかということです。患部はすぐにわかる場合もあれば、わかりにくい場合もあります。⁠どこで失敗したか」には、実は2つの種類があります。微細な違いでもあるので、最近の自動テストではこの2つを区別しないことも多くなってきましたが、本稿では議論を明確化するために分けて説明します。

  • Execution Error: テスト実行中にプロダクトコードから発生する実行時エラー
  • Assertion Failure: テストコードの中に書いた表明(アサーション)の失敗

Execution Errorは、テスト実行中にプロダクトコードから発生した実行時エラーです。プロダクトコードの変更直後などによく発生しがちで、単純なミスのことも多くあります。Execution Errorの患部はスタックトレースやログに現れます。

Execution Errorの問題箇所の絞り込みやすさは、テストサイズを小さくすることによって改善されます。テストサイズが小さくなるにつれてExecution Errorが記録されたスタックトレースやログにアクセスしやすくなるからです。特にテストサイズがSmallの場合は患部がテスト実行時のスタックトレースに直接現れるので、問題箇所の特定が非常に容易になります。これはテストサイズを小さくする利点です。テストサイズに関しては連載の第3回 テストサイズ ~自動テストとCIにフィットする明確なテスト分類基準~を、テストサイズを小さくする方法に関しては、連載の第6回 自動テストのサイズダウン戦略 ~テストダブルを作る前に考えるべきこと~をご覧ください。

Assertion Failureは、テストコードの中に書いた表明(アサーション)の失敗です。プロダクトコードの実行には成功していますが、結果が期待したものではないという状況です。テストが失敗した機能が開発中のものであれば機能がまだ完成していないことを示し、開発後のものであれば欠陥が混入したことを示します。

Assertion Failureが発生した場合、患部はプロダクトコードのどこかにありますが、テストの実行エラーは発生していないので患部がどこにあるかはわかりません。このため修復に必要な時間もわかりませんが、問題箇所の絞り込み速度はテスト範囲(スコープ)を狭くすることによって改善されます。テスト範囲は本連載ではまだくわしく説明していませんが、一般的にはスコープが狭い順にユニットテスト、インテグレーションテスト、E2Eテストなどと呼ばれています。テスト対象となるコードの範囲が狭ければ、たとえ患部がわからなくとも、問題箇所の探索範囲も狭くて済みます。

どのように失敗したか

「どのように失敗したか」は、Execution Errorの場合はどのような例外やエラーが発生したかということであり、Assertion Failureの場合はテストの中に書かれている期待や予想と実際の実行結果がどのくらい違ったのかということです。

「どのように失敗したか」の把握しやすさは、Execution Errorに関しては自動テストの設計で改善できる余地はあまりありませんが、Assertion Failureの場合は、期待値と実測値の差分を適切に表示することで改善されます。これはテストコードの中で使用する検証用の関数(assertionやexpectation)や、モックライブラリ等の適切な活用によって実現できます。代表的な例として、アサーションの使い方の悪い例と良い例、それぞれのコードと失敗例を見てみましょう(コードの詳細は適宜省略しています⁠⁠。

悪い例のコード(Java)
@Test
void 税込価額を税率ごとに区分して合計した金額に対して税額を計算し端数は切り捨てること() {
    var inv = createSimplifiedInvoice();
    // 中略
    var tax = inv.tax();
    assertTrue(tax.reduced() == 40);
}
悪い例の出力
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
  at tdd.SimplifiedInvoiceTest.税込価額を税率ごとに区分して合計した金額に対して税額を計算し端数は切り捨てること(SimplifiedInvoiceTest.java:15)

悪い例ではAssertion Failureの際の情報量が足りません。trueを期待していたがfalseが返ってきた程度の情報量しかないのでテスト対象から実際にどのような値が返ってきたのかわからず、判断が難しくなってしまいます。情報量を増やすためには、より詳しいアサーションに書き直して再実行しなければなりません。

良い例のコード(Java)
@Test
void 税込価額を税率ごとに区分して合計した金額に対して税額を計算し端数は切り捨てること() {
    var inv = createSimplifiedInvoice();
    // 中略
    var tax = inv.tax();
    assertEquals(40, tax.reduced());
}
良い例の出力
org.opentest4j.AssertionFailedError: expected: <40> but was: <39>
  at tdd.SimplifiedInvoiceTest.税込価額を税率ごとに区分して合計した金額に対して税額を計算し端数は切り捨てること(SimplifiedInvoiceTest.java:15)

良い例では、テスト対象から返ってきた39という値が期待値である40と一致しないためテストが失敗していることがわかります。期待値と実測値の値の差が1なので、テスト名から判断するに、税額の切り捨てまわりのロジックを怪しいとにらんで確認するという行動に移れそうです。なお、アサーション関数の引数の順番に注意してください。期待値と実測値の順番は、残念ながら言語やフレームワークによって異なります。ここで例として挙げているJUnitにおいては、期待値を第1引数、実測値を第2引数に渡します。

アサーションの使い方を誤っても成功時の情報量はほぼ変わりませんが、失敗時の情報量は大きく変わります。いざテストが失敗したときに役に立たないテストになってしまわないように注意しましょう。

ドキュメントとしてのテスト出力

情報を取得する目的には、開発の進捗の把握や、テストケースの網羅性の評価といった観点もあります。例えば機能開発ブランチやGitHubのプルリクエストで開発しているときに、開発対象の機能はどのような仕様か、その機能がどこまでできあがっているか、テストは十分かなどを評価するために、テスト実行結果を使うこともできます。

テストケースの構造や名前に沿った出力を行うフォーマットを選択すると、開発中に自分で確認したり、開発がある程度進んでから第三者にレビューしてもらったりといった用途に使えます。テスト実行結果をドキュメントとして活用しやすくするポイントは、テストの構造や名前に気を配ることです。詳しくは連載の第7回 テストコードの認知負荷 ~テストの名前、構造、情報量を工夫する~を参照ください。

私がGitHub上で開発しているextract-git-treeishのテストをspec フォーマット⁠⁠ と呼ばれる形式を指定して出力した実行結果の一部を例に挙げます。テスト実行結果がそのまま仕様のツリーになっていることが読み取れるのではないでしょうか。これをさらにMarkdown形式で出力されるように工夫すると、そのままREADMEなどにドキュメントとして貼り付けられます。

> extract-git-treeish@3.1.0 test:spec
> mocha test --reporter spec

  `exists({ treeIsh, [gitProjectRoot], [spawnOptions] })`: Inquires for existence of `treeIsh`
    returns `Promise` which will:
      ✔ resolve with `true` when tree-ish exists
      ✔ resolve with `false` when tree-ish does not exist
    `treeIsh`(string) is a name of a git tree-ish (commit, branch, or tag) to be inquired
      when `treeIsh` argument is omitted:
        ✔ throw TypeError
      when `treeIsh` argument is not a string:
        ✔ throw TypeError
    `gitProjectRoot`(string) is an optional directory path pointing to top level directory of git project
      when `gitProjectRoot` option is omitted:
        and when `process.cwd()` is inside the git project:
          ✔ resolves as usual
        and when `process.cwd()` is outside the git project:
          ✔ returns `Promise` which will reject with Error
      when specified `gitProjectRoot` is pointing to git project root:
        ✔ resolves as usual
      when specified `gitProjectRoot` is not a git repository (or any of the parent directories):
        ✔ returns `Promise` which will resolve with `false`
      when specified `gitProjectRoot` is pointing to directory that does not exist:
        ✔ returns `Promise` which will reject with Error
      when `gitProjectRoot` argument is not a string:
        ✔ throw TypeError when number
        ✔ throw TypeError when boolean

データとしてのテスト出力

ここまでは「情報」としてのテスト実行結果の出力に注目してきましたが、機械可読なフォーマットでテストの実行結果を保存することで、テスト実行結果を「データ」として扱い、将来の意思決定に使うこともできます。例えば、テストの実行履歴として保存してレポートを取得したり、実行結果の不安定さからテストケース毎の信頼不能性(flakiness)の計算を行ったりといった用途があります。データとして活用するためのテスト実行結果出力フォーマットは、代表的なものとしてはJUnit XML形式と呼ばれているものや、TAP(Test Anything Protocol)などが挙げられます。

コードカバレッジ(自動テストがプロダクトコードのどこをどの程度カバーしているか)の取得もデータとしてのテスト実行結果の活用と言えるでしょう。自分たちのテストがどのくらい対象をカバーしているかをデータとして保存し続けることで、開発が健全に進んでいるかどうかを把握しやすくなります。コードカバレッジをデータとして取得するためのフォーマットとしては、LCOVなどの行指向テキストや、Cobertura形式に代表されるXMLなどが挙げられます。

おわりに

意思決定と行動を促すための情報という観点で自動テストの出力を捉えましょう。

おすすめ記事

記事・ニュース一覧