测试

本指南统一介绍 GPUI Kit 应用和 GPUI 的测试方式。根据要验证的行为选择测试层级:

  • 纯数据转换、校验和状态转换使用普通 Rust #[test]
  • Entity、action、订阅和异步任务使用 #[gpui_kit::test]TestAppContext,按需创建窗口。
  • UI 集成测试渲染真实应用视图,通过 gpui_kit::test 派发事件,再检查控件状态、布局和业务结果。
  • 像素检查使用独立的离屏渲染器;原生窗口和平台集成保留相应测试。

GPUI 类型和测试属性宏都由 gpui-kit 根模块导出,应用无需再添加 GPUI 依赖。下面以完整的 UI 集成测试为例。

使用 gpui_kit::test,可以通过 GPUI 的真实事件分发操作应用视图,再用普通 Rust 断言验证原生无障碍属性、布局和业务结果。测试会创建无头窗口,通过 ElementId 定位控件,点击、输入文本,并检查焦点、值和布局。

本指南介绍进程内的行为与布局自动化。元素快照不会检查像素,也不会启动打包后的应用。像素验证使用下文单独介绍的 GPUI 离屏渲染器。如果需要验证原生窗口、平台集成或视觉效果,应另外保留相应测试。

#配置测试项目

UI 测试直接集成在 gpui-kit 中,通过 test-support feature 启用。下面示例使用包含这些辅助方法的 Kit 源码检出目录,不需要额外测试 crate、GPUI fork 或 Cargo 补丁。

先按照安装说明准备平台依赖。无头测试仍然需要编译 GPUI 的原生依赖。可以在源码目录旁创建独立测试项目:

workspace/
  gpui-kit/
  ui-tests/
    Cargo.toml
    tests/ui.rs

ui-tests/Cargo.toml 中写入:

[package]
name = "ui-tests"
version = "0.1.0"
edition = "2024"
publish = false

[dev-dependencies]
gpui-kit = { path = "../gpui-kit/crates/kit", features = ["test-support"] }

已有应用可以在自己的 package 中添加这个开发依赖。普通 gpui-kit 依赖必须解析到相同来源和版本,测试时 feature 才能合并。将 test-support 放在开发依赖中,让普通应用构建不启用观察功能。直接使用组件 crate 的应用也可以启用 gpui-component/test-support

#一个完整测试

把下面代码复制到 tests/ui.rs。示例使用 GPUI Kit 的统一入口,初始化组件库,用 Root 包装视图,并像真实应用一样将输入状态保存在视图上。

测试会输入 Unicode 姓名,通过 Backspace 编辑,点击 Save,检查状态文本与布局,最后验证保存的业务值。以下代码直接引用仓库集成测试的源码,会实际编译运行。

use gpui_kit::test::{TestSupportExt, TestWindowExt};
use gpui_kit::{
    AppContext, Context, Entity, SharedString, TestAppContext, Window,
    component::{
        Root,
        button::Button,
        input::{Input, InputState},
    },
    div,
    prelude::*,
    px, size,
};

struct Profile {
    name: Entity<InputState>,
    submitted: Option<SharedString>,
}

impl Render for Profile {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let status = self.submitted.as_ref().map_or_else(
            || SharedString::from("Not saved"),
            |name| SharedString::from(format!("Saved: {name}")),
        );
        div()
            .size_full()
            .flex()
            .flex_col()
            .p_4()
            .gap_4()
            .child(Input::new(&self.name).id("name").w(px(240.)))
            .child(
                Button::new("save")
                    .label("Save")
                    .on_click(cx.listener(|this, _, _, cx| {
                        this.submitted = Some(this.name.read(cx).value());
                        cx.notify();
                    })),
            )
            .child(
                div()
                    .id("status")
                    .role(gpui_kit::Role::Status)
                    .test_support()
                    .aria_label(status.clone())
                    .child(status),
            )
    }
}

#[gpui_kit::test]
fn saves_a_profile_through_the_ui(cx: &mut TestAppContext) {
    cx.update(gpui_kit::init);
    let mut profile = None;
    let handle = cx.open_window(size(px(640.), px(480.)), |window, cx| {
        let view = cx.new(|cx| Profile {
            name: cx.new(|cx| InputState::new(window, cx)),
            submitted: None,
        });
        profile = Some(view.clone());
        Root::new(view, window, cx)
    });
    let profile = profile.unwrap();

    cx.update_window(handle.into(), |_, window, cx| {
        window.render_frame(cx);
        assert_eq!(window.find("status").label(), Some("Not saved"));

        window.click("name", cx);
        window.input("Ada 中文", cx);
        let name = window.find("name");
        assert_eq!(name.focused(), Some(true));
        assert_eq!(name.value(), Some("Ada 中文"));
        assert!(name.bounds().size.width > px(0.));
        // Named keys share the same Window API and refresh the resulting frame.
        window.press("backspace", cx);
        assert_eq!(window.find("name").value(), Some("Ada 中"));

        window.click("save", cx);
        let status = window.find("status");
        assert!(status.visible());
        assert_eq!(status.label(), Some("Saved: Ada 中"));
        assert!(status.bounds().top() >= window.find("save").bounds().bottom());
    })
    .unwrap();

    // Verify the application result as well as the native properties.
    cx.update(|cx| {
        assert_eq!(profile.read(cx).submitted.as_deref(), Some("Ada 中"));
    });
}

在自己的应用中,应从 library crate 导入生产视图及其构造函数。不要在测试中另写一份视图实现,否则测试与应用可能逐渐不一致。本例内联定义视图,是为了让整个示例可以直接复制到新项目。

ui-tests/ 中运行:

cargo generate-lockfile
cargo test --test ui --locked

Cargo.lock 一起提交。在 GPUI Kit 源码目录中,可以直接运行同一个示例:

cargo test -p gpui-kit --features test-support --test ui --locked

#选择稳定的测试目标

启用 test-support 后,以下控件在已有原生元素上注册,不增加布局容器:

控件除几何与可见性以外报告的状态
Button无障碍名称、焦点作用域
Input非敏感无障碍值、名称、焦点作用域
Checkbox勾选、半选、名称、焦点作用域
Switch / Toggle勾选、名称、焦点作用域
Radio勾选、选中、名称、焦点作用域
Tab选中、名称
Select无障碍值(包含标题前缀)、展开、焦点作用域
ListItem / SidebarMenuItem几何;其他状态仅在原生无障碍属性提供时可读
Accordion触发器展开状态;标题与面板边界
Tree原生树与节点角色、名称、选中与展开状态;根节点焦点作用域
Table / DataTable原生表格部件;DataTable 行选中状态与根焦点作用域
DatePicker / Calendar日期选择器显示的日期值、展开与焦点作用域;日历项名称与边界
Slider轨道与滑块边界;ElementSnapshot::value() 不读取数值型无障碍属性
Stepper步骤与触发器边界;通过应用内容验证导航结果
Dialog / Sheet宿主焦点作用域与内容表面边界;子控件保留各自属性
Menu菜单项名称与选中状态、菜单焦点作用域、子菜单边界
NotificationAlert 角色与边界;关闭按钮沿用 Button 观察
Dock区域、分组与内容边界及焦点作用域;Tab 保留原生选中状态

优先使用构造函数 ID。Input 和 Select 支持 .id("name"),默认 ID 包含状态 entity ID。 TabBar 内的 Tab 使用下标 ID。Select 已有的 "input" 子元素是触发区域: window.within("language").click("input", cx)

原生 div 只注册观察,不再填写另一份测试状态:

use gpui_kit::TestSupportExt as _;

let target = div().id("details").test_support().child(content);

TestSupportExt 始终可用。关闭 test-support 时,.test_support() 直接返回原生元素, 保留其准确类型;启用后保留身份、布局、事件与无障碍接口,不增加布局容器。 重复调用只保留一个注册项。先调用 .test_support(),再调用 .track_focus(&handle), 让包装器观察实际焦点绑定;Kit 控件在内部完成这件事。focused() 检查焦点作用域内 是否存在键盘焦点,也包括 Input 外框中的编辑器。如果 GPUI 声明元素可聚焦,但没有 观察到绑定,focused() 会报错并给出修复提示,不再静默返回 None。这能发现 .track_focus(&handle).test_support() 的顺序错误;隐式 .focusable() 句柄也无法读取, 应改用显式句柄。没有观察到绑定、也没有声明焦点 action 时返回 None。 这个诊断是尽力而为的,依赖原生 accessibility 的 Action::Focus。 自定义元素如果没有声明此 action,遗漏绑定时仍可能返回 None,因此 None 不能证明元素无法获得焦点。检测到遗漏绑定时,Debug 输出 focused: <binding missed>,格式化本身不会 panic。

快照直接读取 rolearia_toggledaria_selectedaria_expandedaria_labelaria_value。没有 TestProps 或手填的备用值。Input 在测试中启用 已有的无障碍值生成路径,沿用相同的遮蔽与敏感内容限制。Select 的 value() 是包含 标题前缀的无障碍值,不是选中项 ID。

label() 表示无障碍名称,不是屏幕文字;value() 表示无障碍值,不是像素。 组件仍然可能把这些属性写错。不要仅为让视觉断言通过而添加 aria_labelaria_value。完整示例中的 Status 角色和名称服务于生产环境的无障碍播报。 框架不自动发现任意子元素文字,也不提供用模型字符串冒充绘制文本的 text()

disabled() 仅在原生节点暴露禁用标志时返回 Some(true),否则返回 None。 当前 GPUI 的 div 接口不能据此提供确定的启用状态。验证禁用行为时,应尝试交互并 检查应用结果没有变化;不能把 None 当作启用。 这个接口无法可靠地正面断言 enabled 属性。验证按钮接受操作时,应执行操作并断言 它应该产生的结果,例如:

window.click("save", cx);
assert_eq!(window.find("status").label(), Some("Saved: Ada"));

这里应使用应用真实的预期结果。assert_ne!(button.disabled(), Some(true))button.disabled().is_none() 都不能证明按钮可以正常响应。

ID 只需在 GPUI 身份作用域内唯一。窗口级查询遇到重复 ID 会报歧义;可以直接使用已有父级作用域,无须添加测试容器:

window.within("toolbar").click("save", cx);
window.within("dialog").click("save", cx);
let save = window.within("dialog").within("footer").find("save");
assert!(save.visible());

父级本身不必被观察:它的 ID 已经包含在被观察子元素的 GPUI 路径中。 within 要求当前已绘制路径唯一。列表使用 ("row", record_id) 等复合 ID,可保持重排后的记录身份。

#操作与断言

导入 gpui_kit::test::TestWindowExt 后使用以下方法:

API行为
window.find(id)严格返回最近完成帧的 ElementSnapshot;缺失时 panic,列出注册路径与排查提示。
window.try_find(id)缺失时返回 None,歧义仍会 panic。
window.click(id, cx)在中心发送原生鼠标移动、按下与释放。
window.click_at(id, offset, cx)相对于目标左上角的像素偏移点击,适合部分裁剪。
window.right_click(id, cx) / double_click(id, cx)原生右键或两次点击序列。
window.hover(id, cx)移动指针,不按键。
window.scroll(id, delta, cx)原生滚轮事件,ScrollDelta 保留 GPUI 的方向与单位。
window.drag_to(from_id, to_id, cx)定位两个目标,在其中心之间通过真实命中测试拖拽。
window.drag(from, to, cx)窗口坐标之间的左键拖拽,经过真实拖拽创建与放置命中测试。
window.press("backspace", cx)使用 GPUI 按键解析器发送特殊键或快捷键。
window.input(text, cx)向当前焦点逐字符输入,不自动聚焦或替换整个值。

作用域支持 findtry_find、嵌套 withinclickclick_atright_clickdouble_clickhoverscrolldrag_topressinputdrag_to 的两个 ID 都在当前作用域中解析。跨作用域拖拽或指定偏移时,可查询目标后 将窗口坐标传给 window.drag

let mut dialog = window.within("dialog");
dialog.click("name", cx);
dialog.input("Ada", cx);
dialog.press("backspace", cx);
dialog.hover("help", cx);

作用域内的键盘操作不会移动焦点,必须先有一个已观察的焦点绑定位于该作用域中, 否则派发前就报错。input 在每个字符前检查,所以处理器把焦点移到作用域外时, 剩余文字不会输入到其他控件。需要窗口级快捷键时,使用 window.press

