用视觉定位替换 Test ID:Shopify 将移动 E2E 稳定性拉到 98%
Shopify 最大的移动应用曾因 E2E 测试大面积 flaky 而被迫将测试从 PR 阻塞中移除。团队在旧 Appium 栈上看到问题不在测试代码而在框架本身:Test ID 查找元素允许在界面未渲染时就点击,pause(1000) 之类的捷径层层积累。他们放弃修补 Appium,改为在其上封装一个强约束框架:builder API 强制每个操作附带断言,操作词汇表刻意收窄,逃逸口统一命名为 UNSAFE_;识别层改用计算机视觉,通过 PaddleOCR 识别文本、OpenCV 对灰度多尺寸匹配 Polaris 图标,重复元素用相邻关系消歧,Test ID 被降级为需显式选择的 fallback。每轮运行生成带标注的视频,失败可自诊。迁移后测试稳定性从 50% 升至 98%,剩余失败多为网络与模拟器启动问题。文章还给出普适建议:将 API 收窄到几个核心命令、每个动作强制断言且验证断言的前后状态、合并前先跑多轮稳定性门槛。适合受 flaky E2E 困扰的移动端、QA 基础设施与测试工具链工程师。
A core part of Shopify’s DNA is that we make sure to foster a QA culture. For our mobile apps, that culture depends on a small layer of end-to-end (E2E) tests at the top of the test pyramid: flows that drive the real app the way a merchant would, running as blocking CI on every pull request. The pyramid works really well, as long as the suite is trustworthy.
Shopify 的基因里很重要的一部分,就是培养 QA 文化。对我们的移动应用来说,这种文化依赖于测试金字塔顶端的一小层端到端(E2E)测试:像商家那样驱动真实应用,并在每个 pull request 上作为阻断式 CI 运行。只要这套测试是可信的,金字塔就能运转得很好。
The Shopify mobile app (our largest) had gotten to the point where it was blocking more good PRs than bad ones. Tests were getting flaky because screens might take an extra second to load and it got so bad that we had to pull the E2E suite from our PR checks entirely.
This is the story of how we fixed it, hitting 98% test stability—up from 50% using the old API.
Shopify 移动应用(我们最大的应用)已经到了一天拦下好 PR 比坏 PR 还多的地步。测试越来越不稳定,因为屏幕可能多花一秒加载;情况严重到我们不得不把 E2E 套件完全从 PR 检查中撤下。
这就是我们如何修复它的故事:测试稳定率从旧 API 时的 50% 提升到了 98%。
Since 2023, the Shopify app’s E2E tests have run on Appium through WebdriverIO, using React Native Test IDs to find elements. That flexibility turned into a liability: Appium gave us low-level control, but nothing enforced good testing patterns. After tapping one element, tests could immediately try to tap the next before the new screen had rendered, which caused “element not found” failures.
自 2023 年起,Shopify 应用的 E2E 测试通过 WebdriverIO 跑在 Appium 上,用 React Native Test ID 定位元素。这种灵活性变成了负担:Appium 给了我们底层控制力,但没有任何机制强制好的测试写法。点完一个元素后,测试可能在新屏幕渲染之前就急着去点下一个,于是出现“element not found”失败。
The fix was to explicitly wait for elements to appear, but it was just as easy to drop in a pause(1000) that seemed to work locally and usually passed in CI (until a screen took just a bit long to load and it failed!). Over time, those shortcuts piled up into flaky tests. The app was fine, but the test suite kept failing.
解决办法是显式等待元素出现,但也很容易随手写一个 pause(1000),本地跑起来似乎没问题,CI 通常也能过(直到某个屏幕加载稍微慢了一点就失败!)。久而久之,这些捷径积累成了不稳定的测试。应用本身没问题,但测试套件一直在失败。
Even when tests passed, they were often asserting the wrong thing: that a node existed in the component tree, not that a merchant could actually see or use it. We were testing implementation details instead of user experience.
即使测试通过,断言的对象也经常是错的:只验证组件树里存在某个节点,而不是商家真的能看到或使用它。我们测的是实现细节,不是用户体验。
The bottom inset is bad and the last cell is obscured. Our old API would have been able to click on this cell and incorrectly pass.
底部 inset 很糟糕,最后一个单元格被遮挡。旧 API 本可以点击这个单元格并错误地通过。
We’d seen the pattern before: flakiness grows and we then devote immense resources just to keep the suite green. The problem wasn’t that we were bad at bailing out the suite; it was that the framework itself kept creating the same failures. No amount of cleanup would solve that. We had to fix the underlying system.
我们以前就见过这种模式:脆弱性不断增长,然后我们要投入巨大资源才能让套件保持绿色。问题不在于我们不擅长给套件救火,而在于框架本身在不停制造同样的失败。清理再多也解决不了,必须修复底层系统。
We stopped trying to patch Appium and built an opinionated wrapper around it. It’s a two-parter: a strict, builder-style API that makes flaky tests hard to write, and computer vision that finds elements the way a user does, not by crawling the view hierarchy. Under the hood, Appium is still driving the device. Developers just don’t see it anymore, and they can’t reach past the wrapper to do the things that sank the old suite.
我们不再尝试修补 Appium,而是围绕它构建了一个有主见的封装。它由两部分组成:一套严格、构建器风格的 API,让写出不稳定测试变得很难;以及计算机视觉,像用户那样找元素,而不是爬取视图层级。在底层,Appium 仍然在驱动设备,只是开发者再也看不到了,也无法绕过封装去做那些毁掉旧套件的事情。
We write tests against a builder that only exposes actions we’re confident won’t flake:
A few things are deliberate here:
- Every step carries an assertion. You can’t tap, wait, or type without declaring what the screen should show afterward. If the app leaves the expected state, the test fails at the step where reality diverged, not four actions later when something downstream breaks.
我们针对一个构建器编写测试,它只暴露我们有信心不会不稳定的操作:
这里有几点是刻意为之:
- 每一步都带断言。你不能在不声明随后屏幕应显示什么的情况下点击、等待或输入。如果应用偏离了预期状态,测试会在现实出现分歧的那一步失败,而不是四个动作之后、下游某处被破坏时才失败。
- Reusable slices.logIntoApp is a named step sequence that any test in the app can pull in.
- Escape hatches are prefixed UNSAFE_. Options exist that bypass the guardrails (like custom timeouts or script injection), but they’re named to discourage reaching for them. UNSAFE_timeoutInSeconds in a test is a signal for review.
- 可复用切片。logIntoApp 是一个命名步骤序列,应用里的任何测试都可以引入。
- 逃生门以 UNSAFE_ 开头。确实存在绕过护栏的选项(比如自定义超时或脚本注入),但这样命名是为了不鼓励使用它们。测试里的 UNSAFE_timeoutInSeconds 就是一个需要评审的信号。
- Readable enough for AI agents to write. The surface area is small and the grammar is predictable, which means both humans and AI tools produce correct tests on the first try more often.
- 可读性足以让 AI 智能体直接编写。API 表面很小,语法可预测,这意味着人类和 AI 工具都更常一次写出正确的测试。
The bigger change sits one layer down. Every step takes a screenshot and finds its target visually, the way a merchant does: scan the screen for “Save” or a plus icon, then tap. PaddleOCR handles text; OpenCV matches screenshots against SVGs from our Polaris design system. Test IDs still work as a fallback for screens where generated content makes visual matching unreliable, but they’re opt-in through an UNSAFE_testID field. The naming itself discourages using them.
更大的改变在下一层。每一步都会截图,然后像商家那样用视觉寻找目标:扫一眼屏幕找“Save”或加号图标,然后点击。PaddleOCR 负责文本识别;OpenCV 用 Polaris 设计系统中的 SVG 去匹配截图。Test ID 仍然可以作为后备,用于那些动态生成内容导致视觉匹配不可靠的屏幕,但需要通过 UNSAFE_testID 字段主动选择。这个命名本身就让人不想用它们。
The real win is authoring speed. With Test IDs, adding a step meant opening an inspector, drilling into the component tree to find or add a testID, then wiring it up in the test. With computer vision, you look at the simulator, see “Save”, and write touch({ text: 'Save' }). And that’s the whole loop. AI agents get the same advantage: the grammar maps one-to-one with what’s on screen, so “write a test that creates a product” turns into correct code on the first try with no codebase knowledge required.
真正的好处是编写速度。用 Test ID 时,添加一步意味着打开 inspector,钻到组件树里找到或添加一个 testID,再在测试里把它接好。用计算机视觉时,你看着模拟器,看到“Save”,然后写 touch({ text: 'Save' }) 就完成了。AI 智能体也有同样的优势:语法和屏幕上的内容一一对应,所以“写一个创建产品的测试”这句话能第一次就变成正确代码,而且不需要任何代码库知识。
Every run produces an annotated video of what each step was looking for and where it looked. When a test fails, you see exactly why: which text OCR was searching for, what it found instead, where it tapped. Most failures diagnose themselves in a few seconds of video, with no rerun needed.
每次运行都会生成一段带注释的视频,记录每一步在找什么、在哪里找。测试失败时,你能清楚地看到原因:OCR 在搜索哪段文字、实际找到了什么、点击了哪里。大多数失败看几秒钟视频就能自行诊断,不需要重跑。


