8-5-delete-region-test.md 23 KB

Story 8.5: 编写删除区域测试

Status: done

Story

作为测试开发者, 我想要编写删除区域的 E2E 测试, 以便验证区域的删除功能和相关约束。

Acceptance Criteria

Given 编辑区域测试已通过 When 编写删除区域测试用例 Then 验证删除无子级区域的流程 And 验证删除有子级区域时的错误提示 And 验证删除确认对话框的正确操作 And 验证删除成功后列表中不再显示该区域 And 测试在真实浏览器中通过

Tasks / Subtasks

  • 创建测试文件基础结构 (AC: #)
    • 创建 web/tests/e2e/specs/admin/region-delete.spec.ts
    • 配置 test fixtures(adminLoginPage, regionManagementPage)
    • 设置测试组和 beforeEach/afterEach 钩子
  • 实现删除无子级区域测试 (AC: 1, 4, 5)
    • 测试删除省级区域(无子级)
    • 测试删除市级区域(无子级)- 已跳过,需要修复 openDeleteDialog
    • 验证删除成功后列表中不再显示该区域
  • 实现删除有子级区域时的错误处理测试 (AC: 2)
    • 测试删除包含子级区域的父区域
    • 验证显示错误提示信息
    • 验证区域未被删除
  • 实现删除确认对话框操作测试 (AC: 3)
    • 测试取消删除操作
    • 验证取消后区域仍然存在
  • 实现测试数据隔离 (AC: #)
    • 每个测试使用唯一的区域名称
    • 测试后清理测试数据

Dev Notes

Epic 8 背景和上下文

Epic 8: 区域管理 E2E 测试 (Epic B - 业务测试 Epic)

这是 Epic B(区域管理业务测试)的第五个 Story。前置 Story 已完成:

  • Story 8.1: ✅ 已完成 - RegionManagementPage Page Object
  • Story 8.2: ✅ 已完成 - 区域列表查看测试
  • Story 8.3: ✅ 已完成 - 添加区域测试
  • Story 8.4: ✅ 已完成 - 编辑区域测试

依赖:

  • Epic 1: ✅ 已完成(Select 工具基础框架)
  • Epic 2: ✅ 已完成(Select 工具在真实 E2E 测试中验证)
  • Story 8.1: ✅ 已完成(RegionManagementPage Page Object)

区域删除功能概述

区域管理支持删除操作,但有业务约束:

  1. 无子级区域可删除 - 可以直接删除没有子级区域的区域
  2. 有子级区域需先删除子级 - 需要先删除所有子区域才能删除父区域
  3. 删除确认对话框 - 删除操作需要用户确认

删除确认对话框字段(基于 AlertDialog 组件):

  • 确认删除按钮
  • 取消按钮

RegionManagementPage API 参考

删除区域相关方法(来自 Story 8.1):

// 打开删除确认对话框
await regionManagementPage.openDeleteDialog('区域名称');

// 确认删除操作
await regionManagementPage.confirmDelete();

// 取消删除操作
await regionManagementPage.cancelDelete();

// 快捷方法:删除区域
const success = await regionManagementPage.deleteRegion('区域名称');
// 返回: boolean(true = 成功, false = 失败)

// 验证区域是否存在
const exists = await regionManagementPage.regionExists('区域名称');
// 返回: boolean

选择器策略(来自 Story 8.1):

  • 删除按钮: getByRole('button', { name: '删除' })
  • 确认删除按钮: getByRole('button', { name: /^确认删除$/ })
  • 取消按钮: getByRole('button', { name: '取消' })
  • 对话框: [role="alertdialog"]
  • Toast 消息: [data-sonner-toast][data-type="success|error"]

测试文件结构模式

参考 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'));

test.describe.serial('删除区域测试', () => {
  let createdProvinceName: string;

  test.beforeEach(async ({ adminLoginPage, regionManagementPage }) => {
    // 登录
    await adminLoginPage.goto();
    await adminLoginPage.login(testUsers.admin.username, testUsers.admin.password);
    await adminLoginPage.expectLoginSuccess();

    // 导航到区域管理页面
    await regionManagementPage.goto();
  });

  test.afterEach(async ({ regionManagementPage }) => {
    // 清理测试数据
    if (createdProvinceName) {
      try {
        await regionManagementPage.deleteRegion(createdProvinceName);
      } catch (error) {
        console.debug('清理测试数据失败:', error);
      }
    }
  });

  test('应该成功删除无子级的省级区域', async ({ regionManagementPage }) => {
    // 测试实现
  });
});

测试用例设计

1. 删除无子级区域测试:

test.describe('删除无子级区域', () => {
  test('应该成功删除无子级的省级区域', async ({ regionManagementPage }) => {
    // 首先创建一个测试省份
    const provinceName = `测试省_${Date.now()}`;
    await regionManagementPage.createProvince({ name: provinceName });

    // 删除区域
    const success = await regionManagementPage.deleteRegion(provinceName);

    // 验证删除成功
    expect(success).toBe(true);

    // 验证列表中不再显示该区域
    await regionManagementPage.waitForTreeLoaded();
    const exists = await regionManagementPage.regionExists(provinceName);
    expect(exists).toBe(false);
  });

  test('应该成功删除无子级的市级区域', async ({ regionManagementPage }) => {
    const provinceName = `测试省_${Date.now()}`;
    const cityName = `测试市_${Date.now()}`;

    // 创建省和市
    await regionManagementPage.createProvince({ name: provinceName });
    await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });

    // 删除市级区域
    const success = await regionManagementPage.deleteRegion(cityName);

    expect(success).toBe(true);

    // 验证市级区域被删除,但省份仍存在
    await regionManagementPage.waitForTreeLoaded();
    expect(await regionManagementPage.regionExists(cityName)).toBe(false);
    expect(await regionManagementPage.regionExists(provinceName)).toBe(true);
  });
});

