Status: done
作为测试开发者, 我想要编写完整的四级级联选择测试, 以便验证省/市/区/街道联动的完整场景。
Given 单独的区域操作测试已通过 When 编写完整的级联选择流程测试 Then 从选择省份开始,依次选择市、区、街道 And 验证每级选择后,下一级选项正确加载 And 验证上级变更时,下级选择被清空 And 验证完整的添加流程(省份→城市→区域→街道) And 测试在真实浏览器中通过
web/tests/e2e/specs/admin/region-cascade.spec.tsEpic 8: 区域管理 E2E 测试 (Epic B - 业务测试 Epic)
这是 Epic B(区域管理业务测试)的第六个 Story。前置 Story 已完成:
依赖:
区域管理采用树形结构设计,而非传统的下拉框级联选择。这是两种不同的 UI 设计模式:
树形结构模式(当前实现):
传统下拉框模式(未使用):
selectRadixOption 或 selectRadixOptionAsync⚠️ 重要说明: 本 Story 的验收标准中提到的"级联选择"测试,在当前树形结构 UI 中,体现为逐级创建子区域并验证树形结构正确显示的测试场景。
级联选择相关方法(来自 Story 8.1):
// 创建省份(第一级)
await regionManagementPage.createProvince({ name: '测试省', code: '110000' });
// 创建市级子区域(第二级)
await regionManagementPage.createChildRegion('测试省', '市', { name: '测试市', code: '110100' });
// 创建区级子区域(第三级)
await regionManagementPage.createChildRegion('测试市', '区', { name: '测试区', code: '110101' });
// 展开节点查看子区域
await regionManagementPage.expandNode('测试省');
await regionManagementPage.expandNode('测试市');
// 验证区域是否存在
const exists = await regionManagementPage.regionExists('测试区');
// 返回: boolean
// 等待树形结构加载
await regionManagementPage.waitForTreeLoaded();
节点操作方法:
// 展开节点
await regionManagementPage.expandNode('区域名称');
// 收起节点
await regionManagementPage.collapseNode('区域名称');
// 获取区域状态
const status = await regionManagementPage.getRegionStatus('区域名称');
// 返回: '启用' | '禁用' | null
参考 web/tests/e2e/specs/admin/region-add.spec.ts(Story 8.3)的成功模式:
import { test, expect } from '../../utils/test-setup';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const testUsers = JSON.parse(readFileSync(join(__dirname, '../../fixtures/test-users.json'), 'utf-8'));
/**
* 生成唯一区域名称
*/
function generateUniqueRegionName(prefix: string = '测试区域'): string {
const timestamp = Date.now();
const random = Math.floor(Math.random() * 1000);
return `${prefix}_${timestamp}_${random}`;
}
test.describe.serial('级联选择完整流程测试', () => {
const createdProvinces: string[] = [];
test.beforeEach(async ({ adminLoginPage, regionManagementPage }) => {
// 登录
await adminLoginPage.goto();
await adminLoginPage.login(testUsers.admin.username, testUsers.admin.password);
await adminLoginPage.expectLoginSuccess();
// 导航到区域管理页面
await regionManagementPage.goto();
await regionManagementPage.waitForTreeLoaded();
});
test.afterEach(async ({ regionManagementPage }) => {
// 清理测试数据
for (const provinceName of createdProvinces) {
try {
await regionManagementPage.deleteRegion(provinceName);
} catch (error) {
console.debug('清理测试数据失败:', error);
}
}
createdProvinces.length = 0;
});
});
1. 完整三级级联选择测试(省→市→区):
test.describe('三级级联选择(省市区)', () => {
test('应该成功创建完整的省市区三级结构', async ({ regionManagementPage }) => {
const timestamp = Date.now();
const provinceName = `测试省_${timestamp}`;
const cityName = `测试市_${timestamp}`;
const districtName = `测试区_${timestamp}`;
// Step 1: 创建省份
await regionManagementPage.createProvince({ name: provinceName });
await regionManagementPage.waitForTreeLoaded();
expect(await regionManagementPage.regionExists(provinceName)).toBe(true);
// Step 2: 创建市级子区域
await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });
await regionManagementPage.waitForTreeLoaded();
// 展开省份验证市在正确的层级
await regionManagementPage.expandNode(provinceName);
expect(await regionManagementPage.regionExists(cityName)).toBe(true);
// Step 3: 创建区级子区域
await regionManagementPage.createChildRegion(cityName, '区', { name: districtName });
await regionManagementPage.waitForTreeLoaded();
// 展开市验证区在正确的层级
await regionManagementPage.expandNode(cityName);
expect(await regionManagementPage.regionExists(districtName)).toBe(true);
// 添加到清理列表
createdProvinces.push(provinceName);
});
test('应该正确显示三级树形结构', async ({ regionManagementPage }) => {
const timestamp = Date.now();
const provinceName = `级联省_${timestamp}`;
const cityName = `级联市_${timestamp}`;
const districtName = `级联区_${timestamp}`;
// 创建三级结构
await regionManagementPage.createProvince({ name: provinceName });
await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });
await regionManagementPage.createChildRegion(cityName, '区', { name: districtName });
// 展开所有节点
await regionManagementPage.expandNode(provinceName);
await regionManagementPage.expandNode(cityName);
// 验证所有层级都可见
await regionManagementPage.waitForTreeLoaded();
expect(await regionManagementPage.regionExists(provinceName)).toBe(true);
expect(await regionManagementPage.regionExists(cityName)).toBe(true);
expect(await regionManagementPage.regionExists(districtName)).toBe(true);
createdProvinces.push(provinceName);
});
});
2. 多个子区域级联测试:
test.describe('多个子区域级联', () => {
test('应该支持一个省份下多个市', async ({ regionManagementPage }) => {
const timestamp = Date.now();
const provinceName = `多市省_${timestamp}`;
const city1Name = `市1_${timestamp}`;
const city2Name = `市2_${timestamp}`;
// 创建省份
await regionManagementPage.createProvince({ name: provinceName });
// 创建多个市
await regionManagementPage.createChildRegion(provinceName, '市', { name: city1Name });
await regionManagementPage.createChildRegion(provinceName, '市', { name: city2Name });
// 展开省份验证
await regionManagementPage.expandNode(provinceName);
expect(await regionManagementPage.regionExists(city1Name)).toBe(true);
expect(await regionManagementPage.regionExists(city2Name)).toBe(true);
createdProvinces.push(provinceName);
});
test('应该支持一个市下多个区', async ({ regionManagementPage }) => {
const timestamp = Date.now();
const provinceName = `多区省_${timestamp}`;
const cityName = `多区市_${timestamp}`;
const district1Name = `区1_${timestamp}`;
const district2Name = `区2_${timestamp}`;
// 创建省市区
await regionManagementPage.createProvince({ name: provinceName });
await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });
await regionManagementPage.createChildRegion(cityName, '区', { name: district1Name });
await regionManagementPage.createChildRegion(cityName, '区', { name: district2Name });
// 展开验证
await regionManagementPage.expandNode(provinceName);
await regionManagementPage.expandNode(cityName);
expect(await regionManagementPage.regionExists(district1Name)).toBe(true);
expect(await regionManagementPage.regionExists(district2Name)).toBe(true);
createdProvinces.push(provinceName);
});
});
3. 级联编辑场景测试(上级变更):
test.describe('级联编辑场景', () => {
test('编辑区域应保持父子关系', async ({ regionManagementPage }) => {
const timestamp = Date.now();
const provinceName = `编辑省_${timestamp}`;
const cityName = `编辑市_${timestamp}`;
const newCityName = `新市_${timestamp}`;
// 创建省和市
await regionManagementPage.createProvince({ name: provinceName });
await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });
// 编辑市名称
await regionManagementPage.editRegion(cityName, { name: newCityName });
// 验证编辑成功
await regionManagementPage.expandNode(provinceName);
await regionManagementPage.waitForTreeLoaded();
expect(await regionManagementPage.regionExists(newCityName)).toBe(true);
expect(await regionManagementPage.regionExists(cityName)).toBe(false);
createdProvinces.push(provinceName);
});
});
4. 深层级级联测试(省→市→区→多个区):
test.describe('深层级级联', () => {
test('应该支持深层级联选择', async ({ regionManagementPage }) => {
const timestamp = Date.now();
const provinceName = `深层省_${timestamp}`;
const cityName = `深层市_${timestamp}`;
const district1Name = `深层区1_${timestamp}`;
const district2Name = `深层区2_${timestamp}`;
// 创建深层级结构
await regionManagementPage.createProvince({ name: provinceName });
await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });
await regionManagementPage.createChildRegion(cityName, '区', { name: district1Name });
await regionManagementPage.createChildRegion(cityName, '区', { name: district2Name });
// 逐级展开验证
await regionManagementPage.expandNode(provinceName);
await regionManagementPage.waitForTreeLoaded();
expect(await regionManagementPage.regionExists(cityName)).toBe(true);
await regionManagementPage.expandNode(cityName);
await regionManagementPage.waitForTreeLoaded();
expect(await regionManagementPage.regionExists(district1Name)).toBe(true);
expect(await regionManagementPage.regionExists(district2Name)).toBe(true);
createdProvinces.push(provinceName);
});
});
| 方面 | Story 8.5(删除区域) | Story 8.6(级联选择) |
|---|---|---|
| 主要操作 | 删除数据 | 创建层级数据 |
| 测试重点 | 级联约束(有子级不可删) | 层级结构正确性 |
| 数据关系 | 验证父子约束 | 验证父子关联 |
| 操作序列 | 先子后父删除 | 从上到下创建 |
| 树形操作 | 删除节点 | 展开节点验证 |
| 测试复杂度 | 中等 | 较高(多层级) |
目标文件位置:
web/tests/e2e/specs/admin/region-cascade.spec.ts
导入路径:
import { test, expect } from '../../utils/test-setup';
// test-setup 包含:
// - adminLoginPage fixture
// - regionManagementPage fixture
测试命令:
# 运行级联选择测试
cd web
pnpm test:e2e:chromium region-cascade
# 快速失败模式(调试)
timeout 60 pnpm test:e2e:chromium region-cascade
# 运行所有区域管理测试
pnpm test:e2e:chromium region-*.spec.ts
⚠️ 树形结构异步加载
waitForTreeLoaded() 验证更新完成✅ 正确做法:
// 创建子区域后等待树形结构刷新
await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });
await regionManagementPage.waitForTreeLoaded();
// 展开节点后等待子节点可见
await regionManagementPage.expandNode(provinceName);
await regionManagementPage.waitForTreeLoaded();
const exists = await regionManagementPage.regionExists(cityName);
expect(exists).toBe(true);
❌ 避免:
// 避免创建后立即验证,树形结构可能未更新
await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });
const exists = await regionManagementPage.regionExists(cityName); // 可能失败
// 避免展开后立即验证子节点
await regionManagementPage.expandNode(provinceName);
const exists = await regionManagementPage.regionExists(cityName); // 可能未加载
1. 查看树形结构:
# 使用 Playwright Inspector
cd web
pnpm test:e2e:chromium region-cascade --debug
2. 查看错误上下文:
# 测试失败后查看
cat test-results/*/error-context.md
3. 添加调试输出:
test('调试级联选择', async ({ regionManagementPage }) => {
const provinceName = '测试省';
await regionManagementPage.createProvince({ name: provinceName });
console.debug('省份已创建:', provinceName);
await regionManagementPage.expandNode(provinceName);
console.debug('省份已展开');
await regionManagementPage.waitForTreeLoaded();
console.debug('树形结构已刷新');
});
本 Story 的测试覆盖率:
测试通过率目标: 连续运行 10 次,100% 通过
本测试完成后,后续 Story 依赖:
目标文件位置:
web/tests/e2e/specs/admin/region-cascade.spec.ts
遵循模式:
web/tests/e2e/specs/admin/*.spec.tsweb/tests/e2e/pages/admin/*.page.tsweb/tests/e2e/fixtures/*.json与现有测试对齐:
test.describe.serial() 组织测试组beforeEach/afterEach 处理测试设置和清理test-setup.ts 导入generateUniqueRegionName() 生成唯一测试数据源文档和规范:
_bmad-output/planning-artifacts/epics.md_bmad-output/planning-artifacts/architecture.md_bmad-output/project-context.md前置 Story 参考:
_bmad-output/implementation-artifacts/8-1-region-page-object.md_bmad-output/implementation-artifacts/8-3-add-region-test.md_bmad-output/implementation-artifacts/8-4-edit-region-test.md_bmad-output/implementation-artifacts/8-5-delete-region-test.md代码参考:
web/tests/e2e/pages/admin/region-management.page.tsweb/tests/e2e/specs/admin/region-add.spec.tsweb/tests/e2e/utils/test-setup.ts无调试问题(Story 创建阶段)
Story 创建完成 (2026-01-11):
_bmad-output/implementation-artifacts/8-6-cascade-select-test.md Story 文档Story 实现完成 (2026-01-12):
web/tests/e2e/specs/admin/region-cascade.spec.ts 测试文件关键设计决策:
waitForTreeLoaded() 在创建子区域和展开节点后的使用Story 文档:
_bmad-output/implementation-artifacts/8-6-cascade-select-test.md (本文件 - 已更新)新创建文件:
web/tests/e2e/specs/admin/region-cascade.spec.ts (级联选择测试文件 - 10 tests passed, 2 skipped)参考文件 (只读):
web/tests/e2e/pages/admin/region-management.page.tsweb/tests/e2e/specs/admin/region-add.spec.tsweb/tests/e2e/utils/test-setup.ts技术栈:
测试命令:
# 运行级联选择测试
cd web
pnpm test:e2e:chromium region-cascade
# 快速失败模式(调试)
timeout 60 pnpm test:e2e:chromium region-cascade
# 运行所有区域管理测试
pnpm test:e2e:chromium region-*.spec.ts
命名约定:
.spec.ts 后缀test.describe.serial() 分组来自 Architecture.md 的关键决策:
选择器策略(混合策略优先级):
data-testid - 最高优先级aria-label + role - 无障碍标准树形结构特殊处理:
waitForTreeLoaded()测试隔离:
test.describe.serial 时串行)TypeScript 严格模式:
any 类型import 配合 vi.mocked(Vitest)来自 Architecture.md "TypeScript + Playwright 常见陷阱" 部分:
⚠️ 树形结构异步加载必须处理
✅ 正确做法:
// 每次创建子区域后等待
await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });
await regionManagementPage.waitForTreeLoaded();
// 展开节点后等待子节点加载
await regionManagementPage.expandNode(provinceName);
await regionManagementPage.waitForTreeLoaded();
const exists = await regionManagementPage.regionExists(cityName);
❌ 避免:
// 避免假设树形结构立即更新
await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });
const exists = await regionManagementPage.regionExists(cityName); // 可能失败
// 避免假设展开立即显示子节点
await regionManagementPage.expandNode(provinceName);
const exists = await regionManagementPage.regionExists(cityName); // 可能未加载
代码质量:
test.describe.serial() 组织相关测试测试数据:
beforeEach/afterEach 钩子树形操作:
waitForTreeLoaded()waitForTreeLoaded()| 文档 | 路径 |
|---|---|
| PRD | _bmad-output/planning-artifacts/prd.md |
| Architecture | _bmad-output/planning-artifacts/architecture.md |
| Epics | _bmad-output/planning-artifacts/epics.md |
| Project Context | _bmad-output/project-context.md |
| Story 8.1 | _bmad-output/implementation-artifacts/8-1-region-page-object.md |
| Story 8.3 | _bmad-output/implementation-artifacts/8-3-add-region-test.md |
| Story 8.4 | _bmad-output/implementation-artifacts/8-4-edit-region-test.md |
| Story 8.5 | _bmad-output/implementation-artifacts/8-5-delete-region-test.md |
| RegionManagementPage | web/tests/e2e/pages/admin/region-management.page.ts |
| 参考测试 | web/tests/e2e/specs/admin/region-add.spec.ts |
| test-setup | web/tests/e2e/utils/test-setup.ts |
前置 Epic:
当前 Epic (Epic 8):
后续 Epic:
Story ID: 8.6 Story Key: 8-6-cascade-select-test Epic: Epic 8 - 区域管理 E2E 测试 (Epic B) Status: review
交付物:
实现摘要:
web/tests/e2e/specs/admin/region-cascade.spec.ts 测试文件代码审查修复记录 (2026-01-12):
[HIGH-1] AC 未实现 - 添加了基于 API 响应的编辑验证测试,包括:
编辑区域应保持父子关系(基于 API 验证)编辑区域后父级关系应保持不变(API 验证)创建后立即验证父子层级关系[HIGH-2] 测试逻辑错误 - 修正了三级级联创建逻辑:
cityName 作为父级,而非 provinceName[HIGH-3] 相同错误模式 - 修正了所有测试中的相同错误:
应该正确显示三级树形结构 - 使用正确的父级关系应该支持一个市下多个区 - 区创建在市下应该支持深层级联选择 - 区创建在市下应该支持完整四级区域结构 - 区创建在市下完整流程:省→市→区创建并验证 - 正确的层级关系[HIGH-4] 缺少父子层级验证 - 添加了 API 层级验证:
parentId 存在level 正确(市=2, 区=3)[MEDIUM-5] 深层级测试只验证顶层 - 添加了完整的 API 验证
[MEDIUM-6] 区域代码生成 - 已正确使用,添加了注释说明
[MEDIUM-7] 清理逻辑 - 当前实现正确,每个测试使用唯一名称避免重复
// 修复前(错误):
await regionManagementPage.createChildRegion(provinceName, '市', { name: districtName });
// 修复后(正确):
await regionManagementPage.createChildRegion(cityName, '区', { name: districtName });
下一步操作: