国际化与本地化测试:文案抽取、复数与性别规则、RTL 布局、时区与伪本地化

系统讲解国际化与本地化测试的工程化实践:硬编码文案检测与占位符一致性校验、缺失与多余翻译的自动化扫描、CLDR 复数规则与性别/语序差异、RTL 镜像与文本膨胀导致的布局溢出、时区与日期/数字/货币格式断言、伪本地化(pseudo-localization)与 CI 集成。

国际化(i18n)做错的地方,往往不是"翻译不准",而是"代码里根本没法翻译"。 一个英文产品上线德语后按钮文字被截断、上线阿拉伯语后整个界面左右颠倒、上线日语后复数形式全部变成单数、跨年时区算错一天——这些问题的根因都藏在代码里:字符串被拼接而非参数化、布局假设了从左到右、复数用 count == 1 硬判断、日期用本地时区直接格式化。本文要回答的是:如何在代码层面用自动化测试守住 i18n 的四条底线——文案可抽取、占位符一致、布局可伸缩、格式随区域。

国际化测试有个反直觉的特点:最有效的测试是"用假语言跑真代码"。伪本地化(pseudo-localization)把英文替换成带重音符和膨胀字符的"假语言",能在不翻译一个词的情况下,一次性暴露硬编码、布局溢出、字符集缺失三类问题。这条思路贯穿全文。

一、i18n 测试的失败模式

1.1 四类高频缺陷

类别根因典型症状检测手段
硬编码文案写死在代码里切语言后部分文字不变静态扫描 + 伪本地化
占位符不一致翻译时漏掉/改写占位符运行时崩溃或显示 {name}占位符校验器
布局溢出假设文本长度固定按钮截断、换行错乱伪本地化 + 视觉回归
格式错误用本地时区/格式硬编码日期差一天、货币符号错区域感知断言

1.2 测试层次

层次一:静态层   —— 硬编码扫描、缺失翻译、占位符一致性
层次二:单元层   —— 格式化函数、复数规则、日期/数字
层次三:组件层   —— 文本渲染、RTL 镜像、溢出
层次四:视觉层   —— 伪本地化 + 截图对比
层次五:端到端层 —— 切换语言/时区的完整用户旅程

二、文案抽取与占位符

2.1 硬编码检测

最基础的 i18n 测试是"确保没有字符串绕过翻译层":

// 反模式:文案硬编码
<button>Submit</button>
<button title="Close">×</button>

// 正确:全部走翻译函数
<button>{t("common.submit")}</button>
<button title={t("common.close")}>×</button>
// eslint-plugin-i18next:把硬编码变成 lint 错误
// .eslintrc.js
module.exports = {
  plugins: ["i18next"],
  rules: {
    "i18next/no-literal-string": ["error", {
      markupOnly: true,
      ignoreAttribute: ["data-testid", "className", "to", "href"],
    }],
  },
};
# 运行后直接报出所有硬编码位置
npx eslint src/ --rule '{"i18next/no-literal-string": "error"}'
# src/Checkout.tsx
#   12:5  error  string literal "Place Order" is not internationalized

⚠️ 硬编码扫描必须配白名单:data-testid、CSS 类名、路由路径、URL 都是合法的非翻译字符串。白名单收得太紧会淹没在误报里,收得太松会漏掉真硬编码。建议白名单只放"结构性"属性,业务文案一律要求走 t()。

2.2 占位符一致性

翻译时最致命的错误是占位符被漏掉或改名——代码传 {count},翻译写成 {total},运行时就崩溃或显示原始占位符:

// en.json
{ "cart.items": "You have {count} items in your cart" }
// zh.json(错误:占位符名被改了)
{ "cart.items": "您的购物车有 {total} 件商品" }
# 占位符一致性校验器
import json, re, sys

PLACEHOLDER = re.compile(r"\{(\w+)\}")

def placeholders(text):
    return set(PLACEHOLDER.findall(text))

def check(base_file, target_files):
    base = json.load(open(base_file))
    errors = []
    for tf in target_files:
        target = json.load(open(tf))
        for key, base_text in base.items():
            if key not in target:
                errors.append(f"{tf}: 缺失 key {key}")
                continue
            bp, tp = placeholders(base_text), placeholders(target[key])
            if bp != tp:
                errors.append(
                    f"{tf}: {key} 占位符不一致 base={sorted(bp)} target={sorted(tp)}")
    return errors