自定义输入控件需要在实际承载焦点的元素上调用 .id("editor").test_support().track_focus(&focus_handle),使用控件真正的焦点句柄。 未观察的输入控件,或没有绑定焦点句柄的外层容器,即使实际焦点位于作用域内, 也无法通过检查。窗口级 inputpress 向当前焦点派发,但不提供作用域保证。 作用域输入与窗口输入共用同一循环:开始时刷新一次,随后每个字符刷新一次, 每次作用域检查都读取已完成的帧。

ElementSnapshot 是某次完成绘制的独立、不可变记录。它提供 role()path()bounds()visible()focused()disabled()label()value()checked()indeterminate()selected()expanded()。焦点、禁用、勾选、半选、选中、展开状态返回 Option<bool>None 表示无法取得,不等于 false。名称与值也可能无法取得。交互后重新查询:

let before = window.find("agree");
window.click("agree", cx);
assert_eq!(before.checked(), Some(false)); // 原来的帧。
assert_eq!(window.find("agree").checked(), Some(true)); // 新的一帧。

同时断言界面状态与业务结果。验证保存的模型或发出的事件也是集成测试的一部分, 但不能取代相关控件可见状态的验证。文本输入不模拟完整的系统 IME 组合输入; 密码输入框不报告值,需要时通过应用状态验证结果。

#查询前完成一帧

第一次查询、外部直接修改状态或焦点、调整尺寸后,调用 window.render_frame(cx)。 交互方法会在同步派发过程中刷新,包括 press。但外层 window update 尚未返回时, 它们不能完成需要释放该借用的延迟回调。

cx.update_window(handle.into(), |_, window, cx| {
    window.render_frame(cx);
    window.click("name", cx);
    window.input("Ada", cx);
    window.press("backspace", cx);
    assert_eq!(window.find("name").value(), Some("Ad"));
}).unwrap();

使用 TestAppContext::update_window。带类型的 WindowHandle::update 已经借用根 entity, 不能在同一个回调中安全地重绘它。

异步工作或 Select 的延迟提交,应在 async #[gpui_kit::test] 中、window update 外部等待:

use gpui_kit::test::TestAppContextExt;
use std::time::Duration;

cx.wait_for(handle.into(), Duration::from_millis(200), |window, _| {
    window.try_find("result").is_some_and(|snapshot| snapshot.visible())
}).await;

wait_for 按 GPUI 测试执行器时钟每 10ms 刷新并检查条件,超时报错列出注册路径。 它是有界的条件等待,不模拟操作系统事件循环或网络服务;外部依赖需要受控响应。 执行器停驻本身不代表定时器或延迟工作已经完成。

快照永不原地更新。缓存视图保留绘制事实,直到被失效并重绘。卸载目标在释放其 element state 的帧完成后消失;虚拟列表行则在滚动后实际绘制时进入查询结果。

GPUI dispatch_action 会排队执行。继续修改 action 将读取的值之前,应先完成派发, 例如离开 update_window 后运行 cx.run_until_parked();结果或计时器完成使用 wait_for。 旧的非同步 GPUI Animation 使用真实时钟 Instant,推进测试时钟不会让动画结束。 Sheet/Notification 几何测试会等待真实入场时长,再断言最终边界。 Base motion 则可以响应公开的 cx.set_reduce_motion(true) 偏好,用于测试展开后的最终几何。

#覆盖范围与失败排查

仓库通过真实输入、原生属性和布局边界验证以下组件流程。这些是具体的回归契约, 不代表已穷举每个组件的全部配置和组合。

测试文件验证行为
disclosure.rsAccordion 互斥展开、折叠与实际面板几何;Stepper 内容导航;禁用展开与步骤操作;Slider 轨道点击、滑块拖动与禁用行为
collections.rsTree 点击展开、键盘展开/折叠与选择;DataTable 行选择、键盘虚拟滚动与滚轮滚动
date_picker.rs打开、精确预设日期与日历日期选择、月份切换、清除、Escape 与禁用行为
overlays.rsDialog 校验 → 作用域 Input → 保存 → Notification;悬停显示关闭按钮;自动关闭计时;Dialog/Sheet Escape 与焦点恢复;表面边界
menu.rs禁用菜单项、键盘确认、Escape、焦点恢复、子菜单悬停及嵌套菜单项激活
dock.rsTab 选择与重排、跨分组拖放、放大和恢复分割布局

已有表单、Select、HoverCard、虚拟列表、指针、生命周期和隔离测试继续保留。 纯展示组件通过几何或像素断言验证,不虚构交互状态。自定义部件观察已有原生元素; 不支持的属性保持不可用,不提供手填测试值的覆盖入口。

