资讯动态

iOS多语言测试自动化:基于XCTest的国际化UI测试方案

发布时间:2026/10/20 22:38:33 来源:尧图企业网站定制
做iOS开发的谁没被多语言测试折磨过文案一长按钮就截断阿拉伯语一蹦出来整个布局翻车日期格式在不同地区显示得千奇百怪这些问题发布前靠人工一遍遍切换系统语言去点费时费力还总有漏网之鱼。标题里提到的IceCubesApp就是一个特别适合拿来练手的开源客户端项目我最近基于它搞了一套完整的iOS应用国际化测试自动化方案把多语言切换从手动点设置变成了自动化跑用例。这篇就把整个思路、代码封装和踩坑过程完整讲一遍希望能给正在做国际化适配的同路人省点时间。这篇文章适合被多语言测试折磨过的移动端工程师、测试开发也适合刚接触XCTest UI测试、想给自己的App加一层国际化保障的朋友。方案不绑定某个具体业务核心思路和代码工具类可以平移到你自己的项目里。1. 为什么不解决这个痛点多语言版本永远不敢发1.1 人工国际化测试的三个死穴先说最现实的三个问题只要你的App支持超过两种语言几乎都会遇到。第一个是覆盖场景太多。文案、日期、数字、货币、从右向左布局、图片镜像、动态类型适配每个维度乘以语言数量组合出来是上百个用例。人工测试一般只盯主要界面设置页里一个三四级深度的页面翻译错了往往要等用户反馈才知道。第二个是回归成本太高。国际化的Bug有个特点改一行代码可能影响所有语言。比如把一个固定宽度改成自适应约束英文看着没问题德语一个长单词直接把按钮撑爆把字体从系统字体换成自定义字体阿拉伯语和泰语可能直接显示豆腐块。每个版本都得全语言回归一遍纯手工根本扛不住。第三个是复现和反馈太慢。手动切语言进设置、找地区、选语言、等模拟器重启这一套下来半分钟就没了。如果要在多个模拟器、多个语言之间来回切换一天的有效工作其实没多少。1.2 IceCubesApp 为什么适合当自动化试验田IceCubesApp是一个开源的iOS客户端项目虽然是个个人项目但工程结构相当完整涵盖了我见过的不少真实国际化场景复杂时间线列表、Markdown渲染、富文本、多账号切换、设置页、深色模式、动态类型以及几十种语言的本地化资源包。我选择它作为自动化实践对象看中的是这几点语言资源齐全能真实覆盖从简中、繁中这类双字节语言到德语、法语这类长单词语言再到阿拉伯语、希伯来语这种从右向左布局的语言。界面元素复杂列表里有图片、文本、按钮、菜单适合验证各种UI元素的本地化表现。社区活跃本地化翻译文件一直在更新意味着翻译Key的变动很频繁恰恰需要自动化去兜底。把这种真实项目跑通比你用一个“Hello World”Demo练手有用得多。很多布局问题只有界面复杂到一定程度才会暴露。1.3 自动化之后能拿到的实际收益我自己把这套自动化接进日常开发流程之后体感最明显的变化是发布前不用再花一整天翻来覆去地切语言点界面。跑一趟全语言冒烟测试代码执行时间大概十来分钟期间我可以干别的。自动化最直接的价值是每次提测前把主要页面在五种以上语言里过一遍文案缺失、布局溢出、程序崩溃这些低级问题会被拦在提测之前。还能自动保存每个语言下的界面截图给产品和设计做视觉走查时直接把截图甩过去比让他们自己动手切模拟器高效得多。另外UI测试跑出来的截图还能顺带验证翻译资源是否配套。比如代码里新加了一个弹窗但忘了在Localizable.strings补上新Key自动化跑完截图里的文案就是英文Key本身一眼就能看出来。2. 语言切换的原理与方案选型别一开始就写用例2.1 iOS 到底是怎么决定显示哪种语言的写自动化之前我建议先搞清楚系统是怎么匹配语言的不然配置写错了会很困惑。iOS的本地化机制其实不复杂App包里的Localizable.strings、InfoPlist.strings等资源是按语言放在.lproj目录里的比如en.lproj、zh-Hans.lproj、ar.lproj。系统在启动App时会根据当前偏好设置里的语言顺序从这些目录里挑一个最匹配的来加载。这个偏好设置最终会落到用户默认参数AppleLanguages上它是一个数组表示用户偏好的语言顺序。比如[zh-Hans, en]表示优先简体中文其次英文。另一个相关参数是AppleLocale它决定地区格式比如日期怎么写、小数点用什么符号。在手动测试时我们是在模拟器设置里改语言和地区本质就是修改这两个参数。自动化要做的事情也很简单在App启动前用代码或命令行把这两个参数设置成目标语言然后拉起App。关键点在于这是启动期的参数必须在App进程起来之前生效而不是启动之后再改。2.2 三条语言切换路线的对比我在项目里试过三种方式各有适用场景下面放个对比表格。方式原理适用场景优缺点模拟器设置界面手动改修改系统偏好后重启SpringBoard人工探索、少量验证最直观但无法自动化XCTest启动参数/环境变量注入通过-AppleLanguages等参数覆盖当前进程偏好UI测试用例内每个用例独立控制语言粒度细、可复用但对系统级弹窗文案无控制力simctl spawn defaults write直接写入模拟器全局偏好再重启CI环境预置、跨用例统一语言环境适合大批量预置但修改后需要重启模拟器三条路线的本质其实都是改AppleLanguages和AppleLocale区别在于作用域和时机。XCTest的启动参数路线最灵活它让每个测试用例都能声明“本次启动用德语”。比如XCUIApplication().launchArguments [-AppleLanguages, (de)]注意这里的(de)是UserDefaults对数组的plist表示法相当于一个只含“de”的数组。实测中这个方式对App自身的本地化资源是生效的。simctl路线则是预先在系统层改好全局偏好适合在做完整套测试之前先把某个模拟器设置为“稳定的德语环境”。它的缺点是改完必须重启模拟器不好在用例中途频繁切换。2.3 我推荐的组合启动参数 全局预置我的项目实践里两条路不是二选一而是配合使用。在CI构建机上先用脚本把模拟器全局偏好预置成一个稳定的英文环境英文作为系统的“兜底语言”避免系统弹窗文案乱跳。在每一个UI测试用例里通过launchArguments和launchEnvironment指定当前用例想要测试的App语言文档。对于阿拉伯语这类从右向左的语言单独建一个测试计划或测试套件跑完以后再看截图。这样做的理由是系统级弹窗比如定位权限、推送权限的文案跟随系统语言如果测试用例里把系统语言也改来改去那些系统弹窗的按钮文案就会变得不可预测权限处理代码会很难写。保持系统层是英文把App层语言交给启动参数两条变量就隔离了。3. 在 XCTest 里封装一套可复用的多语言切换能力3.1 把语言配置抽象成代码而不是散落在用例里我一向反对在每个用例里手写launchArguments这种长串配置一旦参数格式调整几十个用例跟着改会很痛苦。正确做法是封装一个语言配置工具把“生成语言启动参数”这件事收敛在一个地方。下面这个工具类就是我目前项目里在用的代码不复杂但很实用enum LocalizationHelper { /// 根据语言代码与地区代码生成 App 启动参数 static func launchArguments( language: String, region: String? nil ) - [String] { var arguments [-AppleLanguages, (\(language))] if let region region { arguments [-AppleLocale, \(language)_\(region)] } return arguments } /// 与启动参数配套的环境变量双保险 static func launchEnvironment( language: String, region: String ) - [String: String] { [ AppleLanguages: (\(language)), AppleLocale: \(language)_\(region) ] } } // 定义一份所有测试共用的语言清单 enum LocaleFixture { static let all: [(code: String, region: String)] [ (en, US), (zh-Hans, CN), (de, DE), (ar, AE) ] }测试用例里就可以这样用final class LocalizedSmokeTests: XCTestCase { func testProfileScreenInMultipleLanguages() { let app XCUIApplication() app.launchArguments LocalizationHelper.launchArguments( language: ar, region: AE ) app.launchEnvironment LocalizationHelper.launchEnvironment( language: ar, region: AE ) app.launch() let profileTab app.buttons[tab.profile] XCTAssertTrue(profileTab.waitForExistence(timeout: 5)) profileTab.tap() XCTAssertTrue(app.staticTexts[profile.name.label].exists) } }写到这里我要特别提醒一点XCTest的UI测试是没有内置参数化用例的。如果你想像“遍历四种语言跑同一个测试方法”那样写for循环后面几个语言会共用同一个App进程互相污染状态。我的做法是在测试方法内用for循环时每一轮语言都显式执行app.terminate()再重新launch()或者干脆给每个语言写独立的测试方法由测试计划来控制组合。前者简单直接后者失败报告更清晰。3.2 模拟器级预置语言脚本在跑整个测试计划之前我习惯先用一条脚本把模拟器全局语言环境设置好。这样就算App启动参数因为某种原因失效系统层还有一个稳定的英文环境兜底。下面这段shell脚本是我放在CI脚本里的作用是把指定模拟器设置为英文环境。# 获取当前已启动的模拟器 UDID UDID$(xcrun simctl list devices booted | grep -oE [0-9A-F-]{36} | head -1) # 写全局偏好语言顺序为英文地区为美国 xcrun simctl spawn $UDID defaults write .GlobalPreferences AppleLanguages -array en xcrun simctl spawn $UDID defaults write .GlobalPreferences AppleLocale -string en_US # 重启模拟器让全局配置生效 xcrun simctl shutdown $UDID xcrun simctl boot $UDID这句话我建议你收藏改完模拟器全局偏好后一定重启模拟器。我一开始跳过重启直接跑用例结果有的App读到新配置、有的读不到排查了很久才发现是SpringBoard没有重新加载偏好。加一行重启的代码世界就清静了。3.3 权限弹窗与系统级干扰的处理多语言自动化里有个很容易被忽略的坑首次启动的权限弹窗文案是跟着系统语言走的而不会跟着你给App注入的语言走。如果系统是英文弹窗按钮是“Allow”系统是中文就变成“允许”。如果每个语言用例都要去点击不同的按钮文案维护成本直接原地起飞。我目前的处理方式是分两步第一步在测试脚本里提前把App的权限设置好比如定位、通知、相机通过simctl privacy命令预先授权。xcrun simctl privacy booted grant location-always com.example.myapp xcrun simctl privacy booted grant camera com.example.myapp xcrun simctl privacy booted grant photos com.example.myapp第二步如果用例真的需要处理授权弹窗就注册一个中断监控器用英文系统的“Allow”作为唯一按钮来点击。addUIInterruptionMonitor(withDescription: 系统权限弹窗) { alert in let allowButton alert.buttons[Allow] if allowButton.exists { allowButton.tap() return true } return false } app.launch() app.tap() // 触发一次交互让中断监控生效这里有个容易踩的坑中断监控器不是马上生效的需要有一次UI交互来触发。很多人写完监控器发现弹窗没有被自动处理就是漏了最后这个app.tap()。4. 多语言 UI 断言实测从文案到布局再到格式4.1 拒绝写死文案Accessibility Identifier 才是稳定锚点很多刚写UI自动化的人第一个动作就是app.staticTexts[Home]这在单语言环境下没问题但是一旦切到德语文案变了“Home”就找不到了测试瞬间翻车。我的习惯是测试代码永远不匹配展示文案只匹配开发者在控件上设置的accessibilityIdentifier。这个标识在运行时不会随语言变化是跨语言最稳定的锚点。产品代码里UIKit可以这样加titleLabel.text NSLocalizedString(home.screen.title, comment: ) titleLabel.accessibilityIdentifier home.screen.titleSwiftUI可以这样加Text(verbatim: home.screen.title) .accessibilityIdentifier(home.screen.title)测试代码就变成了XCTAssertTrue(app.staticTexts[home.screen.title].exists)因为拿的是Identifier而不是显示文本跑英文、中文、阿拉伯语都指向同一个元素。这样断言才有跨语言的意义。顺便说一句如果拿不到产品代码的改动权限也可以退而求其次在测试里通过NSPredicate同时匹配多个语言的文案但维护成本较高只能作为临时方案。4.2 从右向左语言下如何进行布局断言国际化测试里最特殊的一类是从右向左语言比如阿拉伯语。这种语言除了文案要翻译整个界面的阅读方向会改变。英文下靠左的返回按钮在阿拉伯语下应该靠右英文下右下角的发布按钮在阿拉伯语下应该跑到左下角。自动化里怎么验证坐标对比是一个办法但它不稳定iPhone SE和iPhone 15 Pro的宽度不同结果会变。更稳的做法是断言“相对位置关系”同一个元素在从右向左布局下它的frame.minX应该比从左向右布局下更小也就是更靠左。下面是我写过的一个用例意图是验证底部发布按钮的位置在RTL下发生了镜像func testComposeButtonMirrorsInRTL() { // 先以英文启动记录按钮位置 var app XCUIApplication() app.launchArguments LocalizationHelper.launchArguments( language: en, region: US ) app.launch() let composeButton app.buttons[compose.button] XCTAssertTrue(composeButton.waitForExistence(timeout: 5)) let minXInLTR composeButton.frame.minX // 切换成阿拉伯语重新启动 app.terminate() app.launchArguments LocalizationHelper.launchArguments( language: ar, region: AE ) app.launch() let minXInRTL composeButton.frame.minX // RTL 下按钮应该比 LTR 时更靠左 XCTAssertLessThan(minXInRTL, minXInLTR) }这种相对断言适应不同屏幕尺寸也适应不同iOS版本比写死某个绝对坐标值靠谱得多。如果你的界面有图片需要水平镜像翻转在SwiftUI里是flipsForRightToLeftLayoutDirection(true)测试同样可以用identifier取到图片元素断言它在RTL下的frame origin发生了变化。4.3 日期、数字、复数的格式化验证放在单元测试里UI测试用来验证“界面上有没有显示”“按钮在不在该在的位置”是可以的但用UI测试去断言日期格式长什么样那就太笨重了。启动App、等界面渲染、截图对比为了看一个DateFormat的结果成本高而且容易受系统版本影响。对于日期、数字、货币、复数规则这类纯格式化逻辑我强烈建议放到单元测试里验证速度极快失败信息也更直接。下面是我在项目里写过的日期格式验证用例核心思路是同一个时间点在不同Locale下格式化结果应该符合各自地区的预期格式。func testMediumDateFormatDiffersByLocale() { let date Date(timeIntervalSince1970: 1_700_000_000) var formatter DateFormatter() formatter.dateStyle .medium formatter.locale Locale(identifier: en_US) let enString formatter.string(from: date) formatter.locale Locale(identifier: zh_CN) let zhString formatter.string(from: date) XCTAssertFalse(enString.isEmpty) XCTAssertFalse(zhString.isEmpty) XCTAssertNotEqual(enString, zhString) }这里我想额外解释一下为什么用“断言两者不同”而不是“断言具体等于某个字符串”不同iOS版本对medium格式的呈现可能有细微差异比如“Nov 14, 2023”和“Nov 14, 2023 at 12:00 AM”锁死具体字符串会让测试变得脆弱。断言“不同地区的格式有差异”反而是稳的。想进一步校验格式规则再额外针对zh_CN断言包含“年”或“月”这类本地化符号即可。复数规则同理比如英文的“1 item”和“2 items”中文的“1个项目”和“2个项目”没有词形变化。这些逻辑应该由本地化翻译Key设计来保证单元测试里注入几个典型数值断言输出符合预期即可。5. 常见问题与排查技巧实录5.1 高频问题速查表我把做国际化自动化这几个月里踩过的坑汇总成了一张表遇到问题可以对照排查。问题可能原因解决方案切换语言后App界面纹丝不动启动参数未生效检查(ar)格式是否正确同时注入launchEnvironment双保险系统弹窗按钮文案匹配不到系统语言与App语言不一致全局偏好预置英文用Allow按钮处理权限弹窗阿拉伯语下断言坐标失败布局没有真正镜像先手动跑一次确认布局方向再检查minX相对值断言列表内容全部消失翻译Key缺失导致显示异常在单元测试里遍历所有Localizable.strings校验Key一致性某些语言字体变成豆腐块自定义字体缺少该语言字形检查字体文件授权范围必要时为RTL语言指定替代字体CI上同一个用例时好时坏模拟器缓存了旧的偏好配置先shutdown再boot确保环境干净截图里的文案还是英文资源包没赶上最新翻译检查构建时是否使用了最新的.lproj文件这里面对我帮助最大的习惯是遇到“时好时坏”的问题先怀疑环境缓存再怀疑代码逻辑。模拟器是个有状态的系统测试跑久了总会残留一些奇怪配置定期清理模拟器、重新创建一套干净的能解决很多玄学问题。5.2 排障工具箱与操作习惯除了上面这张表我还有几个固定会用的小命令都放在一个脚本文件里一旦怀疑环境异常就执行一遍。# 查看当前模拟器的全局语言偏好 xcrun simctl spawn booted defaults read .GlobalPreferences AppleLanguages # 查看App是否安装了本地化资源 xcrun simctl get_app_container booted com.example.myapp data find $(xcrun simctl get_app_container booted com.example.myapp app) -name *.lproj -maxdepth 2 # 清理模拟器缓存 xcrun simctl erase booted排障时我的固定步骤是先defaults read确认模拟器认为的语言是什么再看App包里的.lproj目录是否存在对应语言最后才怀疑测试代码。这个顺序看起来简单但能省下大量无效排查。另外自动化用例里等元素出现我习惯用waitForExistence(timeout: 5)而不是sleep(3)。sleep是定时炸弹在CI负载高的时候特别容易超时sleep时间写短了不稳定写长了拖慢整个测试套件。用等待机制还能顺便断言元素是否出现一举两得。5.3 给 CI 用的几条优化建议这套自动化跑在本地和跑在CI上是两种体验CI上几个特殊问题值得提前预防。第一每个测试计划跑独立的模拟器。多个测试计划共用一个模拟器会出现上一个计划没退干净、弹窗残留等问题。我给每个语言套件分配一个全新模拟器用完就直接删掉。第二失败时自动带截图。在tearDown里做一次失败截图并作为附件附加到测试报告这样CI构建挂了不用再重新跑一遍去看现场。override func tearDown() { if let failureCount testRun?.failureCount, failureCount 0 { let screenshot XCUIScreen.main.screenshot() let attachment XCTAttachment(screenshot: screenshot) attachment.lifetime .keepAlways add(attachment) } super.tearDown() }第三把“多语言截图生成”做成一个独立流程。我有时候不关注断言是否通过只想要一组“英语、中文、德语、阿拉伯语”下的首页截图给设计做走查。于是写了一个专门的测试计划语言写死四种用例只用waitForExistence加截图断言全部跳过。这样既得到了素材又不会因为一个元素找不到影响整批截图产出。这个思路很实用如果你团队里设计和产品经常找你要多语言效果图可以直接搭一条这样的“截图管线”。最后分享一点我自己的体会。做国际化测试自动化最怕的不是技术实现难而是把自动化写成“能跑但没人维护”的摆设。一开始做这套东西时我犯过同样的错直接在用例里写死各种英文文案匹配结果产品重新设计了字符串测试跟着改了三个晚上。后来我狠下心来做了一次重构把语言配置、元素锚点、RTL断言这些通用能力全部下沉成工具层业务用例只管做业务验证。那次重构之后这套自动化就很少再需要维护了每次发版前跑一遍基本只要看截图就能完成大部分审查。现在你如果再问我要不要对现有项目做国际化自动化我的答案永远是要做但记得把基础设施搭稳一次投入长期受益。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