2. 删除有子级区域的错误处理测试:

test.describe('删除有子级区域的错误处理', () => {
  test('应该阻止删除包含子级区域的父区域', async ({ regionManagementPage }) => {
    const provinceName = `测试省_${Date.now()}`;
    const cityName = `测试市_${Date.now()}`;

    // 创建省和市
    await regionManagementPage.createProvince({ name: provinceName });
    await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });

    // 尝试删除省级区域(应该失败)
    await regionManagementPage.openDeleteDialog(provinceName);
    await regionManagementPage.confirmDelete();

    // 等待错误提示
    await regionManagementPage.page.waitForTimeout(1000);
    const errorToast = regionManagementPage.page.locator('[data-sonner-toast][data-type="error"]');
    const hasError = await errorToast.count() > 0;

    // 验证显示错误提示
    expect(hasError).toBe(true);

    // 验证区域未被删除
    await regionManagementPage.waitForTreeLoaded();
    const provinceExists = await regionManagementPage.regionExists(provinceName);
    expect(provinceExists).toBe(true);
  });

  test('应该显示清晰的错误消息', async ({ regionManagementPage }) => {
    const provinceName = `测试省_${Date.now()}`;
    const cityName = `测试市_${Date.now()}`;

    // 创建省和市
    await regionManagementPage.createProvince({ name: provinceName });
    await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });

    // 尝试删除
    await regionManagementPage.deleteRegion(provinceName);

    // 获取错误消息
    const errorToast = regionManagementPage.page.locator('[data-sonner-toast][data-type="error"]');
    const errorMessage = await errorToast.textContent();

    // 验证错误消息包含关键信息
    expect(errorMessage).toContain('子区域');
  });
});

3. 删除确认对话框操作测试:

test.describe('删除确认对话框操作', () => {
  test('取消删除应保持区域存在', async ({ regionManagementPage }) => {
    const provinceName = `测试省_${Date.now()}`;
    await regionManagementPage.createProvince({ name: provinceName });

    // 打开删除对话框但取消
    await regionManagementPage.openDeleteDialog(provinceName);
    await regionManagementPage.cancelDelete();

    // 验证区域仍然存在
    await regionManagementPage.waitForTreeLoaded();
    const exists = await regionManagementPage.regionExists(provinceName);
    expect(exists).toBe(true);
  });

  test('确认删除应移除区域', async ({ regionManagementPage }) => {
    const provinceName = `测试省_${Date.now()}`;
    await regionManagementPage.createProvince({ name: provinceName });

    // 确认删除
    await regionManagementPage.openDeleteDialog(provinceName);
    await regionManagementPage.confirmDelete();

    // 验证区域被删除
    await regionManagementPage.waitForTreeLoaded();
    const exists = await regionManagementPage.regionExists(provinceName);
    expect(exists).toBe(false);
  });
});

4. 级联删除测试(先删除子级再删除父级):

test.describe('级联删除', () => {
  test('应该先删除子级再删除父级', async ({ regionManagementPage }) => {
    const provinceName = `测试省_${Date.now()}`;
    const cityName = `测试市_${Date.now()}`;
    const districtName = `测试区_${Date.now()}`;

    // 创建省市区三级结构
    await regionManagementPage.createProvince({ name: provinceName });
    await regionManagementPage.createChildRegion(provinceName, '市', { name: cityName });
    await regionManagementPage.createChildRegion(cityName, '区', { name: districtName });

    // 先删除区级
    await regionManagementPage.deleteRegion(districtName);
    expect(await regionManagementPage.regionExists(districtName)).toBe(false);

    // 再删除市级
    await regionManagementPage.deleteRegion(cityName);
    expect(await regionManagementPage.regionExists(cityName)).toBe(false);

    // 最后删除省级
    await regionManagementPage.deleteRegion(provinceName);
    expect(await regionManagementPage.regionExists(provinceName)).toBe(false);
  });
});

测试数据管理策略

数据生成工具:

/**
 * 生成唯一区域名称
 */
function generateUniqueRegionName(prefix: string = '测试区域'): string {
  const timestamp = Date.now();
  const random = Math.floor(Math.random() * 1000);
  return `${prefix}_${timestamp}_${random}`;
}

数据清理策略:

  • 使用 test.afterEach 清理每个测试创建的数据
  • 使用 try-catch 处理清理失败的情况
  • 记录清理失败的日志

    test.afterEach(async ({ regionManagementPage }) => {
    if (createdProvinceName) {
    try {
      await regionManagementPage.deleteRegion(createdProvinceName);
      createdProvinceName = '';
    } catch (error) {
      console.debug('清理测试数据失败:', error);
    }
    }
    });
    

与前序 Story 的关键差异

方面 Story 8.4(编辑区域) Story 8.5(删除区域)
主要操作 修改现有数据 删除数据
前置条件 需要先创建测试数据 需要先创建测试数据
对话框类型 编辑对话框 删除确认对话框(AlertDialog)
表单状态 预填充现有数据 无表单,仅确认操作
验证重点 数据更新、状态切换 数据删除、级联约束
数据清理 编辑后仍需删除 删除后无需清理
错误场景 名称重复、必填字段 有子级区域、级联约束

项目结构说明

目标文件位置:

web/tests/e2e/specs/admin/region-delete.spec.ts

导入路径:

import { test, expect } from '../../utils/test-setup';
// test-setup 包含:
// - adminLoginPage fixture
// - regionManagementPage fixture

测试命令:

# 运行删除区域测试
cd web
pnpm test:e2e:chromium region-delete

# 快速失败模式(调试)
timeout 60 pnpm test:e2e:chromium region-delete

# 运行所有区域管理测试
pnpm test:e2e:chromium region-*.spec.ts

TypeScript + Playwright 陷阱预防

⚠️ DOM 结构假设必须验证

  • Story 8.1 已验证删除对话框结构
  • 使用已验证的选择器策略

正确做法:

// 使用 RegionManagementPage 的封装方法
await regionManagementPage.openDeleteDialog(regionName);
await regionManagementPage.confirmDelete();

// 使用精确文本匹配验证
await expect(page.getByText('删除成功', { exact: true })).toBeVisible();

避免:

// 避免直接操作 DOM
await page.locator('.dialog .delete-button').click();

// 避免假设删除立即完成
const exists = await regionExists(regionName);
expect(exists).toBe(false); // 可能需要等待

测试调试技巧

1. 查看 DOM 结构:

# 使用 Playwright Inspector
cd web
pnpm test:e2e:chromium region-delete --debug

2. 查看错误上下文:

# 测试失败后查看
cat test-results/*/error-context.md

3. 添加调试输出:

test('调试测试', async ({ regionManagementPage }) => {
  const result = await regionManagementPage.deleteRegion('测试省');
  console.debug('删除结果:', result);
  console.debug('Toast 消息类型:', result.toastType);
  console.debug('错误消息:', result.errorMessage);
});

测试覆盖率目标

本 Story 的测试覆盖率:

  • 删除无子级区域(省/市/区): 100%
  • 删除有子级区域错误处理: 100%
  • 删除确认对话框操作(确认/取消): 100%
  • 级联删除(先子后父): 100%

测试通过率目标: 连续运行 10 次,100% 通过

后续 Story 依赖

本测试完成后,后续 Story 依赖:

  • Story 8.6: 级联选择完整流程测试 - 可独立进行
  • Story 8.7: 运行测试并收集问题和改进建议

Project Structure Notes

对齐统一项目结构

目标文件位置:

web/tests/e2e/specs/admin/region-delete.spec.ts

遵循模式:

  • 测试文件: web/tests/e2e/specs/admin/*.spec.ts
  • Page Object: web/tests/e2e/pages/admin/*.page.ts
  • Fixtures: web/tests/e2e/fixtures/*.json

与现有测试对齐:

  • 使用 test.describe.serial() 组织测试组
  • 使用 beforeEach/afterEach 处理测试设置和清理
  • 使用 fixtures 从 test-setup.ts 导入

References

源文档和规范:

前置 Story 参考:

代码参考:

Dev Agent Record

Agent Model Used

  • Model: Claude (Sonnet)
  • Date: 2026-01-11

Debug Log References

无调试问题(Story 创建阶段)

Completion Notes List

Story 实现完成 (2026-01-11):

  • ✅ 创建了 web/tests/e2e/specs/admin/region-delete.spec.ts 测试文件
  • ✅ 实现了删除区域的所有核心测试场景:
    • 删除无子级的省级区域(通过)
    • 删除成功后显示成功提示消息(通过)
    • 阻止删除包含子级区域的父区域(通过)
    • 显示清晰的错误消息(通过)
    • 有子级的区域删除后仍存在(通过)
    • 取消删除应保持区域存在(通过)
    • 确认删除应移除区域(通过)
    • 取消后再次删除应该可以成功(通过)
    • 每个测试使用唯一的区域名称(通过)
    • 删除不存在的区域应该失败(通过)
  • ✅ 修复了 RegionManagementPage.cancelDelete() 方法中的选择器问题

代码审查修复 (2026-01-11):

  • [HIGH] 增强 openDeleteDialog 方法:支持展开父节点查找子区域(市/区/街道),类似 openEditDialog 的实现
  • [HIGH] 修复测试数据清理逻辑:移除了测试执行过程中修改清理数组的代码,避免跳过清理
  • [MEDIUM] 替换固定等待为条件等待:Toast 消息检查使用 waitFor() 而非 waitForTimeout()
  • [MEDIUM] 改进错误消息断言:添加对"子区域"等关键词的验证
  • [MEDIUM] 添加 waitForTreeLoaded():在 createChildRegion 后等待树形结构刷新
  • ✅ 所有之前跳过的 5 个测试现已启用

测试结果:

  • 15 个测试通过(所有测试)
  • 0 个测试跳过
  • 0 个测试失败

技术问题记录:

  1. [已修复] 子区域查找问题: openDeleteDialog 方法已增强,现在支持展开父节点查找子区域
  2. [已修复] cancelDelete 选择器问题: 已修复为使用 .locator().getByRole() 模式

File List

Story 文档:

  • _bmad-output/implementation-artifacts/8-5-delete-region-test.md (本文件)

已创建文件:

  • web/tests/e2e/specs/admin/region-delete.spec.ts (测试文件)

已修改文件:

  • web/tests/e2e/pages/admin/region-management.page.ts (修复了 cancelDelete() 方法的选择器问题;增强了 openDeleteDialog() 方法以支持查找子区域)

参考文件 (只读):

  • web/tests/e2e/specs/admin/region-add.spec.ts
  • web/tests/e2e/specs/admin/region-edit.spec.ts
  • web/tests/e2e/utils/test-setup.ts

Project Context Reference

关键项目规则摘要

技术栈:

  • Playwright 1.55.0 - E2E 测试框架
  • TypeScript 5.9.3 - 严格模式
  • @d8d/e2e-test-utils - 内部测试工具包
  • Node.js 20.19.2
  • pnpm 10.18.3 - 包管理

测试命令:

# 运行删除区域测试
cd web
pnpm test:e2e:chromium region-delete

# 快速失败模式(调试)
timeout 60 pnpm test:e2e:chromium region-delete

# 运行所有区域管理测试
pnpm test:e2e:chromium region-*.spec.ts

# 运行所有 E2E 测试
pnpm test:e2e:chromium

命名约定:

  • 测试文件名: kebab-case + .spec.ts 后缀
  • 测试组: 使用 test.describe.serial() 分组
  • 测试名称: 中文描述,格式 "应该..."

必须遵循的架构决策

来自 Architecture.md 的关键决策:

  1. 选择器策略(混合策略优先级):

    • data-testid - 最高优先级
    • aria-label + role - 无障碍标准
    • Text content + role - 兜底方案
  2. 错误处理策略:

    • 使用 E2ETestError 类(来自 e2e-test-utils)
    • 包含完整 ErrorContext
  3. 测试隔离:

    • 每个测试使用独立数据
    • 测试后清理数据
    • 支持并行执行(使用 test.describe.serial 时串行)
  4. TypeScript 严格模式:

    • 所有变量必须有明确类型
    • 禁止使用 any 类型
    • 使用 import 配合 vi.mocked(Vitest)

TypeScript + Playwright 陷阱预防

来自 Architecture.md "TypeScript + Playwright 常见陷阱" 部分:

⚠️ DOM 结构假设必须验证

  • Story 8.1 已验证 DOM 结构
  • 使用 RegionManagementPage 的封装方法

正确做法:

// 使用 Page Object 封装的方法
await regionManagementPage.openDeleteDialog(regionName);
await regionManagementPage.confirmDelete();

// 验证删除后等待树形结构刷新
await regionManagementPage.waitForTreeLoaded();
const exists = await regionManagementPage.regionExists(regionName);

避免:

// 避免直接操作 DOM
await page.locator('.dialog .delete-button').click();

// 避免假设状态立即更新
const exists = await regionExists(regionName);
expect(exists).toBe(false); // 可能需要等待

代码质量检查清单

代码质量:

  • 测试用例有清晰的描述
  • 使用 test.describe.serial() 组织相关测试
  • 每个测试独立运行,不依赖其他测试

测试数据:

  • 使用唯一标识符避免数据冲突
  • 测试后清理测试数据
  • 使用 beforeEach/afterEach 钩子

错误处理:

  • 失败时有清晰的错误消息
  • 使用 try-catch 处理清理操作

参考文档位置

文档 路径
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
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 和 Story

前置 Epic:

  • Epic 1: ✅ 完成 - Select 工具基础框架
  • Epic 2: ✅ 完成 - Select 工具在真实 E2E 测试中验证

当前 Epic (Epic 8):

  • Story 8.1: ✅ 完成 - 创建区域管理 Page Object
  • Story 8.2: ✅ 完成 - 编写区域列表查看测试
  • Story 8.3: ✅ 完成 - 编写添加区域测试
  • Story 8.4: ✅ 完成 - 编写编辑区域测试
  • Story 8.5: 📝 当前 - 编写删除区域测试
  • Story 8.6: ⏳ 待开始 - 编写级联选择完整流程测试

后续 Epic:

  • Epic 9: 🔄 进行中 - 残疾人管理完整 E2E 测试覆盖
  • Epic 10: 🔄 进行中 - 订单管理 E2E 测试

Completion Status

Story ID: 8.5 Story Key: 8-5-delete-region-test Epic: Epic 8 - 区域管理 E2E 测试 (Epic B) Status: done

交付物:

  • Story 文档创建完成
  • 删除区域测试实现
  • 测试在真实浏览器中通过

实现摘要:

  • 创建了 web/tests/e2e/specs/admin/region-delete.spec.ts 测试文件
  • 实现了以下测试场景:
    • 删除无子级的省级区域
    • 删除成功后显示成功提示消息
    • 阻止删除包含子级区域的父区域
    • 显示清晰的错误消息
    • 有子级的区域删除后仍存在
    • 取消删除应保持区域存在
    • 确认删除应移除区域
    • 取消后再次删除应该可以成功
    • 每个测试使用唯一的区域名称
    • 删除不存在的区域应该失败
  • 修复了 RegionManagementPage.cancelDelete() 方法中的选择器问题

测试结果:

  • 15 个测试通过
  • 0 个测试跳过
  • 0 个测试失败

下一步操作:

  1. ✅ 代码审查已完成,所有 HIGH 和 MEDIUM 问题已修复
  2. ✅ 15 个测试全部通过,0 个跳过,0 个失败
  3. ➡️ 进入 Story 8.6(级联选择完整流程测试)