🍸

Mockito + JUnit5でDateとCalendarをmockしたかっただけ(失敗)

2023/02/16に公開

概要

  • Kotlinアプリのユニットテストで、 Date() とか Calendar.getInstance() を使った関数が出てきたのでmockしたかった
  • PowerMockが最近メンテされてなさそうだから生のMockito kotlinだけで書きたかった

問題のコード

アラームサービスがあるとして、その設定時刻を持つ型を AlarmType とし、getNearestメソッドで次回鳴らすべき時刻を取り出せるとする。

AlarmType.kt
data class AlarmTime(val value: Date) {
    companion object {
        // Factory Method
        /** HH:mm:ss 形式の引数からコンストラクト */  // 1970/01/01 の、その時刻
        fun from(timeString: String): AlarmTime {
            return SimpleDateFormat("HH:mm:ss", Locale("JP")).parse(timeString).let {
                AlarmTime(it)
            }
        }
    }

    /** 現在から見て、次のアラーム時刻
     * (今日まだきていなければ今日、もう過ぎていれば明日の時刻) **/
    fun getNearest(): AlarmTime {
        val now = DateUtil.getCalendar()
        return AlarmTime(
            DateUtil.getCalendar().let {
                it.time = this.value
                it.set(now.get(Calendar.YEAR), now.get(Calendar.MONTH), now.get(Calendar.DAY_OF_MONTH))
                if (it.time < now.time) it.add(Calendar.DAY_OF_MONTH, 1) // 過去なら明日に
                it.time
            }
        )
    }
}

実装

シングルトンにInject

まず、Date() とか Calendar.getInstance()は鬼ほど出てくるのでclassとしてconstructor injectionしていてはキリがないと判断し、objectでシングルトンとしてwrapした

DateUtil.kt
object DateUtil {
    var systemDate = SystemDate()
    fun now(): Date = systemDate.now()
    fun getCalendar(): Calendar = systemDate.getCalendar()
}

class SystemDate {
    fun now(): Date = Date()
    fun getCalendar(): Calendar = Calendar.getInstance()
}

objectなのでコンストラクタインジェクションはできないが、後からDateUtil.systemDate = MockSystemDate() という形でMockとすり替えれば良さそうと思った

テストを書いてみる

現在時刻を 2022/01/01 12:00:00 に固定し、

AlarmTimeTest.kt
@DisplayName("AlarmTimeのユニットテスト")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class AlarmTimeTest {

    @BeforeAll()
    fun init() {
        val mockSystemDate = mock<SystemDate> {
            on { now() } doReturn SystemDate().getCalendar().let {
                it.set(2022, 0, 1, 12, 0, 0)
                it.time
            }
            on { getCalendar() } doReturn Calendar.getInstance().let {
		it.set(2022, 0, 1, 12, 0, 0)
		it
	    }
        }
        DateUtil.systemDate = mockSystemDate
    }

    @Test
    @DisplayName("まだきていないので今日の時刻")
    fun getNearest_today() {
        val value = AlarmTime.from("13:00:00")?.getNearest()
        assertEquals(
            SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale("JP")).parse("2022/01/01 13:00:00"),
            value?.value
        )
    }

    @Test
    @DisplayName("もう過去なので明日の時刻")
    fun getNearest_tomorrow() {
        val value = AlarmTime.from("11:00:00")?.getNearest()
        assertEquals(
            SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale("JP")).parse("2022/01/02 11:00:00"),
            value?.value
        )
    }
}

これでgetNearest_today() テストを叩いてみる

expected: <Sat Jan 01 13:00:00 JST 2022> but was: <Thu Jan 01 13:00:00 JST 1970>
Expected :Sat Jan 01 13:00:00 JST 2022
Actual   :Thu Jan 01 13:00:00 JST 1970

org.opentest4j.AssertionFailedError: expected: <Sat Jan 01 13:00:00 JST 2022> but was: <Thu Jan 01 13:00:00 JST 1970>

んんん?

いきなり1970年に戻ってしまった。

原因解析

怪しいところをprintしてみる。

