Android端末のロケールを変更するJUnitテストルール

投稿日:  更新日:

多言語対応したアプリのテストで、対応地域の動作を確認するためにロケールが変更できたら便利です。なので、ロケールを変更するテストルールを作成してみました。

スポンサーリンク

ロケールの仕様(API≧24で変更)

端末のロケール(言語/地域)設定はSettingsアプリより行うことが出来ます。この設定により端末で扱われる文字や単位(通貨、日付、時刻など)がロケールに合った表示になります。

このロケールの仕様がAPI≧24で変更になりました(言語とロケールの解決の概要を参照)。変更点は「複数のロケールに対応(複数言語のユーザのサポート)」です。

今までは端末のロケールは1つでしたが複数設定できるようになり、アプリがサポートするロケールと端末のロケール設定を照らし合わせて、もっとも一致するロケールが選択されます。

これに合わせてSettingsアプリも、複数のロケールが選択できるように変わっています。


API<24API≧24

スポンサーリンク

ロケールを取得する方法

現在のロケールの状態はConfigurationクラスから取得できます。

ロケールの仕様変更(API≧24)でシステム内部の扱い方も変わっています。仕様変更後は複数のロケールが選択できるためリストで扱われます。リストの先頭にあるロケールが優先的に選択されます。

KotlinJava
    @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ツール経由でブロードキャストを投げます。

KotlinJava
    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メソッドを駆使して行います。

KotlinJava
    @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);
    }
※上記のコードはAPI≧24のみ対応

ただ、この方法には注意点があります。

    • GUI操作の動作が遅い
    • AVDの操作方法が基準、カスタマイズされていると動かない
    • 対象のロケールは前もって選択しておく必要がある
スポンサーリンク

テストルールの作成

「ロケールを変更する方法」の注意点を考慮して、テストルールは次のような仕様にしました。

    • AVDはAPI≧21で、実機(市販の端末)はAPI≧24で動作
    • API<24はCustomLocaleアプリで変更する
    • API≧24はSettingsアプリをGUI操作して変更する
    • テスト後にテスト前の状態へ戻す
    • 変更の必要がないときはGUI操作をスキップする

※実機がAPI<24の場合に動作しないのは、プログラムが複雑になりそうだったので諦めたためです。

KotlinJava
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を用いてロケールの変更が可能です。

KotlinJava
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("こんにちは !");
    }
}
スポンサーリンク
スポンサーリンク