よしたろうブログ

設計・人文知・歴史・哲学・漫画とかの話が好きです。

JUnitで学ぶユニットテストの実践的で本質的な考え方 〜後編〜

本記事は以下の記事の後編になります。

またこちらの記事のブラッシュアップ版として以下の記事を書きました。

こちらの方が私にとっては、より質の高い記事だと思ってますのでこちらをご覧いただけると幸いです。以下のような違いがあります。

追加・変更事項

  1. ユニットテスト対象のシステム・コンポーネントの、依存先に対するスタンス
  2. 依存先をモック(テストダブル)で置換する際の線引き。前記事では基本的に全てをテストダブルで置換するスタンスで書きました。本記事ではできる限りテストダブルで置換しない、というスタンスで記事を再構成ています。
  3. 全体的にブラッシュアップし、より多方面をカバー
  4. 参考資料をより多く熟読し、様々な視点から記事を修正・変更・再構成・追加しました。
  5. カバレッジリファクタリングについて
  6. ユニットテストリファクタリングについて
  7. モックなどのテストダブルについて
  8. FIRST の概念をE2Eなどの考え方にまで拡張 etc....

目次はこちら

それでは続きをどうぞ。

5. テストクラスを構造化する

あるユニットテストではテスト対象となるクラスごとに対応するテストクラスを作成しますが、テストケースが増加してくるとテストコード自体の多さに認知的負荷が増大していきます。 また、テストコードは似たようなコードの繰り返しが多くなりがちです。なのでテストクラスを構造化することで可読性を高く保つ必要が出てきます。その際、構造化する上でグループ化を行います。その基準としてAAAを紹介します。まずは以下のコードを見てみましょう。

引用元:JUnit 5 チュートリアル - 単体テストの書き方を学ぶ

テストしたいクラス

package com.vogella.junit5;

public class Calculator {

    public int multiply(int a, int b) {
        return a * b;
    }
}

上記クラスのテストクラス

package com.vogella.junit5;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;

class CalculatorTest {

    Calculator calculator;

    @BeforeEach                                                // ❶
    void setUp() {
        calculator = new Calculator();
    }

    @Test                                                      // ❷
    @DisplayName("掛け算の結果が、20になる")                                      // ❸
    void testMultiply() {
        assertEquals(20, calculator.multiply(4, 5),            // ❹ 
                "Regular multiplication should work");         // ❺
     }

    @RepeatedTest(5)                                           // ❻    
    @DisplayName("0の掛け算の結果は、必ず0になる")
    void testMultiplyWithZero() {
        assertEquals(0, calculator.multiply(0, 5), "Multiple with zero should be zero");
        assertEquals(0, calculator.multiply(5, 0), "Multiple with zero should be zero");
    }
}
  • ❶. @BeforeEach でアノテーションが付けられたメソッドは、各テストの前に毎回実行されます。
  • ❷. @Test アノテーションが付けられたメソッドは、テストメソッドとして定義されます。
  • ❸. @DisplayName を使用して、ユーザーに表示されるテストの名前を定義できます。
  • ❹. これは、期待値と実際の値が同じであることを検証する assert メソッドです。
    • ❺. そうでない場合、メソッドの最後にメッセージが表示されます。なくても問題ありません。
  • ❻. @RepeatedTest は、このテストメソッドが複数回 (この例では 5 回) 実行されることを定義します。

5-1. @Before と @After について

@BeforeAll/@BeforeEach

テストごとに行われる初期化や事前条件準備などの処理は @Before メソッドを使用します。@Beforeメソッドはセットアップメソッドとも呼ばれます。関連性のある一連のメソッドについてテストを追加してゆくと、複数のテストで同じ初期化の処理が行われているということがよくあります。このような場合に @Before メソッドを使うと、冗長なコードを集約し一元的に管理できます。

また、テストメソッドが正常に動作するための事前条件として、必要なデータをDBに準備しておくことが多いのではないでしょうか? またDBの初期化(データの削除)などもこちらで実行するのが一般的な使い方の様です。

@AfterAll/@AfterEach

テストメソッドで出来上がったものの削除が主な用途の様です。

5-1-1. @DatabaseSetup と @ExpectedDatabase について

DBUnitというライブラリを利用すると、DBのデータ検索・追加・更新・削除のテストを実施できるようになります。このライブラリには、

  • テスト前にテストデータを設定する@DatabaseSetup
  • テスト後のデータを検証する@ExpectedDatabase

があります。DBへのCRUDに関しては、これを使用すると上記で紹介した @Before と @After への前処理・後処理の記述が必要なくなります。。また、テスト実施後の実際値が期待値になっているかも検証してくれるので assert の記述量を減らしてくれます。

このアノテーションを使うとCRUD関係のユニットテストの正確性と効率性が大きく上昇するので一つの選択肢として覚えておくべきでしょう。この方法では CSV/xml/xlsx などのファイルを事前に準備し、それを元に事前条件のデータをDBに投入します。投入前に対象DBの対象テーブルの初期化も行ってくれるので一意制約系のエラーも起こりません。また、検証作業も用意したファイルの値を期待値とし、テストメソッド実行後の実際値とを検証してくれます。

5-2. AAAの構造に沿った記述を通じて、テストに視覚的な一貫性を与える

  • Arrange(前提条件、事前準備)
  • Act(テスト対象の実行)
  • Assert(想定結果と実行結果の比較検証)

このような構成は、頭文字を取ってAAAあるいはトリプルAと呼ばれます。

Arrange(前提条件、事前準備)

テストが実行される際に、システムが適切な状態にあることを保証します。オブジェクトを生成してテスト対象とテストクラスでやりとりできる様にしたり、他のAPIを呼び出したりといった処理がここで行われます。システムがすでに適切な状態であるために準備が不要だということもあります。

Act(テスト対象の実行)

テストのコードを実行します。 テスト用のメソッドを呼び出すことによって、テストが開始されます。

Assert(想定結果と実行結果の比較検証)

テスト対象のコードが正しくふるまったかどうか確認します。 コードからの戻り値や、テストにかかわったオブジェクトの状態などがチェックされます。テスト対象のコードと他のオブジェクトとの間で、やりとりが発生したかどうかを調べるということもあります。

それぞれの部分の間には、必ず空行をはさむようにしましょう。 これによって、テストの構造を視覚的にも理解しやすくできます。
4つ目の構成要素として After が追加されることもあります。

After(事後処理)

何らかのリソースを割り当ててテストを実行した場合、その終了後にリソースを解放します。

上記の参考コードは、AAAの構造になっていません。必ずしも当てはめなくてはいけないわけではないのです。AAAの構造にしてみると以下の様になります。

AAA構造のテストクラス(パッケージ・インポート省略)

class CalculatorTest {
    
    // Arrange(前提条件、事前準備)
    Calculator calculator;

    @BeforeEach                                         
    void setUp() {
        calculator = new Calculator();
    }

    // Act(テスト対象の実行)
    Integer multiplyResult = calculator.multiply(4, 5);
    Integer zeroMultiplyResultPatternFirst = calculator.multiply(5, 0);
    Integer zeroMultiplyResultPatternSecound = calculator.multiply(0, 5);

    // Assert(想定結果と実行結果の比較検証)
    @Test                                               
    @DisplayName("掛け算の結果が、20になる")
    void testMultiply() {
        assertEquals(20, multiplyResult, "Regular multiplication should work");  
    }

    // Assert(想定結果と実行結果の比較検証)
    @RepeatedTest(5)                                    
    @DisplayName("0の掛け算の結果は、必ず0になる")
    void testMultiplyWithZero() {
        assertEquals(0, zeroMultiplyResultPatternFirst, "Multiple with zero should be zero");
        assertEquals(0, zeroMultiplyResultPatternSecound, "Multiple with zero should be zero");
    }

    // After(事後処理)
    /*
     * インスタンスの破棄やファイルのcloseなど
     * 必要があれば処理を書く
     */
}

5-2-1. 構想化のアノテーション

  • @Nested
    • ネスト構造を表現するマーカー

インナークラスを作成して @Nested を付与

public class PointCalculatorTest {

    @Nested 
    @DisplayName("掛け算の計算結果") 
    public class Multiply {
        @Test                                               
        @DisplayName("掛け算の結果が、20になる")
        void testMultiply() { ...... }

        @Test 
        @DisplayName("0の掛け算の結果は、必ず0になる")
        void testMultiplyWithZero() { ...... }
    }

スクリーンショット 2023-02-12 23.40.32.png

5-3. メソッドではなくふるまいをテストすることによって、テストの保守を容易にする

テストを作成する際には、個々のメソッドをテストするのではなく、対象のクラスのふるまいに着目すべきです。この意味を理解するために、ATMクラスについて考えてみましょう。このクラスには

  • deposit()
  • withdraw()
  • getBalance()

というメソッ ドがあり、それぞれ入金と出金そして残高照会を行えます。 まず、次のようなテストを作成することにします。

  • makeSingleDeposit (1回の入金を行う)
  • makeMultipleDeposits (複数回の入金を行う)

これらのテストの結果を確認するには、 getBalance() を呼び出す必要があります。しかし、このメソッドのふるまいだけを検証するようなテストを作ろうとは思わないはずです。getBalance() は単にフィールドの値を返すだけで前提として入金・出金などが事前に行われている必要があります。ふるまいはすべて、 他の操作つまり入金と出金に伴って発生します。 withdraw()メソッドについても見てみましょう。  

