12
23

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.

Mockitoを使ったJavaの単体テスト

Last updated at Posted at 2020-08-10

Mockitoを使ったJavaの単体テスト

目的

本投稿ではMockito,JUnitを使用した単体テストの実施方法について解説します.
なお,サンプルはSpring Bootで作成し,コンストラクタインジェクションを使用しています.
そのため,@Mock, @InjectMockなどのアノテーションは出てきません.

Mockitoとは

Mockitoは、MITライセンスの下でリリースされたJava用のオープンソーステストフレームワーク.(by Wikipedia)
モック伊藤と読みます. モキートと読みます.
これを使うことでテスト用のモックオブジェクトを簡単に生成することができます.

モックを使うと何が嬉しいの?

以下のようなテストはテストを書くことが厄介です.

  • DBの値によって動作を分岐させる
    → テストでDBを書き換えるの?テスト用のDBはどうやって準備するの?毎回同じ値で初期化されてる?
  • 意図的にエラーを発生させる
    → ディスクフルが発生した時のテストってどうやるの?
  • 日付に関するテスト
    → システム時刻を変更するの?

テストしたいクラスが依存するクラスと同じように振る舞うクラス(モック)に差し替えることでこれらの解決します.
メリットとして以下が挙げられます.

  • テスト対象が限定的になりテストしやすい
  • 実際にDBにアクセスしないので爆速でテストの実行が完了する
  • 意図的に例外を発生させることができる
  • 依存クラスの実装が完了していなくてもテストできる

材料

  • Java
  • JUnit5
  • Mockito
  • Gradle or Maven (今回はGradleを利用)
  • Spring Boot

インストール

plugins {
	id 'org.springframework.boot' version '2.3.2.RELEASE'
	id 'io.spring.dependency-management' version '1.0.9.RELEASE'
	id 'java'
}

group = 'com.mockito'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter'
	testImplementation('org.springframework.boot:spring-boot-starter-test') {
		exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
	}

	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
}

test {
	useJUnitPlatform()
}

MockitoはSpring Boot sterter testに含まれていますので,Spring Bootのデフォルトから特別追加する必要はありません.
Lombokだけ使いたかったので追加しています.

テスト対象

概要

商品の合計額を計算するPOSシステムを例として取り扱います.
商品の種類ごとに設定された税率を加味して合計金額を算出します.
税率は今後変更される可能性もあるので,DBから取得するものとします.

※ この章はUnitテストで意味のわからないメソッドが出てきてから読んでも問題ありません.

商品オブジェクトの定義
package com.example.mockito;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

/**
 * 商品の区分
 * 
 * @author rcftdbeu
 *
 */
enum ItemType {
  Food, Other
}

/**
 * 商品
 * 
 * @author rcftdbeu
 *
 */
@Getter
@Setter
@AllArgsConstructor
public class Item {
  /** 名称 */
  String name;
  /** 価格 */
  int price;
  /** タイプ */
  ItemType itemType;
}
合計額を計算するサービス
package com.example.mockito;

import java.util.List;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class POS {

  private final TaxRateMaster taxRateMaster;

  /**
   * 商品の合計額を計算する
   *
   * @param itemList
   * @return
   */
  public int culculateTotal(List<Item> itemList) {
    // 合計金額
    int sum = 0;

    // 商品を1点ずつ税率計算して加算
    for (Item item : itemList) {
      sum += item.getPrice() * (1 + taxRateMaster.getTaxRate(item.getItemType()));
    }
    return sum;
  }
}
モック対象のDBアクセスが含まれるクラス
package com.example.mockito;

import java.text.SimpleDateFormat;
import java.util.Date;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;

/**
 * 税率を取得する
 *
 * @author rcftdbeu
 *
 */
@RequiredArgsConstructor
@Component
public class TaxRateMaster {

  private final TaxRepository ripository;

  /**
   * 本日日付
   *
   * @return
   */
  public Date getSysDate() {
    return new Date();
  }

