1年目のエンジニアがユニットテストを書いてみて感じたこと

はじめまして、20新卒エンジニアのmiyoshiryです!
アイスタイル Advent Calender2020の12/11投稿分を担当させていただきます。

業務では主にSpring,vue.jsを使用しています。

配属されてからユニットテストを書く機会が多くあったので、今回は実際にテストを書いてみて感じたことやテストを書くにあたって先輩方からいただいたアドバイスなどを共有したいと思います!

目次

  1. 業務を通して感じたユニットテストのメリット
    1. ユニットテストを書くことによってそのクラスでどのようなことが行われているか知ることができる
    2. リファクタリングした際のテストが簡単
  2. ユニットテストを書くにあたって気を付けていること
    1. 返り値を変換するときはテストとして意味があるかを考えて行う
    2. 比較するときの期待値はテストの目的に合ったものにする
    3. コードカバレッジを100%にすればいいというものではない
    4. 実際に起こりうるテストケースを網羅できているか

業務を通して感じたユニットテストのメリット

業務では主にテストのないクラスにテストクラスを追加したり、非推奨のメソッドを使用しているところを推奨されているメソッドに変えたりといったことをしています。

そういった業務を行う中で感じたメリットが2点あります!

1.ユニットテストを書くことによってそのクラスでどのようなことが行われているか知ることができる

研修が終わって配属されてから一番大変だったことがこれから携わることになるサービスの中身を知るということでした。

はじめのうちはひたすらソースコードとにらめっこをしていたのですが、なかなか全体像をつかむことができませんでした。しかし、テストクラスを実装していくにつれてサービスへの理解も深まっていきました。

テストクラスを作ることでそのクラスに何が渡されて何が返ってくるのかをひとつずつ確認することができるので、テストクラスを作り終えたときにはそのクラスで何が行われているかを理解することができました!

2.リファクタリングをした際のテストが簡単

こちらは非推奨のメソッドを使用しているところを推奨されているメソッドに変える、リファクタリングの作業をするようになって感じたことです!

リファクタリングをする際、コードを改修したら動きが変わっていないかその都度確認が必要です。

改修する度にサービスを動かして確認しているととてつもない時間がかかるので、リファクタリングをする際は対象クラスのテストクラスは必須だといえます。

また、リファクタリングをした際に分岐の条件などを変えてしまっている場合もあるので、テストクラスを作成するときはそのクラスで起こりうる条件をすべて網羅できているかが大事になります。

ユニットテストを書くにあたって気を付けていること

続いてユニットテストを書くにあたって気を付けるようになったことや先輩方いただいたアドバイスを紹介します!

  1. 返り値を変換するときはテストとして意味があるかを考えて行う
  2. 比較するときの期待値はテストの目的に合ったものにする
  3. コードカバレッジを100%にすればいいというものではない
  4. 実際に起こりうるテストケースを網羅できているか

1.返り値を変換するときはテストとして意味があるかを考えて行う

返り値を変換する場合はその値の確認がテストとして意味があるかを考える必要があります。

例えば以下のようなString型の日付をLocalDateTimeにパースするメソッドがあったとします。(ソースコードはJavaです)

@SpringBootApplication
public class DemoApplication {
    String pattern="yyyy-MM-dd HH:mm:ss";
    DateTimeFormatter formatter=DateTimeFormatter.ofPattern(pattern);

    public LocalDateTime toLocalDateTime(String dateTime){
        return LocalDateTime.parse(dateTime, formatter);
    }
}

返り値を変換して確認する方法としては以下のようになります。

@SpringBootTest
class DemoApplicationTests {

  @Test
  void toLocalDateTimeのテスト() {
    //Setup
    var formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    var target = new DemoApplication();

    //Exercise
    var actual = target.toLocalDateTime("2020-01-01 00:00:00");

    //Verify
    assertThat(actual.format(formatter)).isEqualTo("2020-01-01 00:00:00");
  }
}

assertThatでdateメソッドの返り値をチェックするときにactual.format(formatter)で返り値をフォーマットしています。

期待値を変換して確認する方法としては以下のようになります。

@SpringBootTest
class DemoApplicationTests {

  @Test
  void toLocalDateTimeのテスト() {
    //Setup
    var formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    var target = new DemoApplication();

      //Exercise
    var actual = target.toLocalDateTime("2020-01-01 00:00:00");

      //Verify
    assertThat(actual).isEqualTo(LocalDateTime.parse("2020-01-01 00:00:00", formatter));
  }
}

こちらはisEqualToのほうを想定される返り値に変換しています。変換の仕方などによってはテストの意味がなくなってしまうことがあるので、テストの意味を考えて書くようにしましょう。