The runner is a single command that works the same way on a laptop, on a CI emulator, or when calling to a real device in a remote device farm:
This runs every test file matching logout on the iOS devices declared in the RemoteDeviceFarm config. Swap --runner remote-device-farm for --runner local and the same command runs on a simulator on your machine.
运行器是一条单一命令,在笔记本电脑、CI 模拟器或远程设备农场中的真机上,用法都一样:
这会在 RemoteDeviceFarm 配置中声明的 iOS 设备上运行所有匹配 logout 的测试文件。把 --runner remote-device-farm 换成 --runner local,同一命令就会在你机器上的模拟器里运行。
A few weeks after promoting the new API into blocking CI on the Shopify app: 98% test stability, measured as individual test successes divided by total runs—up from 50% using the old API. Remaining test failures are largely from what we would expect: occasional network failures and simulators failing to boot properly.
将新 API 提升为 Shopify 应用上的阻断式 CI 几周后:测试稳定率达到 98%(以单次测试成功数除以总运行次数计算),而旧 API 只有 50%。剩余的测试失败大多是意料之中的:偶尔的网络故障和模拟器启动失败。
We also built a pre-promotion flakiness gate. Before a new test is allowed into the blocking suite, a dedicated pipeline runs it multiple times and rejects it if it fails above a set threshold.
我们还建了一个提升前的脆弱性门槛。新测试要被允许进入阻断式套件之前,专用流水线会多次运行它,如果失败率超过设定阈值就拒绝。
We’ve validated this framework on our biggest app and are now exploring adopting it in our other apps.
For years we believed mobile E2E testing was inherently flaky, and that the best we could do was manage the flakiness. A lot of it turned out to live in the API, not in the tests themselves. When we replaced the API (using the principles of asserting at every step, finding elements the way a user does, and refusing the footguns), we found that a suite that couldn’t stay in blocking CI runs at 98% stability on two platforms.
我们已经在最大的应用上验证了这个框架,现在正在探索在其他应用里采用它。
多年来,我们一直认为移动端 E2E 测试天生不稳定,我们能做的最好只是管理这种不稳定。结果发现,很多脆弱性其实存在于 API,而不是测试本身。当我们替换了 API(遵循每一步都断言、像用户一样找元素、拒绝自伤式功能的原则),我们发现一个曾经无法留在阻断式 CI 里的套件,在两个平台上都能以 98% 的稳定率运行。
As AI increases engineering velocity, frameworks like this become even more valuable. They allow teams to move faster without losing confidence in what they ship.
随着 AI 提升工程速度,这样的框架变得更宝贵。它让团队在加快进度的同时,不致对交付失去信心。
Maybe your CI also suffers from a flaky, hard-to-use, hard-to-interpret end-to-end testing framework. Here’s what we did to get to a better place:
- Limit the API to a small set of essential commands. Deeplink, swipe, type, touch, assert, and relaunch app.
也许你的 CI 也饱受不稳定、难用、难解读的端到端测试框架之苦。以下是我们走到更好状态的做法:
- 把 API 限制为一小组必要命令:deeplink、swipe、type、touch、assert 和 relaunch app。
- Use computer vision to interact with text and icons on screen. We evaluated many open-source OCR libraries, and PaddleOCR was the clear winner. For icon matching, we use OpenCV. We convert everything to grayscale, then match the icon (and its color-inverted variant) across multiple size variations until we find a match. For duplicate elements, we use adjacencies (i.e., “icon1 to the left of icon2”) to disambiguate them.
- 用计算机视觉与屏幕上的文本和图标交互。我们评估了很多开源 OCR 库,PaddleOCR 明显胜出。图标匹配使用 OpenCV。我们会把所有内容转成灰度,然后在多种尺寸下匹配图标(及其反色变体),直到找到匹配。对于重复元素,我们用相邻关系(比如“icon1 在 icon2 左边”)来消除歧义。
- Require every action to include an assertion or refutation. This prevents tests from progressing without verifying that anything actually happened. We also validate our assertions: an assertion must be false before the action, and true afterwards.
- 要求每个动作都包含一个断言或反证。这防止测试在未验证任何实际发生的事情时继续推进。我们还会验证断言本身:断言在动作之前必须为假,在动作之后必须为真。
- Establish test stability before merging. A test is only allowed to pass if it has proven itself stable across multiple runs.
- 在合入前确立测试稳定性。一个测试只有在多次运行中证明自己稳定,才被允许通过。
Now that the framework makes reliable tests the easiest ones to write, E2E testing does the job it was built for: catching bad changes without standing in the way of good ones.
如今,这个框架让可靠的测试成为最容易写出的测试,E2E 测试也就发挥了它本来的作用:拦住坏变更,而不阻碍好变更。