コネヒト開発者ブログ

コネヒト開発者ブログ

Android版ママリアプリのリファクタ事情 ~時刻テスト編~

こんにちは。2017年11月にAndroidエンジニアとしてjoinした@katsutomuです。

前回のエントリーから、髪の毛はアップデートされておりません。そろそろ予定を立てないとな〜と思いつつ、重い腰が上がりません。

さて今回は、時刻テストに関するリファクタリングについて紹介いたします。

はじめに

コネヒト社で開発しているママリ Android 版は、開発が始まってから 5 年以上経過しました。

開発当初からの歴史の中で、さまざまなコードを継ぎ足してきたママリ Android 版は、いくつもの改善ポイントを抱えています。この記事では、ようやくメスを入れられた 「現在時刻に関係したユニットテストの基盤づくり」 の取り組みを紹介します。

前提

背景

現在時刻に関係したユニットテストのやり方についてググれば、ユニットテスト実行時に現在時刻を固定するサンプルコードは色々ありますが、今回は 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エンジニアを募集中です!

hrmos.co

*1:業務委託で参画してくれている水元さんです