AlarmTimeTest.kt
    fun getNearest(): AlarmTime {
        val now = DateUtil.getCalendar()
        return AlarmTime(
            DateUtil.getCalendar().let {
                println("now1: ${now}")
                it.time = this.value
                println("now2: ${now}")
                it.set(now.get(Calendar.YEAR), now.get(Calendar.MONTH), now.get(Calendar.DAY_OF_MONTH))
                if (it.time < now.time) it.add(Calendar.DAY_OF_MONTH, 1) // 過去なら明日に
                it.time
            }
        )
    }
now1: java.util.GregorianCalendar[time=?,areFieldsSet=false,areAllFieldsSet=true,lenient=true,zone=sun.util.calendar.ZoneInfo[id="Asia/Tokyo",offset=32400000,dstSavings=0,useDaylight=false,transitions=10,lastRule=null],firstDayOfWeek=1,minimalDaysInFirstWeek=1,ERA=1,YEAR=2022,MONTH=0,WEEK_OF_YEAR=7,WEEK_OF_MONTH=3,DAY_OF_MONTH=1,DAY_OF_YEAR=47,DAY_OF_WEEK=5,DAY_OF_WEEK_IN_MONTH=3,AM_PM=1,HOUR=4,HOUR_OF_DAY=12,MINUTE=0,SECOND=0,MILLISECOND=335,ZONE_OFFSET=32400000,DST_OFFSET=0]
now2: java.util.GregorianCalendar[time=14400000,areFieldsSet=true,areAllFieldsSet=true,lenient=true,zone=sun.util.calendar.ZoneInfo[id="Asia/Tokyo",offset=32400000,dstSavings=0,useDaylight=false,transitions=10,lastRule=null],firstDayOfWeek=1,minimalDaysInFirstWeek=1,ERA=1,YEAR=1970,MONTH=0,WEEK_OF_YEAR=1,WEEK_OF_MONTH=1,DAY_OF_MONTH=1,DAY_OF_YEAR=1,DAY_OF_WEEK=5,DAY_OF_WEEK_IN_MONTH=1,AM_PM=1,HOUR=1,HOUR_OF_DAY=13,MINUTE=0,SECOND=0,MILLISECOND=0,ZONE_OFFSET=32400000,DST_OFFSET=0]

DateUtil.getCalendar() を2回叩いて2個目を編集したら1個目も変わってしまった。これはつまり、

        val mockSystemDate = mock<SystemDate> {
	....
            on { getCalendar() } doReturn Calendar.getInstance().let {
		it.set(2022, 0, 1, 12, 0, 0)
		it
	    }
        }

でmockした getCalendar() が、毎回同じインスタンスを返しているというのが原因である。ちなみに now() の方は、 Date 型がimmutableなので問題は起きていなかった。

暫定の処置

これはあまりにも不本意であるが、doReturn の代わりに doReturnConsecutively を使う。

https://github.com/mockito/mockito-kotlin/blob/37bbae48634d879640a754a1b122db99f0b00478/tests/src/test/kotlin/test/OngoingStubbingTest.kt#L255-L265

これは呼ばれるたびに値が変わるメソッドのmock用の機能であるが、Listを渡さなければならないので、有限の個数Calendarインスタンスを渡してやることにする。

AlarmTimeTest.kt
    @BeforeAll()
    fun init() {
        val mockSystemDate = mock<SystemDate> {
            on { now() } doReturn Calendar.getInstance().let {
                it.set(2022, 0, 1, 12, 0, 0)
                it.time
            }
//            on { getCalendar() } doReturn Calendar.getInstance().let {
//                it.set(2022, 0, 1, 12, 0, 0)
//                it
//            }
            on { getCalendar() } doReturnConsecutively Array(100) { // ここが酷い!
                Calendar.getInstance()
                    .let {
                        it.set(2022, 0, 1, 12, 0, 0)
                        it
                    }
            }.toList()
        }
        DateUtil.systemDate = mockSystemDate
    }

101回mockされるとバグる。適宜増やしましょう。

まとめ

  • Mockitoの情報はいっぱいあるけどMockito-kotlinの情報は少ない
  • 誰か doReturn で毎回別のインスタンスを返す方法わかったら教えてください(涙)

Discussion