if __name__ == "__main__":
    errs = check("locales/en.json", ["locales/zh.json", "locales/ja.json"])
    for e in errs:
        print("✖", e)
    sys.exit(1 if errs else 0)

2.3 缺失与多余翻译

# 用 i18next-parser 检查 key 覆盖率
npx i18next-parser --fail-on-warnings
# 报告:未使用的 key(代码删了文案没删)与缺失的 key(代码用了但没翻译)
def test_no_missing_translations():
    base = json.load(open("locales/en.json"))
    for locale in ["zh", "ja", "de", "ar"]:
        target = json.load(open(f"locales/{locale}.json"))
        missing = set(base) - set(target)
        assert not missing, f"{locale} 缺失翻译: {sorted(missing)[:10]}"

def test_no_orphan_keys():
    # 扫描代码里实际用到的 key,反向检查是否有从未使用的翻译
    used = scan_source_for_t_keys("src/")
    defined = set(json.load(open("locales/en.json")))
    orphan = defined - used
    # 孤儿 key 不是错误,但要定期清理
    assert len(orphan) < 50, f"孤儿翻译过多: {len(orphan)}"

三、复数与性别规则

3.1 CLDR 复数规则

不同语言的复数规则差异巨大,绝不能用 count == 1 硬判断:

语言复数形式例子
英语2 种(one/other)1 item / 2 items
中文1 种(other)1 件 / 2 件
阿拉伯语6 种zero/one/two/few/many/other
俄语3 种(one/few/many)1 товар / 2 товара / 5 товаров
波兰语3 种1 plik / 2 pliki / 5 plików
// i18next 用 Intl.PluralRules 自动选择复数形式
// locales/ru.json
{
  "cart.items_one": "{{count}} товар",
  "cart.items_few": "{{count}} товара",
  "cart.items_many": "{{count}} товаров",
  "cart.items_other": "{{count}} товара"
}
// 测试:验证各语言的复数选择正确
import { IntlPluralRules } from "intl-pluralrules";

const cases = [
  { locale: "en", count: 1, expect: "one" },
  { locale: "en", count: 2, expect: "other" },
  { locale: "ru", count: 1, expect: "one" },
  { locale: "ru", count: 2, expect: "few" },
  { locale: "ru", count: 5, expect: "many" },
  { locale: "ar", count: 0, expect: "zero" },
  { locale: "ar", count: 2, expect: "two" },
  { locale: "zh", count: 5, expect: "other" },
];

test.each(cases)("$locale 复数 $count → $expect", ({ locale, count, expect }) => {
  const pr = new Intl.PluralRules(locale);
  expect(pr.select(count)).toBe(expect);
});
# 用 babel 验证复数规则
from babel.core import Locale

def test_plural_rules():
    cases = [("ru", 1, "one"), ("ru", 2, "few"), ("ru", 5, "many"),
             ("ar", 0, "zero"), ("ar", 2, "two"), ("zh", 5, "other")]
    for loc, n, expect in cases:
        rules = Locale(loc).plural_form
        assert rules(n) == expect, f"{loc}({n}) = {rules(n)}, want {expect}"

⚠️ 复数规则必须用 CLDR 数据,不能自己写 if-else。俄语、波兰语、阿拉伯语的规则复杂到没人能凭直觉写对,而 ICU/CLDR 的数据是语言学家维护的权威来源。测试的价值在于"验证你确实用了 CLDR,而不是手写的 count == 1"。

3.2 性别与语序

部分语言有语法性别,且形容词/动词随性别变化;语序也可能完全不同:

// 反模式:拼接句子(语序被写死)
{ "message": "Hi " + name + ", you have " + count + " new messages" }

// 正确:整句参数化,让翻译者决定语序
{ "message": "Hi {name}, you have {count} new messages" }
// 日语翻译可自由调整语序:{name}さん、新着メッセージが{count}件あります
def test_no_string_concatenation_in_i18n():
    # 扫描源码,检测 "t(...) + " 这类拼接(破坏语序)
    import re, pathlib
    bad = []
    for f in pathlib.Path("src").rglob("*.tsx"):
        for i, line in enumerate(f.read_text().splitlines(), 1):
            if re.search(r"\bt\([^)]*\)\s*\+", line):
                bad.append(f"{f}:{i}: {line.strip()}")
    assert not bad, "检测到 i18n 字符串拼接(破坏语序):\n" + "\n".join(bad)

四、RTL 与布局溢出

4.1 RTL 镜像

阿拉伯语、希伯来语是右到左(RTL)书写。布局必须用逻辑属性而非物理属性:

/* 反模式:物理属性,RTL 下错位 */
.card { margin-left: 16px; padding-right: 8px; text-align: left; }

/* 正确:逻辑属性,自动随书写方向镜像 */
.card {
  margin-inline-start: 16px;   /* LTR 左,RTL 右 */
  padding-inline-end: 8px;
  text-align: start;           /* LTR 左,RTL 右 */
}
<!-- dir 属性驱动整页镜像 -->
<html lang="ar" dir="rtl">
// 测试:断言 RTL 下关键元素的逻辑位置
test("RTL 下图标应出现在文字右侧", async ({ page }) => {
  await page.goto("/ar");
  const icon = page.locator(".button-icon");
  const label = page.locator(".button-label");
  const iconBox = await icon.boundingBox();
  const labelBox = await label.boundingBox();
  // RTL 下图标应在标签右边
  expect(iconBox.x).toBeGreaterThan(labelBox.x);
});

test("RTL 下不应有水平滚动条", async ({ page }) => {
  await page.goto("/ar");
  const overflow = await page.evaluate(
    () => document.documentElement.scrollWidth > document.documentElement.clientWidth);
  expect(overflow).toBe(false);
});

4.2 文本膨胀

同一段文案,不同语言的长度差异极大——德语通常比英语长 30%,某些语言甚至翻倍:

语言相对英语长度例子(“Settings”)
德语+30%Einstellungen
芬兰语+40%Asetukset
俄语+20%Настройки
中文-40%设置
// 测试:按钮文本不得溢出容器
test("长语言下按钮不截断", async ({ page }) => {
  await page.goto("/de");
  const btn = page.locator(".primary-button");
  const overflowed = await btn.evaluate((el) => el.scrollWidth > el.clientWidth + 1);
  expect(overflowed).toBe(false);
});

4.3 伪本地化

伪本地化把英文替换成"膨胀 + 重音"的假语言,一次性暴露三类问题:

// 伪本地化转换:a → à, 并加长 40%,用括号包裹
function pseudo(text) {
  const map = { a: "à", e: "é", i: "î", o: "ô", u: "û", c: "ç", n: "ñ" };
  const accented = text.replace(/[aeiouc n]/g, (ch) => map[ch] || ch);
  return `[${accented}${"·".repeat(Math.ceil(text.length * 0.4))}]`;
}
// "Submit" → "[Sùbmît···]"
# 用 i18next-pseudo 或框架内置的 pseudo-locale
# 优点:不翻译也能测,且立刻暴露硬编码(硬编码文字不会变)

ℹ️ 伪本地化是性价比最高的 i18n 测试:它不需要任何真实翻译,就能同时验证"文案是否全部可抽取"(硬编码不会变)、“布局能否容纳更长的文本”(膨胀 40%)、“字体能否显示重音字符”(字符集完整性)。在真实翻译到位之前,先用伪本地化把结构问题全部扫掉。

五、时区与日期格式

5.1 时区:最容易差一天的地方

// 反模式:用本地时区解析
new Date("2026-01-01");        // 在 UTC-5 会变成 2025-12-31T19:00
new Date("2026-01-01T00:00:00"); // 同上,按本地时区解释

// 正确:显式带时区或只用日期部分
new Date("2026-01-01T00:00:00Z");      // 明确 UTC
new Date(2026, 0, 1);                   // 本地日期(无时区歧义)
def test_date_not_shifted_across_timezones():
    import os, time
    from datetime import date
    # 同一逻辑日期,在不同 TZ 下应一致
    for tz in ["UTC", "America/New_York", "Asia/Tokyo"]:
        os.environ["TZ"] = tz
        time.tzset()
        d = parse_user_date("2026-01-01")     # 业务函数
        assert d == date(2026, 1, 1), f"{tz} 下日期偏移为 {d}"
// Playwright 里指定时区做端到端测试
test.use({ timezoneId: "Asia/Shanghai" });
test("订单日期按用户时区显示", async ({ page }) => {
  await page.goto("/orders");
  await expect(page.locator(".order-date")).toHaveText("2026-01-01 08:00");
});

5.2 日期、数字、货币格式

// 反模式:手写格式化
`${y}-${m}-${d}`;                    // 顺序写死,美国是 MM/DD/YYYY
`$${amount.toFixed(2)}`;             // 货币符号写死