  /**
   * 軽減税率適用日以降か
   *
   * @return
   */
  private boolean isAfterApplyDate() {
    String strDate = "2019/10/1 00:00:00";
    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
    try {
      Date applyDate = dateFormat.parse(strDate);
      return getSysDate().after(applyDate);
    } catch (Exception e) {
    }
    return false;
  }

  /**
   * 税率を取得する
   *
   * @param type
   * @return
   */
  public Double getTaxRate(ItemType type) {
    // 軽減税率適用後
    if (isAfterApplyDate()) {
      if (type == ItemType.Food) {
        return ripository.getFoodTaxRate();
      } else {
        return ripository.getOtherTaxRate();
      }
    }

    // 軽減税率適用前
    return ripository.getOldTaxRate();
  }
}

リポジトリ
package com.example.mockito;

import org.springframework.stereotype.Repository;

/**
 * DBから税率を取得する処理<br>
 * DBは使用しないので未実装<br>
 * コンパイルエラーを回避するためだけに存在
 * 
 * @author rcftdbeu
 *
 */
@Repository
public class TaxRepository {

  public Double getFoodTaxRate() {
    return null;
  }

  public Double getOtherTaxRate() {
    return null;
  }

  public Double getOldTaxRate() {
    return null;
  }
}

Unit Test

やっと本題です.

Mockitoで利用できるものにmockspyの2種類があります.

mock : 対象のクラスの全てのメソッドがreturn nullで置き換えられたモック.初期化も不要.
spy : 対象のクラスと同じオブジェクトで初期化が必要.必要に応じて特定のメソッドだけ変更できる.

Mock

mockを利用したテストを実施していきます.

package com.example.mockito;

import static org.hamcrest.MatcherAssert.*;
import static org.hamcrest.Matchers.*;
import static org.mockito.Mockito.*;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

public class MockTest {

  private static POS pos;
  private static TaxRateMaster master;
  private static TaxRepository repository;

  @Nested
  static class Mockを利用したテスト {

    // テストの前に1度だけ実行
    @BeforeAll
    static void init() {
      master = mock(TaxRateMaster.class);
      pos = new POS(master);
    }

    // 各テストの前に実行
    @BeforeEach
    void setup() {
      // 食料品の税率は8%
      when(master.getTaxRate(ItemType.Food)).thenReturn(0.08);
      // その他の税率は10%
      when(master.getTaxRate(ItemType.Other)).thenReturn(0.10);
    }

    @Test
    void 食料品の軽減税率計算_8パーセントで計算() {
      // 100円のおにぎりを追加
      List<Item> itemList = new ArrayList<Item>();
      Item onigiri = new Item("おにぎり", 100, ItemType.Food);
      itemList.add(onigiri);

      // 計算
      int sum = pos.culculateTotal(itemList);

      // 結果確認
      assertThat(sum, is(108));
    }

    @Test
    void その他の軽減税率計算_10パーセントで計算() {
      // 500円の雑誌を追加
      List<Item> itemList = new ArrayList<Item>();
      Item onigiri = new Item("雑誌", 500, ItemType.Other);
      itemList.add(onigiri);

      // 計算
      int sum = pos.culculateTotal(itemList);

      // 結果確認
      assertThat(sum, is(550));
    }
  }
}

init()

モックを初期化しています.
POSが依存しているTaxRateMasterをモックに差し替えています.
構造としては以下のようになっています.
POSの実体 ー TaxRateMasterのモック

setup()

モックの動作を定義しています.

when()でどのメソッドが呼ばれたに,thenReturn()で何を返すかを定義します.

特定の引数で実行された時の動作を定義する場合は,その値を直接指定します.
任意の引数で実行された時の動作を定義する場合は,anyString()やany(ItemType.class)などを指定します.

記法について

when().thenReturn()で記述する方法とdoReturn().when()で記述する方法の2種類があります.
setupの例を書き換えるとdoReturn(0.08).when(master).getTaxRate(ItemType.Food);となります.

どちらが良いかは議論が分かれるところです.

