開発日報

窓際エンジニアの開発備忘。日報は嘘です。

【忘備】グーグルのソフトウェアエンジニアリング ~ ユニットテスト ② -脆いテストを防ぐ- ~

  • 脆いテスト: バグのない無害かつ無関係な変更で壊れるてすと。

変化しないテストを目指す

脆いテストを防ぐため、理想的には「変化しないテスト」を目指す。
 → 仕様変更以外の理由で変更しないテストのこと。

プログラム変更の要因

ここではテスト変更の要因のことを言っているわけではないことに注意。

  • 純粋なリファクタリング

    • 内部構造のリファクタリングのテストは、既存の仕様や振る舞いに影響を与えていないことを保証しているべき。
    • よって既存のテストへの変更が入ることは不適切
  • バグ修正

    • バグの存在は既存のテストコードにおけるケース漏れを意味する。
    • よって抜けていたテストケースの追加はあるものの、既存のテストへの変更が入ることは不適切
  • 新機能

    • 原則、新機能追加は既存機能の動作変更を伴うべきではない。
    • よって、リファクタリング同様新機能追加時に既存のテストへの変更が入ることは不適切である。
  • システム仕様・挙動の変更

    • テストによって担保されるべき挙動が変更されるので、この場合は既存テストへの変更が入る。

理想としては、システムを拡張する際には

これまでに書かれた全テストをいじらなければならない可能性があるわけではなく、行っている変更に関連する少数の新しいテストのみ書けばよい

状態にしておくべき。

公開APIに対するテスト

理想的にはテストは、要件が変化しない限りテストも変化しないことが望ましい。

テストの実装としては、、

  • テスト対象システムのユーザーが呼び出す方法と同じ方法でテストコードでもシステムを呼び出すコードを書く必要がある
    • つまり、プログラムの公開APIに対する呼び出しを行う

例1)テスト対象コード

public void  processTransaction(Transaction transaction {
  if (isValid(transaction)) {
    saveToDatabase(transaction);
  }
}

private boolean isValid(Transaction t) {
  return t.getAmount() < t.getSender().getBalance();
}

private void saveToDatabase(Transaction) {
  String s = t.getSender() + "," + t.getRecipient() + "," + t.getAmount();
  database.put(t.getId(), s);
}

public void setAccountBalance(String accountName, int balance) {
  // 残高(balance)を直接データベースに書き込む。
}

public void getAccountBalance(String accountName) {
  // アカウント残高を確定するためにデータベースからトランザクションを読み込む
}

このコードをテストするのが以下のコードである

例2)トランザクションAPIの簡潔なテスト

@Test
public void emptyAccountShouldNotBeValid() {
  assertThat(processe.idValid(newTransaction().setSender(EMPTY_ACCOUNT))
    .isFalse();
}

@Test
public void shouldSaveSerializedData() {
  processer.saveToDatabase(newTransaction()
    .setId(123)
    .setSender("me")
    .setRecipient("you")
    .setAmount(100));
  assertThat(database.get(123).isEqualTo("me,you,100));
}

このテストはテストコードからの呼び出しと、実際のユーザーからの呼び出しとではかなり違う形でやり取りしている。

このテストはシステム(テスト対象コード)の内部状態をのぞき込んで依存している(privateメソッドの中身までテストしている)ので、このテストは脆くなっている。

例3)公開APIのテスト

@Test
public void shouldTransferFunds() {
  processor.setAccountBalance("me", 150);
  processor.setAccountBalance("you", 20);
  processor.processTransaction(newTransaction()
    .setSender("me")
    .setRecipient("you")
    .setAmount(100));
  assertThat(processor.getAccountBalance("me")).isEqualTo(50);
  assertThat(processor.getAccountBalance("you")).isEqualTo(120);
}

@Test
public void shouldNotPerformInvalidTransactions() {
  processor.setAccountBalance("me", 50);
  processor.setAccountBalance("you", 20);
  processor.processTransaction(newTransaction()
    .setSender("me")
    .setRecipient("you")
    .setAmount(100));
  assertThat(processor.getAccountBalance("me")).isEqualTo(50);
  assertThat(processor.getAccountBalance("you")).isEqualTo(20);
}

このテストは、例1の公開API(パブリックメソッド)のみテストしている。このようなテストは実際のシステムの呼び出し側のコードと同じようなやり方でテスト対象コードにアクセスしている。

故に、このテストが破綻するときはシステムの既存ユーザー側の呼び出し元も破綻している場合のみのため、このテストは脆くない。

ユニットテストの適切な範囲の指針

  • 「ヘルパークラス」のような別クラスの支援のためのクラスは支援対象のクラスを通じてテストすべき。

    • ヘルパークラスを直接テストすべきではない(脆い)。
  • だれでもアクセス可能なように設計されているクラス(Utilクラス等)は直接テストされるべき

  • アクセスに制限があるが、特定の文脈で有用な機能を提供するクラス(サポートライブラリ)も直接テストされるべき。また、そのユーザー(使用箇所)へのテストもすべき。

真理

  • 公開APIへのテスト > 実装詳細のテスト

    • システムに対してある変更(仕様や挙動に関する)が起きたときのみテストを失敗させたい。
  • privateメソッドやヘルパークラスへのテストは脆い

    • 特定の仕様や振る舞いの内部構造に癒着(強く依存)したテストのため

相互作用ではなく状態をテストせよ

  • ステートテスト:システムのメソッドを呼び出した後、システムがどんな状態になっているかをテストする。

    • 内部構造(実装)に依存するテストではなく、脆くない。
  • インタラクションテスト:システム呼び出しに応じて期待される一連の動作を行ったか?

    • 脆い。APIではなく内部構造に依存したテストのため。
      • モックオブジェクトを多用するとより脆くなる
        • 本物のオブジェクトが望ましい。