Android端末の日付を変更するJUnitテストルール(Api23~34対応)

投稿日:  更新日:

スケジュール管理やアラーム機能を提供するアプリのテストで、端末の日付を自由に変更できたら便利です。

ですので、端末の日付を変更するテストルールを作成してみました。

この記事は、以前に投稿した「Android端末の日付を変更するJUnitテストルール」を改訂したものです。

API23~34で動作するように、テストルールの記述を改良しています。

※環境:Android Studio Jellyfish | 2023.3.1
    Kotlin 1.9.0
    Compose Compiler 1.5.1
    androidx.test.uiautomator:uiautomator 2.3.0
    androidx.test.ext:junit 1.1.5
    androidx.test:rules 1.5.0
    junit:junit 4.13.2

スポンサーリンク

日付を変更する方法

日付を変更する方法として、次の3つがあげられます。

  • dateツールを使う
  • プログラム中から行う
  • SettingsアプリをGUI操作

dateツールを使う

adb shellコマンドでAndroidの仮想端末へ接続し、dateツールを使って変更します。

adb shellコマンド
$ adb devices                   -> 起動中のエミュレータを調べる
List of devices attached
emulator-5554   device

$ adb -s emulator-5554 shell    -> 仮想端末へ接続
emu64xa:/ $ su                  -> Rootへ移行
emu64xa:/ #                     -> プロンプト#がRootの証し

使い方は次の通りです。コマンドライン中に“SET”があれば時刻を設定し、無ければ現在時刻を表示します。

# date -h
usage: date [-u] [-r FILE] [-d DATE] [+DISPLAY_FORMAT] [-D SET_FORMAT] [SET]

Set/get the current date/time. With no SET shows the current date.

Default SET format is "MMDDhhmm[[CC]YY][.ss]"

※MM ... 月、DD ... 日、hh ... 時、mm ... 分

例えば、12月31日23時59分に変更してみましょう。

# date
Fri May  3 07:03:04 GMT 2024
# date 12312359
Tue Dec 31 23:59:00 GMT 2024
# date
Tue Dec 31 23:59:02 GMT 2024

ただし、この方法は「Root権限が必要」です。

UiAutomatorのUiDeviceからadb shellを発行できますが、この場合はRootになれないので使えません。

プログラム中から行う

システムのリソースへアクセスする権限を、プログラム(アプリ)へ持たせる必要があります。

しかし、一般の開発者に、このようなプログラミングは許されていません。

ですので、この方法は不可能です。

端末にプリインストールされているSettingsアプリは、この権限を持ちます。端末のベンダーがアプリの実装を行っているからです。

SettingsアプリをGUI操作

UiAutomatorのUiDeviceを使うと、ユーザが行う端末の操作と同等なことが、プログラム中からできます。

これを使い、Settingsアプリを開いてGUI操作で日付を変更します。

日付変更のピッカー

日付変更のGUIには、2タイプのピッカーがあります。カレンダーとドラムタイプです。

【カレンダータイプ】

カレンダータイプのピッカー
【ドラムタイプ】

ドラムタイプのピッカー

実機において、採用するピッカーはSettingsアプリの実装を行うベンダーが決めます。

Settingアプリはプリインストールされるため、OSのバージョンアップ以外でピッカーが変わることはありません。

しかし、AVD(エミュレータ)において、採用するピッカーは起動時に決まるようです。

ピッカーの選択に法則性が見つけられません。ですので、ランダムなのかも知れません。

ピッカーのタイプはリソース名により判別が可能です。

/* Pickerのタイプ */
internal enum class PickerType(val res: String) {
    DIAL("android:id/radial_picker"),
    CALENDAR("android:id/date_picker_day_picker"),
    DRUM("android:id/numberpicker_input")
}

internal fun getDatePickerType(
    uiDev: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
): PickerType {
    return if(hasObjByRes("android:id/datePicker")) {
        val _isDrum = uiDev.hasObject(By.res(PickerType.DRUM.res))
        val _isCalendar = uiDev.hasObject(By.res(PickerType.CALENDAR.res))
        when {
            _isDrum -> PickerType.DRUM
            _isCalendar -> PickerType.CALENDAR
            else -> throw UiObjectNotFoundException("Unknown picker !")
        }
    }
    else
        throw UiObjectNotFoundException("Picker not found !")
}
サブ関数(hasObjBy***)
internal fun hasObjByRes(
    resource: String,
    uiDev: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
) = uiDev.wait(Until.hasObject(By.res(resource)), 2000)

