17
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

テスタブルなコードをもうちょっと簡単に理解する

Last updated at Posted at 2020-01-27

概要

この前 テスタブルなコード とか 凝集度・結合度・循環的複雑度 とかいう記事を投稿したんですが、新人の方にはちょっとむずかしい内容になってるかもと思ったので、もうちょっと簡単に書き直してみることにしました。ということで、結構やさしめです(だと思います…多分…?)

なお、ここでいう「テスト」は主に単体テストを指すことにします。また「何故テストが必要か」や「どういうテストが必要か」といったものは長くなっちゃっうのでここでは論じません。また、コードの例ではクラス名などといったものは基本は省略します。

また、サンプルコードはなるべくはわかりやすい初歩的なものだけを使うようにしてます。

テストとは何か

端的にいうと、「特定の入力で正しい出力がされること」の確認を指します。Aという入力をしたらBという結果が返ってくることを期待している場合、それを確認するのがテストです。簡単でしょ?

1. 簡単なテストの例

たとえば、以下のようなメソッドがあるとします。(よく単体テストの説明とかででてくるやつですね)

public int add(int a, int b) {
    return a + b;
}

きっと多少でもプログラミングをしてたら上記が何をしてるかはすぐわかると思います。
そうです。aとbという数値を渡したら、なんとaとbを足した数が返ってくるんです!

これは、以下のようなテストを書けばいいだけです。一瞬でテストが書けちゃうし、どういうテストをすべきかも一瞬でわかるので、まさにテスタブルなコードといえます。

@Test
public void testAdd() {
    // 3と4を渡すとねぇ、なんと7が返ってくるんだよ!!
    assertThat(add(3,4), is(7));
}

2. もう少し難しいケースを考える

もし上記を見て「こう書けばいいのか!!」って完全に理解できた人はこれ以上読む必要はありません。ただ、実際のシステムはもっともっと複雑ですよね。というわけで、上記よりは複雑なケースを考えてみます。

public int sub(int a , int b) {
    int c = a - b;
    if (c < 0 ) {
        return 0;
    }
    return c;
}

こちらは引き算です。ただ普通の引き算と違って、0以上の整数しか返しません。まだまだ簡単ですね。
これのテストケースとしては、以下を考慮する必要がありそうです。

  • aがbよりも大きいケース
  • aとbが同じケース (これは必須じゃないけど)
  • aがbよりも小さいケース

というわけで、テストケースとしては以下になります。この程度ならまだまだ作りやすいので、これもテスタブルなコードとは言えそうですね。

assertThat(sub(5, 4), is(1));
assertThat(sub(5, 5), is(0));
assertThat(sub(5, 6), is(0));

3. もっと難しいケースを考える

ただ、システムってこんな単純じゃないですよね。というわけで、もうちょっと難しいものを考えてみます。

class Range {
  private int start;
  private int end;
  private int step;
  // コンストラクタで指定するものとする
  
  public List<Integer> generate() {
    List<Integer> result = new ArrayList<>();
    for(int i = start; i <= end; i += step) {
       if (i >= 10000) {
           break;
       }
       result.add(i);
    }
    return result;
  }  
}
  1. や2. と比べると、だいぶ複雑になってきましたね。この場合は以下のテストが必要でしょう。
  • endがちょうどstart + step * nで終わるケース
  • そうでないケース
  • endが10000を超えるケース
  • startが10000を超えてるケース
  • startとendが同値であるケース
  • start > endであるケース

テストケースは以下の感じです。

assertThat(new Range(1, 3, 2).generate(), is(Arrays.asList(1,3)));
assertThat(new Range(1, 4, 2).generate(), is(Arrays.asList(1,3)));
assertThat(new Range(0, 12000, 2000).generate(), is(Arrays.asList(0, 2000, 4000, 6000, 8000)));
assertThat(new Range(10000, 15000, 1).generate(), is(new ArrayList<Integer>()));
assertThat(new Range(1, 1, 2).generate(), is(Arrays.asList(1)));
assertThat(new Range(2, 1, 1).generate(), is(new ArrayList<Integer>())));

まだ、テストケース自体はすぐかけるのである程度はテスタブルなコードとは言えそうです。ただ、考慮するべきケースが増えてきたのでそこは注意が必要ですね。

