LoginSignup
19
15

More than 3 years have passed since last update.

テストコードを負債化させないために備えるべき3つの資質

Posted at

はじめに

テストコードのないプロダクトコードは負債ですが、きちんとメンテナンスされていないテストコードもまた負債です。
テストコードが負債化するとプロダクトコードも負債化が進み、プロダクトのレガシー化が加速するでしょう。

そうさせないためには、プロダクトコードと同じようにテストコードに投資をする必要があります。つまり、保守性の高いテストコードを書くように普段から心がけるべきです。
本投稿ではテストコードの保守性を高めるためのポイントをご紹介します。

なぜテストコードに投資するの?

テストコードは散らかる

プロダクトコードは、一般化されていて抽象的です。対して、テストコードは特殊化されていて具体的です。具体的とはどういうことかというと、個々のテストケースでは具体的な値が割り当てられたデータが使用されます。例えば任意の整数xではなく、x = 2という値を取り扱います。
一般に、あるプロダクトコードの断片(メソッドや関数)は複数のテストケースによって正しく動作するか否か検証されます。

test-01.png

データに具体的な値が割り当てられた複数のテストケースから成るということは、プロダクトコードに比べてテストコードの分量が大きくなるということを意味します。数倍、時には数十倍になることもあるでしょう。
つまりテストコードはその性質上、肥大化しがちであり、放っておくと散らかりがちなのです。

散らかったテストコードが引き起こすもの

TDD(テスト駆動開発)では、RED→GREEN→REFACTORのサイクルで開発作業を進めます。

test-02.png

  1. 失敗するテストコードを書く
  2. テストを通す最小のプロダクトコードを書く
  3. プロダクトコードをきれいにする

既にできあがった機能に仕様変更が入る場合のフローも同じです。
まずは変更される仕様に対応するテストケースを特定し、そのテストケースを新しい仕様に合わせて修正します(この時点で、プロダクトコードは未変更なので、そのテストは失敗する)。
次にプロダクトコードを修正して、テストが通るようになったらリファクタリングを行います。

もしテストコードが散らかっていたらどうなるでしょうか?
散らかっているテストコードには以下のような特徴があります。

  • 何がどこにあるのかがよくわからない
  • テストケースの網羅性が担保されているかよくわからない
  • それが何のテストをしているのよくわからない

このような状況で、(おそらく納期が差し迫った状況で)仕様変更を実装しなければいけない開発者はどう振る舞うでしょうか?
TDDのサイクルを無視して、直接プロダクトコードを変更してしまうというのは想像に難くないと思います。プロダクトコードを修正すると、おそらく失敗するテストケースが出現するでしょう(プロダクトコードの振舞いを修正したのにテストコードが何の影響も受けないとしたら、それはそれで大きな問題)。そして、失敗したテストケースを修正して通るようにするのです。

負債は膨れ上がる

まずプロダクトコードを修正して、失敗するテストケースを通るように修正する。

この逆転したサイクルが常態化してしまうと、テストコードの負債は雪だるま式に膨れ上がっていきます。テストを通るようにプロダクトコードを修正するのではなく、通るようにテストコードを修正するという作業は苦痛です。本来、我々を安心させてくれるはずのテストコードという資産が、お荷物となってしまう状況はまさに負債としか言えません。

そうならないためにも、テストコードにはプロダクトコードと同等の注意を払って扱い、メンテナンスしていくべきなのです。

テストコードに望まれる3つの資質

可読性や保守性を高めるために、テストコードは次の3つを満たすようにするとよいでしょう。

  1. 構造化されている(Structured)
  2. 整理されている(Organized)
  3. 自己文書化されている(Self-documenting)

構造化されている(Structured)

テストコードはその対象とするプロダクトコードと比べて何倍もの大きさとなることを考えれば、プロダクトコードと同じ構造ではテストコードがすぐに肥大化してしまうのは明らかです。例えばJUnitではFooクラスというプロダクトコードに対するテストコードはFooTestクラスとするのがお決まりになっていますが、何の構造化も行わずにFooTestクラスにあらゆるテストケースを突っ込んではいないでしょうか?

テストコードは、何らかのテスト観点によってテストケースを分類し、その分類を構造に反映するべきです。例えば以下のような分類です。

  • メソッド単位、メソッドのグルーピング単位
  • 正常系、異常系、例外系などの分類単位

