見出し画像

Googleのソフトウェアエンジニアリング(品質に関する部分)を読んだメモ・感想/12章 ユニットテスト

こんにちは。kubopです。

QA活動をしていく中で、Googleのソフトウェアエンジニアリングの11章〜14章を読みました。読書メモを書いていたのですが、忘れないよう、思い出しながら感想や、まとめを書きます。

※ まだまだ理解しきれていない部分があるため、解釈が異なる点があるかもしれません。

前回はこちら

今回は12章、ユニットテストについて

テストの範囲: Scopeについておさらい

テストには、規模Sizeと、範囲Scopeがある。
今回は小範囲であるユニットテストについて。

小範囲

  • ユニットテストと呼ばれる。

  • 個別のクラス・メソッドのような口座名義の小さな注目部分のロジックを検証する。

中範囲

  • インテグレーションテストと呼ばれる。

  • 少数のコンポーネント間の相互作用を検証する。

  • サーバー⇄データベースなど。

大範囲

  • ファンクショナルテストと呼ばれる。

  • E2E、システムテストなどとも呼ばれる。

  • システムの相互作用や、単一クラス・メソッドでは表現出来ない挙動を検証する。

ユニットテストの目的

バグの防止と同じように、テストの最重要項目はエンジニアの生産性
より範囲の広いテストに比べて、ユニットテストは生産性最適化のための優れた手段になりうる。

ユニットテストの利点

  • 規模が小さく、高速で決定性がある。

  • 開発者がワークフローの一部として作成、頻繁に実行でき、フィードバックが即時に得られる。

  • 大規模なシステム構成の理解なしに作業中のコードにテスト対象を絞れる。

  • ユニットテストは素早く簡単に書けるので、カバレッジが高水準になることが多い。そのため、エンジニアは自身を持って変更を行える。

ユニットテストは、こうした利点のために日に何回も実行されることがある。
そのため、これらのテストには保守性がとても重要になる。

保守性のあるテストとは、「とにかく動作するもの」
テストを書いた後で、エンジニアはテストが失敗するまではそのテストについて再度考える必要がない。
そして、「テストが失敗 = 明確な原因のあるバグが存在する」ということが成り立つ。

保守性の重要さ

保守性のない脆いコードは、以下のようなことを引き起こす。

ある社員が数行の修正を行った際に、大量のテストエラーが発生する。
その数行の修正自体にバグはないはずなのに、テストが必要とする前提条件を破ってしまっているために多大なテストコードの修正が要される。

無害かつ無関係な変更に対し、テストが破綻を来す場合、
そのテストは
1. 脆く、
2. 不明確である
可能性がある。

そのような脆く、不明確であるテストは、エンジニアの生産性を削いでしまうことがある。

脆いテストとは

脆いテストは、本番のコードへの無関係な変更で、バグは何も持ち込まないのにもかかわらず失敗するテストのこと。

変更のたびにテストのセットをエンジニアが手作業で調整しなければいけないのならば、それは「自動テストスイート」と称することは出来ない。

変更しないテストを目指そう

古いテストの更新に費やす時間は、もっと価値のある仕事に費やすことができない時間になる。

理想のテストとは、変化しないテスト。つまり、テスト対象システムの要件が変更されない限り、書かれたテストコードは2度と変更する必要がない。

4種類のコードの変更理由

リファクタリング

テストの変更は許されない。
むしろ、テストコードを頼りに変更される。

新機能

既存テストを変更しなければならない場合は何かが間違っている。
新機能自体が、元々の仕様を壊している可能性がある。

バグ修正

テストの追加はあれど、変更は許されない。

挙動の変更

既存テストの変更を余儀なくされるため、コストが高い。

テストコードが変更されることが許されるのは、挙動の変更のみ。

テストが変化する必要がないことを証明する

要件が変更しない限り、テストコードには変化する必要がない、ことを証明する方法。

1. 公開API経由のテストをする。

テスト対象システムのユーザーが呼びだるのと、全く同じ方法で呼び出すテストを書くこと。
クラスの、Publicな外部に露出しているメソッドのみをテストすること。

しかしながら、「何が」、「公開API」を構成するかは明確ではない。
ユニット(Unit:単位・単体)とは、個別の関数程度に小さいか、あるいはいくつかの関連するパッケージ・モジュールのセット程度に広範である可能性がある。

補足: ユニットであることとは

  • ヘルパークラスであるならば、それ自体はユニットとはみなされない。

    • 実際はヘルパークラスを利用するクラスを通じてテストされるべき。

  • クラスが、誰でもアクセス可能なように設計されているなら、ユニットと捉えるべき。(この時テストは、別クラスからアクセスするのと同じように行う)

  • サポートライブラリー的なUtilクラスにおいてはユニットとみなされる。(ここらへんの解釈は間違っているかも…)

