🍸
Mockito + JUnit5でDateとCalendarをmockしたかっただけ(失敗)
概要
- 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
を使う。
これは呼ばれるたびに値が変わるメソッドの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