23、本地化测试:字符串资源测试、RTL布局测试、区域设置测试

本地化测试,说白了就是检查你的App在不同语言、不同地区下能不能正常工作。我见过太多App,中文界面完美无瑕,一切换到阿拉伯语就界面错乱、文字截断、甚至直接崩溃。嗯,这章我们就来聊聊怎么用Compose UI测试把这些坑都填上。

核心要点:本地化测试不是简单的翻译检查,而是验证字符串资源是否正确加载、RTL布局是否正常渲染、区域设置是否影响业务逻辑。这三者缺一不可。

23.1 字符串资源测试:别让翻译"跑偏"

字符串资源测试,听起来简单,但坑不少。我个人习惯在测试中直接验证资源ID对应的字符串值,而不是硬编码中文或英文。

23.1.1 基础字符串加载测试

@Test
fun testStringResourceLoaded() {
    // 使用ComposeTestRule获取上下文
    composeTestRule.activity.apply {
        val expected = getString(R.string.app_name)
        assertEquals("我的应用", expected)
    }
}

// 更推荐的方式:通过Compose的stringResource
@Test
fun testStringInCompose() {
    composeTestRule.setContent {
        val greeting = stringResource(R.string.greeting)
        Text(text = greeting)
    }
    composeTestRule.onNodeWithText("你好,世界").assertExists()
}

我在项目中遇到过一个问题:某个翻译人员把占位符写错了,导致运行时崩溃。后来我加了一个测试,专门检查所有带占位符的字符串格式是否正确。

23.1.2 占位符参数验证

@Test
fun testStringWithPlaceholder() {
    composeTestRule.setContent {
        val message = stringResource(R.string.welcome_message, "张三")
        Text(text = message)
    }
    // 验证占位符被正确替换
    composeTestRule.onNodeWithText("欢迎,张三").assertExists()
}

// 批量测试所有语言
@Config(locales = ["en", "zh", "ar", "ja"])
@Test
fun testAllLocalesPlaceholder() {
    composeTestRule.setContent {
        val message = stringResource(R.string.order_count, 5)
        Text(text = message)
    }
    // 不同语言下,数字5应该出现在正确位置
    composeTestRule.onNode(hasText(containsString("5"))).assertExists()
}

小技巧:我建议在CI中跑一个自动化脚本,遍历所有strings.xml文件,检查占位符数量是否一致。比如中文有2个%s,英文也必须有2个。这个脚本能提前发现80%的翻译问题。

23.2 RTL布局测试:镜像世界的挑战

RTL(Right-to-Left)布局,说白了就是让界面从右往左排列。阿拉伯语、希伯来语都用这种布局。你想想看,一个左对齐的按钮,在RTL下应该变成右对齐。如果没处理好,用户体验会非常糟糕。

23.2.1 检测布局方向

@Test
fun testRtlLayout() {
    composeTestRule.setContent {
        val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
        Text(text = if (isRtl) "RTL模式" else "LTR模式")
    }
    // 模拟RTL环境
    composeTestRule.activity.apply {
        resources.configuration.uiMode = 
            Configuration.UI_MODE_NIGHT_NO
        // 设置布局方向为RTL
        window.decorView.layoutDirection = View.LAYOUT_DIRECTION_RTL
    }
}

我曾经接手过一个项目,RTL下所有图标都反了。原因很简单:用了startend属性,但图标本身没有镜像翻转。嗯,这里要注意:图标也需要根据布局方向做镜像处理。

23.2.2 使用Semantics验证RTL

@Test
fun testRtlSemantics() {
    composeTestRule.setContent {
        Row(
            modifier = Modifier.semantics {
                testTag = "rtlRow"
            }
        ) {
            Text("左")
            Text("右")
        }
    }
    
    // 在RTL下,语义顺序应该反转
    composeTestRule.onNodeWithTag("rtlRow")
        .assert(hasContentDescription("右 左"))
}

避坑指南:我曾经在测试RTL时,发现Compose的Row默认不会自动反转子元素顺序。你需要显式设置layoutDirection或者使用Arrangement来适配。否则测试结果会跟实际表现不一致。

23.3 区域设置测试:数字、日期、货币的"方言"

区域设置(Locale)影响的可不只是语言。数字格式、日期格式、货币符号、甚至排序规则都会变。我见过一个App,在美国显示"$1,000.50",到了德国变成了"$1.000,50"——用户直接懵了。