この分類がソースコードの構造として表現されていれば、「何がどこにあるのかがよくわからない」という課題は解決あるいは相当に軽減されるでしょう。

整理されている(Organized)

以前、とある開発者に「JUnitのテストクラスを作成しましたが、テストパターンのEXCELはどこに格納しましょうか?」と質問されたことがあります。裏を返すと、テストコードだけを見ても必要なパターンが網羅されているかどうか判断が難しいということです。
テスト仕様書を作成してそれに沿った打鍵テストを実行する場合、込み入ったテストケースの場合はデシジョンテーブル等を使ってテストパターンを一覧化して整理しますよね。自動化されたテストコードも、パターンの網羅性を確認できるように一覧化・整理された状態を保つべきです。
とくに複数の入力変数の値の組み合わせによって出力が決まる振舞いに対しては、パターン数が多くなりますから、整理せずに列挙してしまうと大変な状態になってしまいます。

きちんとテストパターンを整理・一覧化して「テストケースの網羅性が担保されているかよくわからない」という課題に対応しましょう。

自己文書化されている(Self-documenting)

ここで自己文書化とは、テストコードを見ただけでテストの目的や条件が明快にわかることと捉えてください。
テストコードはプロダクトコードと同様に、書かれる回数よりも読まれる回数の方がずっと多いです。他の開発者や、時間が経って記憶の薄れている将来の自分にとって、容易に意図が伝わるテストコードを書くようにしましょう。

具体的には、テストの目的はメソッド名(あるいはアノテーション)でわかりやすく表現します。その際、日本語で会話する開発者で構成されたチームなら日本語で記述した方が誤解が少なくてよいでしょう。

テスト条件はAAA(トリプルエー)やBDDスタイルで記述するのがよいです。AAAは、Arrange/Act/Assertに分けてテストコードを記述するテストパターンです。

  • Arrangeでは、テストの事前条件を満たすように準備を行う
  • Actでは、テスト対象の処理を実行する
  • Assertでは、テストの事後条件の検証を行う

このようなルールに沿って自己文書化されたテストコードを書くことで、「それが何のテストをしているのよくわからない」という課題を解決できるはずです。

JUnit 5によるサンプル

上に述べた3つの資質について、JUnit 5を使ってどのように達成できるかをサンプルを用いて説明します。

テスト対象(System under test)

以下のような、ある日付における年齢を取得するgetAgeAtメソッドと、数え年での年齢を取得するgetKazoedoshiAtメソッドを持つPersonクラスに対するユニットテストを記述するとします。

Person.java
public class Person {

    private String firstName;
    private String lastName;
    private LocalDate birthDate;

    // ...

    public Optional<Integer> getAgeAt(LocalDate theDate) {
        // To be implemented
    }

    public Optional<Integer> getKazoedoshiAt(LocalDate theDate) {
        // To be implemented
    }
}

保守性の低いテストコード

冒頭部分を抜粋しますが、getAgeAtメソッドに対するテストケース、getKazoedoshiAtメソッドに対するテストケースが合計15ケース、単純に列挙されています。

