スケジュール管理やアラーム機能を提供するアプリのテストで、端末の日付を自由に変更できたら便利です。
ですので、端末の日付を変更するテストルールを作成してみました。
この記事は、以前に投稿した「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ツールを使って変更します。
使い方は次の通りです。コマンドライン中に“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 !") }
ピッカーの操作
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() } }
Automaticモード
端末の日付の制御方法に、「Automaticモード」があります。
Automaticモードは、ネットワーク(ntp)から日付(時刻を含む)を取得して、端末を正確な日付へ自動調整する機能です。
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) } }
関連記事: