jsharness 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.harness/README.md +199 -0
  2. package/.harness/agents/code-reviewer/contract.yaml +64 -0
  3. package/.harness/agents/developer/contract.yaml +72 -0
  4. package/.harness/agents/gate-controller/contract.yaml +64 -0
  5. package/.harness/agents/project-manager/contract.yaml +77 -0
  6. package/.harness/agents/prompt-templates.md +352 -0
  7. package/.harness/agents/requirements-analyst/contract.yaml +64 -0
  8. package/.harness/agents/solution-designer/contract.yaml +75 -0
  9. package/.harness/agents/tester/contract.yaml +92 -0
  10. package/.harness/config/models.yaml +67 -0
  11. package/.harness/dev-map/backend/api-definition.md +131 -0
  12. package/.harness/dev-map/backend/auth-security.md +131 -0
  13. package/.harness/dev-map/backend/conventions-java.md +471 -0
  14. package/.harness/dev-map/backend/conventions.md +192 -0
  15. package/.harness/dev-map/backend/database.md +106 -0
  16. package/.harness/dev-map/backend/structure.md +140 -0
  17. package/.harness/dev-map/decisions.md +275 -0
  18. package/.harness/dev-map/frontend/api-integration.md +139 -0
  19. package/.harness/dev-map/frontend/components.md +178 -0
  20. package/.harness/dev-map/frontend/conventions.md +416 -0
  21. package/.harness/dev-map/frontend/state-management.md +170 -0
  22. package/.harness/dev-map/frontend/structure.md +103 -0
  23. package/.harness/dev-map/overview.md +267 -0
  24. package/.harness/docs/integration-test-plan.md +248 -0
  25. package/.harness/docs/team-guidelines/README.md +161 -0
  26. package/.harness/docs/team-guidelines/arch-team.md +811 -0
  27. package/.harness/docs/team-guidelines/collaboration.md +556 -0
  28. package/.harness/docs/team-guidelines/pm-team.md +337 -0
  29. package/.harness/docs/team-guidelines/qa-team.md +562 -0
  30. package/.harness/docs/team-guidelines/rd-team.md +714 -0
  31. package/.harness/docs/training-materials.md +280 -0
  32. package/.harness/gate/baseline.js +220 -0
  33. package/.harness/gate/checks/build-gates-frontend.js +152 -0
  34. package/.harness/gate/checks/build-gates-java.js +155 -0
  35. package/.harness/gate/checks/build-gates.js +119 -0
  36. package/.harness/gate/checks/engineering-consistency.js +138 -0
  37. package/.harness/gate/checks/security-quality.js +129 -0
  38. package/.harness/gate/checks/static-compliance.js +313 -0
  39. package/.harness/gate/checks/test-compliance.js +114 -0
  40. package/.harness/gate/index.js +315 -0
  41. package/.harness/mcp/config.yaml +435 -0
  42. package/.harness/rules/global/coding-standard.md +232 -0
  43. package/.harness/rules/global/commit-convention.md +165 -0
  44. package/.harness/rules/global/process-discipline.md +192 -0
  45. package/.harness/rules/global/security-baseline.md +306 -0
  46. package/.harness/rules/project/frontend-vue3.md +293 -0
  47. package/.harness/rules/project/java-backend.md +460 -0
  48. package/.harness/rules/project/web-specific.md +231 -0
  49. package/.harness/skills/build.md +192 -0
  50. package/.harness/skills/code-review.md +251 -0
  51. package/.harness/skills/docker-build.md +227 -0
  52. package/.harness/skills/docs-update.md +164 -0
  53. package/.harness/skills/java-build.md +261 -0
  54. package/.harness/skills/lint-check.md +482 -0
  55. package/.harness/skills/task-board-maintenance.md +105 -0
  56. package/.harness/skills/test-api.md +461 -0
  57. package/.harness/skills/test-e2e.md +431 -0
  58. package/.harness/skills/test-unit.md +649 -0
  59. package/.harness/skills/vue-frontend-build.md +344 -0
  60. package/.harness/specs/quality-feedback/implementation-guide.md +350 -0
  61. package/.harness/task-board.md +121 -0
  62. package/.harness/workflow/definition.yaml +504 -0
  63. package/.harness/workflow/validate.js +320 -0
  64. package/.harness/workflow/variants.yaml +253 -0
  65. package/README.md +237 -0
  66. package/bin/jsharness.js +53 -0
  67. package/lib/index.mjs +778 -0
  68. package/package.json +1 -0