when().thenReturn()派

  • ⭕️ if文の同じ順序で読みやすい
  • ❌ spyで利用できない場合がある
  • ❌ voidを返すメソッドでthenThrowが利用できない
    → 正しく設計していればdoReturnを使う機会はない.

doReturn().when()派

  • ⭕️ 気にせずいつでも利用できる
  • ❌ doReturn()に記載する値がObject型になりコンパイラでエラーを検出できない
    → 微妙な違いを覚えるくらいならdoReturnで統一した方が楽だし,型チェックは実行すればわかる.

個人的には読みやすいwhen().thenReturn()が好きなので,本投稿でもこちらの記法としています.

テストの実行

特別なことはなく,テスト用のItemインスタンスを作成し,テスト対象のculculateTotalを実行するだけです.

Spy

@Nested
  static class Spyを利用したテスト {

    // テストの前に1度だけ実行
    @BeforeAll
    static void init() {
      repository = mock(TaxRepository.class);
      master = spy(new TaxRateMaster(repository));
      pos = new POS(master);
    }

    // 各テストの前に実行
    @BeforeEach
    void setup() {
      // 軽減税率適用以前の税率は8%
      when(repository.getOldTaxRate()).thenReturn(0.08);
      // 食料品の税率は8%
      when(repository.getFoodTaxRate()).thenReturn(0.08);
      // その他の税率は10%
      when(repository.getOtherTaxRate()).thenReturn(0.10);
    }

    @Nested
    class 軽減税率適用前 {
      @BeforeEach
      void setup() throws ParseException {
        // 現在の日付を取得したときに軽減税率適用前の日を返すように設定する
        String strDate = "2019/9/30";
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd");
        Date applyDate = dateFormat.parse(strDate);
        when(master.getSysDate()).thenReturn(applyDate);
      }

      @Test
      void 食料品の軽減税率計算_8パーセントで計算() {

        // 100円のおにぎりを追加
        List<Item> itemList = new ArrayList<Item>();
        Item onigiri = new Item("おにぎり", 100, ItemType.Food);
        itemList.add(onigiri);

        // 計算
        int sum = pos.culculateTotal(itemList);

        // 結果確認
        assertThat(sum, is(108));
      }

      @Test
      void その他の軽減税率計算_8パーセントで計算() {
        // 500円の雑誌を追加
        List<Item> itemList = new ArrayList<Item>();
        Item onigiri = new Item("雑誌", 500, ItemType.Other);
        itemList.add(onigiri);

        // 計算
        int sum = pos.culculateTotal(itemList);

        // 結果確認
        assertThat(sum, is(540));
      }
    }

    @Nested
    class 軽減税率適用後 {
      @BeforeEach
      void setup() throws ParseException {
        // 現在の日付を取得したときに軽減税率適用前の日を返すように設定する
        String strDate = "2019/10/1 00:00:01";
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
        Date applyDate = dateFormat.parse(strDate);
        when(master.getSysDate()).thenReturn(applyDate);
      }

      @Test
      void 食料品の軽減税率計算_8パーセントで計算() {
        // 100円のおにぎりを追加
        List<Item> itemList = new ArrayList<Item>();
        Item onigiri = new Item("おにぎり", 100, ItemType.Food);
        itemList.add(onigiri);

        // 計算
        int sum = pos.culculateTotal(itemList);

        // 結果確認
        assertThat(sum, is(108));
      }

      @Test
      void その他の軽減税率計算_10パーセントで計算() {
        // 500円の雑誌を追加
        List<Item> itemList = new ArrayList<Item>();
        Item onigiri = new Item("雑誌", 500, ItemType.Other);
        itemList.add(onigiri);

        // 計算
        int sum = pos.culculateTotal(itemList);

        // 結果確認
        assertThat(sum, is(550));
      }
    }
  }

init()

Spyを初期化しています.
POSが依存しているTaxRateMasterをSpyに設定し,
構造としては以下のようになっています.
POS実体 ー TaxRateMasterのSpy ー TaxRepositoryのモック