例えばあるヘルパークラスを作成する時は、そのヘルパークラスのテストはもちろん重要だけど、そのヘルパークラスを利用するユニットの公開APIのテストコードを書いた方が、コスパが良いということ。

2. 相互関係ではなく、状態をテストする。

システムが期待通りの挙動を行うこと検証するには2つの方法がある。

ステート(状態)テスト

システムそれ自体を観察して、システムのメソッドを呼び出した後に、どのような状態になっているのかを確認する。

インタラクション(相互作用)テスト

システムが呼び出しに応じて期待される一連の動作を対象オブジェクトに対して行ったかチェックする。

ステートテスト VS インタラクションテスト

ユニットテストが、テスト対象のコードが正しく動作していることを確認する方法は、一般的に2つあります。それは、状態をテストする方法と、インタラクションをテストする方法です。この2つの違いは何でしょうか?
状態をテストするということは、テスト対象のコードが正しい結果を返すかどうかを検証することです。

https://testing.googleblog.com/2013/03/testing-on-toilet-testing-state-vs.html

ステート(状態)テストの例:

public void testSortNumbers() {
  NumberSorter numberSorter = new NumberSorter(quicksort, bubbleSort);
  // 返り値がソートされたリストかどうかを判定する。
  //  正しい結果が返されるのであれば、どのソートアルゴリズムが使われても問題がない。
  assertEquals(
      new ArrayList(1, 2, 3),
      numberSorter.sortNumbers(new ArrayList(3, 1, 2)));
}

インタラクション(相互作用)の例: 

public void testSortNumbers_quicksortIsUsed() {
  // モッククラスを利用する。
  NumberSorter numberSorter = new NumberSorter(mockQuicksort, mockBubbleSort);
  numberSorter.sortNumbers(new ArrayList(3, 1, 2));
  // numberSorter.sortNumbers()がクイックソートを使用したことを確認する。
  // mockQuicksort.sort() が一度も呼び出されなかったり、
  // 間違った引数で呼び出されたりした場合は、テストは失敗する。
  verify(mockQuicksort).sort(new ArrayList(3, 1, 2));
}

2 番目のテストはコードカバレッジはよいのですが、ソートが正しく動作しているかどうかはわかりません。インタラクションを使ったテストがパスしたからといって、コードが正しく動作しているとは限りません。このため、多くの場合はインタラクションではなく状態をテストすることになります。

https://testing.googleblog.com/2013/03/testing-on-toilet-testing-state-vs.html

一般に、インタラクションをテストすべきなのは、 正しさがコードの出力内容だけでなく、その出力がどのように決定されるのかにも 依存している場合です。

相互作用をテストしたい他の例としては、どのようなものがあるでしょうか。

- テスト対象のコードがメソッドを呼び出す際に、 呼び出す数や順番が異なると副作用 (例: メールを一通だけ送りたい)、遅延 (例: 特定の数のディスク読み取りをしたい)、 マルチスレッドの問題 (例: 間違った順番でメソッドを呼ぶとデッドロックする) など望ましくない振る舞いが起こる可能性がある場合です。

相互作用をテストすることで、これらのメソッドが適切にコールされない場合にテストが失敗することを保証します。

- UI のレンダリングの詳細が UI のロジックから抽象化されている (MVC や MVP を使用している場合など) 場合にテストを行います。
コントローラ/プレゼンターのテストでは、ビューの特定のメソッドがコールされたことだけを気にし、実際に何がレンダリングされたかを気にしないので、ビューとのインタラクションをテストすることができます。同様に、ビューをテストする際に、コントローラ/プレゼンターとのやりとりをテストすることができます 。

https://testing.googleblog.com/2013/03/testing-on-toilet-testing-state-vs.html

基本的にはステートテストを行い、どのような状態になっているのかを確認するテストのほうが、インタラクションテストよりも脆くなくなる。

3. 明確なテストを書く。

テストの失敗は、2つの理由のうち一方が発生することで起こる。

  • システムに問題があるか、不完全。

  • テスト自体に欠陥がある。

テストが失敗した場合、以上2つのうちどちらに属するかはエンジニアは判断しなければならないが、
その判断の速度は、テストの明確性に依存している。

テストの明確性は、テストコード自体がドキュメントの役割を果たしたり、新しいテストの基礎としての役割を果たす。

テストは完全、かつ簡潔にする

テストが明確性を達成するには、完全性と簡潔性が必要な属性。

ユニットテストは、私たちのコードが正しいことを確認するための重要なツールです。しかし、良いテストを書くということは、単に正しさを検証するだけではありません。良いユニットテストは、読みやすく保守しやすいように、他のいくつかの特性を示す必要があります。

良いテストの特性のひとつは、明快さです。
明瞭さとは、テストが人間にとって読みやすいドキュメントであり、 テストされるコードをその公開 API の観点から説明するものであるべきだということです。
テストは、実装の詳細について直接言及すべきではありません。クラスのテストの名前は、そのクラスが行うすべてのことを表すべきであり、 テスト自身はそのクラスの使い方の例となるべきものです。

さらに重要な性質として、完全性と簡潔性のふたつがあります。テストが完全であるとは、それを理解するために必要なすべての情報が含まれていること、 そして簡潔であるとは、他の邪魔な情報が含まれていないことです。このテストは、その両方において失敗しています。

https://testing.googleblog.com/2014/03/testing-on-toilet-what-makes-good-test.html
@Test public void shouldPerformAddition() {
  Calculator calculator = new Calculator(new RoundingStrategy(), 
      "unused", ENABLE_COSIN_FEATURE, 0.01, calculusEngine, false);
  int result = calculator.doComputation(makeTestComputation());
  assertEquals(5, result); // 5という数字はどこから来たのか・・・!?
}

多くの邪魔な情報がコンストラクタに渡され、重要な部分は不明確です。
ヘルパーメソッド(doComputation)の目的を明確にすることでテストはより完全なものになり、Calculatorを構築するのに無関係な詳細を隠すために別のヘルパーを使用することでより簡潔なものにすることができます。

https://testing.googleblog.com/2014/03/testing-on-toilet-what-makes-good-test.html
@Test public void shouldPerformAddition() {
  Calculator calculator = newCalculator();
  int result = calculator.doComputation(makeAdditionComputation(2, 3));
  assertEquals(5, result);
}

テストの本体部分は、重要でない情報は含まずに、テストを理解するのに必要な情報だけを全て含むべき。

メソッドではなく、挙動をテストする

よく、コードのメソッドに呼応したテストコードを書くことがあるが、時間の経過とおともに問題になることがある。
テストされるメソッドが複雑になるにつれて、そのテストの複雑性も増大してしまい。実際はなにをしているかがわからなくなる。

↓このテストは、どんなシナリオでしょうか?

TEST_F(BankAccountTest, WithdrawFromAccount) {
  Transaction transaction = account_.Deposit(Usd(5));
  clock_.AdvanceTime(MIN_TIME_TO_SETTLE);
  account_.Settle(transaction);


  EXPECT_THAT(account_.Withdraw(Usd(5)), IsOk());
  EXPECT_THAT(account_.Withdraw(Usd(1)), IsRejected());
  account_.SetOverdraftLimit(Usd(1));
  EXPECT_THAT(account_.Withdraw(Usd(1)), IsOk());
}
  1. 5ドル持っていて、5ドル引き出せた。

  2. その後1ドルを引き出そうとして拒否された。

  3. しかし、1ドルの上限で当座貸し越しを有効にすると1ドル引き出せる。

このテストは、1つではなく3つのシナリオをテストしている。

TEST_F(BankAccountTest, CanWithdrawWithinBalance) {
  DepositAndSettle(Usd(5));  // 共通の設定は、ヘルパーメソッドに抽出しよう。
  EXPECT_THAT(account_.Withdraw(Usd(5)), IsOk());
}
TEST_F(BankAccountTest, CannotOverdraw) {
  DepositAndSettle(Usd(5));
  EXPECT_THAT(account_.Withdraw(Usd(6)), IsRejected());
}
TEST_F(BankAccountTest, CanOverdrawUpToOverdraftLimit) {
  DepositAndSettle(Usd(5));
  account_.SetOverdraftLimit(Usd(1));
  EXPECT_THAT(account_.Withdraw(Usd(6)), IsOk());
}

この方法でテストを書くと、多くの利点があります。

- 各テストメソッドで読むべきコードが少ないので、ロジックを理解しやすい。
- 各テストのセットアップコードは、単一のシナリオにのみ対応する必要があるため、よりシンプルになります。
- あるシナリオの副作用が、後のシナリオの仮定を誤って無効化したり覆い隠したりすることがない。
- あるテストのシナリオが失敗しても、他のシナリオは失敗の影響を受けないので、実行される。
- テスト名が各シナリオを明確に表現しているため、どのシナリオが存在するのかを容易に知ることができる。

https://testing.googleblog.com/2018/06/testing-on-toilet-keep-tests-focused.html

各メソッドではなく、各々の挙動に対してテストを書くべき。
挙動は

〜という条件下で(given)
〜である場合(when)
〜その場合は(then)

と言ったように表現すると良い。
メソッドと挙動の対応付けは、N対Nである。

