多言語対応したアプリのテストで、対応地域の動作を確認するためにロケールが変更できたら便利です。なので、ロケールを変更するテストルールを作成してみました。
目次
ロケールの仕様(API≧24で変更)
端末のロケール(言語/地域)設定はSettingsアプリより行うことが出来ます。この設定により端末で扱われる文字や単位(通貨、日付、時刻など)がロケールに合った表示になります。
このロケールの仕様がAPI≧24で変更になりました(言語とロケールの解決の概要を参照)。変更点は「複数のロケールに対応(複数言語のユーザのサポート)」です。
今までは端末のロケールは1つでしたが複数設定できるようになり、アプリがサポートするロケールと端末のロケール設定を照らし合わせて、もっとも一致するロケールが選択されます。
これに合わせてSettingsアプリも、複数のロケールが選択できるように変わっています。
API<24 | API≧24 |
---|---|
ロケールを取得する方法
現在のロケールの状態はConfigurationクラスから取得できます。
ロケールの仕様変更(API≧24)でシステム内部の扱い方も変わっています。仕様変更後は複数のロケールが選択できるためリストで扱われます。リストの先頭にあるロケールが優先的に選択されます。
@TargetApi(24) private fun _GetLocaleList(): LocaleList { val _Config = Resources.getSystem().configuration return _Config.locales } private fun _GetCurrentLocale(): Locale { val _Config = Resources.getSystem().configuration return if (Build.VERSION.SDK_INT >= 24) { val _LocaleList = _Config.locales _LocaleList[0] } else { _Config.locale // API≧24で非推奨 } }
@TargetApi(24) private LocaleList _GetLocaleList() { Configuration _Config = Resources.getSystem().getConfiguration(); return _Config.getLocales(); } private Locale _GetCurrentLocale() { Configuration _Config = Resources.getSystem().getConfiguration(); if(Build.VERSION.SDK_INT >= 24) { LocaleList _LocaleList = _Config.getLocales(); return _LocaleList.get(0); } else { return _Config.locale; // API≧24で非推奨 } }
ロケールを変更する方法
CustomLocaleアプリを使う
端末にインストールされたCustomLocaleアプリを使って変更します。
使い方はブロードキャストを投げるだけです。ブロードキャストを投げると、CustomLocaleアプリのレシーバが反応してロケールの変更を行ってくれます。
テストではUiDevice(UiAutomator)からadb shellコマンドが発行できるので、amツール経由でブロードキャストを投げます。
private fun _ChangeLocaleByApp(locale: Locale) { val _UiDevice = UiDevice.getInstance( InstrumentationRegistry.getInstrumentation() ) val _Command = StringBuffer() _Command.append("am broadcast ") _Command.append("-a com.android.intent.action.SET_LOCALE ") _Command.append("--es com.android.intent.extra.LOCALE ") _Command.append(locale.toString()) _Command.append(" com.android.customlocale2") _UiDevice.executeShellCommand(_Command.toString()) }
private void _ChangeLocaleByApp(Locale locale) throws Exception { UiDevice _UiDevice = UiDevice.getInstance( InstrumentationRegistry.getInstrumentation() ); StringBuffer _Command = new StringBuffer(); _Command.append("am broadcast "); _Command.append("-a com.android.intent.action.SET_LOCALE "); _Command.append("--es com.android.intent.extra.LOCALE "); _Command.append(locale.toString()); _Command.append(" com.android.customlocale2"); _UiDevice.executeShellCommand(_Command.toString()); }
ただ、この方法には注意点があります。
-
- 複数のロケールが選択されている(API≧24の時)場合、1つに変わってしまう
- CustomLocaleアプリはAPI≧28でインストールされなくなった
- CustomLocaleアプリは実機(市販の端末)でインストールされていない
SettingsアプリをGUI操作
Settingsアプリを開いてGUIの操作で変更します。つまり、ユーザが手で操作するのと同じことをプログラム的に行わせようというわけです。
UiDeviceからGUIが操作できるので、dragメソッドを駆使して行います。
@TargetApi(24) private fun _ChangeLocaleByGUI(locale: Locale) { val _UiDevice = UiDevice.getInstance( InstrumentationRegistry.getInstrumentation() ) // ----- 選択可能なLanguageのリストを取得 val _LocaleList = _GetLocaleList() val _Index = _LocaleList.indexOf(locale) val _Number = _LocaleList.size() // ----- SettingのLanguageページを表示、Pickerを取得 val _ResName = "com.android.settings:id/dragList" _ShowSettingsLanguage() _UiDevice.wait(Until.findObject(By.res(_ResName)), 2000) val _UiObject = _UiDevice.findObject(By.res(_ResName)) val _Rect = _UiObject.visibleBounds // ----- Picker上でロケールを変更(dragして移動) val _DragPoints = _DragFromAToB(_Index, _Number, _Rect) _UiDevice.drag( _DragPoints[0].x, _DragPoints[0].y, // From _DragPoints[1].x, _DragPoints[1].y, // To 50 * _Index // 移動が速いとリストの入れ替えが失敗する ) // ----- SettingのLanguageページを閉じる _UiDevice.pressBack() } private fun _DragFromAToB(index: Int, number: Int, area: Rect): Array<Point> { val _ItemHeight = (area.bottom - area.top) / number // 少し長めにDragして移動 val _FromX = area.right - 10 val _FromY = area.top + _ItemHeight * (index + 1) - 5 val _FromPoint = Point(_FromX, _FromY) val _ToX = area.right - 10 val _ToY = area.top + 5 val _ToPoint = Point(_ToX, _ToY) return arrayOf(_FromPoint, _ToPoint) } private fun _ShowSettingsLanguage() { val _Context = InstrumentationRegistry.getInstrumentation().targetContext val _Intent = Intent(Settings.ACTION_LOCALE_SETTINGS) _Intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK _Context.startActivity(_Intent) }
@TargetApi(24) private void _ChangeLocaleByGUI(Locale locale) { UiDevice _UiDevice = UiDevice.getInstance( InstrumentationRegistry.getInstrumentation() ); // ----- 選択可能なLanguageのリストを取得 LocaleList _LocaleList = _GetLocaleList(); int _Index = _LocaleList.indexOf(locale); int _Number = _LocaleList.size(); // ----- SettingのLanguageページを表示、Pickerを取得 String _ResName = "com.android.settings:id/dragList"; _ShowSettingsLanguage(); _UiDevice.wait(Until.findObject(By.res(_ResName)), 2000); UiObject2 _UiObject = _UiDevice.findObject(By.res(_ResName)); Rect _Rect = _UiObject.getVisibleBounds(); // ----- Picker上でロケールを変更(dragして移動) Point[] _DragPoints = _DragFromAToB(_Index, _Number, _Rect); _UiDevice.drag( _DragPoints[0].x, _DragPoints[0].y, // From _DragPoints[1].x, _DragPoints[1].y, // To 20 * _Index // 移動が速いとリストの入れ替えが失敗する ); // ----- SettingのLanguageページを閉じる _UiDevice.pressBack(); } private Point[] _DragFromAToB(int index, int number, Rect area) { int _ItemHeight = (area.bottom - area.top) / number; // 少し長めにDragして移動 int _FromX = area.right - 10; int _FromY = area.top + _ItemHeight * (index + 1) - 5; Point _FromPoint = new Point(_FromX, _FromY); int _ToX = area.right - 10; int _ToY = area.top + 5; Point _ToPoint = new Point(_ToX, _ToY); return new Point[]{_FromPoint, _ToPoint}; } private void _ShowSettingsLanguage() { Context _Context = InstrumentationRegistry.getInstrumentation().getTargetContext(); Intent _Intent = new Intent(android.provider.Settings.ACTION_LOCALE_SETTINGS); _Intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); _Context.startActivity(_Intent); }
ただ、この方法には注意点があります。
-
- GUI操作の動作が遅い
- AVDの操作方法が基準、カスタマイズされていると動かない
- 対象のロケールは前もって選択しておく必要がある
テストルールの作成
「ロケールを変更する方法」の注意点を考慮して、テストルールは次のような仕様にしました。
-
- AVDはAPI≧21で、実機(市販の端末)はAPI≧24で動作
- API<24はCustomLocaleアプリで変更する
- API≧24はSettingsアプリをGUI操作して変更する
- テスト後にテスト前の状態へ戻す
- 変更の必要がないときはGUI操作をスキップする
※実機がAPI<24の場合に動作しないのは、プログラムが複雑になりそうだったので諦めたためです。
class LocaleTestRule(var testLocale: Locale) : TestRule { private val currLocale: Locale = _GetCurrentLocale() // ------------------------------------------------------------------------ override fun apply(base: Statement, description: Description): Statement { return object : Statement() { override fun evaluate() { try { _ChangeLocale(testLocale) // 前処理 base.evaluate() // テストの実施 } finally { _ChangeLocale(currLocale) // 後処理 } } } } fun changeLocale(locale: Locale) { testLocale = locale _ChangeLocale(testLocale) } // ------------------------------------------------------------------------ private fun _ChangeLocale(locale: Locale) { if (locale == _GetCurrentLocale()) return // 変更の必要性の有無 if (Build.VERSION.SDK_INT >= 24) { val _LocaleList = _GetLocaleList() if (_LocaleList.indexOf(locale) < 0) { // 変更が可能であるか val _Buffer = StringBuffer() _Buffer.append("\"").append(locale.toString()).append("\"") _Buffer.append(" isn't usable locale.") throw IllegalArgumentException(_Buffer.toString()) } _ChangeLocaleByGUI(locale) } else { _ChangeLocaleByApp(locale) } } ... }
public class LocaleTestRule implements TestRule { private Locale mCurrLocale; private Locale mTestLocale; public LocaleTestRule(@NonNull Locale locale) { mTestLocale = locale; mCurrLocale = _GetCurrentLocale(); } // ------------------------------------------------------------------------ @Override public Statement apply(final Statement base, Description description) { return new Statement() { @Override public void evaluate() throws Throwable { try { _ChangeLocale(mTestLocale); // 前処理 base.evaluate(); // テストの実施 } finally { _ChangeLocale(mCurrLocale); // 後処理 } } }; } public void changeLocale(@NonNull Locale locale) throws Exception { mTestLocale = locale; _ChangeLocale(locale); } // ------------------------------------------------------------------------ private void _ChangeLocale(Locale locale) throws Exception { if (locale.equals(_GetCurrentLocale())) return; // 変更の必要性の有無 if (Build.VERSION.SDK_INT >= 24) { LocaleList _LocaleList = _GetLocaleList(); if (_LocaleList.indexOf(locale) < 0) { // 変更が可能であるか StringBuffer _Buffer = new StringBuffer(); _Buffer.append("\"").append(locale.toString()).append("\""); _Buffer.append(" isn't usable locale."); throw new IllegalArgumentException(_Buffer.toString()); } _ChangeLocaleByGUI(locale); } else { _ChangeLocaleByApp(locale); } } ... }
テストルールの使用方法
使用方法はその他のテストルールと変わりません。@Ruleを付けてプロパティ(Javaの場合はフィールド)を宣言するだけです。
ターゲットのロケールの指定はLocaleクラスで行うようになっています。Localeクラスに言語と地域を指すパラメータが記載されているので、それを用います。日本ならLocale.JAPAN、英語圏ならLocale.USやLocale.UKなどです。
テストメソッドの途中でもchangeLocaleを用いてロケールの変更が可能です。
class Sample_Test { private val context = InstrumentationRegistry.getInstrumentation().targetContext @get:Rule var localeRule = LocaleTestRule(Locale.US) // ------------------------------------------------------------------------ @Test fun StringResource_ロケールen_US() { assertThat(context.resources.getString(R.string.hello)) .isEqualTo("Hello !") } @Test fun StringResource_ロケールja_JP() { localeRule.changeLocale(Locale.JAPAN) assertThat(context.resources.getString(R.string.hello)) .isEqualTo("こんにちは !") } }
public class Sample_Test { private Context mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); @Rule public LocaleTestRule mLocaleRule = new LocaleTestRule(Locale.US); // ------------------------------------------------------------------------ @Test public void StringResource_ロケールen_US() throws Exception { assertThat(mContext.getResources().getString(R.string.hello)) .isEqualTo("Hello !"); } @Test public void StringResource_ロケールja_JP() throws Exception { mLocaleRule.changeLocale(Locale.JAPAN); assertThat(mContext.getResources().getString(R.string.hello)) .isEqualTo("こんにちは !"); } }