9.5 可测试性设计

> **本节目标**:让 RML 代码可被高效测试——ViewModel 纯逻辑单测、组件快照测试、跨模块集成测试。

9.5.1 RML 测试的三层金字塔

                    ┌──────────┐
                    │  E2E     │  少量:完整用户流程
                    └──────────┘
                ┌───────────────┐
                │  集成测试       │  中量:跨模块协作
                └───────────────┘
        ┌───────────────────────────┐
        │  单元测试(ViewModel / Model)│  大量:纯逻辑
        └───────────────────────────┘

- **单元测试**:最快、最多,覆盖 ViewModel 的命令和计算属性 - **集成测试**:中速,覆盖视图 + Service 的协作 - **E2E**:最慢、最少,覆盖关键用户流程

9.5.2 让 ViewModel 可单测的关键

ViewModel 难测的根因是它依赖 `ViewContext`。可单测的 ViewModel 遵循两条规则:

1. **纯逻辑方法不依赖 `cx`**:计算、校验、派生数据的方法只读 `self` 2. **副作用方法依赖 trait 而非具体实现**:通过依赖注入替换

反例:不可测的 ViewModel

#[derive(IModel)]
pub struct UserViewModel {
    pub user: User,
}

impl UserViewModel {
    #[command]
    pub fn save(&mut self, cx: &mut ViewContext<Self>) {
        let resp = reqwest::blocking::post("/users").json(&self.user).send().unwrap();
        if resp.status().is_success() {
            self.user.saved = true;
            cx.notify();
        }
    }
}

无法在无网络环境下测试,也无法验证失败分支。

正例:可测的 ViewModel

pub trait UserRepo: 'static {
    fn save(&self, user: &User) -> Task<Result<()>>;
}

#[derive(IModel)]
pub struct UserViewModel {
    pub user: User,
    pub is_saving: bool,
    pub error: Option<SharedString>,
    repo: Arc<dyn UserRepo>,
}

impl UserViewModel {
    // ✅ 纯逻辑:可独立单测
    pub fn can_save(&self) -> bool {
        !self.is_saving && !self.user.email.is_empty()
    }

    // ✅ 命令:依赖 trait,可注入 mock
    #[command]
    pub fn save(&mut self, cx: &mut ViewContext<Self>) {
        if !self.can_save() { return; }
        self.is_saving = true;
        self.error = None;
        cx.notify();
        let repo = self.repo.clone();
        let user = self.user.clone();
        cx.spawn(|this, mut cx| async move {
            let result = repo.save(&user).await;
            let _ = this.update(&mut cx, |this, cx| {
                this.is_saving = false;
                match result {
                    Ok(_) => this.user.saved = true,
                    Err(e) => this.error = Some(e.to_string().into()),
                }
                cx.notify();
            });
        }).detach();
    }
}

单元测试

#[cfg(test)]
mod tests {
    use super::*;

    struct MockUserRepo { should_fail: bool }
    impl UserRepo for MockUserRepo {
        fn save(&self, _user: &User) -> Task<Result<()>> {
            Task::ready(if self.should_fail {
                Err(anyhow!("mock error"))
            } else { Ok(()) })
        }
    }

    #[test]
    fn can_save_returns_false_when_saving() {
        let vm = UserViewModel {
            user: User { email: "a@b.com".into(), ..Default::default() },
            is_saving: true,
            error: None,
            repo: Arc::new(MockUserRepo { should_fail: false }),
        };
        assert!(!vm.can_save());
    }

    #[test]
    fn can_save_returns_false_when_email_empty() {
        let vm = UserViewModel {
            user: User { email: "".into(), ..Default::default() },
            is_saving: false,
            error: None,
            repo: Arc::new(MockUserRepo { should_fail: false }),
        };
        assert!(!vm.can_save());
    }
}

纯逻辑方法可在无 GPUI 环境下测试,速度快、覆盖率高。

9.5.3 Model 的纯函数测试

Model 是纯数据,测试最简单:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn todo_is_overdue_when_past_due_date() {
        let now = Utc::now();
        let todo = TodoItem {
            due_date: Some(now - Duration::days(1)),
            ..Default::default()
        };
        assert!(todo.is_overdue(now));
    }

    #[test]
    fn todo_not_overdue_without_due_date() {
        let todo = TodoItem { due_date: None, ..Default::default() };
        assert!(!todo.is_overdue(Utc::now()));
    }
}

Model 测试不依赖任何框架,应当覆盖所有边界条件。

9.5.4 组件快照测试

组件的 `.rml` 渲染结果可被快照化,确保 UI 不被意外改坏。

#[cfg(test)]
mod tests {
    use rml_testing::snapshot_view;

    #[test]
    fn button_renders_label() {
        let view = Button::new("Save".into());
        snapshot_view(view, "button-save").assert_matches();
    }

    #[test]
    fn button_renders_disabled_state() {
        let mut view = Button::new("Save".into());
        view.disabled = true;
        snapshot_view(view, "button-disabled").assert_matches();
    }
}

首次运行生成快照,后续运行比对。UI 主动变更时用 `RML_UPDATE_SNAPSHOTS=1` 更新。

9.5.5 集成测试:视图 + Service

集成测试验证 ViewModel 与真实 Service(或其 mock)的协作:

// tests/login_flow.rs
use my_app::views::login::LoginViewModel;
use my_app::services::MockAuthRepo;

#[test]
fn login_flow_shows_error_on_failure() {
    let mut cx = TestContext::new();
    cx.set_global(MockAuthRepo { should_fail: true });

    let mut vm = LoginViewModel::new(cx.clone());
    vm.email = "a@b.com".into();
    vm.password = "wrong".into();
    vm.login(&SubmitEvent::default(), &mut cx.with_view(&mut vm));

    cx.run_until_parked(); // 等待异步任务完成

    assert!(vm.error.is_some());
    assert!(!vm.is_loading);
}

`TestContext` 是 RML 提供的测试工具,模拟 `ViewContext` 但不真正渲染。

9.5.6 测试覆盖策略

| 层 | 测试类型 | 覆盖目标 | | ---------- | ------ | ----------------------------- | | Model | 单元 | 所有派生方法、边界条件 | | ViewModel | 单元 | 所有 `#[computed]`、命令的状态机分支 | | Service | 单元 | 用 mock HTTP / 文件系统测试 I/O 路径 | | Component | 快照 | 关键状态组合的渲染输出 | | View+Service | 集成 | 关键用户流程(登录、提交、加载) | | 全应用 | E2E | 关键业务路径(端到端) |

9.5.7 可测试性的设计准则

1. **纯逻辑与副作用分离**:能纯函数的就纯函数 2. **依赖 trait 而非具体类型**:Service 必须有 trait 3. **状态机显式化**:用枚举而非 `bool` 组合表达状态 4. **避免隐式全局**:少用 `cx.global`,多用注入 5. **命令方法短小**:一个命令只做一件事,便于覆盖分支

9.5.8 何时不必测试

- **`.rml` 模板**:模板是声明,逻辑在 ViewModel,模板测试由快照覆盖 - **纯样式**:样式变更由视觉走查 - **一次性脚本**:无复用价值的代码

测试的目的是**支撑重构**,对不会变化的代码过度测试是浪费。

下一节 → [9.6 反模式与代码异味](./anti-patterns.md)