没有覆盖全状态的 UI 是定时炸弹。
happy path 永远只覆盖 20% 真实场景。剩下 80%(loading/empty/error/permission-denied/…)出问题就是客诉。
任何前端 UI 相关的”开始”动作,必须触发:
不触发:纯样式调整、文案修改、纯图标库替换。
按顺序执行,任何一步失败都不能 commit:
状态 = 数据状态 × 权限状态 × 网络状态 × 异步状态 × 用户输入。
5 个维度交叉展开成矩阵:
| 维度 | 可能值 |
|---|---|
| 数据 | 有数据、空数据、加载中、部分数据(分页/无限滚动)、数据过期 |
| 权限 | 所有者、被授权、只读、无权限、未登录 |
| 网络 | 在线、离线、慢网络、API 失败、超时 |
| 异步 | 进行中、成功、失败、可重试、不可重试 |
| 用户输入 | 合法、非法(验证失败)、边界(最小/最大)、冲突 |
全状态覆盖意味着:每个 cell 至少 1 个 E2E case。
每个 cell 必填:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- state: empty + online + owner
design: "[Figma URL 或截图 fixture 路径]"
copy: "还没有任何内容,点击新建"
assertion: "shows empty illustration, shows CTA button"
screenshot_fixture: "tests/fixtures/empty.png"
- state: loading + online + owner
design: "[Figma URL]"
copy: "加载中..."
assertion: "shows skeleton, hides content"
screenshot_fixture: "tests/fixtures/loading.png"
- state: error + offline + owner
design: "[Figma URL]"
copy: "网络连接失败,请检查后重试"
assertion: "shows offline icon, shows retry button, hides content"
screenshot_fixture: "tests/fixtures/offline.png"
关键:每个 cell 的文案和设计必须显式写——不能”fallback 到默认”。
状态分类 = 任何前端 UI 必须覆盖的最小集合。
| 分类 | 触发场景 | 必备 UI 元素 |
|---|---|---|
| loading | 数据请求中 | 骨架屏/spinner |
| success | 数据成功 | 实际内容 |
| empty | 无数据 | 空状态插图 + CTA |
| error | 业务错误 | 错误提示 + 重试按钮 |
| timeout | 超时 | 错误提示 + 取消按钮 |
| partial | 部分数据 | 已加载 + 加载更多 |
| permission-denied | 无权限 | 权限申请引导 |
| stale | 数据过期 | 重新加载按钮 |
| offline | 离线 | 离线提示 + 重试 |
| optimistic | 乐观更新 | 立即响应 + 失败回滚 |
任何一个 cell 走”fallback 到 success 状态” = 隐藏 bug。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// cypress/e2e/state-matrix.spec.js
describe('Order list state matrix', () => {
// 数据维度
it('shows empty state when no orders', () => { ... })
it('shows loading state while fetching', () => { ... })
it('shows partial state during pagination', () => { ... })
it('shows stale state after 24h', () => { ... })
// 权限维度
it('shows permission-denied for non-owner', () => { ... })
// 网络维度
it('shows offline state when no network', () => { ... })
it('shows error state on 500', () => { ... })
it('shows timeout state after 30s', () => { ... })
// 用户输入维度
it('shows validation error on invalid form', () => { ... })
it('shows conflict state on duplicate submit', { ... })
})
每个 cell 一个 case。少于 10 个 case = 状态矩阵不完整。
1
2
3
4
5
6
// playwright.config.ts + visual regression
// 每个状态有 baseline 截图,PR 改动后自动对比
test('order list empty state matches design', async ({ page }) => {
await page.goto('/orders')
await expect(page).toHaveScreenshot('empty.png', { maxDiffPixels: 100 })
})
没有视觉回归 = 任何像素改动都不会被捕获。
| 失败模式 | 表现 | 后果 |
|---|---|---|
| 只测 happy path | “成功的我能跑通” | 80% 场景是 error/empty/loading |
| fallback 到默认 | “出错就显示空” | 用户看到的是无意义的”空” |
| loading 卡死 | spinner 一直转 | 用户不知道在干嘛 |
| 错误信息模糊 | “请求失败” | 用户不知道该怎么办 |
| 离线无提示 | 直接显示旧数据 | 用户不知道数据可能过期 |
| 权限错误不引导 | 403 直接弹错 | 用户不知道申请权限 |
| 无视觉回归 | 改一行 CSS 漏测 | 整个页面布局错乱没人发现 |
🚩 你(agent)写完 happy path 就 commit
🚩 你的状态矩阵少于 10 个 cell
🚩 你的 E2E 只有 “test login” 一个 case
🚩 你的”loading 状态” = “请求完之前什么都不显示”
🚩 你的错误提示 = alert('错误')
🚩 你的离线状态 = “网络失败就当没数据”
🚩 你没跑过截图 diff
| 借口 | 反驳 |
|---|---|
| “UI 简单不需要状态矩阵” | 简单 UI 也有 5+ 状态(loading/success/empty/error/timeout) |
| “loading 直接转圈就行” | spinner 卡死是经典 bug |
| “错误信息差不多就行” | 用户看到 “请求失败” 不知道下一步 |
| “离线太极端了” | 地铁、电梯、飞机模式到处都是 |
| “视觉回归成本高” | 不做视觉回归,CSS 一改就崩 |
| “happy path 通过就够了” | 用户活在 80% 非 happy path 里 |
1
2
3
4
5
6
7
8
9
10
11
// 只写成功状态的 UI
function OrderList({ orders }) {
return (
<div>
{orders.map(o => <OrderCard order={o} />)}
</div>
)
}
// 测试只有一个 case
it('shows orders', () => { ... })
// 用户看到:loading 卡死 / error 时空白 / 离线时假数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function OrderList({ orders, status, error }) {
return (
<>
{status === 'loading' && <Skeleton />}
{status === 'error' && <ErrorState onRetry={refetch} />}
{status === 'empty' && <EmptyState onCreate={openCreateModal} />}
{status === 'success' && orders.map(o => <OrderCard order={o} />)}
{!navigator.onLine && <OfflineBanner />}
</>
)
}
// 12 个 E2E case 覆盖全矩阵
// 视觉回归 baseline 12 张截图
// 任何 UI 改动都被测试和 diff 捕获
UI 的”看起来对”和”真的对”差 10 个状态。 没覆盖全状态的用户体验 = 一半用户在另一半体验里抱怨。