setup()

TaxRateMasterのSpyで現在の日付を取得する部分だけ定義した日付に置き換えています.

テストの実行

mockの時と同じで,特に解説する箇所はありません.

その他いろいろな使い方

1度目と2度目で返す値を変更する

when(master.getTaxRate(ItemType.Food)).thenReturn(0.08)
  .thenReturn(0.10);

thenReturnをつなげて書くことで,1回目,2回目の呼び出しで異なる値を返すことができます.
1回目は登録,2回目は更新などの処理の時に利用できます.

メソッドが呼び出されたことを検証する

verify(master).getTaxRate(ItemType.Food);

verifyを使うことでモックのメソッドが呼び出されたことを検証できます.
また,メソッド実行時の引数が,指定の引数で実行されているかも検証できます.

メソッドの呼び出し回数をクリアする

@BeforeEach
void setup() {
  clearInvocations(master);
}

モックのインスタンスは1つで,複数のテストから実行されるとverifyで検証する時に他のテストでの呼び出し回数もカウントしてしまうので,テスト実行前にclearInvocationsでモックの呼び出し回数をリセットする必要があります.

メソッドが複数回呼び出されたことを検証する

verify(master, times(2)).getTaxRate(ItemType.Food);

verifyの引数にtimesを追加することで指定した回数だけメソッドが呼び出されたことを検証できます.

メソッドが呼び出されていないことを検証する

verify(master, never()).getTaxRate(ItemType.Food);

verifyの引数にneverを追加することでメソッドが呼び出されていないことを検証できます.
times(0)でも同じです.

引数を検証する

検証したいメソッドの引数がStringやBooleanなどのプリミティブ型であれば,verifyの後のメソッドの引数で検証できましたが,その他のオブジェクトの場合は,
ArgumentCaptorを利用することで,引数を取得することができます.

@Test
void 引数を取得() {
  // 引数を取得するためのArgumentCaptorを生成
  ArgumentCaptor<ItemType> argCaptor = ArgumentCaptor.forClass(ItemType.class);

  // 100円のおにぎりを追加
  List<Item> itemList = new ArrayList<Item>();
  itemList.add(new Item("おにぎり", 100, ItemType.Food));
  
  // 計算実行
  pos.culculateTotal(itemList);
  
  // 引数を取得する
  verify(master).getTaxRate(argCaptor.capture());
  ItemType executedItemType = argCaptor.getValue();
  
  // 実行時の引数を検証
  assertThat(executedItemType, is(ItemType.Food));
}

例外発生時の動作を検証する

@Test
void 例外発生() {
  when(master.getTaxRate(ItemType.Food)).thenThrow(new RuntimeException("DB接続失敗"));

  // 100円のおにぎりを追加
  List<Item> itemList = new ArrayList<Item>();
  itemList.add(new Item("おにぎり", 100, ItemType.Food));

  // 計算
  assertThrows(RuntimeException.class, () -> pos.culculateTotal(itemList));

  // thenThrowの設定をクリア
  reset(master);
}

thenReturnの変わりにthenThrowを設定することで例外を発生するさせることができます.
例外は該当のメソッドがThrowする例外だけ設定することができます.今回のメソッドでは特に例外を設定していないのでRuntimeExceptionを投げています.
また,他のテストでは例外を投げて欲しくないので,resetでクリアしています.

その他テスト関連

staticメソッドをmockにしたいのですが

デフォルトではmock化できません.
staticメソッドはmockと相性が悪く,可能であれば非staticにする方法をご検討ください.
Power mockを利用するとmock化できるようですが,JUnit5での動かし方が分かりませんでした.

privateメソッドをテストしたいのですが

こちらもmock化できません.
privateメソッドはpublicメソッドを通じてテストできるはずですので,publicメソッドのテストを実装しましょう.
どうしてもという場合は,Power mockを利用する,または,リフレクションなどの黒魔術に手を出すことになります.

12
23
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
12
23

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?