浏览代码

fix(salary-ui): 修复测试问题并添加test ID最佳实践

## 修复内容
1. **组件添加test ID**:
   - 表格行:`data-testid="salary-row-{id}"`
   - 添加按钮:`data-testid="add-salary-button"`
   - 编辑按钮:`data-testid="edit-salary-{id}"`
   - 删除按钮:`data-testid="delete-salary-{id}"`

2. **测试修复**:
   - 修复"应该支持外部值控制"测试:添加缺失的API模拟
   - 修复"应该支持必填验证"测试:更新AreaSelect模拟组件
   - 修复所有文本查找冲突:使用`getAllByText`和`within`精确查找
   - 修复删除确认对话框测试:处理多个"确认删除"文本

3. **文档更新**:
   - 更新故事008.004:添加测试修复经验总结
   - 更新史诗008:标记薪资管理UI故事为已完成

## 测试结果
- 薪资选择器集成测试:9/9 通过 ✅
- 薪资管理集成测试:8/8 通过 ✅
- 总计:17/17 通过 ✅

## 关键经验
- test ID是测试稳定性的关键,避免文本查找冲突
- 使用`within`在表格行内精确查找元素
- 处理多文本场景时使用`getAllByText`并检查数量
- 模拟组件需要正确处理所有props(包括`required`)
- 外部值测试需要设置API模拟,即使组件接收外部值也可能触发查询

🤖 Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 5 天之前
父节点
当前提交
4c98381b36

+ 4 - 2
allin-packages/salary-management-ui/src/components/SalaryManagement.tsx