  • makeSinglewithdrawal (1回の出金を行う)
  • makeMultiplewithdrawals (複数回の出金を行う)
  • attempt ToWithdraw Too Much (残高以上の出金を行う)

出金のテストを行うためには、まず入金が必要です(残高の初期値を指定してATM オブジェクトを初期化するということも可能ですが、ここでも実質的に入金が行われています)。入金なしにテストを行えるような、簡単あるいは意味のある方法はありません。何故なら、入金などの操作が行われた事実が先になければ意味がないからです。ユニットテストを作成する際には、まず全体的な観点を持つべきです。つまり、個々のメソッドをテストするのではなく、それぞれの組み合わせからなるクラスとしての本来求められている機能、ふるまいをテストすることが必要となります。

5-4. テストクラスとテスト対象コードの関係

JUnit テストクラスは、テスト対象クラスと同じプロジェクトに配置されます。 ただし、両者のコードはプロジェクト内の別の位置に分けて置くべきです。 テスト対象クラスは納品されても問題はありませんが、テストのコードはプロジェクト内にとどまるのが一般的です。ユニットテストを作成するのはプログラマーです。 顧客やエンドユーザーそして非プログラマーは、ユニットテストを実行することも目にすることもないでしょう。 ユニットテストには一方通行の関係があり、テストはその対象コードに依存していますが、この依存関係は単方向です。 テスト対象コードはテストクラスを知り得る必要はありません。

6. テストクラスと対象クラスの分離

ソフトウェアをデプロイする際に、テストのコードを含めるということはほとんどありません。読み込まれる JAR ファイルのサイズが増加して低速化を招くほか、コードへの攻撃対象領域が増大するという問題も発生します。テストを含めるかという判断とは別に、プロジェクト内のどこにテストのコードを置くべきかという点についても検討する必要があります。主な選択肢は以下の3つです。

  1. テスト対象のコードとパッケージ名を一致させ、同じディレクトリに配置
  2. テスト対象のコードとパッケージ名を一致させ、別のディレクトリに配置
  3. 実運用向けのコードと似ているが異なるパッケージ構造を定義し、別のディレクト リに配置

6-1. テスト対象のコードとパッケージ名を一致させ、同じディレクトリに配置

実装は簡単ですが、実際のシステムで使用されるべきではありません。テストのコー ドを除いて出荷したいという場合に、スクリプトなどを使った分別の処理が必要になるためです。例えばファイル名 (Test* class など) に基づいて抽出を行ったり、リフレクションのAPIを使ってテストのコードを検出するといった手順が必要です。1つのディレクトリに含まれるファイルが多すぎるというのも問題です。

6-2. テスト対象のコードとパッケージ名を一致させ、別のディレクトリに配置

テスト対象のコードとパッケージ名を一致させ、別のディレクトリに配置します。ほとんどのケースで、この方法がとられています。 EclipseMaven などのツールも、このモデルに基づいています。

 スクリーンショット 2023-02-06 0.26.47.png

ここでは、 src・test の両ディレクトリに ealthycoderapp パッケージが用意されています。 テスト用のealthycoderapp.BMICalculatorTest クラスは、 test ディレクトリ配下の BMICalculatorTest.java に記述されます。 一方、テスト対象のealthycoderapp.BMICalculator クラスは src ディレクトリに含まれます。

test ディレクトリの構造は src ディレクトリと一致しているため、それぞれのテストは対象のクラスと同じバッケージに含まれることになります。 つまり、テストクラスはテスト対象クラス内のアクセス修飾子なしのメンバーにアクセスできます。

6-3. 実運用向けのコードと似ているが異なるパッケージ構造を定義し、別のディレクトリに配置

スクリーンショット 2023-02-06 0.32.05.png

ここで test ディレクトリに置かれているコードは、実運用向けのコードとは異なる test.ealthycoderapp パッケージに含まれます。テスト用のパッケージ名に接頭辞 (ここではtest)を加えるということがよく行われますが、まったく別の組織名などを指定することもあります。テストを実運用向けのコードとは別のパッケージに置いた場合、テストは public な API だけを介したものになります。多くの開発者は、このやり方を健全な設計上の判断だと考えています。

6-4. プライベートフィールドの公開、 プライベートメソッドの公開

テストの際には実運用向けのコードが持つ public な API だけを使うべきだ、と主張する方々もいます。 そこでは、テストの中から public 以外のメソッドにアクセスすることはカプセル化の概念に反すると考えられています。つまり、 非public のコードを使ったテストは、テスト対象の実装詳細に依存されてしまうことになります。 この実装が変更されると(たとえ public なふるまいに変更がなかったとしても)テストが失敗してしまう可能性が生じます。

private な実装の詳細をテストすることによって、コードの質が下がってしまう可性が生まれます。これには理由があります。クラス内の private メソッドは直接呼び出せないため、単純にはテストができません。内部処理を強引にリフレクションでテストすることで、テストコードが内部実装に依存することになり、リファクタリングや機能追加の際に障壁となります。テストが実装の詳細を知り依存が多くなる(密結合)と、コードへの小さな変更が多数のテストの失敗を招くことになります。リファクタリングへの弊害につながります。リファクタリングの頻度が低下すれば、その分だけコードの質の低下も加速します。

private なフィールドについてアサーションを行いたい場合はゲッターメソッドを用意しなければなりません。なんらかの操作(メソッドの実行)を行った時に、プライベートフィールドの値が期待した通りに変化していることを検証したい場合に、プライベートフィールドを参照するためです。実運用向けのコードと同じパッケージにテストが置かれているなら、ゲッターはアクセス修飾子なしのメソッドとして定義できます。

プライベートフィールドの状態をゲットする

public class Counter {
    private int count;
    public void countUp() {
        count++;
    }

