🐈

【備忘録】JavaでUUID.randomUUID()やLocalDateTime.now()を単体テストする方法

2021/07/09に公開

はじめに

お疲れ様です.最近はGoばかり仕事で書いていましたが,久しぶりにJavaを書く機会があった+テストの書き方でメモしておきたいことがあったので備忘録として残しておきます.

環境

Java周りのバージョンは以下の通りです.

Components Version
openjdk 11.0.10
Gradle 7.1.1
org.springframework.boot 2.5.2
org.junit.jupiter 5.7.0
org.mockito 3.11.2

やりたいこと

UUID.randomUUID()LocalDateTime.now() のように,実行する度に異なる値を返却するstaticメソッドがあります.これらを内包するメソッドの単体テストを書く方法を整理します.

例えば,以下のような感じでDB登録用のUUIDを払い出すメソッドがあったとします.

KeyUtils.java
package com.numacci.sample.util;

import java.util.UUID;

public class KeyUtils {

    public static UUID issue() {
        return UUID.randomUUID();
    }
}

このレベルで振る舞いが自明あれば,単体テストは不要だよというケースも少なくないと思います.が,例えばこのメソッドに分岐やロジックが入ったり,プロジェクト都合で単体テストを書かなければならない状況になった場合,issue() を実行する度に異なる値を返すこのメソッドのテストをパッと書けるでしょうか?

以下のように LocalDateTime.now() を内部で実行する canRegister() メソッドも同様で,実行する度に現在時刻が変わることに頭を悩まされそうです.

ScheduleServiceImpl.java
package com.numacci.sample.service.impl;

import java.time.LocalDateTime;
import org.springframework.stereotype.Service;

@Service
public class ScheduleServiceImpl implements ScheduleService {

    @Override
    public boolean canRegister(LocalDateTime datetime) {
        if (datetime == null) return false;

        LocalDateTime now = LocalDateTime.now();
        // we can register the schedule after 10 minutes of the current time
        return datetime.isAfter(now.plusMinutes(10));
    }
}

後者については,テストで渡す日時として極端な値をセットすればよいかもしれませんが,「現在時刻から10分後以降でしかスケジュール登録できない」という機能である以上,極端すぎる日時は不自然でしょう.

こういった実行する度に値が変わってしまうstaticメソッドを含む単体テストには,DI (Dependency Injection) のテストでもお馴染みの mockito を利用することができます.

この記事では,mockitoを用いたstaticメソッドの単体テストの書き方について整理していきます.

build.gradleの修正

今回の依存性は次のような感じです.

build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // standard dependencies
    testImplementation 'org.junit.jupiter:junit-jupiter:5.7.0'
    testImplementation 'org.mockito:mockito-core:3.11.2'
    testImplementation 'org.mockito:mockito-junit-jupiter:3.11.2'

    // required to mock static method
    testImplementation 'org.mockito:mockito-inline:3.11.2'
}

SpringでWebアプリケーションを作ろうとしたら上3つは自動的に含まれていると思います.

手動で追加していますが,standard dependencies とコメントで書いてある3つもお馴染みのライブラリだと思います.SpringのプロジェクトでJUnit5を利用できるようにして,mockitoを使えるようにして,mockitoのJUnit5拡張ライブラリを追加しています.

staticメソッドのMock機能が追加されたのがバージョン3.4.0からなので,mockitoのバージョンはそれ以上を指定してあげてください.

依存性としては最後の1つが重要で,この mockito-inline を追加しないとテストコードの実行に失敗します.失敗時に以下のエラーログが出ますが,要するに「staticなメソッドのmockをしたいなら mockito-inline を追加してね」と言っています.(多大なる意訳)

The used MockMaker SubclassByteBuddyMockMaker does not support the creation of static mocks

Mockito's inline mock maker supports static mocks based on the Instrumentation API.
You can simply enable this mock mode, by placing the 'mockito-inline' artifact where you are currently using 'mockito-core'.

以上が依存性についてです.

テストの書き方

本題のテストの書き方です.まずDIとかが含まれていない簡単な KeyUtils.java の方からテストを書いていきます.

KeyUtilsTest.java
package com.numacci.sample.util;

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

import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;

public class KeyUtilsTest {

    private static final UUID uuid = UUID.fromString("5af48f3b-468b-4ae0-a065-7d7ac70b37a8");

    @Test
    public void testIssue() {
        // initialize the mock of the static method
        MockedStatic<UUID> mock = Mockito.mockStatic(UUID.class);
        // define the behaviour of the mock
        mock.when(UUID::randomUUID).thenReturn(uuid);

        assertEquals(uuid, KeyUtils.issue());
    }

}

まず Mockito.mockStatic() でMockしたい UUID クラスをMock化します.MockedStatic<T>T の部分にはMockしたいクラスを指定してください.

次に,when()thenReturn() を用いてMockの振る舞いを指定してあげます.:: はJava8で追加されたメソッド参照です.when() 内で指定したメソッドが呼び出されたとき,thenReturn() で定義した値を返却する,といった書き方となります.この辺の書き方はmockitoを用いたMockの書き方と同じですね.

ここまで来ると毎回異なる値を返す UUID.randomUUID() のMock化ができているので,後は assertEquals() を使って実行結果を評価してあげます.

次にDIを含む ScheduleServiceImpl.java のテストも書いていきます.DIがあるからといって特別な変化は起こらず,上述したstaticメソッドのMock化と通常のDIのMockテストの書き方で書けます.

ScheduleServiceTest.java
package com.numacci.sample.service;

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertFalse;

import java.time.LocalDateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;

public class ScheduleServiceTest {

    private static final LocalDateTime datetime = LocalDateTime.of(2021, 7, 8, 22, 6, 0, 0);

    @InjectMocks
    private ScheduleServiceImpl service;

    @BeforeEach
    public void setup() {
        MockitoAnnotations.openMocks(this);
    }

    @Test
    public void testCanRegister() {
        // initialize the mock of the static method with CALLS_REAL_METHODS
        MockedStatic<LocalDateTime> mock = Mockito.mockStatic(LocalDateTime.class, Mockito.CALLS_REAL_METHODS);
        // define the behaviour of the mock
        mock.when(LocalDateTime::now).thenReturn(datetime);

        // [case] can register
        assertTrue(service.canRegister(LocalDateTime.of(2021, 7, 8, 22, 20, 0, 0)));
        // [case] cannot register
        assertFalse(service.canRegister(LocalDateTime.of(2021, 7, 8, 22, 10, 0, 0)));
    }
}

@InjectMocksMockitoAnnotations.openMocks() はmockitoを用いたMockテストではお馴染みだと思います.DIコンテナに登録されるテスト対象を @InjectMocks で注入しておき,openMocks() を用いてMockの初期化を行います.

testCanRegister() 内では,testIssue() と同様のことをしています.まずは LocalDateTime のstaticメソッドをMock化して,振る舞いを定義して,最後に実行した結果を評価しています.

KeyUtilsTest.java との違いは,CALLS_REAL_METHODSmockStatic() の第2引数として指定しているか否かですが,この子の有り無しでテストコード実行の振る舞いが結構変わります.

CALLS_REAL_METHODS の役割

簡単に書くと,「Mockしたいクラスの部分的なMock化を可能にする」オプションです.

mockitoの javadoc にも以下の通り記載があります.

Answer can be used to define the return values of unstubbed invocations.
This implementation can be helpful when working with legacy code. When this implementation is used, unstubbed methods will delegate to the real implementation. This is a way to create a partial mock object that calls real methods by default.

例えば先程のテストコードで CALLS_REAL_METHODS を指定しなかった場合,テストは失敗します.デバッグ用の sysout を仕込み,以下の CALLS_REAL_METHODS オプションを外した以下のテストコードを実行してみましょう.

public void testCanRegister() {
    MockedStatic<LocalDateTime> mock = Mockito.mockStatic(LocalDateTime.class);
    mock.when(LocalDateTime::now).thenReturn(datetime);

    System.out.println("now " + LocalDateTime.now());
    System.out.println("of  " + LocalDateTime.of(2021, 7, 8, 22, 10, 0, 0));

    assertTrue(service.canRegister(LocalDateTime.of(2021, 7, 8, 22, 20, 0, 0)));
    assertFalse(service.canRegister(LocalDateTime.of(2021, 7, 8, 22, 10, 0, 0)));
}

このテストコードは以下の通り失敗します.

...
> Task :test FAILED
now 2021-07-08T22:06
of  null

expected: <true> but was: <false>
Expected :true
Actual   :false
<Click to see difference>

org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
    ...
    at com.numacci.sample.service.ScheduleServiceTest.testCanRegister(ScheduleServiceTest.java:37)
    ...

ログを見ると,ScheduleServiceImpl.canRegister() メソッドの第1行目の分岐に引っかかって false が返ってきてしまっているようです.

if (datetime == null) return false;

実際,デバッグ用に仕込んでおいた2つの sysout の結果も以下の通りとなっています.now() の実行ではMockされたものが出力されていますが,of() の実行では null が返却されています.

now 2021-07-08T22:06
of  nul

mockitoを既に使ったことがある方であればピンと来たと思います.私達はテストコードを実行する際の LocalDateTime.now() の振る舞いを固定するために LocalDateTime クラスをMock化しています.しかし,Mockの振る舞いとしては now() が実行されたときの定義しかしていません.

mock.when(LocalDateTime::now).thenReturn(datetime);

そのため,クラスとしてはMock化されているのにメソッドとしての振る舞いが定義されていない of()null を返してしまう,ということです.

これを回避するために利用するのが CALLS_REAL_METHODS オプションです.このオプションを指定すると,振る舞いを定義していないメソッドがテストコード実行中に呼ばれた場合,Mockとしてではなく通常のメソッドとして実行されます.

言い換えると,when()thenReturn() でMockを定義した (=スタブ化された) staticメソッドからは指定された値が,スタブ化されていないstaticメソッドからは実際にメソッドが実行された結果が返されることになります.

KeyUtilsTest.java の方では CALLS_REAL_METHODS オプションをつけていない+Mock定義していない UUID.fromString() を使っていて正常実行されていますが,これは UUID クラスがMock化される前に fromString() が実行されているため,本来のメソッドの実行結果が得られています.

このあたりの混乱もありそうかつ意図しない null の返却を防ぐためにも,CALLS_REAL_METHODS オプションは基本的につけておいていいのかなと思います.

おわりに

以上,実行の度に異なる値を返すstaticメソッドの単体テスト方法でした.mockito自体は馴染み深い方が多いと思いますが,mockStatic() によるstaticメソッドのMock化機能はまだ比較的新しいものです.

今まではPowerMockを用いてstaticメソッドのテストをしているケースも多かったみたいですが,調べて出てくるコードはJUnit5化されていないものが多く,書き方に少し困ると思います.(筆者は困りました)

この記事が少しでも多くの人の助けになれば幸いです.

Discussion