##4. もっともっと難しいケース
ただ、システムってこんな単純じゃないですよね。というわけで、もうちょっと難しいものを考えてみます。
そもそもこれまではデータ結合といって、単純なデータしか渡していません。データ結合の場合、せいぜい 2^循環的複雑度のテストケースを書けば全網羅できます。
結合度や循環的複雑度などはこちらでも解説してます

ということで、以下のクラスを扱うようなケースを考えます。


public class User {
   public enum Gender {
      MALE, FEMALE
   }
   String acccountId;
   String firstName;
   String familyName;
   Gender gender;
   Date dateOfBirth;
   Date lastLoggedIn;
}

// 誕生日の場合で表示を変える
public String greeting(@NotNull User user) {
    Date now = new Date();
    Date birth = user.getDateOfBirth();
    if (birth.getMonth() == now.getMonth() && birth.getDate() == now.getDate()) {
         int age = now.getYear() - birth.getYear();
         return String.format("ようこそ%s%sさん! %d歳のお誕生日おめでとう!", age, user.getFamilyName(), user.getFirstName() );
    }

    return String.format("ようこそ%s%sさん!",  user.getFamilyName(), user.getFirstName() );
}

さて、このメソッドをテストするのはちょっと面倒そうです。面倒だよね?
まずこちらはスタンプ結合に結合度がランクアップしており、Userクラスで使われていないものも存在します。
これをちゃんとテストするには、実装者はUserクラスの何が使われていて何が使われていないのかをしっかり把握している必要があります。

テストケースとしては以下が考えられます。

  • 誕生日ではないケース(月が異なる)
  • 誕生日ではないケース(日が異なる)
  • 誕生日のケース

ひとつだけテストケースを書くと以下のケースでしょうか。

User user = new User();
user.setFirstName("かずき");
user.setFamilyName("もだ");
Date date = new Date();
date.setDate(date.getDate() - 1 * 3600 * 1000 * 24 * 365); // 1歳ということで
user.setBirthOfDate(date);

assertThat(greeting(user), is("ようこそもだかずきさん!1歳のお誕生日おめでとう!"));

ちなみに、上記は場合によっては失敗します

上のテストケースですけど、 date.setDate(date.getDate() - 1 * 3600 * 1000 * 24 * 365); の部分でうるう年のこととか考慮してないんで、場合によっては失敗します。なので、ちゃんとテストするならその部分も考慮してテストケースを作る必要がありそうです。。。
(そもそもJavaではもうDateは非推奨になってるから、使うならJoda Timeとか使う方がいいですね)

4-2.更にテストが難しいケース

上のは、まだシステムとして作ったとしてもテストはしやすい方かと思います。ではここにもうちょっと仕様を追加してみましょう。

// 誕生日の場合で表示を変える
public String greeting(@NotNull User user) {
    Date now = new Date();
    Date birth = user.getDateOfBirth();
    String honorific = user.getGender() == User.Gender.MALE ? "くん" : "さん";
    if (birth.getMonth() == now.getMonth() && birth.getDate() == now.getDate()) {
         int age = now.getYear() - birth.getYear();
         return String.format("ようこそ%s%s%s! %d歳のお誕生日おめでとう!", user.getFamilyName(), user.getFirstName(), honorific, age);
    }

    return String.format("ようこそ%s%s%s!",  user.getFamilyName(), user.getFirstName(), honorific );
}

上の greeting に、男女の違いで処理を分けるようにしました。結合度が「スタンプ結合」から「制御結合」へとランクアップしてます。
これも、テストコードを書くこと自体はまだ簡単な方(ただし今のやり方だと失敗するケースがある)ではありますが、男性、女性それぞれで確認する必要があるために書くべきテストケースの量が2倍に増えます。何かの戦闘力みたいですね。

4-3. 更に更に難しいケース

ここで急にお客様が「誕生日じゃない日も、場合によって出すべき言葉を変えたいんですよねぇ。あと時間によっても言葉を変えたいんですよ」って言い始めたとします。
それを率直に実装すると以下のようになるかと思います。

// 誕生日の場合で表示を変える
public String greeting(@NotNull User user) {
    Date now = new Date();
    Date birth = user.getDateOfBirth();
    String honorific = user.getGender() == User.Gender.MALE ? "くん" : "さん";
    if (birth.getMonth() == now.getMonth() && birth.getDate() == now.getDate()) {
         int age = now.getYear() - birth.getYear();
         return String.format("ようこそ%s%s%s! %d歳のお誕生日おめでとう!", user.getFamilyName(), user.getFirstName(), honorific, age);
    }

    String greeting = String.format("ようこそ%s%s%s!",  user.getFamilyName(), user.getFirstName(), honorific );

    int hour = new Date().getHours();
    if (hour >= 5 && hour < 11) {
        greeting += "おはよう!";
    } else if (hour >= 11 && hour <= 16) {
        greeting += "こんにちは!";
    } else {
        greeting += "こんばんわ!";
    }

    int ran = new Random().nextInt(2);
    if (ran == 0) {
        greeting += "いつもありがとうございます!";
    } else {
        greeting += "ごきげんよう!";
    }
   
    return greeting;
}

(´・ω・`)

これはもうテストするのは嫌ですね。やりたくない。無理矢理できなくはないですけど、やるくらいなら逃げますね。遠くに。グアム島あたりに。グアム行きたい。

これをテストしづらくしているのは、ずばり以下のポイントです。

  • 内部でDate や Randomをnewしてる
    → そのせいで、「おはよう」〜「こんばんは」部分はテストを行う時間に影響を受ける。うけないようにするために強制的にMock化が必要
    → Randomも同様
  • 誕生日の判定も年齢判定も時間帯の判定も何でもこのメソッド内で行っている

というわけで、まずはこれをテスタブルなコードに書き換えましょう。
(すみません、ここからやっと本題です)

4-3をテスタブルなコードにする

以下の方針でリファクタリングをします。

誕生日判定などを分ける

この greeting は挨拶をすることだけに特化させてあげます。なので、それ以外の部分は分けてしまいましょう。

class User {
   // プロパティは省略
   boolean isBirthday(Date now) {
      return dateOfBirth.getMonth() == now.getMonth() && dateOfBirth.getDate() == now.getDate();
   }

   int age(Date now) {
      return now.getYear() - dateOfBirth.getYear();
   }

   String fullName() {
      return familyName + firstName;
   }
}

こうすると、誕生日判定や年齢の判定はそれぞれ個別でテストができるようになります。たとえば、

   int age(Date now) {
      return now.getYear() - dateOfBirth.getYear();
   }

のテストとか、見るからに結構やりやすそうですよね。以下のようにできます。

Date now = mock(Date.class);
Date birth = mock(Date.class);
User user = new User();
user.setDateOfBirth(birth);

when(birth).getYear().thenReturn(2000);
when(now).getYear().thenReturn(2020);

assertThat(user.age(now), is(20));

オブジェクトを扱う以上初期化などは必要になってはきますが、 1. と同じくらいシンプルでテスタブルなものになっています。

そして、greetingは以下のように書き換えることができるでしょう。

// 誕生日の場合で表示を変える
public String greeting(@NotNull User user) {
    Date now = new Date();
    String honorific = user.getGender() == User.Gender.MALE ? "くん" : "さん";
    if (user.isBirthday(now)) {
         return String.format("ようこそ%s%s! %d歳のお誕生日おめでとう!", user.fullName(), honorific, user.age());
    }

    // ...省略
}

これの何がいいかというと、Userをmockにすることで上記の判定は簡単にテストできるようになります。
isBirthDay()やfullName()やage()はUser側で単体テストをして担保をするので、ここで改めてやる必要はありません。なので、ここはモックでも問題ないのです。

User user = mock(User.class);
when(user).isBirthday(any()).thenReturn(true);
when(user).fullName().thenReturn("もだかずき");
when(user).getGender().thenReturn(User.Gender.MALE);
when(user).age().thenReturn(20);

assertThat(greeting(user), is("ようこそもだかずきくん!20歳のお誕生日おめでとう!"));

内部でNewしてるDateやRandomを外だしする

次に、内部で生成しているものを外だしします。内部でnewせず、外から渡すことを「Dependency Injection(DI)」といいます。
(ついでだから、時間帯クラスも作成します)

// 時間帯を返せるクラス
public class TimeFrame {
   public enum Type {
       MORNING, NOON, EVENING
   }
   private final Date source;
   public TimeFrame(Date date) {
      this.source = date;
   }

   public Date date() {
      return this.source;
   }
   
   public Type type() {
      // 略。指定した時間によってTypeを返す
   }
}

// 誕生日の場合で表示を変える
public String greeting(@NotNull User user, TimeFrame timeFrame, Random randomGenerator) {
    String honorific = user.getGender() == User.Gender.MALE ? "くん" : "さん";
    if (user.isBirthday(timeFrame.date())) {
         return String.format("ようこそ%s%s! %d歳のお誕生日おめでとう!", user.fullName(), honorific, user.age());
    }

    String greeting = String.format("ようこそ%s%s!",  user.fullName(), honorific );

    switch (timeFrame.type()) {
       case MORNING:
          greeting += "おはよう!";
          break;
       case NOON:
          greeting += "こんにちは!";
          break;
       case EVENING;
          greeting += "こんばんわ!";
          break;
    }
    
    int ran = randomGenerator.nextInt(2);
    if (ran == 0) {
        greeting += "いつもありがとうございます!";
    } else {
        greeting += "ごきげんよう!";
    }
   
    return greeting;
}

こうすることで、if文内部の複雑さも消えて、greetingはただ内容によって結果を変えて出力するだけのメソッドに変わりました。
また、テストケースは相変わらず多いですがTimeFrameやRandomもMock化させることでテストの作りやすいテスタブルなコードになっています。

もっともっとシンプルにする

上記でも、まだ条件分岐が多くてやりづらいですよね。なので、以下のようなものを作って更にシンプルにしてみましょう。

public class MessageGenerator {
    @Inject
    Random randomGenerator;

    public String appendMessage() {
       int ran = randomGenerator.nextInt(2);
       if (ran == 0) {
           return "いつもありがとうございます!";
       } else {
           return "ごきげんよう!";
       }
    }

    public String fullName(User user) {
       String honorific user.getGender() == User.Gender.MALE ? "くん" : "さん";
       return String.format("%s%s", user.fullName(), honorific);
    }

    public String hello(TimeFrame.Type timeFrameType) {
       switch (timeFrame.type()) {
          case MORNING:
             return "おはよう!";
          case NOON:
             return "こんにちは!";
          case EVENING;
             return "こんばんわ!";
        }
    }
}

この @Inject みたいに、Google Guiceみたいに簡単にDIを実現できるライブラリがあります。
それを利用すると、テストが断然しやすくなります。

最終的に、以下のようにできます。

@Inject
MessageGenerator generator;

public String greeting(@NotNull User user, TimeFrame timeFrame) {
    if (user.isBirthday(timeFrame.date())) {
         return String.format("ようこそ%s! %d歳のお誕生日おめでとう!", generator.fullName(user), user.age());
    }

    String greeting = String.format("ようこそ%s!",  generator.fullName(user));
    greeting += generator.hello(timeFrame.type());
    greeting += generator.appendMessage();
   
    return greeting;
}

はい、複雑度も下がって、このコードはとてもテストがしやすくなりました。このメソッド自体のテストのパターンはわずか2パターンだけで良いでしょう。
もちろん、その分MessageGeneratorのテストが増えたりしますので、テストの数自体は変わらないと思います。ただ格段にテスタブルな感じになるはずです。

このコードはとてもシンプルなので「そんなのにここまでしなくても」と思うかもしれませんが、たとえば内部でDBに接続してるクラスや、外部APIを呼び出してるクラスマルチスレッドを呼び出しているクラスのようにとてもテストがしづらいクラスも、同様の方法で分解&こちらで制御がしづらいクラスはDIで外だししてテスト時にMockにするようにすると、テストがシンプルになるはずです。
(今回では、DateクラスやRandomクラスが「制御しづらいクラス」にあたります)。

処理を分解するとテスタブルなコードになる

基本的に、システムは4のように、要件によってどんどん変わったりいろんなクラスを利用することで複雑性が上がっていきます。ただ、それらの大部分は1.〜3.のようなコードに独立させて分解させることができます。

また**「意味のある」** 粒度でコードを分解すると、クラスの意味が明確になり、コードの理解もしやすくなります。
(コードの理解をしやすくなる、というのもテスタブルなコードに必要な要素です)

DIの利用を心がける

またもう一つの技として、Dependency Injectionのことも上げました。
それを利用することで、モック作成などが簡単になり、テストの作成自体が容易になります。

「単体テストめっちゃ大変…」と思う場合、一度「分解すること」と「DIを利用すること」をやってみて下さい。

良いコーディングライフを☆

17
8
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
17
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?