    public int getCount() {
        return count;
    }
}
public class CounterTest {
    // オブジェクト生成時にcountが0であること
    @Test  
    public void objectGenerationAtCountZeroIs() {    
        Counter counter = new Counter();        
        assertEquals(0, counter.getCount());
    }
    // countUpを実行するとcountが1であること
    @Test
    public void countUpInvokeAfterCount1Is() {
        Counter counter = new Counter();
        count.countUp();
        assertEquals(1, counter.getCount());
    } 
}

private なフィールドを公開するというのは private なメソッドを公開するのとはまったく異なります。テスト用に外部からのアクセスを可能にしたとしても、テストと実運用向けのコードとの間に過剰な密結合が生じることはありません。テストと対象のコードのパッケージ名を一致させている場合、アクセス修飾子のない(パッケージプライベート)メソッドはテストのコードからアクセスできます。なるべくフィールドの可視性は狭く、メソッドも増やしたくない、というならば、フィールドの可視性をパッケージプライベートとすることがオススメです。パッケージプライベートは、privateの次に可視性の狭く、同一パッケージからのアクセスを許可しますが、他のパッケージからのアクセスは許可しません。パッケージプライベートを使えば、プロダクションコードもテストコードも可読性を保ちつつ、カプセル化も大きく崩しません。

一致していない場合には、リフレクションのAPIを使ってアクセス制御を回避することができますが、リフレクションには頼らないというのが最善の策です。

6-4-1. プライベートメソッドのテストについて

private メソッドは原則としてユニットテストすべきではありません。適切に設計された private メソッドであれば、呼び出し元のメソッド経由で網羅性はテスト可能です。仮に private メソッドをテストする必要が出たら、設計を見直す必要があります。privateメソッドは、呼び出し元のメソッド経由でテストするのが原則です。

private メソッドをテストしたくなるような設計は、何らかの問題があると言えます。テストしたくなる private メソッドが多く存在している状態は、ほぼ間違いなくSRP (Single Responsibility Principle、 単一責任の原則) に反しているそうですが、子この辺り正直よくわかりません。

privateメソッドのテストへの対応には例えば以下のようなものが挙げられます。すでにいくつかは紹介しているものも含まれています。

  1. public メソッド経由でテストする
  2. 別クラスに切り出して public メソッドとする
  3. テスト対象の可視性を一段上げる
  4. リフレクション(無い言語では不可)でアクセスしてテストを書く

一つずつ見ていきましょう。

  • ①パブリックメソッド経由でテストする
    多くの場合、そのクラスのパブリックメソッド経由でプライベートメソッドのテストも同時に行えます。Private メソッドはテスト対象内に隠されていて、それは Public メソッドによってのみ実行されるはずです。そのため、Private メソッドにバグがあったとしても Public メソッドのテストによって検知されるはずです。

  • ②別クラスのパブリックメソッドとする
    プライベートなメソッドのテストを書きたいということは、実はテスト対象の責務が多すぎること(SRP違反)を示唆している場合があります。テストがどうしても書きたい場合は、その責務はテスト対象のプライベートな振る舞いではなく、他の誰かのパブリックな振る舞いの可能性があります。テスト対象のプライベートメソッドを「クラスの抽出」や「メソッド/関数の移動」を使って、テスト対象のコラボレータのパブリックメソッドとして抽出し、普通にパブリックメソッドとしてテストすることができます。

  • ③テスト対象の可視性を一段上げる
    Java ではパッケージプライベートがあり、これは同一のパッケージからのみアクセスできる可視性のことです。テストを同一パッケージに配置することでテストからアクセスできるような設計を行うことがあります(4-4-2. テスト対象のコードとパッケージ名を一致させ、別のディレクトリに配置)。ただし、JavaScript はこの手段をとれません。

  • ④プライベートのまま、リフレクションでアクセスしてテストを書く
    リフレクションは最後の手段であり、強力な手段でもあります。プロダクトコードに手を入れることができない状況や、レガシーコード(テストコードの無いコード)に対する「仕様化テスト(Characterization Test)」を書いているような状況では、リフレクションは唯一の、かつ強力な手段になります。プライベートメソッドにテストを書くことのデメリットを理解しつつ、行うしかありません。

内部処理を強引にリフレクションでテストすると、テストコードが内部実装に依存することになり、リファクタリングや機能追加の際に障壁となります。JavaScript やリフレクションが存在しない言語ではこの選択はできません。

参考:リフレクションでprivateをテストしてみる

プライベートフィールドの状態をリフレクションで取得する

public class Counter {
    private int count;
    public void countUp() {
        count++;
    }
}
public class CounterTest {
    @Test
    public void objectGenerationAtCountZeroIs() throws Exception {
        Counter counter = new Counter();
        int actualCount = getCountByReflection(counter);
        assertEquals(0, counter.getCount());
    }

    @Test
    public void countUpInvokeAfterCount1Is() throws Exception {
        Counter counter = new Counter();
        counter.countUp();
        int actualCount = getCountByReflection(counter);
        assertEquals(1, counter.getCount());
    }

    int getCountByReflection(Counter obj) throws Exception {
        // クラスのメタ情報にアクセスして、フィールドのメタ情報も取得する
        Field field = Counter.class.getDeclaredField("count");
        // メタ情報にアクセスしてアクセス可能性を不可から可能に書き換え、ゲットする
        field.setAccessible(true);
        return field.getInt(obj);
    }

}

引用元:プライベートメソッドのテストは書かないもの? - t-wadaのブログ

まとめ 繰り返すと、プライベートなメソッドや関数をテストする必要は無いと考えています。プライベートなメソッドは、実装の詳細であるからです。

ホワイトボックステストを書きたくなるのは、テストの問題ではなく、設計の問題だ。コードがきちんと動いているかどうかを変数を使って確かめたくなるときは、設計を改善する機会であると私は考えている。不安に負けて変数をチェックしてしまえば、改善の機会は失われる。 『テスト駆動開発』 第29章 xUnitのパターン p.226

自動テストを書くモチベーションの一つとして「リファクタリングの支えになる」ことが挙げられますが、リファクタリングとは簡単に言うと「外部から見た振る舞いを変えずに内部の実装をきれいにすること」です。外部から見た振る舞いは、多くの場合自動テストで検証されます。

しかし、プライベートメソッドに対するテストは内部の実装に対するテストになってしまうことが多く、そして内部の実装に対するテストはリファクタリングの妨げになりがちです。自動テストの助けを借りて積極的にリファクタリングを行いたいのに、その自動テストがリファクタリングの妨げになる。これはとても皮肉な状況であり、避けられれば避けたいものです。このような状況は「構造的結合が強い」と表現されます。

プログラマーのテストは、振る舞いの変化に敏感であり、構造の変化に鈍感でなければいけない。つまり、プログラムの振る舞いが安定しているように見えるなら、テストを変えるべきではない。 プログラマーテストの原則 by Kent Beck - Waicrew - Medium

テスト「できる」ことと「すべきである」ことは異なります。リフレクションを使えばプライベートなメソッドのテストは「できる」のですが、そのテストはやがて実装改善の邪魔になりかねません。

「できる」ことと「すべきである」を区別し、目的に添わない場合「しない」という選択をできる様にしなくていはいけませんね。

また、設計の問題として、処理の一部を別クラスに抽出したり、package private に変更するなどの対応が考えられるようです。 そもそもテストする時は関係なく全部できる様にしたらあかんのか?と素人の僕は素朴に感じてしまいます。言語の標準サポートでできる様でけんのか?と。出来たのにやらずにいたのであればそれには理由があって、設計の見直しを示唆させるためなんかと思ったり。

引用元:privateメソッドをテストしたい

表出した事象を叩き潰してはいけない。ここでは「privateメソッドをテストしたい」と感じたことが表出した事象。

「privateメソッドをテストしてはいけない」って言葉は「privateメソッド だから テストしてはいけない」という呪いに容易に変わってしまう。そうじゃないって言いたい。「privateメソッドをテストしたい」と思った感覚は、その瞬間その場その人にとって、絶対に正しい。「そのprivateメソッドをそのままテストしなきゃいけない」かどうかは別の話。

開発の初期段階はともかく最終的にはpublicな属性を相手にしたテストで賄ってしまって削除しちゃったほうがいいと思います。

結局のところprivateメソッドをprivateなままテストするのは(少なくとJavaにおいては)リファクタリングの妨げになるので、よほどの理由があるときにそのマイナスをプロダクトとして許容して行うものだと思う。

6-5. テストを分離するメリット

テストケースごとに1つずつテストのメソッドを用意し、検証対象のふるるまいを的確に表す名前をつけるほうが管理しやすくなります。 テストを分離することによって、以下のようなメリットを得られます。

  1. アサーションが失敗すると、そのテストの名前が報告されます。 これを通じて、どのふるまいに問題があったのかすぐに知ることができます。
  2. 失敗したテストの分析にかかる手間を最小限にできます。 JUnit ではテストごとに 個別のインスタンスが使われるため、あるテストが失敗したとしても別のテストに影響が及ぶことはありません。
  3. すべてのテストケースが実行されることを保証できます。 java.lang. AssertionError が throw されるため、実行中のテストメソッドは終了します (JUnit 本体がこれをcatchし、 テストを失敗であると報告します)。失敗したアサーションよりも後に記述されているコードは実行されません。そして、次のテストメソッドの実行に移り、再びテストが実行されます。

7. ドキュメントとしてのテストとして一貫性のある名付けを

1つのテストにさまざまなシナリオを含めると、その分だけテストの名前は一般的で無意味なものにせざるを得ません。そうなれば、そのテストで何が行われるのかまったくわかりません。個々のふるまいに着目した詳細なテストを作成するようになると、テスト名にもきちんとした名前を与えられます。

テスト対象の文脈を示すのではなく、文脈の中でふるまいを呼び出すと何が起こるのか示しましょう。つまり「振る舞い + 結果」を示すことです。

悪い名前 テスト内容に即した良い名前
makeSingleWithdrawal
(1回出金する)
withdrawalReducesBalanceByWithdrawnAmount
(出金を行うとその分だけ残高が減る)
attemptToWithdrawTooMuch
(多額の出金を試みる)
withdrawalOfMoreThanAvailableFundsGeneratesError
(残高以上の出金を行うとエラーが発生する)
multipleDeposits
(複数回の入金)
multipleDepositsIncreaseBalanceBySumOfDeposits
(複数回入金を行うとその合計額の分だけ残高が増加する)

正確な名前をつけることによって 他のプログラマーはテストの内容をよりよく理解できるようになります。長い文からなる名前は、理解が難しくなります。 多くのテストで名前が長すぎるという場合には、そもそも設計が誤っている可能性もあります。

理解しやすい名前は、次のような構造です。「振る舞い + 結果」

  • doingSomeOperationGeneratesSomeResult
    • 何らかの処理を行うと何らかの結果が発生する
  • someResultOccursUnderSomeCondition
    • 何らかの条件下では何らかの結果が発生する
  • givenSomeContextWhenDoingSomeBehaviorThenSomeResultOccurs
    • 何らかの条件下で、何らかのふるまいを行うと何らかの結果が発生する
  • whenDoingSomeBehaviorThenSomeResultOccurs
    • 何らかのふるまいを行うと何らかの結果が発生する

形式は複数ありますが、何を選択するかはあまり重要ではなく(個人的には短い方が好き)、選択に一貫性を持たせることのほうが大切です。他人にとって意味のあるテストにしましょう。

7-1. 意味のあるテスト

作成したテストを誰か(または自分自身)がわかりにくいと感じた場合に、単にコメントを追加するというのは望ましくありません。まずは、テストの名前を改善することから始めるべきです。

  1. ローカル変数の名前を改善する。
  2. 意味のある定数を導入する。
  3. Hamcrest アサーションを利用する。
  4. 長いテストは、より短く焦点を絞った複数のテストに分割する。
  5. 些末なコードはヘルパーメソッドや @Before メソッドに移動する。

説明のコメントを追加するのではなく、テストの名前やコード自体を通じてストーリーを伝えるようにします。

7-2. テストに名前をつける @DisplayName

とはいえ、メソッド名だけだと解り辛いですね。長いとそれだけで認知負荷が上昇します。このアノテーションを使用して、引数に名前を渡して別名をつけましょう。IDEのテスト実行欄に、その名前でテスト名一覧が表示されます。こちらでも、上記の様に「振る舞い + 結果」を書く様にします。何十個

    @Test                                               
    @DisplayName("掛け算の結果が、20になる")
    void testMultiply() {
        assertEquals(20, multiplyResult, "Regular multiplication should work");  
    }

    @RepeatedTest(5)                                    
    @DisplayName("0の掛け算の結果は、必ず0になる")
    void testMultiplyWithZero() {

        assertEquals(0, zeroMultiplyResultPatternFirst, "Multiple with zero should be zero");
        assertEquals(0, zeroMultiplyResultPatternSecound, "Multiple with zero should be zero");
    }

スクリーンショット 2023-02-12 20.45.28.png

8. 良いユニットテストであるための原則『FIRST』

FIRSTの原則は次の単語の頭文字をとって名付けれています。