ピッカーの操作

UiAutomatorのUiDeviceにより、ピッカーを操作します。

ドラムタイプは特別な処理が必要です。

  • 「年月」を合わせてから「日」を合わせます。
    ⇒ 月の日数を確定させるためです。
  • 日を「28」に退避してから年月日を合わせています。
    ⇒ 月ドラムが「2月」を通過すると、日ドラムが「28」または「29」に変更されるためです。
  • 年の境界(例:12月⇒1月)を跨がない。
    ⇒ 年ドラムの繰り上がりを避けるためです。
  • 月の強化(例:31日⇒1日)を跨がない。
    ⇒ 月ドラムの繰り上がりを避けるためです。
カレンダードラム
「<」または「>」ボタンで目的の年月までページ送りし、日をクリックします。

/**
 * カレンダータイプのピッカー
 */
internal fun setDateWithCalendar(
    year: Int,
    month: Int,
    day: Int,
    uiDev: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
) {
    require(year >= 1910) { "year >= 1910" }
    require(month >= 0) { "month >= 0" }
    require(day >= 1) { "day >= 1" }

    val _target = Calendar.getInstance().apply {
        clear()
        set(Calendar.YEAR, year)
        set(Calendar.MONTH, month % 12) // ↓↓↓ うるう年はCalendar任せ ↓↓↓
        val _maxDays = getActualMaximum(Calendar.DAY_OF_MONTH)
        set(Calendar.DAY_OF_MONTH, (day - 1) % _maxDays + 1)
    }
    val _current = Calendar.getInstance()

    val _tYear = _target[Calendar.YEAR]
    val _tMonth = _target[Calendar.MONTH]
    val _tDay = _target[Calendar.DAY_OF_MONTH]
    val _cYear = _current[Calendar.YEAR]
    val _cMonth = _current[Calendar.MONTH]


    // 年月の指定(カレンダーのページ送り)
    val _step = (_tYear - _cYear) * 12 + (_tMonth - _cMonth)
    val _turnBotton = if (_step > 0)
        findObjByRes("android:id/next", uiDev)
    else
        findObjByRes("android:id/prev", uiDev)
    for (i in 0 until Math.abs(_step)) {
        _turnBotton.click()
        uiDev.waitForIdle(2000)
    }
    _turnBotton.recycle()

    // 日付の指定(カレンダーの日付選択)
    findObjByTextAndClick(_tDay.toString(), uiDev)
}
それぞれのドラムをスワイプして、年月日に合わせます。

/**
 * ドラムタイプのピッカー
 */
internal fun setDateWithDrum(
    year: Int,
    month: Int,
    day: Int,
    uiDev: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
){
    require(year >= 1910) {"year >= 1910"}
    require(month >= 0) {"month >= 0"}
    require(day >= 1) {"day >= 1"}

    val _target = Calendar.getInstance().apply {
        clear()
        set(Calendar.YEAR, year)
        set(Calendar.MONTH, month % 12) // ↓↓↓ うるう年はCalendar任せ ↓↓↓
        val _maxDays = getActualMaximum(Calendar.DAY_OF_MONTH)
        set(Calendar.DAY_OF_MONTH, (day - 1) % _maxDays + 1)
    }
    val _current = Calendar.getInstance()

    // 28日に一時退避
    if(_current[Calendar.DAY_OF_MONTH] > 28) {
        val _temp = (_target.clone() as Calendar).apply {
            set(Calendar.DAY_OF_MONTH, 28)
        }
        setFieldWithDrum(uiDev, Calendar.DAY_OF_MONTH, _current, _temp)
        _current.set(Calendar.DAY_OF_MONTH, 28)
    }
    
    // 年月日の指定(ドラムのスワイプ)
    setFieldWithDrum(uiDev, Calendar.YEAR, _current, _target)
    _current.set(Calendar.YEAR, _target[Calendar.YEAR])
    setFieldWithDrum(uiDev, Calendar.MONTH, _current, _target)
    _current.set(Calendar.MONTH, _target[Calendar.MONTH])
    setFieldWithDrum(uiDev, Calendar.DAY_OF_MONTH, _current, _target)
    _current.set(Calendar.DAY_OF_MONTH, _target[Calendar.DAY_OF_MONTH])
}

