用户旅程技能
加载: base.md + playwright-testing.md
用于定义和测试真实用户体验 - 不仅仅是规格,而是实际的人类通过您的应用程序的流程。
哲学
规格测试功能。旅程测试体验。
一个功能可以通过所有规格,但仍然提供糟糕的体验。用户旅程捕获:
- 用户实际导航的方式(不是我们认为他们应该做的)
- 每个步骤的情感状态(沮丧、困惑、高兴)
- 从错误中恢复(用户会犯错误)
- 现实世界的条件(慢速网络、中断、分心)
旅程文档结构
_project_specs/
├── journeys/
│ ├── _template.md # 旅程模板
│ ├── critical/ # 必须工作的旅程(收入、核心价值)
│ │ ├── signup-to-first-value.md
│ │ ├── checkout-purchase.md
│ │ └── login-to-dashboard.md
│ ├── common/ # 频繁用户路径
│ │ ├── browse-and-search.md
│ │ ├── update-profile.md
│ │ └── invite-team-member.md
│ └── edge-cases/ # 错误恢复,不寻常的路径
│ ├── payment-failure-retry.md
│ ├── session-timeout-recovery.md
│ └── offline-reconnection.md
旅程模板
# 旅程:[名称]
## 概览
| 属性 | 值 |
|-----------|-------|
| **优先级** | 临界 / 高 / 中 |
| **用户类型** | 新 / 返回 / 管理员 |
| **频率** | 每日 / 每周 / 一次性 |
| **成功指标** | 转化率,完成时间,退出率 |
## 用户目标
用户想要完成什么?从他们的视角写。
> "我想[目标],这样我就可以[好处]。"
## 前提条件
- 用户状态(登录,有订阅,首次访问)
- 数据状态(购物车中有商品,有团队成员)
- 环境(移动,桌面,慢速连接)
## 旅程步骤
### 第1步:[入口点]
**用户操作:**用户做什么
**系统响应:**他们应该看到/体验什么
**成功标准:**
- [ ] 页面在<2秒内加载
- [ ] 主要CTA立即可见
- [ ] 用户知道下一步该做什么
**潜在摩擦:**
- 慢速加载时间 → 显示骨架/加载器
- 不清晰的CTA → A/B测试副本变体
---
### 第2步:[下一个行动]
**用户操作:**...
**系统响应:**...
**成功标准:**
- [ ] ...
**潜在摩擦:**
- ...
---
## 错误场景
### E1:[错误名称]
**触发器:**什么导致这个错误
**用户看到:**错误消息/状态
**恢复路径:**用户如何回到正轨
**测试:**如何验证恢复工作
## 跟踪指标
- 完成旅程的时间
- 每个步骤的退出率
- 错误率和恢复率
- 用户满意度(如果有调查)
## E2E测试参考
链接到Playwright测试:`e2e/tests/journeys/[name].spec.ts`
临界旅程示例
注册到首次价值
# 旅程:注册到首次价值
## 概览
| 属性 | 值 |
|-----------|-------|
| **优先级** | 临界 |
| **用户类型** | 新 |
| **频率** | 一次性 |
| **成功指标** | %在5分钟内达到"aha时刻" |
## 用户目标
> "我想快速尝试这个产品,看看它是否解决了我的问题。"
## 前提条件
- 首次访问网站
- 没有账户
- 来自着陆页或广告
## 旅程步骤
### 第1步:着陆页
**用户操作:**点击"开始免费"或"立即尝试"
**系统响应:**注册表单出现(模态或新页面)
**成功标准:**
- [ ] CTA在折叠上方可见
- [ ] 没有分散注意力的元素
- [ ] 清晰的价值主张可见
**潜在摩擦:**
- 表单字段太多 → 减少到只有电子邮件+密码
- 缺少社交登录 → 添加Google/GitHub选项
### 第2步:账户创建
**用户操作:**输入电子邮件和密码(或使用社交登录)
**系统响应:**
- 创建账户
- 发送验证电子邮件(不阻塞在它上面)
- 重定向到入门
**成功标准:**
- [ ] 账户在<3秒内创建
- [ ] 没有电子邮件验证墙(稍后验证)
- [ ] 显示清晰的下一步
**潜在摩擦:**
- 电子邮件已存在 → 提供登录链接
- 密码弱 → 在线显示要求,而不是提交后
### 第3步:入门(快速获胜)
**用户操作:**完成1-2个设置问题
**系统响应:**
- 个性化体验
- 显示进度指示器
- 引导到第一个行动
**成功标准:**
- [ ] 最多3个问题
- [ ] 可用跳过选项
- [ ] <60秒总计
**潜在摩擦:**
- 问题太多 → 用户放弃
- 没有跳过选项 → 用户感觉被困
### 第4步:首次价值(Aha时刻)
**用户操作:**完成核心行动(创建第一个X,看到第一个结果)
**系统响应:**
- 庆祝成功
- 显示交付的价值
- 建议下一步
**成功标准:**
- [ ] 用户体验到核心价值
- [ ] 完成感觉有回报
- [ ] 清晰的继续路径
## 错误场景
### E1:电子邮件已注册
**触发器:**用户尝试现有电子邮件
**用户看到:**"已经有账户?登录或重置密码"
**恢复路径:**点击登录或重置
**测试:**`signup-existing-email.spec.ts`
### E2:社交登录失败
**触发器:**OAuth提供商错误
**用户看到:**"无法连接。尝试电子邮件注册或再试一次。"
**恢复路径:**显示电子邮件注册表单作为回退
**测试:**`social-login-failure.spec.ts`
## 跟踪指标
- 注册 → 第一次价值:目标<5分钟
- 每个步骤的退出率
- 社交与电子邮件注册比例
- 入门跳过率
检查购买
# 旅程:检查购买
## 概览
| 属性 | 值 |
|-----------|-------|
| **优先级** | 临界(收入) |
| **用户类型** | 任何 |
| **频率** | 可变 |
| **成功指标** | 检查完成率 |
## 用户目标
> "我想快速安全地支付,没有惊喜。"
## 旅程步骤
### 第1步:购物车审查
**用户操作:**在检查之前查看购物车
**系统响应:**
- 显示所有商品及其图片、价格
- 显示小计、税费、运费
- 清晰的"检查"CTA
**成功标准:**
- [ ] 稍后不显示隐藏费用
- [ ] 容易修改数量
- [ ] 保存的商品可见
### 第2步:检查开始
**用户操作:**点击"检查"
**系统响应:**
- 显示检查表单或重定向到支付
- 进度指示器(第1步/3)
- 订单摘要侧边栏
**成功标准:**
- [ ] 访客检查选项
- [ ] 快速检查(Apple/Google Pay)突出显示
- [ ] 如果登录,表单字段预填充
### 第3步:支付
**用户操作:**输入支付信息
**系统响应:**
- 安全输入字段(Stripe/支付提供商)
- 实时验证
- 清晰的"支付$XX"按钮
**成功标准:**
- [ ] 卡片验证内联,而不是提交后
- [ ] 多种支付选项
- [ ] 安全指示器可见
### 第4步:确认
**用户操作:**提交支付
**系统响应:**
- 处理指示器
- 成功页面显示订单详情
- 发送电子邮件确认
**成功标准:**
- [ ] 确认在5秒内
- [ ] 订单号清晰可见
- [ ] 下一步清晰(运输、访问等)
## 错误场景
### E1:支付被拒绝
**触发器:**卡被处理器拒绝
**用户看到:**"支付被拒绝。请尝试另一张卡。"
**恢复路径:**
- 停留在支付步骤
- 预填充其他字段
- 提供替代支付方式
**测试:**`payment-declined-recovery.spec.ts`
### E2:检查期间会话超时
**触发器:**用户离开太久
**用户看到:**购物车保留,需要重新认证
**恢复路径:**
- 快速登录
- 返回到同一检查步骤
- 购物车内容完整
**测试:**`checkout-session-timeout.spec.ts`
使用Playwright进行旅程测试
旅程测试结构
// e2e/tests/journeys/signup-to-value.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Journey: Signup to First Value', () => {
test.describe.configure({ mode: 'serial' }); // 按顺序运行
test('Step 1: Landing page has clear CTA', async ({ page }) => {
await page.goto('/');
// CTA在不滚动的情况下在视口中可见
const cta = page.getByRole('button', { name: /get started|try free/i });
await expect(cta).toBeVisible();
await expect(cta).toBeInViewport();
});
test('Step 2: Can create account quickly', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /get started/i }).click();
// 最少字段
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
// 完成注册
const startTime = Date.now();
await page.getByLabel('Email').fill('newuser@example.com');
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByRole('button', { name: /sign up|create/i }).click();
// 应该很快到达入门
await expect(page).toHaveURL(/onboarding|welcome|setup/);
expect(Date.now() - startTime).toBeLessThan(5000); // < 5秒
});
test('Step 3: Onboarding is skippable', async ({ page }) => {
// ... 登录为新用户 ...
await page.goto('/onboarding');
// 跳过选项存在
const skipButton = page.getByRole('button', { name: /skip/i });
await expect(skipButton).toBeVisible();
});
test('Step 4: Can reach first value in < 5 min', async ({ page }) => {
// 完整旅程计时
const journeyStart = Date.now();
// ... 完成完整旅程 ...
// 验证首次价值交付
await expect(page.getByText(/success|created|done/i)).toBeVisible();
// 总时间检查
const totalTime = (Date.now() - journeyStart) / 1000 / 60; // 分钟
expect(totalTime).toBeLessThan(5);
});
});
错误恢复测试
// e2e/tests/journeys/checkout-recovery.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Journey: Checkout Error Recovery', () => {
test('recovers from payment decline gracefully', async ({ page }) => {
// 设置:添加商品到购物车,去检查
await page.goto('/products');
await page.getByTestId('add-to-cart').first().click();
await page.getByRole('link', { name: 'Checkout' }).click();
// 使用Stripe测试卡拒绝
const stripeFrame = page.frameLocator('iframe[name*="stripe"]');
await stripeFrame.getByPlaceholder('Card number').fill('4000000000000002');
await stripeFrame.getByPlaceholder('MM / YY').fill('12/30');
await stripeFrame.getByPlaceholder('CVC').fill('123');
await page.getByRole('button', { name: /pay/i }).click();
// 验证友好错误
await expect(page.getByText(/declined|try another/i)).toBeVisible();
// 验证仍在检查(没有被踢出)
await expect(page).toHaveURL(/checkout/);
// 验证可以使用不同的卡重试
await stripeFrame.getByPlaceholder('Card number').fill('4242424242424242');
await page.getByRole('button', { name: /pay/i }).click();
// 现在应该成功
await expect(page).toHaveURL(/success|confirmation/);
});
test('preserves cart after session timeout', async ({ page, context }) => {
// 添加商品到购物车
await page.goto('/products');
await page.getByTestId('add-to-cart').first().click();
// 清除会话(模拟超时)
await context.clearCookies();
// 返回网站
await page.goto('/cart');
// 购物车应该被保留(本地存储或恢复)
await expect(page.getByTestId('cart-item')).toHaveCount(1);
});
});
用户体验验证
每个旅程步骤的UX检查表
## UX验证检查表
### 清晰度
- [ ] 用户知道他们在哪里(面包屑,进度)
- [ ] 用户知道下一步该做什么(清晰的CTA)
- [ ] 用户知道刚才发生了什么(反馈)
### 速度
- [ ] 页面加载<2秒
- [ ] 操作完成<3秒
- [ ] 显示长时间操作的进度
### 宽容
- [ ] 错误容易被撤销
- [ ] 错误解释出了什么问题
- [ ] 清晰的恢复路径
### 可访问性
- [ ] 键盘导航工作
- [ ] 屏幕阅读器宣布变化
- [ ] 焦点管理正确
- [ ] 颜色对比度足够
### 移动
- [ ] 触摸目标>=44px
- [ ] 没有水平滚动
- [ ] 表单不意外缩放
- [ ] 在慢速3G上工作
自动化UX检查
// e2e/utils/ux-validators.ts
import { Page, expect } from '@playwright/test';
export async function validatePageLoad(page: Page, maxMs = 2000) {
const timing = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
return nav.loadEventEnd - nav.startTime;
});
expect(timing).toBeLessThan(maxMs);
}
export async function validateCTAVisible(page: Page, ctaText: RegExp) {
const cta = page.getByRole('button', { name: ctaText });
await expect(cta).toBeVisible();
await expect(cta).toBeInViewport();
}
export async function validateNoLayoutShift(page: Page) {
const cls = await page.evaluate(() => {
return new Promise<number>((resolve) => {
let clsValue = 0;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!(entry as any).hadRecentInput) {
clsValue += (entry as any).value;
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
setTimeout(() => {
observer.disconnect();
resolve(clsValue);
}, 1000);
});
});
expect(cls).toBeLessThan(0.1); // 好的CLS分数
}
export async function validateAccessibility(page: Page) {
// 检查交互元素的焦点可见
const buttons = page.getByRole('button');
const count = await buttons.count();
for (let i = 0; i < Math.min(count, 5); i++) {
await buttons.nth(i).focus();
await expect(buttons.nth(i)).toBeFocused();
}
}
旅程指标仪表板
用这些指标跟踪旅程的健康状况:
// lib/journey-metrics.ts
interface JourneyMetric {
journey: string;
step: string;
timestamp: Date;
duration: number;
success: boolean;
userId?: string;
}
// 在您的分析中跟踪(PostHog, Mixpanel等)
export function trackJourneyStep(metric: JourneyMetric) {
analytics.track('journey_step', {
journey_name: metric.journey,
step_name: metric.step,
duration_ms: metric.duration,
success: metric.success,
});
}
// 应用中的示例用法
const journeyStart = Date.now();
// ... 用户完成步骤 ...
trackJourneyStep({
journey: 'signup_to_value',
step: 'account_creation',
timestamp: new Date(),
duration: Date.now() - journeyStart,
success: true,
});
常见旅程模式
逐步披露旅程
用户首先看到简单的视图,需要时再揭示复杂性。
第1步:只显示基本选项
第2步:"高级"展开更多选项
第3步:专家模式解锁一切
引导设置旅程
手把手引导新用户完成初始配置。
第1步:欢迎+单一选择
第2步:核心偏好
第3步:可选集成(可跳过)
第4步:第一次行动指导
第5步:成功+移除训练轮
恢复旅程
用户在失败或放弃后返回。
第1步:识别返回用户
第2步:恢复先前状态
第3步:承认发生了什么
第4步:提供清晰的前进路径
第5步:完成原始目标
反模式
- 仅快乐路径 - 测试错误恢复,不仅仅是成功
- 规格驱动测试 - 测试用户目标,而不是功能
- 忽略时间 - 测量旅程需要多长时间
- 仅限桌面 - 分别测试移动旅程
- 跳过情感 - 考虑用户的挫败点
- 没有指标 - 跟踪旅程完成和退出
- 静态旅程 - 随着用户行为的演变而更新
快速参考
旅程优先级
| 优先级 | 标准 | 测试频率 |
|---|---|---|
| 临界 | 收入,核心价值 | 每次部署 |
| 高 | 日常用户操作 | 每日 |
| 中 | 每周功能 | 每周 |
| 低 | 边缘情况 | 变更时 |
package.json脚本
{
"scripts": {
"test:journeys": "playwright test e2e/tests/journeys/",
"test:journeys:critical": "playwright test e2e/tests/journeys/critical/",
"test:journeys:report": "playwright show-report"
}
}
旅程文档检查表
- [ ] 用户目标清晰说明
- [ ] 所有步骤记录
- [ ] 每个步骤的成功标准
- [ ] 覆盖错误场景
- [ ] 定义恢复路径
- [ ] 识别指标
- [ ] 链接E2E测试