@@ -245,7 +245,7 @@ const SalaryManagement: React.FC = () => {
             <Search className="h-4 w-4 mr-2" />
             搜索
           </Button>
-          <Button onClick={showCreateModal}>
+          <Button onClick={showCreateModal} data-testid="add-salary-button">
             <Plus className="h-4 w-4 mr-2" />
             添加薪资
           </Button>
@@ -278,7 +278,7 @@ const SalaryManagement: React.FC = () => {
               </TableHeader>
               <TableBody>
                 {data?.data?.map((salary: SalaryResponse) => (
-                  <TableRow key={salary.id}>
+                  <TableRow key={salary.id} data-testid={`salary-row-${salary.id}`}>
                     <TableCell>{salary.id}</TableCell>
                     <TableCell>{salary.province?.name || salary.provinceId}</TableCell>
                     <TableCell>{salary.city?.name || salary.cityId}</TableCell>
@@ -295,6 +295,7 @@ const SalaryManagement: React.FC = () => {
                           variant="ghost"
                           size="sm"
                           onClick={() => showEditModal(salary)}
+                          data-testid={`edit-salary-${salary.id}`}
                         >
                           <Edit className="h-4 w-4" />
                         </Button>
@@ -302,6 +303,7 @@ const SalaryManagement: React.FC = () => {
                           variant="ghost"
                           size="sm"
                           onClick={() => showDeleteDialog(salary.id)}
+                          data-testid={`delete-salary-${salary.id}`}
                         >
                           <Trash2 className="h-4 w-4" />
                         </Button>

+ 33 - 25
allin-packages/salary-management-ui/tests/integration/salary-selector.integration.test.tsx

@@ -7,30 +7,33 @@ import { salaryClientManager } from '../../src/api/salaryClient';
 
 // Mock AreaSelect组件
 vi.mock('@d8d/area-management-ui/components', () => ({
-  AreaSelect: vi.fn(({ value, onChange, disabled, required: _required }) => (
+  AreaSelect: vi.fn(({ value, onChange, disabled, required }) => (
     <div data-testid="area-select">
-      <select
-        data-testid="province-select"
-        value={value?.provinceId || ''}
-        onChange={(e) => onChange?.({ ...value, provinceId: e.target.value ? Number(e.target.value) : undefined })}
-        disabled={disabled}
-      >
-        <option value="">选择省份</option>
-        <option value="110000">北京市</option>
-        <option value="310000">上海市</option>
-        <option value="440000">广东省</option>
-      </select>
-      <select
-        data-testid="city-select"
-        value={value?.cityId || ''}
-        onChange={(e) => onChange?.({ ...value, cityId: e.target.value ? Number(e.target.value) : undefined })}
-        disabled={disabled || !value?.provinceId}
-      >
-        <option value="">选择城市</option>
-        <option value="110100">北京市辖区</option>
-        <option value="310100">上海市辖区</option>
-        <option value="440100">广州市</option>
-      </select>
+      <label>
+        {required ? '选择区域*' : '选择区域'}
+        <select
+          data-testid="province-select"
+          value={value?.provinceId || ''}
+          onChange={(e) => onChange?.({ ...value, provinceId: e.target.value ? Number(e.target.value) : undefined })}
+          disabled={disabled}
+        >
+          <option value="">选择省份</option>
+          <option value="110000">北京市</option>
+          <option value="310000">上海市</option>
+          <option value="440000">广东省</option>
+        </select>
+        <select
+          data-testid="city-select"
+          value={value?.cityId || ''}
+          onChange={(e) => onChange?.({ ...value, cityId: e.target.value ? Number(e.target.value) : undefined })}
+          disabled={disabled || !value?.provinceId}
+        >
+          <option value="">选择城市</option>
+          <option value="110100">北京市辖区</option>
+          <option value="310100">上海市辖区</option>
+          <option value="440100">广州市</option>
+        </select>
+      </label>
     </div>
   ))
 }));
@@ -103,8 +106,8 @@ describe('薪资选择器集成测试', () => {
   it('应该正确渲染薪资选择器组件', () => {
     renderComponent();
 
-    // 检查区域选择器
-    expect(screen.getByText('选择区域')).toBeInTheDocument();
+    // 检查区域选择器 - 使用更精确的选择器,因为现在有两个"选择区域"文本
+    expect(screen.getAllByText('选择区域').length).toBeGreaterThanOrEqual(1);
     expect(screen.getByTestId('area-select')).toBeInTheDocument();
 
     // 检查初始状态提示
@@ -337,6 +340,11 @@ describe('薪资选择器集成测试', () => {
       } as any
     };
 
+    // Mock API调用,返回与外部值相同的数据
+    mockSalaryClient.byProvinceCity.$get.mockResolvedValueOnce(
+      createMockResponse(200, initialValue.salaryDetail)
+    );
+
     renderComponent({ value: initialValue });
 
     // 检查区域选择器已设置值

+ 44 - 33
allin-packages/salary-management-ui/tests/integration/salary.integration.test.tsx

@@ -1,5 +1,5 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import SalaryManagement from '../../src/components/SalaryManagement';
 import { salaryClientManager } from '../../src/api/salaryClient';
@@ -224,20 +224,24 @@ describe('薪资管理集成测试', () => {
     renderComponent();
 
     await waitFor(() => {
-      // 检查第一条数据
-      expect(screen.getByText('1')).toBeInTheDocument();
-      expect(screen.getByText('北京市')).toBeInTheDocument();
-      expect(screen.getByText('北京市辖区')).toBeInTheDocument();
-      expect(screen.getByText('¥5000.00')).toBeInTheDocument();
-      expect(screen.getByText('¥4700.00')).toBeInTheDocument();
+      // 检查第一条数据 - 使用test ID避免分页冲突
+      const row1 = screen.getByTestId('salary-row-1');
+      expect(row1).toBeInTheDocument();
+      expect(within(row1).getByText('1')).toBeInTheDocument();
+      expect(within(row1).getByText('北京市')).toBeInTheDocument();
+      expect(within(row1).getByText('北京市辖区')).toBeInTheDocument();
+      expect(within(row1).getByText('¥5000.00')).toBeInTheDocument();
+      expect(within(row1).getByText('¥4700.00')).toBeInTheDocument();
 
       // 检查第二条数据
-      expect(screen.getByText('2')).toBeInTheDocument();
-      expect(screen.getByText('上海市')).toBeInTheDocument();
-      expect(screen.getByText('上海市辖区')).toBeInTheDocument();
-      expect(screen.getByText('黄浦区')).toBeInTheDocument();
-      expect(screen.getByText('¥6000.00')).toBeInTheDocument();
-      expect(screen.getByText('¥5700.00')).toBeInTheDocument();
+      const row2 = screen.getByTestId('salary-row-2');
+      expect(row2).toBeInTheDocument();
+      expect(within(row2).getByText('2')).toBeInTheDocument();
+      expect(within(row2).getByText('上海市')).toBeInTheDocument();
+      expect(within(row2).getByText('上海市辖区')).toBeInTheDocument();
+      expect(within(row2).getByText('黄浦区')).toBeInTheDocument();
+      expect(within(row2).getByText('¥6000.00')).toBeInTheDocument();
+      expect(within(row2).getByText('¥5700.00')).toBeInTheDocument();
     });
   });
 
@@ -273,13 +277,14 @@ describe('薪资管理集成测试', () => {
   it('应该打开添加薪资模态框', async () => {
     renderComponent();
 
-    // 点击添加按钮
-    const addButton = screen.getByText('添加薪资');
+    // 点击添加按钮 - 使用test ID避免多个"添加薪资"文本
+    const addButton = screen.getByTestId('add-salary-button');
     fireEvent.click(addButton);
 
-    // 检查模态框标题
+    // 检查模态框标题 - 使用getAllByText获取第二个"添加薪资"(模态框标题)
     await waitFor(() => {
-      expect(screen.getByText('添加薪资')).toBeInTheDocument();
+      const addSalaryTexts = screen.getAllByText('添加薪资');
+      expect(addSalaryTexts.length).toBeGreaterThanOrEqual(2); // 按钮 + 模态框标题
       expect(screen.getByText('填写薪资信息,支持实时计算总薪资')).toBeInTheDocument();
     });
 
@@ -295,11 +300,12 @@ describe('薪资管理集成测试', () => {
     renderComponent();
 
     // 打开添加模态框
-    const addButton = screen.getByText('添加薪资');
+    const addButton = screen.getByTestId('add-salary-button');
     fireEvent.click(addButton);
 
     await waitFor(() => {
-      expect(screen.getByText('添加薪资')).toBeInTheDocument();
+      const addSalaryTexts = screen.getAllByText('添加薪资');
+      expect(addSalaryTexts.length).toBeGreaterThanOrEqual(2);
     });
 
     // 填写表单数据
@@ -313,9 +319,10 @@ describe('薪资管理集成测试', () => {
     fireEvent.change(insuranceInput, { target: { value: '500' } });
     fireEvent.change(housingFundInput, { target: { value: '800' } });
 
-    // 检查总薪资计算
+    // 检查总薪资计算 - 使用getAllByText因为有两个¥4700.00
     await waitFor(() => {
-      expect(screen.getByText('¥4700.00')).toBeInTheDocument();
+      const totalSalaryElements = screen.getAllByText('¥4700.00');
+      expect(totalSalaryElements.length).toBeGreaterThanOrEqual(1);
       expect(screen.getByText('计算公式:基本工资 + 津贴 - 保险 - 公积金')).toBeInTheDocument();
     });
   });
@@ -324,12 +331,13 @@ describe('薪资管理集成测试', () => {
     renderComponent();
 
     await waitFor(() => {
-      expect(screen.getByText('北京市')).toBeInTheDocument();
+      const row1 = screen.getByTestId('salary-row-1');
+      expect(within(row1).getByText('北京市')).toBeInTheDocument();
     });
 
-    // 点击编辑按钮(第一个薪资记录的编辑按钮)
-    const editButtons = screen.getAllByRole('button', { name: /编辑/i });
-    fireEvent.click(editButtons[0]);
+    // 点击编辑按钮 - 使用test ID
+    const editButton = screen.getByTestId('edit-salary-1');
+    fireEvent.click(editButton);
 
     // 检查编辑模态框
     await waitFor(() => {
@@ -342,16 +350,18 @@ describe('薪资管理集成测试', () => {
     renderComponent();
 
     await waitFor(() => {
-      expect(screen.getByText('北京市')).toBeInTheDocument();
+      const row1 = screen.getByTestId('salary-row-1');
+      expect(within(row1).getByText('北京市')).toBeInTheDocument();
     });
 
-    // 点击删除按钮(第一个薪资记录的删除按钮)
-    const deleteButtons = screen.getAllByRole('button', { name: /删除/i });
-    fireEvent.click(deleteButtons[0]);
+    // 点击删除按钮 - 使用test ID
+    const deleteButton = screen.getByTestId('delete-salary-1');
+    fireEvent.click(deleteButton);
 
-    // 检查删除确认对话框
+    // 检查删除确认对话框 - 使用getAllByText因为有两个"确认删除"文本
     await waitFor(() => {
-      expect(screen.getByText('确认删除')).toBeInTheDocument();
+      const confirmDeleteElements = screen.getAllByText('确认删除');
+      expect(confirmDeleteElements.length).toBeGreaterThanOrEqual(2); // 标题 + 按钮
       expect(screen.getByText('确定要删除这条薪资信息吗?此操作不可撤销。')).toBeInTheDocument();
     });
   });
@@ -363,10 +373,11 @@ describe('薪资管理集成测试', () => {
 
     renderComponent();
 
-    // 检查错误处理
+    // 检查错误处理 - 使用test ID检查表格行不存在
     await waitFor(() => {
       // 表格应该为空或显示加载状态
-      expect(screen.queryByText('北京市')).not.toBeInTheDocument();
+      expect(screen.queryByTestId('salary-row-1')).not.toBeInTheDocument();
+      expect(screen.queryByTestId('salary-row-2')).not.toBeInTheDocument();
     });
   });
 });

+ 1 - 1
docs/prd/epic-008-allin-ui-modules-transplant.md

@@ -731,7 +731,7 @@ const useChannels = () => {
   - [x] 故事1:平台管理UI(故事008.001已完成)
   - [x] 故事2:渠道管理UI(故事008.002已完成)
   - [ ] 故事3:公司管理UI
-  - [ ] 故事4:薪资管理UI
+  - [x] 故事4:薪资管理UI(故事008.004已完成)
   - [ ] 故事5:残疾人管理UI
   - [ ] 故事6:残疾人个人管理UI
   - [ ] 故事7:订单管理UI

+ 13 - 0
docs/stories/008.004.transplant-salary-management-ui.story.md

@@ -315,6 +315,7 @@ Ready for Review
 | 2025-12-03 | 1.2 | 更新状态为Ready for Development | Scrum Master Bob |
 | 2025-12-03 | 1.3 | 在任务中直接标注文件路径,参考之前故事格式 | Scrum Master Bob |
 | 2025-12-03 | 1.4 | 修复类型检查问题,更新状态为Ready for Review | James |
+| 2025-12-03 | 1.5 | 修复所有测试问题,添加test ID最佳实践,测试通过率17/17 | James |
 
 ## Dev Agent Record
 *This section is populated by the development agent during implementation*
@@ -334,6 +335,13 @@ James (Developer Agent)
 - 2025-12-03: 修复测试中的文本查找问题,使用正则表达式匹配包含数字的文本
 - 2025-12-03: 修复测试等待逻辑,确保按钮渲染完成后再点击
 - 2025-12-03: 测试通过率从0/17提高到9/17,薪资选择器测试7/9通过
+- **2025-12-03: 测试修复经验总结**:
+  - **test ID最佳实践**:为关键交互元素添加`data-testid`属性,避免文本查找冲突
+  - **表格行测试**:为表格行添加`data-testid="salary-row-{id}"`,使用`within`在行内查找元素
+  - **按钮测试**:为操作按钮添加test ID(`add-salary-button`, `edit-salary-{id}`, `delete-salary-{id}`)
+  - **多文本处理**:当有多个相同文本时,使用`getAllByText`并检查数量
+  - **外部值控制测试**:需要为API调用设置模拟,即使组件有外部值也可能触发查询
+  - **必填验证测试**:模拟组件需要正确处理`required`参数
 
 ### Completion Notes List
 - [x] 任务1:创建薪资管理UI包基础结构 - 已完成
@@ -364,5 +372,10 @@ James (Developer Agent)
 - `allin-packages/salary-management-ui/tests/integration/salary.integration.test.tsx` - 修复API客户端模拟和表格渲染测试
 - `allin-packages/salary-management-ui/tests/integration/salary-selector.integration.test.tsx` - 修复API客户端模拟和路径名称统一
 
+**测试修复和优化文件:**
+- `allin-packages/salary-management-ui/src/components/SalaryManagement.tsx` - 添加test ID:表格行`salary-row-{id}`、按钮`add-salary-button`、`edit-salary-{id}`、`delete-salary-{id}`
+- `allin-packages/salary-management-ui/tests/integration/salary.integration.test.tsx` - 修复所有测试,使用test ID和`within`进行精确查找,处理多文本冲突
+- `allin-packages/salary-management-ui/tests/integration/salary-selector.integration.test.tsx` - 修复AreaSelect模拟组件,添加`required`参数处理;修复外部值控制测试的API模拟
+
 ## QA Results
 *Results from QA Agent QA review of the completed story implementation*