
共享测试,不共享代码:Shopify Reflex 如何让智能体无模拟器测试双端应用
Shopify Reflex 将原生应用逻辑拆成无 UI 的 Core,用统一 ViewModel 契约暴露状态与动作,使同一套测试在无模拟器时可以同时驱动 iOS 与 Android。
在 《原生开发已经成为 Shopify 移动端的未来》中,Shopify 解释了为什么从 React Native 回到 Swift 与 Kotlin。在 Helix 一文中,它又介绍了编码智能体如何通过小步检查点和严格闸门重建页面。两篇文章都提到一个命令行工具:智能体不需要模拟器,就能查看应用状态并执行动作。本文讲的是这个命令行工具背后的架构,Shopify 把它叫作 Reflex。
Shopify 决定用原生技术重建应用时,重新回到了一个基本问题:如果大部分代码都由智能体来写,移动应用应该怎样构建?最大的瓶颈是反馈回路。智能体几分钟就能写出一个页面,但要证明页面确实可用,通常需要模拟器、构建、点击脚本以及大量等待。Shopify 曾投入模拟器工具来提速,后来还是撞到了天花板。
Reflex 就是由此形成的架构。应用逻辑以无头方式运行;每个页面都通过一份很小的契约暴露自身状态和可执行动作;命令行工具用同一份测试文件驱动两个平台。它带来了极快的反馈回路,也提供了一条让两套原生实现保持同步的可行路径。
Reflex 改变了什么
Reflex 是一套基于 MVVM 的架构,把应用塑造成智能体能够快速构建、测试和驱动、同时无需渲染界面的形态。它由四根支柱组成:
- 不含界面的 Core 模块。除视图以外的一切都放在不导入 UI、也不依赖具体设备的模块中。因此,应用逻辑可以在 macOS 或 JVM 上无头运行。
- 严格的 ViewModel 契约。每个页面都遵循相同的 Elm 风格结构:动作进入,状态输出,并且只有一个 reducer 能修改状态。
- ViewModel 内省。每个 ViewModel 都通过 JSON 契约暴露名称、状态和可接受的动作。应用之外的进程,例如命令行工具,可以读取页面并对其执行操作。
- 与平台无关的命令行工具。智能体通过这份契约与应用交互,无需启动模拟器。同一个测试用例可以同时覆盖 iOS 与 Android。
这四项设计合在一起,让智能体可以在没有设备的情况下逐个功能地探索应用。因为同一份测试文件会跑在两套原生实现上,某个功能不可能只在一个平台发布,而让另一个平台悄悄落后。
从智能体一侧看,接口刻意保持得很小:
ls输出当前页面的名称、JSON 状态和可接受的动作。do Action [json]调用一个动作,模拟用户交互。
命令行工具既可以连接无头的 Core 进程,也可以连接真实构建。无论哪种方式,它都只与 ViewModel 对话,从不接触渲染后的界面。
$ dev cli ios --fixtures
> ls
InventoryDetail
state {"productId": "1", "stockCount": 12, "isSaved": false}
actions Increment, Decrement, Save, SetQuantity {quantity}, goBack
> do Increment
> ls
InventoryDetail
state {"productId": "1", "stockCount": 13, "isSaved": false}
actions Increment, Decrement, Save, SetQuantity {quantity}, goBack
这就是完整的用户界面。没有 TUI,没有配色,也没有加载动画。智能体不需要这些装饰。Shopify 没有花在终端美化上的时间,都投入到了反馈回路本身。
共享测试,不共享代码
两套原生应用意味着每个页面都有两份实现,因此必须有某种机制保证它们等价。Shopify 的答案后来沉淀成一条原则:共享测试,不共享代码。实现写两次,测试只写一次。
平台之间不共享代码
iOS 使用 Swift,Android 使用 Kotlin。两边各自使用原生 UI 工具包和并发模型,中间没有跨平台层。在智能体承担大部分编码工作后,第二套原生实现的成本已经远低于过去。抽象层却仍然要付出完整代价:增加维护面,隐藏之后做性能优化时需要的平台行为,还让每次平台升级都变成两步迁移。
把同一个 ViewModel 写两遍,比维护两套原生代码之间的桥更便宜。
用测试强制平台一致性
如果不共享代码,就必须有别的机制让两个平台保持一致。在 Shopify,这个机制就是测试套件。每个行为测试都要跑两个平台,只在一个平台通过就无法合并。真正被共享的产物是测试。
常见的几种测试方案各有问题:
| 方案 | 优势 | Shopify 指出的代价 |
|---|---|---|
| 跨平台端到端测试 | 一套测试可以覆盖两个应用,并走完真实流程。 | 仍然需要模拟器,速度达不到智能体回路的要求,失败时要靠视频或截图排查。 |
| XCUITest 与 Espresso 原生 UI 测试 | 比大范围端到端测试更快、更稳定。 | 必须按平台各写一次,在一个平台通过并不能说明另一个平台也正确。 |
| 单元测试 | 速度很快。 | 测的是应用的 mock 而不是应用本身;每个平台各有一套,两套测试也会像两套代码一样逐渐漂移。 |
| Reflex 命令行行为测试 | 无头运行,使用真实的 reducer、服务、导航,并只在外部边界替换 fixture;一份文件覆盖两个平台。 | 不验证像素,也不验证每个视觉控件是否正确接线。这部分仍由独立视觉审查和截图测试负责。 |
最终得到的方案更接近灰盒测试。测试看不到像素,也看不到私有内部实现,只能看到 UI 能看到的东西:ViewModel 的状态和它接受的动作。下层全都是真实应用逻辑,包括真实 reducer、真实服务、真实 GraphQL 客户端和真实导航。只有在应用原本要访问网络或设备的进程边界上,才用 fixture 替换外部依赖。整个测试套件不使用任何 mock 框架。
从第一性原理建立契约
把 UI 从等式中拿掉
Shopify 用一个很小的库存管理应用举例。用户从产品列表中选择一件商品,打开详情页,再调整库存数量。这个例子故意省略了很多细节,用来突出架构形状。
示例代码是 Swift,但 Kotlin 实现具有相同结构,读起来几乎一样。每个平台应用被拆成两个模块,再增加一个命令行模块:
- Core 负责 ViewModel、服务、导航状态、GraphQL、持久化和深链。它在 iOS 上是普通 Swift 包,在 Android 上是普通 JVM 模块,既不导入 UIKit,也不导入
android.*。 - Mobile 负责 SwiftUI 或 Compose 页面,以及 Keychain、推送、相机、分享面板等平台集成。Mobile 依赖 Core,Core 完全不知道 Mobile 的存在。
- CLI 像 Mobile 一样依赖 Core,但运行在 macOS 或 JVM 上,而不是手机里。这正是它能在没有屏幕时控制应用的原因。
Core 为它需要的一切平台能力声明协议,并通过构造函数接收实现。这是普通的依赖倒置,不需要依赖注入框架。移动应用和命令行工具只是构造 Core 的不同宿主。命令行工具用 fixture 替代真实平台服务。
// 应用侧:InventoryManagementMobile/InventoryManagementApp.swift
let core = InventoryManagementCore(
storage: KeychainStorage()
)
// 命令行侧:InventoryManagementCLI/main.swift
// 同一个 Core,不依赖平台 UI。
let core = InventoryManagementCore(
storage: InMemoryStorage()
)
这种拆分带来几项具体收益:
- 导航位于 Core。压入新页面只是 navigator 上的一次状态变化,因此命令行工具不创建任何视图也能走遍整个应用。深链同理。
- Core 可以在语言支持的平台上运行。Swift Core 能在 macOS 构建,原则上也能运行在 Linux;Kotlin Core 是纯 JVM 模块,因此 Android 行为测试只需要 JVM,不需要模拟器。
- 测试不渲染。没有布局过程、动画,也不用等待视图出现。一次动作就是一次函数调用,随后读取 JSON。
- Fixture 通过构造函数传入。GraphQL 响应、Keychain 条目、生物识别可用性和功能开关都由构造函数进入。模拟边界情况只需换一个 fixture,不必在测试中途 patch mock。
无头执行改变了测试的经济账。Shopify 报告称,超过 1,700 个行为测试在两个平台的 CI 上大约四分钟跑完。
Reflex 本身没有第三方依赖。核心部件只有一个基础 ViewModel 类、一个很小的内省协议,以及让 Core 保持零平台导入的纪律。对人类来说,维持这条纪律很难,但智能体很擅长反复执行严格模式。
应用的结构
在 Core 内部,应用是一棵由普通对象组成的树。根对象持有网络客户端、当前会话和持久化。根下面是 Tab、Push、Modal 等 navigator,每个 navigator 持有当前屏幕上的路由。叶子节点是每页一个 ViewModel。这棵树里没有任何对象知道 UI 的存在。
视图位于 Core 之外的 Mobile 模块。一个视图绑定一个 ViewModel,读取它的状态,再调用 send(action:)。视图与应用其余部分的关系就只有这些。
Introspection Kit 是独立模块,定义 ViewModel 与命令行工具之间的契约。每个 ViewModel 通过一个小扩展把自己描述成节点:名称、JSON 状态和可接受动作。命令行工具与测试 SDK 只访问这一层,应用自身从不调用它。
ViewModel:动作进入,状态输出
每个页面都由一个 ViewModel 支撑,其设计受到 Elm 架构影响。组成部分如下:
- State 保存页面显示的所有内容。视图只读 State,不读其他东西。
- Action 是公开接口,用枚举表示视图或命令行工具能够要求 ViewModel 执行的全部操作,并通过
send(action:)分派。 - Message 是内部接口。视图动作会被包进 message,服务返回的异步结果也以 message 形式进入。只有 ViewModel 能看见 message。
- update(state, message) 是 reducer。它是唯一修改状态的地方,所有路径最终汇合到这里。
- Services 通过构造函数注入协议实现,提供网络、存储和其他副作用。
视图只有两项职责:读取状态,调用 send(action:)。它从不直接调用 ViewModel 的其他方法,也从不写状态。
// InventoryManagementCore,伪 Swift,省略样板代码。
final class InventoryDetailViewModel: ViewModel {
class State {
let productId: String
var stockCount = 0
var isSaved = false
}
enum Action { // 视图或命令行可以执行的动作。
case increment, decrement, save
case setQuantity(Int) // 动作可以携带参数。
}
enum Message { // reducer 处理的全部消息。
case viewAction(Action)
case saved // 服务结果以 message 返回。
}
let service: InventoryService // 由构造函数注入的协议。
override func update(state: State, message: Message) {
switch message {
case .viewAction(.increment):
state.stockCount += 1
case .viewAction(.decrement) where state.stockCount > 0:
state.stockCount -= 1
case .viewAction(.setQuantity(let quantity)):
state.stockCount = quantity
case .viewAction(.save):
service.save(state.stockCount) { self.send(message: .saved) }
case .saved:
state.isSaved = true
default:
break
}
}
func send(action: Action) {
send(message: .viewAction(action))
}
}
Action 是公开接口,Message 是内部接口,update 是唯一写入者。因为结构足够严格,智能体可以正确重复几百次。
Introspection Kit:从外部观察 ViewModel
命令行工具需要读取 ViewModel,并向它发送动作。Introspection Kit 用一个协议定义这层关系:
public protocol IntrospectionNode {
var name: String { get }
var state: [String: JSONValue] { get }
var actions: [IntrospectionAction] { get }
var child: (any IntrospectionNode)? { get }
}
ViewModel 通过内省扩展返回一个节点,节点持有指回 ViewModel 的引用。Android 有一处结构差异:Kotlin 扩展不能强行让类型实现接口,因此需要一个独立类,例如 InventoryDetailViewModelIntrospectionNode,与 ViewModel 放在一起。
应用本身从不调用这层接口。删除内省文件后,应用依然完整可用,只是失去了从终端驱动它的能力。
// InventoryManagementCore/InventoryDetailViewModel+Introspection.swift
extension InventoryDetailViewModel: IntrospectionProviding {
var introspectionNode: IntrospectionNode {
Introspection(viewModel: self)
}
private struct Introspection: IntrospectionNode {
unowned let viewModel: InventoryDetailViewModel
let name = "InventoryDetail"
var state: [String: JSONValue] {
[
"productId": .string(viewModel.state.productId),
"stockCount": .number(viewModel.state.stockCount),
"isSaved": .bool(viewModel.state.isSaved),
]
}
var actions: [IntrospectionAction] {
[
IntrospectionAction(title: "Increment") {
viewModel.send(action: .increment)
},
IntrospectionAction(title: "Decrement") {
viewModel.send(action: .decrement)
},
IntrospectionAction(title: "Save") {
viewModel.send(action: .save)
},
IntrospectionAction(
title: "SetQuantity",
input: SetQuantityInput.self
) {
viewModel.send(action: .setQuantity($0.quantity))
},
]
}
}
}
@IntrospectionInput
// 生成命令行展示的 JSON schema,也可以手写。
struct SetQuantityInput: Codable {
let quantity: Int
}
一个节点有四个部分:
- name 是页面的稳定标识。测试用它判断当前所在位置。
- state 是 ViewModel 状态编码后的 JSON。用户能看到的每个字段都应在这里。
- actions 为每个 Action case 提供一个条目,包括标题和调用
send(action:)的闭包。动作可以接收参数。SetQuantity声明输入类型,命令行会在闭包执行前把 JSON 对象解码成该类型。@IntrospectionInput宏从属性生成输入 schema,因此格式错误的数据在到达 ViewModel 前就会被拒绝,也不需要手工维护另一份 schema。 - child 是指向下一层节点的可选链接。命令行从根节点沿着 child 找到当前焦点,容器也通过这条路径向下传递动作。
手工编写节点,或让智能体编写,都完全可以。因为节点形状严格,它也可以在构建期自动生成:
@Introspectable
// 在构建期从 State 和 Action 生成上面的节点。
final class InventoryDetailViewModel: ViewModel {
class State { ... }
enum Action { case increment, decrement, save, setQuantity(Int) }
...
}
三条规则保证契约不掺假:
- 用户能看到的内容放进 state,用户能执行的动作放进 actions。节点只是 ViewModel 的纯 JSON 视图。
- 节点从不重复 UI 逻辑。每次都要列出所有动作,不允许条件判断。库存为零时 Decrement 是否生效,是 ViewModel 的决定,在 update 里作出,与按钮点击走的是同一处逻辑。如果节点代码里开始出现
if,这条规则应回到 ViewModel,结果应进入 state。 - 动作只能通过
send(action:)进入。节点不得调用 ViewModel 的其他方法,也不得修改状态。命令行能做到的事,不会超过视图能做的事。
这份契约不只用于测试。Shopify 希望应用天生面向智能体。命令行使用的状态与动作接口,也正是 Shopify AI 助手 Sidekick 在应用内执行操作时使用的接口。这里没有第二套集成层。终端里的智能体能做的事,应用内智能体也能做。
节点树
内省节点逐对象镜像应用对象树,一个对象对应一个节点,通过 child 相连。应用根节点的 child 是 navigator,navigator 的 child 是顶层路由的 ViewModel。命令行始终聚焦这条链最深的节点,这与用户只和屏幕最上层交互的规则一致。
容器可以有意把少量动作借给焦点子节点。栈导航会向下传递 goBack,Tab 导航会传递 selectTab,普通 ViewModel 不向任何子节点传递动作。如果智能体要求命令行执行一个既不在焦点节点、也没有被祖先借出的动作,命令行会拒绝,并列出当前可用动作。
传输层
在无头命令行中,Core 进程从标准输入读取逐行分隔的 JSON,再把响应写到标准输出。在真实构建中,一个很小的 socket 服务通过端口提供相同协议,并与页面所绑定的同一批 ViewModel 对话。这就是为什么输入命令时模拟器会更新:它完全没有操作渲染后的 UI,只是视图在观察状态。该服务在 release 构建中被编译移除,最终发布的应用里并不存在。
测试奖杯
Shopify 的测试分布不太像金字塔,更像一个中间特别宽的奖杯:
- 单元测试覆盖基础件和共享组件,例如格式化器、解析器、基础 ViewModel 和分页辅助函数。数量少、范围小。
- 命令行行为测试覆盖业务逻辑、校验规则、导航、错误处理和完整用户旅程。这是测试主体,同一份文件在两个平台上运行。它们也是 Helix 的行为闸门:检查点只有在 iOS 与 Android 的命令行测试都通过后才能继续。
- 顶部薄层包含少量真实集成旅程和性能套件,通过相同协议驱动真实构建。
强制零网络
行为测试会话会安装一个守卫,拦截宿主进程中的所有 HTTP 请求。只要尝试访问网络,测试就失败,即使应用捕获异常并恢复也一样。每次网络调用和每个外部依赖都必须有 fixture。这样测试套件没有 I/O,也不会因网络抖动而产生 flaky。代价是 fixture 很大。一个旅程可能包含十几个 GraphQL 响应,人类维护起来很枯燥,但智能体可以处理。
失败信息可读
灰盒测试失败时,消息会明确指出哪个断言没有通过。智能体可以直接根据结果行动,无需打开调试器。
测试读起来像用户旅程
Shopify 在 Bun 测试运行器上为 Introspection Kit 写了一个小型测试 SDK。它与命令行使用同一套 JSON 协议,没有运行时依赖,并增加了两个辅助函数:
precondition等待当前焦点节点变成预期页面。do执行一个动作,并等待新状态满足断言。
同一份测试可以不加修改地跑在 iOS 与 Android 上,由测试框架选择平台:
// 每个旅程都从列表开始,然后打开一件商品。
async function openInventoryDetail(session, id = "1") {
// 等待焦点节点成为 InventoryList。
await session.precondition("InventoryList");
// 执行动作,然后轮询直到断言成立。
await session.do({
action: "SelectProduct",
payload: {id},
expectation: ({name, state}) => {
expect(name).toBe("InventoryDetail");
expect(state.productId).toBe(id);
},
});
}
test("保存成功时,用户可以调整数量并保存", async () => {
await using session = createBehaviorSession({
fixtures: [fixture("Inventory.stockCount2")],
});
await openInventoryDetail(session);
await session.do({
action: "Decrement",
expectation: ({state}) => expect(state.stockCount).toBe(1),
});
await session.do({
action: "Decrement",
expectation: ({state}) => expect(state.stockCount).toBe(0),
});
// Decrement 始终存在。库存为零时由 ViewModel 决定它是 no-op。
await session.do({
action: "Decrement",
expectation: ({state}) => expect(state.stockCount).toBe(0),
});
// 带参数的动作接收 JSON payload。
await session.do({
action: "SetQuantity",
payload: {quantity: 40},
expectation: ({state}) => expect(state.stockCount).toBe(40),
});
await session.do({
action: "Save",
expectation: ({state}) => expect(state.isSaved).toBe(true),
});
});
test("保存失败时,数量保持未保存状态", async () => {
await using session = createBehaviorSession({
fixtures: [fixture("Inventory.saveFails")],
});
await openInventoryDetail(session);
await session.do({
action: "Save",
expectation: ({state}) => expect(state.isSaved).toBe(false),
});
});
两个辅助函数都会轮询几秒,然后在失败时给出最后观察到的状态。没有 sleep,也没有“等待元素出现”的辅助函数。测试就是用户做了什么、看到了什么的记录。
Shopify 完全可以把这些测试拆得像单元测试一样细,但故意没有这样做。单元测试失败能精确定位到很小的实现细节;这里失败时已经包含旅程步骤、动作、预期状态和实际状态。智能体擅长读取这些证据并找到原因。把步骤折叠进一个旅程,可以节省执行时间和维护成本。需要不同 fixture 的失败场景,例如保存失败,则单独保留,因为它们的测试准备不同,而不是断言本身不同。
预热之后,上面的测试在任一平台上都能在几百毫秒内运行完毕,完全不需要设备。
同一个测试放在 iOS 模拟器上大约需要两秒。
为什么没有打包成开源库
Shopify 说,这篇文章就是开源发布。没有库可以发布,因为真正有用的部分不是公司的代码。Introspection Kit 与 Shopify 自己的会话模型、navigator 和 fixture 格式绑定,这些都无法干净地迁移到另一个应用。能够迁移的是 Reflex 这套架构。一个能力足够的编码智能体可以把它实现到你的代码库中,并使用你的语言。
Shopify 列出把 Reflex 引入应用所需的条件:
- 把平台与 Core 分开。建立一个不导入 UI 的模块,所有平台能力都通过构造函数注入。如果 Core 不能在 macOS 或 JVM 上构建,边界就还不够干净。
- 让每个 ViewModel 长得一样。动作进入,状态输出,只有一个 update,服务由构造函数传入。
- 用扩展加入内省。状态暴露为 JSON,每个动作都是调用
send(action:)的闭包,不允许条件逻辑。 - 建立节点树并聚焦叶子。应用根节点和 navigator 也是节点。容器可以向下借出少量动作,其余动作都不可见,就像屏幕外的控件对用户不可操作一样。
- 任何网络访问都失败。所有外部依赖都使用 fixture。这是大规模测试套件保持确定性的唯一办法。
- 写 skill,而不是只写文档。教智能体如何运行命令行、添加 fixture、命名测试。只有智能体能够发现并主动使用命令行,它才会真正有用。
- 去掉只服务于人的复杂度。不要 TUI,不要装饰性输出,不要交互向导。这是给智能体用的工具,保持简单和快速。
Reflex 给了 Shopify 一个与智能体节奏匹配的反馈回路,以及一套让两个原生应用保持同步的测试。今天,这套测试是 iOS 与 Android 应用的共同事实来源,大部分由智能体编写。而要弄清应用到底会做什么,最快的办法仍然是打开终端,输入 ls。
常见问题
不测试 UI,这些测试还有价值吗?
很有价值。页面出问题,多数时候问题在逻辑,而这些测试正好覆盖逻辑。检查按钮是否正确接到 send(action:) 很少是 bug 的主要来源。UI 验证仍由其他环节负责:每个 Helix 检查点都要和参考应用做视觉审查,少数对像素敏感的组件保留截图测试,团队每天也在真实使用应用。ViewModel 行为测试、视觉审查和截图测试共同决定页面能否发布。
为什么不用单元测试加 XCUITest、Espresso?
单元测试和原生 UI 测试都要按平台各写一次,两者都不能证明两个平台一致。它们也没有给智能体一套在预先写好的测试之外探索应用的方法。而后来的实践表明,探索和测试本身同样重要。能够通过 ls 走遍应用的智能体,调试和开发新功能都更快。同一套接口还让应用天然支持 Sidekick,否则就要另做一层集成。
Appium 或智能体驱动的设备自动化呢?
Shopify 会在适合的场景使用这些工具。因为代码库由自己掌握,它可以暴露灰盒接口,让智能体直接读取状态,而不是从无障碍树或截图中推断状态。这减少了智能体理解屏幕所需的往返次数。更重要的是,无头执行让成千上万个场景可以在 CI 上几分钟跑完。如果都做成端到端测试,完整覆盖需要数小时,还要投入大量并行设备基础设施。
RobotWorld 视角
Reflex 不是机器人系统,也没有对感知、操作、物理动力学或安全性作任何承诺。它与物理 AI 的关联更窄,但很重要:它展示了怎样把一个由智能体操作的系统,变成确定、可内省的状态机。软件智能体可以调用 ls 读取类型化状态,选择动作,再校验下一状态。机器人栈也需要同样的纪律,但每个动作之外还必须加入时间戳、不确定性、传感器来源、执行器前置条件和安全边界。
最值得迁移的是边界设计。Reflex 把 UI 与设备集成留在 Core 之外,再为所有外部依赖注入 fixture。在机器人领域,类似拆分意味着让策略和任务逻辑远离硬件驱动,注入仿真或录制的感知与执行器适配层,并暴露一套外部编码智能体可以检查的类型化动作接口。节点树的“聚焦叶子”规则也能用于分层控制器:智能体应只看到当前活动技能可执行的动作,而不是整个栈里所有低层命令。
边界也同样明确。无头 ViewModel 能在毫秒级验证导航规则,是因为状态转移是确定的。真实机器人不可能用 fixture 消除时序、接触、感知误差和环境不确定性。Reflex 式测试必须与仿真、硬件在环测试、受监控部署和物理安全控制并存。更准确的理解是:它提供了一种面向智能体的控制层架构模式,不能证明物理行为可以脱离设备完成认证。
原文来源与编辑说明
本文是对 Shopify Engineering 于 2026 年 11 月 5 日发布的 《Reflex: an architecture for building mobile apps with agents (2026)》的完整翻译与改编,原作者为 Thiago Magalhaes。原文以 Gist 嵌入的 7 个代码示例已内联到正文,保证长期可读。架构图和 GIF 来自原文。“RobotWorld 视角”是 RobotWorld 额外加入的编辑分析,不属于 Shopify 原文。
原文来源:Shopify Engineeringhttps://shopify.engineering/reflex