挙動駆動のテストは、メソッド駆動のテストより明確となる傾向にある。
挙動駆動のテストは自然言語を読むに比較的近い形で読めるため、脳の負荷が低い。

補足: 自然言語により近い方法でテストを書こう。
原因と結果を明確にするテストを書く方法。

挙動を強調するようにテストを構成する

挙動駆動でテストが作成される場合に、全ての挙動に3つの部分が存在する。

  • 〜という条件下で(given)

    • システムがどのように構成されるかを定義する。

  •  〜である場合(when)

    • システムに対して行われる動作を定義する。

  • 〜その場合は(then)

    • 結果を検証する。

Rspecであれば、describe、context、itのような感じ。

テストされる挙動に因んだ命名をする

メソッド駆動のテストでは、そのメソッドに因んで命名される。
(例えばupdateBalanceは、testUpdateBalanceとなる)

優れた名称は、システムに対して行われる動作と、期待される結果の両方を説明する。

例えば、BankAccountクラスのテストでshouldNotAllowWithdrawalsWhenBalanceIsEmptyと命名されているものは
「BankAccountは口座残高が空の場合は引き出しを許可するべきではない」ということがメソッド名から自明。
また、このような名称は、「and」がついた時点で複数の挙動をテストしている、ということがわかるようになり、適切なテストの分割を促す。

テストにロジック入れない

ロジックは、演算子・ループ・条件分岐の、プログラミング言語の命令的な要素である。
あるテストコードがロジックを含む場合は、その結果を確定するために脳内での計算が必要になる。

明確な失敗メッセージを書く

どのようにテストが書かれるかも重要であるが、失敗する際にエンジニアが何を見るかを大切にすると良い。
理想は、テストが失敗した際にテスト自体を見る必要なしに、ログの失敗メッセージを読むだけで問題の原因がすぐにわかる。
失敗メッセージは、期待される結果・実際の結果・関連する要因全てを明確に表現されているべき。


誤: "テスト失敗: アカウントが閉鎖されている"
正: "[閉鎖]状態のアカウントを期待したが、以下のアカウントを得た。
        名前: `hogehoge account`、状態: `開放`"

テストコードは、DRYではなくDAMP

通常、コードはDRYが推奨されているものの、テストコードではコードが複雑になることでコストが増えてしまう。
システムのコードは再利用・変更のしやすさのためにDRYなコードを書くことが多いが、テストコードの場合は明確性を高めるためにDAMP(Descriptive And Meaningful Phrases)を目指すべき。

ドメイン特有の用語を理解するのに訓練が必要な場合は、改善の余地があることを示している。
理想的なドメイン独自の用語は、説明を必要としないほど説明的で意味のあるフレーズを含んでいます。

http://blog.jayfields.com/2006/05/dry-code-damp-dsls.html

共有値

テストで利用されるデータの共有の値をセットとして定義することはテストスイートが多くなるにつれて、どの値が選択されたのか、どれが利用されているのかを理解することが難しくなる。

この場合は、テストの作成者が関心のある値のみの指定を要求し、デフォルト値があるヘルパーメソッドを用いてデータを構築することが望ましい。

初期設定の共有

テストが実行されるより前に、テストに必要なオブジェクト郡を先に宣言しておく。
しかし、この場合は何百行に渡るテストがある場合に、どの値がどこでセットしたのかが不明確になる可能性がある。
そのため、初期設定メソッド内で定義されたデフォルト値をオーバーライドしてしまって利用すると、わかりやすくなる。

結論・まとめ・思ったこと

小範囲、ユニットテストでは、保守性が大切。

あまり関係のない部分でユニットテストが暴発することはたまにある。
例えば、テストコード内で、とあるイベントをフックし、エラーを発生させるなど。本番コード内部の挙動に依存している時とか。

挙動の変更以外では、テストコードを変更する必要がない。

意外と意識していないと、テストコードもいじりがちかも…。

ユニット単位を明確に定義し、その公開メソッドをテストする。

ユニットをどう定義するかは個々の判断によりそうな感じがするので、組織内で明確に定義した方が良いかもなと思う。

主に状態のテストをする。

これはある程度できてそう。

明確で挙動駆動のテストコードを書く。

ほとんど意識できていなかった部分。
ユニットテストとはいえ、メソッドを直にテストすることが多かった。

ロジックは避け、説明的で意味のあるテストコードを書く。

これは一番できていないと感じた。
ついついDRYになってしまったり、繰り返し部分を共通化しようと躍起になってしまう。

長い…!
Google Testing Blogの内容は切り出して一つの記事にしてもよかったかも…!


この記事が気に入ったらサポートをしてみませんか?