23.3.1 数字格式化测试

@Test
fun testNumberFormatByLocale() {
    composeTestRule.setContent {
        val formatted = remember {
            NumberFormat.getNumberInstance(Locale.GERMANY)
                .format(1234567.89)
        }
        Text(text = formatted)
    }
    // 德国格式:1.234.567,89
    composeTestRule.onNodeWithText("1.234.567,89").assertExists()
}

@Config(locales = ["de"])
@Test
fun testGermanLocale() {
    composeTestRule.setContent {
        val price = stringResource(R.string.price, 1234.56)
        Text(text = price)
    }
    // 验证德国价格格式
    composeTestRule.onNodeWithText("1.234,56 €").assertExists()
}

23.3.2 日期与货币测试

@Test
fun testDateFormatByLocale() {
    val testDate = LocalDate.of(2024, 3, 15)
    
    composeTestRule.setContent {
        val formatted = remember {
            DateTimeFormatter
                .ofLocalizedDate(FormatStyle.LONG)
                .withLocale(Locale.JAPAN)
                .format(testDate)
        }
        Text(text = formatted)
    }
    // 日本格式:2024年3月15日
    composeTestRule.onNodeWithText("2024年3月15日").assertExists()
}

// 货币测试
@Test
fun testCurrencyFormat() {
    composeTestRule.setContent {
        val formatted = remember {
            NumberFormat.getCurrencyInstance(Locale.UK)
                .format(99.99)
        }
        Text(text = formatted)
    }
    // 英国格式:£99.99
    composeTestRule.onNodeWithText("£99.99").assertExists()
}

个人经验:我建议在测试中覆盖至少三种区域设置:en_US(美式)、de_DE(欧式)、ar_SA(阿拉伯)。这三个基本能覆盖大部分格式差异。如果App面向特定市场,再加对应的区域设置。

23.4 知识体系图:本地化测试全景

本地化测试全景图 字符串资源测试 资源ID加载验证 占位符参数匹配 多语言批量验证 RTL布局测试 布局方向检测 Semantics语义验证 图标镜像处理 区域设置测试 数字格式验证 日期格式验证 货币符号验证 目标:确保App在全球市场中的一致性与正确性

23.5 实战:组合测试场景

在实际项目中,这三个测试往往是组合出现的。我分享一个我常用的测试模板:

@RunWith(ParameterizedTest::class)
class LocalizationTest {
    
    data class LocaleTestCase(
        val locale: Locale,
        val expectedDateFormat: String,
        val expectedNumberFormat: String,
        val isRtl: Boolean
    )
    
    companion object {
        @JvmStatic
        @ParameterizedTest.Parameters
        fun data() = listOf(
            LocaleTestCase(Locale.US, "3/15/24", "1,234.56", false),
            LocaleTestCase(Locale.GERMANY, "15.03.24", "1.234,56", false),
            LocaleTestCase(Locale("ar", "SA"), "15/03/24", "1٬234٫56", true)
        )
    }
    
    @ParameterizedTest
    @Test
    fun testLocaleCombined(localeCase: LocaleTestCase) {
        composeTestRule.setContent {
            CompositionLocalProvider(
                LocalLayoutDirection provides 
                    if (localeCase.isRtl) LayoutDirection.Rtl 
                    else LayoutDirection.Ltr
            ) {
                Column {
                    Text(stringResource(R.string.date, localeCase.locale))
                    Text(stringResource(R.string.number, localeCase.locale))
                }
            }
        }
        // 验证布局方向
        if (localeCase.isRtl) {
            composeTestRule.onRoot().assert(hasLayoutDirection(LayoutDirection.Rtl))
        }
    }
}

重要提醒:参数化测试虽然强大,但不要贪多。我建议每个区域设置单独写一个测试类,而不是把所有语言塞到一个测试里。否则某个语言失败会导致整个测试套件变红,排查起来很麻烦。

本地化测试,说白了就是让你的App在全世界都能"说人话"。字符串资源保证翻译正确,RTL保证布局不崩,区域设置保证格式统一。这三板斧砍下去,大部分本地化问题都能提前发现。嗯,今天就聊到这里,希望这些经验对你有帮助。


公众号:蓝海资料掘金营,微信deep3321