2.比較するときの期待値はテストの目的に合ったものにする

返ってきた値を比較するときはどの値と比較するかも重要になってきます。

例えば以下のようなLong型のidとString型のnameを受け取ってkeyとvalueにその値を入れたMapを返すメソッドがあったとします。

@SpringBootApplication
public class DemoApplication {
public Map<Long, String> toMap(Long id, String name) {
    Map<Long, String> map = new HashMap<>();
    map.put(id, name);
    return map;
  }

変数nameと文字列”name”を比較するようなテストは以下のようになります。

@SpringBootTest
class DemoApplicationTests {

@Test
  void toMapのテスト() {
    //Setup
    var name = "name";
    var target = new DemoApplication();

    //Exercise
    var actual = target.toMap(1L, name);

    //Verify
    assertThat(actual.get(1L)).isEqualTo("name");
  }
}

こうするとテストは通りますが、正確には変数nameの値を入れているのに文字列”name”と比較していることになります。
中身は同じですが、同一インスタンスではないですね。

この書き方だと以下のような、Long型のidとName型のnameを受け取ってkeyとvalueにその値を入れたMapを返すメソッドのテストを行った際にテストが通りません。

public class Name {
  private String name;

  public Name(String name){
      this.name = name;
  }
}
@SpringBootApplication
public class DemoApplication {
public Map<Long, Name> toMap(Long id, Name name) {
    Map<Long, Name> map = new HashMap<>();
    map.put(id, name);
    return map;
  }

なので、上記のメソッドをテストしたいときは以下のように書きます。

@SpringBootTest
class DemoApplicationTests {

@Test
  void toMapのテスト() {
    //Setup
    var name = new Name("name");
    var target = new DemoApplication();

    //Exercise
    var actual = target.toMap(1L, name);

    //Verify
    assertThat(actual.get(1L)).isSameAs(name);
  }
}

こうすることで同一インスタンスであるか検証することができます。

テストの目的によってどこまでの比較が必要かは変わってくるので二つの違いを知った上で選びましょう。

3.コードカバレッジを100%にすればいいというものではない

初めてテストクラスを作成したときはカバレッジを100%にすることばかり意識していました。

やっと、カバレッジが100%になったので先輩にレビューをしていただいたところ、テストケースがふたつほど不足していました。

僕はカバレッジが100%になったらテストケースも網羅できていると思っていたのですが、そんなことはありませんでした。

逆に、起こりうるテストケースを網羅したのにカバレッジが100%にならない場合はテスト対象のクラス自体に不要なメソッドや条件分岐があることになります。

コードカバレッジはテストケースを網羅したなと思った時に確認するのが良いのかなと思いました!

4.実際に起こりうるテストケースを網羅できているか

メリットの2でも少し出てきましたが、テストクラスを作成するときは起こりうるケースを網羅できているかということが重要になります。

以下のようなListの先頭要素がnullでなければその値のmapの結果を、nullであれば空文字を返すメソッドがあったとします。

@SpringBootApplication
public class DemoApplication {

  final String DEFAULT_NAME = "";

  public String map(List<String> name) {
    return Optional
        .ofNullable(name.get(0))
        .orElse(DEFAULT_NAME);
  }
}

このメソッドのテストを行う場合、Listにnull以外の値が入っている場合と値がnullの場合のケースが必要になります。

@SpringBootTest
class DemoApplicationTests {

 @Test
  void mapのテスト_LIstの先頭要素がnullでない場合() {
    //Setup
    var nameList = new ArrayList();
    nameList.add("name");
    var target = new DemoApplication();

    //Exercise
    var actual = target.map(nameList);

    //Verify
    assertThat(actual).isEqualTo(nameList.get(0));
  }

  @Test
  void mapのテスト_Listの先頭要素がnullの場合() {
    //Setup
    var nameList = new ArrayList();
    nameList.add(null);
    var target = new DemoApplication();

    //Exercise
    var actual = target.map(nameList);

    //Verify
    assertThat(actual).isEmpty();
  }
}

特にListなどは中身が空のケースを書かなくてもカバレッジが100%になってしまうこともあるので、Listなどを使用するときはケースをよく考える必要があります。

先輩からのレビューで一番指摘が多かったのが、空の場合のケースを追加してください!でした。

最後に

入社してから初めてユニットテストを書いてみて多くの気づき、学びがありました。
特にリファクタリングの作業をするまでは、サービスを動かしてテストすればいいじゃん!と思っていたのですが、リファクタリングをしてみてユニットテストの重要性に気づくことができました!

皆さんもユニットテストのこと大事にしてあげてください!!

新卒1年目のエンジニアです。年に4回は高尾山登ります。