通过 WindowExt 打开 Dialog、Sheet 或 Notification 的视图,需要像生产应用一样挂载 Root::render_dialog_layerRoot::render_sheet_layerRoot::render_notification_layer 返回的子元素。仅构造 Root 不会自动挂载这些覆盖层。

重复控件使用 within。Sheet 的 "sheet" 宿主作用域包含 "sheet-content" 内容表面; Dialog 的 "dialog" 作用域包含以层下标标识的表面。子菜单也包含 "popup-menu", 打开子菜单时应保留已解析的父作用域,或在 "submenu" 下查询。 不要假定打开另一层之后,原先唯一的 ID 仍然唯一。

目标缺失或不可见时点击会 panic。禁用控件仍接收原生事件,由控件自己决定是否响应。 可见性结合几何、视口与内容裁剪、目标计算样式,不判断像素遮挡;覆盖层仍会拦截点击。 click_at(id, point(px(10.), px(10.)), cx) 可以选择裁剪后可见的部分,不会绕过命中测试。

观察依赖 feature,因此被测制品与生产制品并非逐字节相同。透明包装器不增加布局盒子, 但可见性检查会额外计算一次样式;style/drag 谓词不能依赖调用次数。 GPUI 没有公开未观察祖先的继承绘制透明度,因此无法推断该情况。 实现没有使用 GPUI fork 或 Cargo patch 绕过这些限制。

失败时按具体情况检查注册路径、观察配置、完成帧、键盘焦点、裁剪与覆盖层、异步完成条件。

#独立验证绘制结果

值或勾选标志正确,不代表控件正确绘制。GPUI 提供 HeadlessAppContext::with_platformWindow::render_to_imageHeadlessAppContext::capture_screenshot,可以生成真实离屏图片。当前锁定版本的 平台 crate 仅在 macOS 提供 Metal 离屏渲染器。在支持 Metal 的 Mac 上执行:

cargo test -p gpui-kit --features test-support --test rendering --locked

该目标设置了 test = false,默认 Cargo 命令不会选择它。macOS CI job 已增加必须 通过的独立步骤,显式执行 --test rendering;Linux 和 Windows 只运行交互与布局 测试。这使用 Cargo 的 显式目标选择。 目标还使用 harness = false,因为 AppKit 必须在主线程初始化;普通 Rust 测试即使指定 --test-threads=1 仍运行在工作线程。其他平台明确报告跳过像素验证; macOS 缺少渲染能力时测试失败,不用假图片替代。

测试向真实 Kit 控件注入两种故障:checked() 仍为 true,但勾号资源丢失; value() 仍正确,但输入文字变透明。故障图片必须与正常控件不同,重复绘制正常 Checkbox 的图片必须一致。另一个原生事件测试断开 Checkbox 的状态更新处理器, 验证点击不会凭空产生已勾选结果。

这些测试验证能否发现特定错误,不是完整的基准图片回归测试。应用的视觉回归应在 固定字体、尺寸、主题、焦点和动画状态下,将图片与已审查的预期结果比较。状态与 图片断言能发现不同的故障;两者都不能证明打包应用或完整 IME 行为正确。 可执行示例见 crates/kit/tests/rendering.rs

#接入 CI

Kit 仓库在 macOS、Linux 和 Windows 矩阵中运行交互与布局测试。macOS job 还运行两个 Metal 像素测试,失败会使 job 失败。以下是用于 Kit 检出目录的最小 macOS workflow:

name: UI tests
on: [push, pull_request]
jobs:
  test:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: ./script/bootstrap
      - run: cargo test -p gpui-kit --features test-support --locked
      - run: cargo test -p gpui-kit --features test-support --test rendering --locked

应用仓库需要安装自身的平台依赖,并改为在测试 package 中运行 cargo test --test ui --locked。将锁定版本的 Kit 源码放到 manifest 声明的路径,再按照普通原生构建的环境配置增加 Linux 和 Windows job。

仓库测试还覆盖只读与禁用输入、焦点变化、缓存视图、挂载与卸载、多窗口隔离、原生命中测试,以及列表从 1,000 个元素缩减后的清理。大列表用例验证正确性,不是渲染性能基准。