  • Fast(迅速)
  • Isolated(隔離)
  • Repeatable(繰り返し)
  • Self-validating(自律的検証)
  • Timely(タイムリー)

途中で『Mock』が出てくるので解説

指定した元クラス(テスト対象クラスか、テスト対象クラスの依存クラス)のテストダブルとして振る舞うオブジェクトの一種類が Mock オブジェクトです。このテストダブルオブジェクトは、元クラスのオブジェクトを要するすべての場面で使うことができます。

引用元;https://bearsunday.github.io/manuals/1.0/ja/test.html

テストダブル (Test Double) とは、ソフトウェアテストでテスト対象が依存しているコンポーネントを置き換える代用品のことです。テストダブルは以下のパターンがあります。

  • スタブ (テスト対象に「間接的な入力」を提供)
  • モック (テスト対象からの「間接的な出力」をテストダブルの内部で検証)
  • スパイ (テスト対象からの「間接的な出力」を記録)
  • フェイク (実際のオブジェクトに近い働きのより単純な実装)
  • ダミー(テスト対象の生成に必要だが呼び出しが行われない)

テスト対象のシステム(SUT)がテストダブルの出力を使用するのがスタブです。例えばいつもtrueを返すようなメソッドを持つテストダブルはスタブです。モックはSUTからテストダブルへの間接的出力の検証をテストコードではなく、テストダブル内部で行います。スパイはモックと同じようにSUTの間接的出力の検証を行うためのものですが、その検証をテストコードで行うためにテストコードから読み取り可能な記録が行われます。

モックはテスト対象に「間接的な入力」を提供しつつもテスト対象からの「間接的な出力」を内部で検証するなど、スタブの機能も持ち合わせています。

スタブとスパイが対になる関係で、モックはどちらにもなり得るものです。

テスト対象が依存する外部のコンポーネントの振る舞いを真似するものがテストダブルで、その振る舞いの責務によって上記のようなパターンを使い分けます。

引用元:xUnit Test PatternsのTest Doubleパターン(Mock、Stub、Fake、Dummy等の定義)

Test Doubleの用途 Test Doubleはしばしば以下の用途で用いられます。

  • コスト的・時間的・環境的にテストで実行困難なコンポーネントを置き換える
  • テストを高速化する。例えばDBをメモリ上のFake Objectに置き換えたりする。
  • まだ実装していないコードの代替となる。
  • 複雑で使いにくいコードを簡略化する。例えば多数のクラスや設定に依存しているコンポーネントを、単純なTest Doubleに置き換えてしまう。
  • テスト対象の間接出力を取得し検証する。
  • テスト対象の間接入力を操作する。

8-1. Fast(迅速)

二つの意味があります。

  1. コードよりも先にユニットテストを作成する(テスト駆動開発:TDD)
    1. 従来の手法(plain old unit testing またはPOUT)との違いはテストが先だという点
  2. テストの実行そのものが迅速に完了すること
    1. テスト対象メソッドのみを操作し、実行は数ミリ秒で完了

※TDDについては触れません、というか触れれません。まずは、POUT から順に学んでいくつもりです。

迅速にテストを実行できない場合とはどの様な場合でしょうか

DBへのCRUD・ファイル出力・ネットワークといった外部リソースに依存するメソッドのテストを実行する場合です。セットアップに時間がかかります。例えば、セットアップごとに対象テーブルの初期化として全削除を実行し必要データを挿入する、などです。

テスト対象メソッドの独立性が保たれていない場合、つまり副作用(戻り値を返す以外に外部に影響を及ぼす、もしくは戻り値が外部の状態変化に依存して変化してしまう)があるメソッドは確認のための手段そのものにも手間がかかりますし、実行そのものにも時間がかかります。テスト対象メソッドが外部の他の何かに依存している状態はいずれテストの実行が困難なることが装いされます。もし、この様なテストが3000個あり、一回のテストに500msec(0.5秒)かかる場合、全体のテストを一度実行するのに25分かかってしまいます。

対応策として

  • 外部システムにアクセスする箇所を抑える
    • そういったコードに依存する箇所を減らすのが最も重要。N+1問題に似ている。
    • Mock も検討材料。
  • DBに保存されている値を使用するテストでは、引数に固定値を代入する
    • DBの値は変更されうるし、テスト条件によっては昨日まではOKだったのに今はNGなどの結果になりうる。副作用を無駄に起こさない様にする。
  • テストの並列実行
    • マルチスレッド環境などで並列にテストを実行する。
  • 共有テスト
    • セットアップ後の状態や事前準備したオブジェクトを共有し、コストを下げる
    • デメリットとして問題の切り分けなどし辛くなる。単一責任原則に抵触しやすい
  • カテゴリ化テスト
    • 特定のテストケースのみを実行し、不要なテストを実行しない
    • @Tag
      • テストクラスやテストメソッドに@Tag アノテーションでタグを付与すると、テストを発見・実行するときにフィルタリングすることができる。
      • 実行順序を変えるなどという用途では使用しない。実行順序に依存してテスト結果が変わる様であればそれは問題がある。

8-2. Isolate(テストを隔離する)

良いユニットテストは、コードの中の小さな一部分に着目して検証を行います。対象とするコードが(直接的にも間接的にも)増えれば増えるほど、テストの質は下がりやすくなります。

テスト対象のコードが、 データベースにアクセスしている状態は、そいデータに依存していること表します。 データベースに依存したテストを行うということは、副作用をもつ振る舞いであるということになります。DBの値によって結果が変動する可能性がるため様々なケースを想定し、テストコードを書く必要があります。

  1. 適切なデータが格納されているかどうかのチェック処理
  2. データベースが共有されている場合
    1. 外部で発生した自分とは関係のない変更によってテストが壊れる。
  3. 排他制御などの影響を受けた場合
    1. 単純にデータベースにアクセスするだけのコードはうまく機能しなくなる可能性。

よいユニットテストは、他のユニットテストや同一メソッド内の他のシナリオにも依存しません。テストの順序を工夫すれば、作成が面倒なデータを使い回せると思われるかもしれません。しかし、そうすると依存関係が連鎖し、硬直したコードになってしまいます。そういったコードはいずれ必ず問題を引き起こし、修正の必要に迫られます。安定したテストを幾度も実現するためには、コントロールできないような外部システムや環境からテストを完全に切り離す必要があります。

どんなテストも、時期や順序に依存せず実行でき、繰り返し成功しなければなりません。それぞれのテストがふるまいのうち小さな部分だけに注目するように心がければ(テストケースを可能な限り小さな単位で多く作成する)、独立性の高いテストの作成は容易なはずです。

SOLIDの中の SRP (Single Responsibility Principle, 単一責任の原則)では、クラスは目的を1つだけ持った小さなものであるべきとされ、1つのクラスに対して変更が必要になる理由は1つだけであるべきともされています。この原則は多くの設計に当てはまる原則ですが、テストメソッドの設計にも当然ながら当てはまります。問題の局所化ができる事で、テスト失敗時の原因特定へのノイズを減らすことができます。

もしテスト失敗の原因が複数存在するのであれば、SRP に違反していること示唆しています。

8-3. Repeatable(繰り返し)

自動化されたユニットテストであれば、 何度でも繰り返してテストを実行することが容易となるはずです。であれば、不具合などを早い段階でフィードバックでき、安心してリファクタリングや機能拡張を行うことができるようになるわけです。繰り返し可能なテストとは実行するたびに同じ結果を得られるという事を意味します。実現するためには、自分がコントロールできないような外部の環境からテストを完全に切り離す必要があります。

しかし、管理下にないコンポーネントとの相互的なやりとりが不可欠だと言うこともあります。上記で軽く紹介しましたが、現在時刻を元に処理を行うコードでは、時刻にも関わらず繰り返し可能なテストを作成するのは容易ではありません。また、システムが他のコンポーネントに依存しており、そのコンポーネントをテスト環境で利用できないこともあります。

その際は、上記で紹介したやり方や、Mock オブジェクトの仕組みを使うことでテスト対象のコードを、不確定要素から隔離することができます。ここでも副作用が不具合を発生させることが解りました。

8-4. Self-Validating(自律的検証)

8-5. Timely(適切なタイミングでテストする)

こちらの記事をご覧ください。
元の文章をこちらに移植する前に再構成し以下の記事で公開してまったので文脈が異なるためこちらに記載できませんでした。。。。

終わりに

長かったです。疲れました。次は実際のコードでも記事にまとめたいと思います。

参考図書:オライリー 実践JUnit

おまけ~アサーションの種類~

JUnit の assert 系のメソッドには、以下のものがあります。

  • assertEquals:2 つの値が等しいかどうかをテスト
  • assertTrue:引数の値が true かどうかをテスト
  • assertFalse:引数の値が false かどうかをテスト
  • assertNull:引数の値が null かどうかをテスト
  • assertNotNull:引数の値が null でないかどうかをテスト
  • assertSame:2 つの参照が同じインスタンスかどうかをテスト
  • assertNotSame:2 つの参照が異なるインスタンスかどうかをテスト
  • assertArrayEquals:2 つの配列が等しいかどうかをテスト
  • assertThrows:指定した関数が例外をスローするかどうかをテスト
  • assertNotEquals:2 つの値が等しくないかどうかをテスト
  • assertThat:値が指定された条件を満たすかどうかを確認
  • assertThrows:例外がスローされるかどうかを確認
  • assertDoesNotThrow:例外がスローされないかどうかを確認
  • assertTimeout:指定された時間内に実行が終了するかどうかを確認
  • assertTimeoutPreemptively:指定された時間内に実行が終了するかどうかを確認
  • assertLinesMatch:2つの文字列が一致するかどうかを確認
  • assertAll:複数のアサーションをまとめて確認

assertTimeout と assertTimeoutPreemptively の違い

assertTimeoutPreemptivelyは、実行が長引いた場合にアサーションを実行して、実行を中断できるようになっています。