private fun setFieldWithDrum(
    uiDev: UiDevice,
    field: Int,
    current: Calendar,
    target: Calendar
) {
    val _cField = current[field]
    val _tField = target[field]
    val _times = _tField - _cField

    if(_times != 0) {
        val _nField = if(_times > 0) current.next(field) else current.prev(field)

        val _cLabel = when(field) {
            Calendar.YEAR -> _cField.toString()
            Calendar.MONTH -> getMonthName(_cField)
            Calendar.DAY_OF_MONTH -> "%02d".format(_cField)
            else -> _cField.toString()
        }
        val _nLabel = when(field) {
            Calendar.YEAR -> _nField.toString()
            Calendar.MONTH -> getMonthName(_nField)
            Calendar.DAY_OF_MONTH -> "%02d".format(_nField)
            else -> _nField.toString()
        }

        val _toObj = findObjByText(_cLabel, uiDev)
        val _to = _toObj.visibleCenter
        val _fromObj = findObjByText(_nLabel, uiDev)
        val _from = _fromObj.visibleCenter

        swipeDrum(uiDev, _from, _to, Math.abs(_times))

        _toObj.recycle()
        _fromObj.recycle()
    }
}
サブ関数(swipeDrum)
private fun swipeDrum(
    uiDev: UiDevice,
    from: Point,
    to: Point,
    times: Int
) {
    val _deltaY = ((from.y - to.y) * 0.3f).toInt() // スワイプ距離へ遊び
    repeat(times) {
        uiDev.swipe(from.x, from.y + _deltaY, to.x, to.y, 20)
        uiDev.waitForIdle(2000)
    }
}
サブ関数(Calendar.next/prev)
private fun Calendar.next(field: Int) =
    (this.clone() as Calendar).apply{ add(field, 1) }[field]
private fun Calendar.prev(field: Int) =
    (this.clone() as Calendar).apply{ add(field, -1) }[field]
サブ関数(getMonthName)
private inline fun getMonthName(month: Int) : String {
    require(month in 0..11) {"month in 0..11"}
    return arrayOf(
        "Jan","Feb","Mar","Apr", "May","Jun"
        ,"Jul","Aug", "Sep","Oct","Nov","Dec")[month]
}
サブ関数(findObjBy***)
internal fun findObjByRes(
    resource: String,
    uiDev: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
): UiObject2 {
    return uiDev.wait(Until.findObject(By.res(resource)), 2000)
        ?: throw UiObjectNotFoundException("ResourceName:\"${resource}\"")
}

internal fun findObjByText(
    text: String,
    uiDev: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
): UiObject2 {
    return uiDev.wait(Until.findObject(By.text(text)), 2000)
        ?: throw UiObjectNotFoundException("Text:\"${text}\"")
}

internal fun findObjByTextAndClick(
    text: String,
    uiDev: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
) {
    findObjByText(text, uiDev).apply {
        click()
        recycle()
    }
}
スポンサーリンク

Automaticモード

端末の日付の制御方法に、「Automaticモード」があります。

Automaticモードは、ネットワーク(ntp)から日付(時刻を含む)を取得して、端末を正確な日付へ自動調整する機能です。

Automaticモードの間は任意の日付を設定することが出来ません。ですので、任意の日付を設定したければ、Automaticモードの無効化が必要になります。

Automaticモード切替

有効⇒無効

    private fun _SetSystemDate(year: Int, month: Int, day: Int) {
        require(MIN_YEAR <= year && year <= MAX_YEAR) {
            "${MIN_YEAR} <= year(${year}) <= ${MAX_YEAR}"
        }

        val _api = Build.VERSION.SDK_INT
        val _setDateMesg = PageMesgs.getValue(_api).setdate
        val _automaticMesg = PageMesgs.getValue(_api).auto
        val _okMesg = PageMesgs.getValue(_api).ok
        val _cancelMesg = PageMesgs.getValue(_api).cancel

        // 「Date & time」設定ページを開く
        showDateTimePage(context)

        // 時刻の自動(Network)設定を無効化
        findObjByText(_setDateMesg, uiDev).apply {
            if (! isEnabled)
                findObjByTextAndClick(_automaticMesg, uiDev)
            recycle()
        }

        // 年月日の変更
        findObjByTextAndClick(_setDateMesg, uiDev)      // Pickerを開く
        val _picker = getDatePickerType(uiDev)
        Log.i(TAG, "PickerType(API${Build.VERSION.SDK_INT}) = ${_picker}")
        try {
            when(_picker) {
                PickerType.DRUM ->
                    setDateWithDrum(year, month, day, uiDev)
                PickerType.CALENDAR ->
                    setDateWithCalendar(year, month, day, uiDev)
                else -> {}
            }
            findObjByTextAndClick(_okMesg, uiDev)       // OKボタン
        } catch (e: UiObjectNotFoundException) {
            findObjByTextAndClick(_cancelMesg, uiDev)   // CANCELボタン
            throw UiObjectNotFoundException("Date:${year}/${month + 1}/${day}")
        }

        // 「Date & time」設定ページを閉じる
        findObjByText(_setDateMesg, uiDev).recycle()
        uiDev.pressBack()       // バックボタン押下
    }

無効⇒有効

    private fun _SetAutomatic() {
        val _api = Build.VERSION.SDK_INT
        val _setDateMesg = PageMesgs.getValue(_api).setdate
        val _automaticMesg = PageMesgs.getValue(_api).auto

        // 「Date & time」設定ページを開く
        showDateTimePage(context)

        // 時刻の自動(Network)設定を有効化
        findObjByText(_setDateMesg, uiDev).apply {
            if (isEnabled)
                findObjByTextAndClick(_automaticMesg, uiDev)
            recycle()
        }

        // 「Date & time」設定ページを閉じる
        findObjByText(_setDateMesg, uiDev).recycle()
        uiDev.pressBack()       // バックボタン押下
    }

Settingsページの文面の違い

APIにより、Settingsアプリの文面が異なります。マップで管理し、APIで切り替えます。

/* Settingのページで用いられるメッセージ */
internal data class MesgData(
    val auto: String,
    val setdate: String,
    val settime: String,
    val ok: String,
    val cancel: String
)
internal val PageMesgs = mapOf(
    23 to MesgData("Automatic date & time", "Set date", "Set time", "OK", "CANCEL"),
    24 to MesgData("Automatic date & time", "Set date", "Set time", "OK", "CANCEL"),
    25 to MesgData("Automatic date & time", "Set date", "Set time", "OK", "CANCEL"),
    26 to MesgData("Automatic date & time", "Set date", "Set time", "OK", "CANCEL"),
    27 to MesgData("Automatic date & time", "Set date", "Set time", "OK", "CANCEL"),
    28 to MesgData("Automatic date & time", "Set date", "Set time", "OK", "CANCEL"),
    29 to MesgData("Use network-provided time", "Date", "Time", "OK", "Cancel"),
    30 to MesgData("Use network-provided time", "Date", "Time", "OK", "Cancel"),
    31 to MesgData("Set time automatically", "Date", "Time", "OK", "Cancel"),
    32 to MesgData("Set time automatically", "Date", "Time", "OK", "Cancel"),
    33 to MesgData("Set time automatically", "Date", "Time", "OK", "Cancel"),
    34 to MesgData("Set time automatically", "Date", "Time", "OK", "Cancel")
)
スポンサーリンク

「Data & TIme」ページを開く

日付の変更を行うには、GUI操作を行う前にSettingsアプリの「Data & TIme」ページを開く必要があります。

/**
 * Settingsアプリの「Time & Date」ページを開きます。
 *
 * @param context コンテキスト
 */
internal fun showDateTimePage(
    context: Context = InstrumentationRegistry.getInstrumentation().context
) {
    val intent = Intent(Settings.ACTION_DATE_SETTINGS)
    intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
    context.startActivity(intent)
}
スポンサーリンク

テストルールの作成

ここまで、説明した内容を統合して、テストルールを作成します。

仕様は次の通りです。

  • サポートするAPI:API≧23
  • 指定可能な期間:2015~2030
  • 日付の変更はテスト中のみ有効、テスト後はAutomaticモードが有効
/**
 * システム年月日を設定するルールを提供します.
 * テストが終わると自動的にAutomaticモード(Networkに経由による日付合わせ)に変更されます.
 *
 * @param year 年
 * @param month 月(0,1,2,...,11)
 * @param day 日
 */
@TargetApi(23)
class SysDateTestRule(
    var year: Int = -1, var month: Int = -1, var day: Int = -1
) : TestRule {

    val context = InstrumentationRegistry.getInstrumentation().context
    val uiDev = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

    companion object {
        private const val MIN_YEAR = 2015
        private const val MAX_YEAR = 2030
    }

    // ----- ルール本体 -----------------------------------------------
    override fun apply(base: Statement, description: Description): Statement {
        return object : Statement() {
            override fun evaluate() {
                try {
                    setSysDate()        // 前処理
                    base.evaluate()     // テストの実施
                } finally {
                    setAutomatic()      // 後処理
                }
            }
        }
    }

    /**
     * システム年月日をネット経由で自動的に設定します.
     */
    fun setAutomatic() {
        Log.i(TAG, "setAutomatic")
        _SetAutomatic()
        year = -1
        month = -1
        day = -1
    }

    /**
     * システム年月日を指定値に設定します
     * @param year 年
     * @param month 月(0,1,2,...,11)
     * @param day 日
     */
    fun setSysDate(
        year: Int = this.year, month: Int = this.month, day: Int = this.day
    ) {
        Log.i(TAG, "setSysDate ${year}/${month}/${day}")
        if (year >= 0 && month >= 0 && day >= 0) {
            _SetSystemDate(year, month, day)
            this.year = year
            this.month = month
            this.day = day
        }
    }
	
    // ----------------------------------------------------------------
    private fun _SetAutomatic() { ... }
    private fun _SetSystemDate(year: Int, month: Int, day: Int) { ... }
}

※テストルールの作り方については「JUnitのテストルールの作り方」を参照

スポンサーリンク

テストルールの使用

使用方法はその他のテストルールと変わりません。@Ruleを付けてプロパティを宣言するだけです。

コンストラクタで日付の指定関数で日付の指定
class Rule_SysDate_Test1 {
    
    @get:Rule
    var dateRule = SysDateTestRule(2024, 0, 1)

    @Before
    fun setUp() {
    }

    @After
    fun tearDown() {
    }

    // ----------------------------------------------------------------
    @Test
    fun A1_コンストラクタで日付の指定() {
        val _date = LocalDate.now()
        Truth.assertThat(_date.year).isEqualTo(2024)
        Truth.assertThat(_date.monthValue - 1).isEqualTo(0)
        Truth.assertThat(_date.dayOfMonth).isEqualTo(1)
    }
}
class Rule_SysDate_Test2 {

    @get:Rule
    var dateRule = SysDateTestRule()

    @Before
    fun setUp() {
    }

    @After
    fun tearDown() {
    }

    // ----------------------------------------------------------------
    @Test
    fun B1_関数で日付の指定_未来() {
        dateRule.setSysDate(2025, 2, 3) // 2025.03.03

        val _date = LocalDate.now()
        Truth.assertThat(_date.year).isEqualTo(2025)
        Truth.assertThat(_date.monthValue - 1).isEqualTo(2)
        Truth.assertThat(_date.dayOfMonth).isEqualTo(3)
    }
}
スポンサーリンク

関連記事:

新たなテストルールの作り方を紹介します。 JUnitとAndroidJUnitにはいくつかの有用なテストルールがすでに用意されています。しかし、十分とは言えません。テストルールを自作してテスト環境を機能拡張しましょう! ...
多言語対応したアプリのテストで、対応地域の動作を確認するためにロケールが変更できたら便利です。なので、ロケールを変更するテストルールを作成してみました。 ...
GUIを用いたアプリのテストで、テストの開始前にアニメーションを無効化することが定石となっています。理由は「テストの安定性を確保するためである」と、ドキュメントに記載されています。 この無効化の処理を自動で行えたら便利です。なので、アニメーションを無効にするテストルールを作成してみました。 ...
スケジュール管理やアラーム機能を実装するアプリのテストで、端末の時刻を自由に変更できたら便利です。なので、端末の時刻を変更するテストルールを作成してみました。 この記事は「Android端末の時刻を変更するJUnitテストルール(Api23~34対応)」で改訂されました。 ...
スケジュール管理やアラーム機能を実装するアプリのテストで、端末の日付を自由に変更できたら便利です。なので、端末の日付を変更するテストルールを作成してみました。 ※この記事は「Android端末の日付を変更するJUnitテストルール(Api23~34対応)」で改訂されました。 ...
地図を表示して自身の地理的位置(Geolocation)を管理するアプリのテストで、疑似的に端末の位置が変更できたら便利です。なので、端末の位置を変更するテストルールを作成してみました。 ...
スケジュール管理やアラーム機能を提供するアプリのテストで、端末の時刻を自由に変更できたら便利です。 ですので、端末の時刻を変更するテストルールを作成してみました。 この記事は、以前に投稿した「Android端末の時刻を変更するJUnitテストルール」を改訂したものです。 API23~34で動作するように、テストルールの記述を改良しています。 ※環境:Android Studio Jellyfish | 2023.3.1     Kotlin 1.9.0     Compose Compiler 1.5.1     androidx.test.uiautomator:uiautomator 2.3.0     androidx.test.ext:junit 1.1.5     androidx.test:rules 1.5.0     junit:junit 4.13.2 ...
スポンサーリンク