// 正确:Intl API
new Intl.DateTimeFormat("en-US").format(d);   // 1/1/2026
new Intl.DateTimeFormat("de-DE").format(d);   // 1.1.2026
new Intl.NumberFormat("de-DE").format(1234.5); // 1.234,5(逗号是小数点!)
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(1234.5);
// $1,234.50
from babel.numbers import format_currency, format_decimal
from babel.dates import format_date

def test_number_and_date_formats():
    # 德语:千分位是点,小数点是逗号
    assert format_decimal(1234.5, locale="de_DE") == "1.234,5"
    assert format_decimal(1234.5, locale="en_US") == "1,234.5"
    # 日期顺序因区域而异
    assert format_date(__import__("datetime").date(2026, 1, 1), locale="en_US") == "Jan 1, 2026"
    assert format_date(__import__("datetime").date(2026, 1, 1), locale="de_DE") == "1. Jan. 2026"

⚠️ 数字格式的坑比想象中多:德语里 1.234,5 表示一千二百三十四点五,1,234.5 表示一点二三四五。如果后端把德语用户输入的 1,5 当整数解析,会得到 15 而不是 1.5。测试必须覆盖"输入解析"和"输出格式化"两个方向,且都要按区域。

六、伪本地化与自动化集成

6.1 把伪本地化接进 CI

jobs:
  i18n-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - name: 硬编码扫描
        run: npx eslint src/ --rule '{"i18next/no-literal-string": "error"}'
      - name: 占位符一致性
        run: python scripts/check_placeholders.py
      - name: 缺失翻译
        run: python scripts/check_missing_translations.py
      - name: 复数规则
        run: npx jest tests/i18n/plural.test.js
      - name: 伪本地化视觉回归
        run: npx playwright test --project=pseudo-locale
      - name: RTL 布局断言
        run: npx playwright test --project=rtl

6.2 多语言视觉回归

// playwright.config.js:为每种语言跑一套视觉回归
const locales = ["en", "de", "ja", "ar", "zh"];
export default {
  projects: locales.map((locale) => ({
    name: `visual-${locale}`,
    use: { locale, timezoneId: "Asia/Shanghai" },
  })),
};
test("首页在目标语言下布局稳定", async ({ page }, testInfo) => {
  await page.goto("/");
  // 每种语言独立快照,任何语言的布局回退都会被抓到
  await expect(page).toHaveScreenshot(`home-${testInfo.project.name}.png`);
});

ℹ️ 多语言视觉回归要和伪本地化配合:伪本地化抓"结构问题"(溢出、硬编码),真实语言视觉回归抓"内容问题"(翻译质量、断行)。前者在翻译前就能跑,后者在翻译后跑。两者叠加,才能覆盖"结构 + 内容"两个维度。

七、常见陷阱

陷阱现象对策
字符串拼接语序被写死,翻译没法调整句参数化
手写复数判断俄语/阿拉伯语全错用 CLDR/Intl.PluralRules
物理 CSS 属性RTL 下布局错位用逻辑属性
只测英语溢出/截断漏测伪本地化 + 长语言用例
本地时区解析日期跨年差一天显式时区或纯日期
手写数字/货币格式小数点逗号混淆用 Intl / babel
硬编码扫描无白名单误报淹没真问题白名单只放结构性属性

八、总结

国际化与本地化测试的核心,是把"语言差异"从"翻译问题"降维成"工程问题":文案抽取用静态扫描守住"可翻译性",占位符校验守住"翻译后不崩溃",CLDR 复数规则守住"语法正确",逻辑 CSS 属性与伪本地化守住"布局可伸缩",Intl/Babel 守住"格式随区域"。延伸阅读可参考 https://plumephp.com/visual-testing-regression/ 了解如何用截图对比守住多语言布局回归,https://plumephp.com/accessibility-testing/ 了解语言标签与辅助技术的可访问性协同,https://plumephp.com/testing-frontend-component-testing/ 了解组件级测试如何承接文本渲染与交互断言。一句话收尾:国际化不是"上线前找翻译补一下",而是从写第一行代码起就要守住的结构约束——把可抽取、可伸缩、可本地化三件事测出来,产品才真正配得上"全球化"这个词。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「testing」更多文章

  1. 智能合约测试:Foundry 单元与集成、Fork 主网、模糊与不变量、Gas 与升级验证
  2. 并发竞态测试:数据竞争检测、确定性复现、TSan/Loom 与调度扰动
  3. 实时通信与 WebSocket 测试:连接生命周期、消息时序、断线重连与并发压测