こんにちは。2017年11月にAndroidエンジニアとしてjoinした@katsutomuです。
前回のエントリーから、髪の毛はアップデートされておりません。そろそろ予定を立てないとな〜と思いつつ、重い腰が上がりません。
さて今回は、時刻テストに関するリファクタリングについて紹介いたします。
はじめに
コネヒト社で開発しているママリ Android 版は、開発が始まってから 5 年以上経過しました。
開発当初からの歴史の中で、さまざまなコードを継ぎ足してきたママリ Android 版は、いくつもの改善ポイントを抱えています。この記事では、ようやくメスを入れられた 「現在時刻に関係したユニットテストの基盤づくり」 の取り組みを紹介します。
前提
- io.kotest v4.6.3
背景
現在時刻に関係したユニットテストのやり方についてググれば、ユニットテスト実行時に現在時刻を固定するサンプルコードは色々ありますが、今回は io.kotest
と組み合わせて、少し書きやすくしてみます。
実装
現在時刻を提供するクラス
まずは現在時刻を提供するクラスです。
現状、まだ移行が完了していないため org.threeten.bp.XXX
を使っていますが java.time.XXX
でも同じです。
import androidx.annotation.VisibleForTesting import org.threeten.bp.Clock import org.threeten.bp.Instant import org.threeten.bp.LocalDate import org.threeten.bp.LocalDateTime import org.threeten.bp.ZoneId import org.threeten.bp.ZonedDateTime /** * 現在時刻を提供するクラス */ object CurrentTimeProvider { private val systemClock = Clock.systemDefaultZone() private var currentClock: Clock = systemClock fun currentZoneId(): ZoneId = currentClock.zone fun toLocalDate(): LocalDate = LocalDate.now(currentClock) fun toLocalDateTime(): LocalDateTime = LocalDateTime.now(currentClock) fun toZonedDateTime(): ZonedDateTime = ZonedDateTime.now(currentClock) fun toInstant(): Instant = currentClock.instant() fun toMillis(): Long = currentClock.millis() @VisibleForTesting object Test { const val DEFAULT_YEAR = 2000 const val DEFAULT_MONTH = 1 const val DEFAULT_DAY_OF_MONTH = 1 const val DEFAULT_HOUR = 0 const val DEFAULT_MINUTE = 0 const val DEFAULT_SECOND = 0 const val DEFAULT_NANO_OF_SECOND = 0 /** * 現在時刻を固定する。 */ fun fixed( year: Int = DEFAULT_YEAR, month: Int = DEFAULT_MONTH, dayOfMonth: Int = DEFAULT_DAY_OF_MONTH, hour: Int = DEFAULT_HOUR, minute: Int = DEFAULT_MINUTE, second: Int = DEFAULT_SECOND, nanoOfSecond: Int = DEFAULT_NANO_OF_SECOND, zoneId: ZoneId = ZoneId.of("Asia/Tokyo"), ) { val fixedInstant = ZonedDateTime .of( year, month, dayOfMonth, hour, minute, second, nanoOfSecond, zoneId, ) .toInstant() currentClock = Clock.fixed(fixedInstant, zoneId) } /** * 現在時刻を固定する。 */ fun fixed(time: ZonedDateTime) { currentClock = Clock.fixed(time.toInstant(), time.zone) } /** * 現在時刻を固定する。 */ fun fixed(instant: Instant) { currentClock = Clock.fixed(instant, currentZoneId()) } /** * 現在時刻の固定を解除する。 */ fun tick() { currentClock = systemClock } } }
現在時刻を固定する拡張関数
今回は io.kotest.core.spec.style.ExpectSpec
を対象にしています。
実際のテストコードは test: suspend TestContext.() -> Unit
で実行し、その実行前後で現在時刻の固定と解除をします。
import io.kotest.core.spec.style.scopes.ExpectSpecContainerContext import io.kotest.core.test.TestContext suspend fun ExpectSpecContainerContext.expectOnFixedTime( name: String, year: Int = CurrentTimeProvider.Test.DEFAULT_YEAR, month: Int = CurrentTimeProvider.Test.DEFAULT_MONTH, dayOfMonth: Int = CurrentTimeProvider.Test.DEFAULT_DAY_OF_MONTH, hour: Int = CurrentTimeProvider.Test.DEFAULT_HOUR, minute: Int = CurrentTimeProvider.Test.DEFAULT_MINUTE, second: Int = CurrentTimeProvider.Test.DEFAULT_SECOND, nanoOfSecond: Int = CurrentTimeProvider.Test.DEFAULT_NANO_OF_SECOND, test: suspend TestContext.() -> Unit, ): ExpectSpecContainerContext { CurrentTimeProvider.Test.fixed(year, month, dayOfMonth, hour, minute, second, nanoOfSecond) expect(name, test) CurrentTimeProvider.Test.tick() return this }
実際に現在時刻を固定したテスト
ExpectSpecContainerContext
に対して定義した拡張関数 expectOnFixedTime()
を使います。固定したい時刻を引数で指定します。
ExpectSpecContainerContext#expect(...)
と近いインターフェースにしておいたので、同じような使い方で書けるようになりました。
class ExpectSpecExtensionTest : ExpectSpec({ context("current time") { val expectFixedYear = 1987 val expectFixedMonth = 3 val expectFixedDayOfMonth = 30 expectOnFixedTime("fixed", year = expectFixedYear, month = expectFixedMonth, expectFixedDayOfMonth) { CurrentTimeProvider.toZonedDateTime().apply { year shouldBe expectFixedYear month.value shouldBe expectFixedMonth dayOfMonth shouldBe expectFixedDayOfMonth } } }
おわりに
今回は、現在時刻に関係したユニットテストの基盤づくりの一例を紹介しました。 今後も継続的に改善を進めていく予定です。最後までお読みいただきありがとうございました!
今回の改修を主導してくれた、もっさん*1に感謝します!!
PR
コネヒトでは、バリバリとリファクタリングを進めてくれるAndroidエンジニアを募集中です!
*1:業務委託で参画してくれている水元さんです