PersonTest.java
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PersonTest {

    private Person person;
    private Person personBirthDateNull;

    @BeforeAll
    void setup() {
        person = new Person("太郎", "山田", LocalDate.of(2019, 5, 3));
        personBirthDateNull = new Person("太郎", "山田");
    }

    @Test
    void 生まれた日は0歳であること() {
        Optional<Integer> age = person.getAgeAt(LocalDate.of(2019, 5, 3));
        assertThat(age.isPresent(), is(true));
        assertThat(age.get(), is(0));
    }

    @Test
    void 生まれた翌年の誕生日前日は0歳であること() {
        Optional<Integer> age = person.getAgeAt(LocalDate.of(2020, 5, 2));
        assertThat(age.isPresent(), is(true));
        assertThat(age.get(), is(0));
    }

    // ...

構造化する(Make it structured)

JUnit 5では内部クラスを使ってテストクラスをネスト構造にできますので、それによって構造化します。

  • レベル1:テスト対象メソッドで分類(例:GetAgeクラス)
  • レベル2:正常系/異常系/例外系で分類(例:HappyCasesクラス)
PersonStructuredTest.java
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DisplayName("構造化されたPersonのテスト")
class PersonStructuredTest {

    private Person person;
    private Person personBirthDateNull;

    @BeforeAll
    void setup() {
        person = new Person("太郎", "山田", LocalDate.of(2019, 5, 3));
        personBirthDateNull = new Person("太郎", "山田");
    }

    @TestInstance(TestInstance.Lifecycle.PER_CLASS)
    @Nested
    @DisplayName("年齢")
    class GetAge {

        @TestInstance(TestInstance.Lifecycle.PER_CLASS)
        @Nested
        @DisplayName("正常系")
        class HappyCases {

            @Test
            void 生まれた日は0歳であること() {
                Optional<Integer> age = person.getAgeAt(LocalDate.of(2019, 5, 3));
                assertThat(age.isPresent(), is(true));
                assertThat(age.get(), is(0));
            }

            @Test
            void 生まれた翌年の誕生日前日は0歳であること() {
                Optional<Integer> age = person.getAgeAt(LocalDate.of(2020, 5, 2));
                assertThat(age.isPresent(), is(true));
                assertThat(age.get(), is(0));
            }
            // ...

テストランナーの表示結果も構造化されて見通しがよくなりました。

test-03.png

整理する(Make it organized)

getAgeAtメソッドによる年齢の検証のように入力パラメータをいろいろと変えてテストしたい場合、パラメーター化テストを使うとパターンの一覧化が可能となります。(今回の例だと3パターンしかないので、パラメーター化テストにすると却って冗長なのですが、そこはサンプルということで..)
※注意点:単純に使用するパラメーターだけをまとめると各々のテストの意図が不明確になるため、説明用のパラメーターも用意するのがよいでしょう。

PersonOrganizedTest.java

        @TestInstance(TestInstance.Lifecycle.PER_CLASS)
        @Nested
        @DisplayName("正常系")
        class HappyCases {

            @ParameterizedTest(name = "{index} {3} {0}-{1}-{2} => {4}歳")
            @MethodSource("prepArguments")
            void 年齢が正しいこと(int y, int m, int d, String description, int expectedAge) {
                LocalDate date = LocalDate.of(y, m, d);
                Optional<Integer> age = person.getAgeAt(date);
                assertThat(age.isPresent(), is(true));
                assertThat(age.get(), is(expectedAge));
            }

            // パラメーターの供給元メソッド(1行1行がテストケースに使用される)
            Stream<Arguments> prepArguments() {
                return Stream.of(
                        arguments(2019, 5, 3, "生まれた日", 0),
                        arguments(2020, 5, 2, "生まれた翌年の誕生日前日", 0),
                        arguments(2020, 5, 3, "生まれた翌年の誕生日", 1)
                );
            }

        }

テスト結果の表示は以下のようになります。

test-04.png

自己文書化する(Make it self-documenting)

AAAパターンに対するツールの直接的なサポートはないので、コードの書き方の工夫となります。

  • コメントによって明示的にArrange/Act/Assertを分割する
  • 変数名の付け方も工夫して意図を明確にする
    • 例えば、任意の日付の変数名をanyDateとすることで、右辺の実際の日付値には意味がないことを表現
PersonSelfDocumentingTest.java
        @TestInstance(TestInstance.Lifecycle.PER_CLASS)
        @Nested
        @DisplayName("異常系")
        class UnhappyCases {

            @Test
            void 誕生日不明の場合は年齢を取得できないこと() {
                // Arrange
                Person person = jiroBirthdayUnknown;
                LocalDate anyDate = LocalDate.of(2020, 1, 1);

                // Act
                Optional<Integer> age = person.getAgeAt(anyDate);

                //Assert
                assertThat(age.isPresent(), is(false));
            }

            @Test
            void 生まれる以前の日付に対しては年齢を取得できないこと() {
                // Arrange
                Person person = taroBornOn20190503;
                LocalDate dateBeforeBirthday = LocalDate.of(2019, 5, 2);

                // Act
                Optional<Integer> age = person.getAgeAt(dateBeforeBirthday);

                // Assert
                assertThat(age.isPresent(), is(false));
            }
        }

参考リソース

19
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
15