@@ -0,0 +1,649 @@
1
+ # 单元测试技能 (test-unit)
2
+
3
+ > **执行角色**: 开发实现 Agent / 测试验证 Agent
4
+ > **触发时机**: 功能开发完成时、PR 提交前、CI 流水线中
5
+
6
+ ---
7
+
8
+ ## 执行命令
9
+
10
+ ```bash
11
+ # 运行全部单元测试
12
+ npm run test
13
+
14
+ # 或带覆盖率报告
15
+ npm run test:coverage
16
+
17
+ # 运行特定测试文件
18
+ npm run test -- --testPathPattern="UserService"
19
+
20
+ # 监听模式(开发时)
21
+ npm run test:watch
22
+ ```
23
+
24
+ ## 覆盖率收集
25
+
26
+ ### 配置要求
27
+
28
+ ```javascript
29
+ // jest.config.js 或 vitest.config.ts
30
+ module.exports = {
31
+ coverageThreshold: {
32
+ global: {
33
+ branches: 80, // 分支覆盖率 ≥ 80%
34
+ functions: 80, // 函数覆盖率 ≥ 80%
35
+ lines: 80, // 行覆盖率 ≥ 80%
36
+ statements: 80 // 语句覆盖率 ≥ 80%
37
+ }
38
+ },
39
+ collectCoverageFrom: [
40
+ 'src/**/*.{ts,tsx,js,jsx}',
41
+ '!src/**/*.d.ts',
42
+ '!src/**/*.test.{ts,tsx}',
43
+ '!src/**/*.stories.{ts,tsx}',
44
+ '!src/main.{ts,tsx}', // 入口文件
45
+ '!src/vite-env.d.ts' // 类型声明
46
+ ]
47
+ };
48
+ ```
49
+
50
+ ### 覆盖率分级标准
51
+
52
+ | 等级 | 分支覆盖率 | 说明 |
53
+ |------|-----------|------|
54
+ | 🟢 优秀 | ≥ 90% | 高质量代码 |
55
+ | 🟡 合格 | 70% - 89% | 达标,建议提升 |
56
+ | 🔴 不合格 | < 70% | 门禁 FAIL |
57
+
58
+ ### 新代码额外要求
59
+
60
+ 新编写或修改的代码,覆盖率必须 **≥ 85%**。
61
+
62
+ ## 测试用例编写规范
63
+
64
+ ### 结构要求(AAA 模式)
65
+
66
+ ```typescript
67
+ describe('UserService', () => {
68
+ describe('getUserById', () => {
69
+ it('应该根据 ID 返回用户信息', async () => {
70
+ // Arrange(准备)
71
+ const mockUserId = 'user-001';
72
+ const expectedUser = { id: mockUserId, name: 'Test User' };
73
+ userRepository.findById.mockResolvedValue(expectedUser);
74
+
75
+ // Act(执行)
76
+ const result = await userService.getUserById(mockUserId);
77
+
78
+ // Assert(断言)
79
+ expect(result).toEqual(expectedUser);
80
+ expect(userRepository.findById).toHaveBeenCalledWith(mockUserId);
81
+ });
82
+ });
83
+ });
84
+ ```
85
+
86
+ ### 必须覆盖的场景
87
+
88
+ 每个函数/方法必须覆盖:
89
+
90
+ | 场景 | 示例 |
91
+ |------|------|
92
+ | **正常路径** | 有效输入 → 正确输出 |
93
+ | **边界条件** | 空值、最大值、最小值 |
94
+ | **异常情况** | 无效输入、服务不可用 |
95
+ | **异步行为** | 成功回调、超时处理 |
96
+
97
+ ```typescript
98
+ describe('getUserById', () => {
99
+ it('正常:返回用户信息', async () => { /* ... */ });
100
+
101
+ it('边界:ID 为空字符串时抛出 ValidationError', async () => {
102
+ await expect(userService.getUserById(''))
103
+ .rejects.toThrow(ValidationError);
104
+ });
105
+
106
+ it('异常:用户不存在时返回 null', async () => {
107
+ userRepository.findById.mockResolvedValue(null);
108
+ const result = await userService.getUserById('nonexistent');
109
+ expect(result).toBeNull();
110
+ });
111
+
112
+ it('异常:数据库连接超时时抛出 DatabaseError', async () => {
113
+ userRepository.findById.mockRejectedValue(new Error('Connection timeout'));
114
+ await expect(userService.getUserById('user-001'))
115
+ .rejects.toThrow(DatabaseError);
116
+ });
117
+ });
118
+ ```
119
+
120
+ ## 失败报告分析
121
+
122
+ ### 测试失败时的诊断步骤
123
+
124
+ ```
125
+ 1. 查看失败信息
126
+ ├── 断言失败?→ 检查预期值和实际值的差异
127
+ ├── 超时失败?→ 检查是否有未解决的 Promise
128
+ ├── 编译错误?→ 检查类型和导入
129
+ └── Hook 失败?→ 检查 beforeAll/afterAll 设置
130
+
131
+ 2. 定位原因
132
+ ├── 代码逻辑变更导致预期失效?→ 更新测试用例
133
+ ├── 测试数据不足?→ 补充边界用例
134
+ ├── Mock 配置错误?→ 检查 mock 行为定义
135
+ └── 异步竞态?→ 添加 waitFor / act 包裹
136
+
137
+ 3. 修复并验证
138
+ ├── 修复代码或测试
139
+ ├── 重跑失败用例(不重跑全部以节省时间)
140
+ └── 确认覆盖率未下降
141
+ ```
142
+
143
+ ## 测试结果输出模板
144
+
145
+ ```yaml
146
+ test_result_unit:
147
+ timestamp: "2026-05-20T22:00:00Z"
148
+ framework: "vitest" | "jest"
149
+ status: pass | fail
150
+
151
+ summary:
152
+ total_tests: 156
153
+ passed: 154
154
+ failed: 2
155
+ skipped: 0
156
+
157
+ coverage:
158
+ lines: 82.5
159
+ functions: 81.2
160
+ branches: 78.9
161
+ statements: 83.1
162
+
163
+ failed_tests:
164
+ - name: "UserService > getUserById > should throw on empty id"
165
+ file: "src/services/UserService.test.ts:42"
166
+ error: "Expected 'ValidationError' but received 'null'"
167
+
168
+ baseline_comparison:
169
+ coverage_delta: +1.2 # 相比基线的变化(正=提升,负=下降)
170
+ test_count_delta: +5 # 测试数量变化
171
+ regression: false # 是否有回归
172
+ ```
173
+
174
+ ---
175
+
176
+ # ════════════════════════════════════════════════════════
177
+ # Java 后端单元测试(Spring Boot + JUnit5)
178
+ # ════════════════════════════════════════════════════════
179
+
180
+ > **适用技术栈**: Spring Boot 3.x / JDK21 / JUnit5 / Mockito
181
+ > **参考规则**: `rules/project/java-backend.md` §13 测试规范
182
+
183
+ ---
184
+
185
+ ## 执行命令
186
+
187
+ ```bash
188
+ # === 编译 + 全量测试 ===
189
+ mvn clean test # 运行全部单元测试
190
+
191
+ # === 带覆盖率报告 ===
192
+ mvn clean verify # 包含 JaCoCo 覆盖率
193
+
194
+ # === 运行指定测试类 ===
195
+ mvn test -Dtest=UserServiceTest # 指定类
196
+ mvn test -Dtest="com.jieshun.service.**" # 通配符匹配包
197
+
198
+ # === 跳过测试编译/执行 ===
199
+ mvn clean compile -DskipTests # 仅编译,跳过测试
200
+ mvn clean package -Dmaven.test.skip # 完全跳过测试阶段
201
+
202
+ # === 快速诊断(显示详细错误)===
203
+ mvn test -e # 显示完整错误堆栈
204
+ mvn test -Dsurefire.useFile=false # 直接输出到控制台
205
+
206
+ # === 并行测试加速 ===
207
+ mvn test -T 4 # 4 线程并行
208
+ ```
209
+
210
+ ---
211
+
212
+ ## JUnit5 核心注解体系
213
+
214
+ ### 基础注解
215
+
216
+ | 注解 | 用途 | 说明 |
217
+ |------|------|------|
218
+ | `@Test` | 标记测试方法 | 替代 JUnit4 的 `@Test` |
219
+ | `@BeforeEach` | 每个测试前执行 | 替代 `@Before` |
220
+ | `@AfterEach` | 每个测试后执行 | 替代 `@After` |
221
+ | `@BeforeAll` | 类初始化时一次 | 必须配合 `static` |
222
+ | `@AfterAll` | 类销毁时一次 | 必须配合 `static` |
223
+ | `@DisplayName("描述")` | 测试名称 | 中文可读名称 |
224
+ | `@Disabled` | 跳过测试 | 替代 `@Ignore` |
225
+ | `@Tag("slow")` | 标签分组 | 用于选择性运行 |
226
+ | `@Nested` | 内部测试类 | 组织相关测试 |
227
+ | `@Timeout(3)` | 超时限制 | 单位:秒 |
228
+
229
+ ### 参数化测试
230
+
231
+ ```java
232
+ @ParameterizedTest
233
+ @ValueSource(strings = {"", " "})
234
+ @DisplayName("空字符串应抛出异常")
235
+ void shouldThrowOnBlank(String input) {
236
+ assertThatThrownBy(() -> service.process(input))
237
+ .isInstanceOf(BusinessException.class)
238
+ .hasMessageContaining("不能为空");
239
+ }
240
+
241
+ @ParameterizedTest
242
+ @CsvSource({
243
+ "user-001, 张三, ACTIVE",
244
+ "user-002, 李四, INACTIVE"
245
+ })
246
+ @DisplayName("根据 ID 应返回正确用户状态")
247
+ void shouldReturnCorrectStatus(String id, String name, String status) {
248
+ User user = userService.findById(id);
249
+ assertThat(user.getName()).isEqualTo(name);
250
+ assertThat(user.getStatus().name()).isEqualTo(status);
251
+ }
252
+ ```
253
+
254
+ ---
255
+
256
+ ## Mockito Mock 模式
257
+
258
+ ### 常用注解配置
259
+
260
+ ```java
261
+ @ExtendWith(MockitoExtension.class) // 启用 Mockito
262
+ class UserServiceTest {
263
+
264
+ @Mock
265
+ private UserRepository userRepository; // Mock 依赖
266
+
267
+ @Mock
268
+ private RedisTemplate<String, String> redis; // Mock 外部服务
269
+
270
+ @InjectMocks
271
+ private UserService userService; // 自动注入 Mock
272
+
273
+ @BeforeEach
274
+ void setUp() {
275
+ // 额外配置(可选)
276
+ lenient().when(redis.hasKey(anyString())).thenReturn(false);
277
+ }
278
+ }
279
+ ```
280
+
281
+ ### 常用 Mock 操作
282
+
283
+ ```java
284
+ // ✅ 定义返回值
285
+ when(userRepository.findById("user-001"))
286
+ .thenReturn(Optional.of(User.builder().id("user-001").name("张三").build()));
287
+
288
+ // ✅ 定义异常抛出
289
+ when(userRepository.findById("nonexist"))
290
+ .thenThrow(new EntityNotFoundException("用户不存在"));
291
+
292
+ // ✅ 验证调用次数
293
+ verify(userRepository, times(1)).save(any(User.class));
294
+ verify(userRepository, never()).deleteById(anyString());
295
+ verify(redis, atLeastOnce()).hasKey(anyString());
296
+
297
+ // ✅ 参数捕获验证
298
+ ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
299
+ verify(userRepository).save(captor.capture());
300
+ assertThat(captor.getValue().getName()).contains("张三");
301
+
302
+ // ❌ 不要这样做:Mock 返回 null 后未处理
303
+ // when(service.call()).thenReturn(null); // 容易 NPE
304
+ // 推荐:
305
+ when(service.call()).thenReturn(Optional.empty());
306
+ ```
307
+
308
+ ### BDDMockito (行为驱动风格)
309
+
310
+ ```java
311
+ @Test
312
+ @DisplayName("BDD 风格:用户注册成功流程")
313
+ void shouldRegisterUserSuccessfully() {
314
+ // GIVEN — 准备数据
315
+ UserReqVO req = UserReqVO.builder()
316
+ .phone("13800138000").name("王五").build();
317
+ when(userRepository.findByPhone(req.getPhone())).thenReturn(Optional.empty());
318
+
319
+ // WHEN — 执行操作
320
+ RespVO result = userService.registerUser(req);
321
+
322
+ // THEN — 验证结果
323
+ then(result).isNotNull();
324
+ then(result.getCode()).isEqualTo(0);
325
+ then(userRepository).should(times(1)).save(any(User.class));
326
+ }
327
+ ```
328
+
329
+ ---
330
+
331
+ ## Spring Boot Test 配置
332
+
333
+ ### 单元测试(不启动容器)
334
+
335
+ ```java
336
+ @ExtendWith(MockitoExtension.class)
337
+ class OrderServiceTest {
338
+ // 纯 Service 层测试,不加载 Spring 上下文
339
+ // 速度快 (~100ms/test),适合纯逻辑测试
340
+ }
341
+ ```
342
+
343
+ ### Web 层切片测试
344
+
345
+ ```java
346
+ @WebMvcTest(UserController.class) // 只加载 Controller 层
347
+ @AutoConfigureMockMvc
348
+ class UserControllerTest {
349
+
350
+ @Autowired
351
+ private MockMvc mockMvc;
352
+
353
+ @MockBean
354
+ private UserService userService; // Mock Service 层
355
+
356
+ @Test
357
+ @DisplayName("GET /api/v1/users/{id} 应返回用户信息")
358
+ void shouldReturnUser() throws Exception {
359
+ // given
360
+ given(userService.getUserById("user-001"))
361
+ .willReturn(RespVO.success(UserVO.builder().id("user-001").build()));
362
+
363
+ // when & then
364
+ mockMvc.perform(get("/api/v1/users/user-001"))
365
+ .andExpect(status().isOk())
366
+ .andExpect(jsonPath("$.code").value(0))
367
+ .andExpect(jsonPath("$.data.id").value("user-001"));
368
+ }
369
+ }
370
+ ```
371
+
372
+ ### 集成测试(启动完整上下文)
373
+
374
+ ```java
375
+ @SpringBootTest
376
+ @Testcontainers // 自动管理 Docker 容器
377
+ @ActiveProfiles("test") // 使用 application-test.yml
378
+ class UserRepositoryIntegrationTest {
379
+
380
+ @Container
381
+ static MySQLContainer mysql = new MySQLContainer("mysql:8.0")
382
+ .withDatabaseName("test_db")
383
+ .withUsername("test")
384
+ .withPassword("test");
385
+
386
+ @DynamicPropertySource
387
+ static void configureProperties(DynamicPropertyRegistry registry) {
388
+ registry.add("spring.datasource.url", mysql::getJdbcUrl);
389
+ registry.add("spring.datasource.username", mysql::getUsername);
390
+ registry.add("spring.datasource.password", mysql::getPassword);
391
+ }
392
+
393
+ @Autowired
394
+ private UserRepository userRepository;
395
+
396
+ @Test
397
+ @DisplayName("数据库 CRUD 操作正常")
398
+ void shouldCRUD() {
399
+ User saved = userRepository.save(User.builder().name("集成测试").build());
400
+ assertThat(saved.getId()).isNotNull();
401
+ assertThat(userRepository.findById(saved.getId())).isPresent();
402
+ }
403
+ }
404
+ ```
405
+
406
+ ---
407
+
408
+ ## JaCoCo 覆盖率配置
409
+
410
+ ### pom.xml 插件配置
411
+
412
+ ```xml
413
+ <plugin>
414
+ <groupId>org.jacoco</groupId>
415
+ <artifactId>jacoco-maven-plugin</artifactId>
416
+ <version>0.8.11</version>
417
+ <executions>
418
+ <execution>
419
+ <goals>
420
+ <goal>prepare-agent</goal> <!-- 准备 agent -->
421
+ </goals>
422
+ </execution>
423
+ <execution>
424
+ <id>report</id>
425
+ <phase>test</phase>
426
+ <goals>
427
+ <goal>report</goal> <!-- 生成报告 -->
428
+ </goals>
429
+ </execution>
430
+ <execution>
431
+ <id>check</id>
432
+ <phase>verify</phase>
433
+ <goals>
434
+ <goal>check</goal> <!-- 门禁检查 -->
435
+ </goals>
436
+ <configuration>
437
+ <rules>
438
+ <rule>
439
+ <element>BUNDLE</element>
440
+ <limits>
441
+ <!-- 分支覆盖率 -->
442
+ <limit>
443
+ <counter>BRANCH</counter>
444
+ <value>COVEREDRATIO</value>
445
+ <minimum>0.80</minimum>
446
+ </limit>
447
+ <!-- 行覆盖率 -->
448
+ <limit>
449
+ <counter>LINE</counter>
450
+ <value>COVEREDRATIO</value>
451
+ <minimum>0.80</minimum>
452
+ </limit>
453
+ <!-- 方法覆盖率 -->
454
+ <limit>
455
+ <counter>METHOD</counter>
456
+ <value>COVEREDRATIO</value>
457
+ <minimum>0.80</minimum>
458
+ </limit>
459
+ <!-- 类覆盖率 -->
460
+ <limit>
461
+ <counter>CLASS</counter>
462
+ <value>MISSEDCOUNT</value>
463
+ <maximum>0</maximum> <!-- 不允许有未覆盖的类 -->
464
+ </limit>
465
+ </limits>
466
+ </rule>
467
+ </rules>
468
+ </configuration>
469
+ </execution>
470
+ </executions>
471
+ </plugin>
472
+ ```
473
+
474
+ ### 排除不需要覆盖的代码
475
+
476
+ ```xml
477
+ <configuration>
478
+ <excludes>
479
+ <!-- 配置类、实体、DTO、VO 不需要单测 -->
480
+ <exclude>**/config/**</exclude>
481
+ <exclude>**/entity/**</exclude>
482
+ <exclude>**/dto/**</exclude>
483
+ <exclude>**/vo/**</exclude>
484
+ <exclude>**/*Application.*</exclude>
485
+ <exclude>**/*Config.*</exclude>
486
+ <exclude>**/exception/**</exclude>
487
+ <!-- 枚举通常也不需要 -->
488
+ <exclude>**/enums/**</exclude>
489
+ </excludes>
490
+ </configuration>
491
+ ```
492
+
493
+ ### 覆盖率查看
494
+
495
+ ```bash
496
+ # 报告位置
497
+ target/site/jacoco/index.html # HTML 可视化报告
498
+
499
+ # CLI 查看简要数据
500
+ cat target/site/jacoco/jacoco.csv # CSV 格式的逐行覆盖率
501
+ ```
502
+
503
+ ---
504
+
505
+ ## AAA 模式 Java 示例
506
+
507
+ ### 标准 AAA 结构
508
+
509
+ ```java
510
+ class UserServiceTest {
511
+
512
+ @Test
513
+ @DisplayName("根据有效 ID 返回用户信息")
514
+ void shouldReturnUserByValidId() {
515
+ // ========== ARRANGE (准备) ==========
516
+ String userId = "user-001";
517
+ User expected = User.builder()
518
+ .id(userId).name("测试用户").phone("13800138000")
519
+ .status(UserStatus.ACTIVE).build();
520
+
521
+ when(userRepository.findById(userId)).thenReturn(Optional.of(expected));
522
+
523
+ // ========== ACT (执行) ==========
524
+ RespVO result = userService.getUserById(userId);
525
+
526
+ // ========== ASSERT (断言) ==========
527
+ assertThat(result.getCode()).isEqualTo(0);
528
+ assertThat(result.getData())
529
+ .usingRecursiveComparison() // 递归比较所有字段
530
+ .ignoringFields("createTime", "updateTime")
531
+ .isEqualTo(expected);
532
+ }
533
+
534
+ @Test
535
+ @DisplayName("ID 为空时应抛出参数校验异常")
536
+ void shouldThrowOnEmptyId() {
537
+ // Arrange
538
+ String emptyId = "";
539
+
540
+ // Act & Assert (使用 AssertJ 断言库)
541
+ assertThatThrownBy(() -> userService.getUserById(emptyId))
542
+ .isInstanceOf(ConstraintViolationException.class)
543
+ .hasMessageContaining("ID 不能为空");
544
+
545
+ // 验证不会访问 Repository
546
+ verify(userRepository, never()).findById(anyString());
547
+ }
548
+
549
+ @Test
550
+ @DisplayName("用户不存在时返回业务异常")
551
+ void shouldThrowWhenNotFound() {
552
+ // Arrange
553
+ String nonExistId = "ghost-999";
554
+ when(userRepository.findById(nonExistId)).thenReturn(Optional.empty());
555
+
556
+ // Act & Assert
557
+ assertThatThrownBy(() -> userService.getUserById(nonExistId))
558
+ .isInstanceOf(JscicdBizException.class)
559
+ .hasFieldOrPropertyWithValue("errorCode", "USER_NOT_FOUND");
560
+ }
561
+ }
562
+ ```
563
+
564
+ ### 异步测试示例
565
+
566
+ ```java
567
+ @Test
568
+ @DisplayName("异步发送通知成功")
569
+ void shouldSendNotificationAsync() throws Exception {
570
+ // Arrange
571
+ NotificationDTO dto = NotificationDTO.builder()
572
+ .userId("user-001").type(NotificationType.SMS).content("验证码1234").build();
573
+
574
+ // Act
575
+ CompletableFuture<RespVO> future = notificationService.sendAsync(dto);
576
+
577
+ // Assert — 使用 CompletableFuture.get(timeout) 等待完成
578
+ RespVO result = future.get(5, TimeUnit.SECONDS);
579
+
580
+ assertThat(result.getCode()).isEqualTo(0);
581
+ verify(smsClient, times(1)).send(anyString(), anyString());
582
+ }
583
+ ```
584
+
585
+ ---
586
+
587
+ ## 失败诊断步骤
588
+
589
+ ```text
590
+ Java 单元测试失败时的诊断路径:
591
+
592
+ 1. 查看失败信息
593
+ ├── 编译错误? → 检查 import / 泛型擦除 / Lombok 注解处理器
594
+ ├── AssertionError? → 检查期望值和实际值的差异 (AssertJ 会给出详细 diff)
595
+ ├── Exception in test? → 查看是否为 expected exception 或真正的 bug
596
+ └── NoBeanDefFoundError? → 缺少 @MockBean / @SpyBean
597
+
598
+ 2. 定位原因
599
+ ├── Mock 未生效? → 检查 @ExtendWith(MockitoExtension.class)
600
+ ├── Spring 上下文启动慢? → 减少使用 @SpringBootTest,优先切片测试
601
+ ├── 数据库连接失败? → 使用 @Testcontainers 或 H2 内存数据库
602
+ └── 并发问题? → 检查 @Transactional 回滚 / ThreadLocal 清理
603
+
604
+ 3. 修复并验证
605
+ ├── 修复代码或测试
606
+ ├── mvn test -Dtest=FailedTestClass#failedMethodName (只跑失败的)
607
+ └── mvn jacoco:report 确认覆盖率未下降
608
+ ```
609
+
610
+ ---
611
+
612
+ ## 测试结果输出模板 (Java)
613
+
614
+ ```yaml
615
+ test_result_java:
616
+ timestamp: "2026-05-20T22:00:00Z"
617
+ framework: "JUnit5 + Mockito + Spring Boot Test"
618
+ build_tool: "Maven"
619
+ status: pass | fail
620
+
621
+ summary:
622
+ total_tests: 248
623
+ passed: 245
624
+ failed: 2
625
+ errors: 1
626
+ skipped: 5
627
+ duration_seconds: 45.2
628
+
629
+ coverage_jacoco:
630
+ instructions: 82.3
631
+ branches: 78.5
632
+ lines: 84.1
633
+ methods: 88.2
634
+ classes: 95.0
635
+
636
+ failed_tests:
637
+ - name: "com.jieshun.service.UserServiceTest > shouldUpdateUserProfile"
638
+ file: "src/test/java/com/jieshun/service/UserServiceTest.java:87"
639
+ error: "expected: <ACTIVE> but was: <INACTIVE>"
640
+
641
+ surefire_report:
642
+ location: "target/surefire-reports/"
643
+ xml_report: "TEST-com.jieshun.service.UserServiceTest.xml"
644
+
645
+ baseline_comparison:
646
+ coverage_delta: +1.5
647
+ test_count_delta: +12
648
+ regression: false
649
+ ```