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.
- package/.harness/README.md +199 -0
- package/.harness/agents/code-reviewer/contract.yaml +64 -0
- package/.harness/agents/developer/contract.yaml +72 -0
- package/.harness/agents/gate-controller/contract.yaml +64 -0
- package/.harness/agents/project-manager/contract.yaml +77 -0
- package/.harness/agents/prompt-templates.md +352 -0
- package/.harness/agents/requirements-analyst/contract.yaml +64 -0
- package/.harness/agents/solution-designer/contract.yaml +75 -0
- package/.harness/agents/tester/contract.yaml +92 -0
- package/.harness/config/models.yaml +67 -0
- package/.harness/dev-map/backend/api-definition.md +131 -0
- package/.harness/dev-map/backend/auth-security.md +131 -0
- package/.harness/dev-map/backend/conventions-java.md +471 -0
- package/.harness/dev-map/backend/conventions.md +192 -0
- package/.harness/dev-map/backend/database.md +106 -0
- package/.harness/dev-map/backend/structure.md +140 -0
- package/.harness/dev-map/decisions.md +275 -0
- package/.harness/dev-map/frontend/api-integration.md +139 -0
- package/.harness/dev-map/frontend/components.md +178 -0
- package/.harness/dev-map/frontend/conventions.md +416 -0
- package/.harness/dev-map/frontend/state-management.md +170 -0
- package/.harness/dev-map/frontend/structure.md +103 -0
- package/.harness/dev-map/overview.md +267 -0
- package/.harness/docs/integration-test-plan.md +248 -0
- package/.harness/docs/team-guidelines/README.md +161 -0
- package/.harness/docs/team-guidelines/arch-team.md +811 -0
- package/.harness/docs/team-guidelines/collaboration.md +556 -0
- package/.harness/docs/team-guidelines/pm-team.md +337 -0
- package/.harness/docs/team-guidelines/qa-team.md +562 -0
- package/.harness/docs/team-guidelines/rd-team.md +714 -0
- package/.harness/docs/training-materials.md +280 -0
- package/.harness/gate/baseline.js +220 -0
- package/.harness/gate/checks/build-gates-frontend.js +152 -0
- package/.harness/gate/checks/build-gates-java.js +155 -0
- package/.harness/gate/checks/build-gates.js +119 -0
- package/.harness/gate/checks/engineering-consistency.js +138 -0
- package/.harness/gate/checks/security-quality.js +129 -0
- package/.harness/gate/checks/static-compliance.js +313 -0
- package/.harness/gate/checks/test-compliance.js +114 -0
- package/.harness/gate/index.js +315 -0
- package/.harness/mcp/config.yaml +435 -0
- package/.harness/rules/global/coding-standard.md +232 -0
- package/.harness/rules/global/commit-convention.md +165 -0
- package/.harness/rules/global/process-discipline.md +192 -0
- package/.harness/rules/global/security-baseline.md +306 -0
- package/.harness/rules/project/frontend-vue3.md +293 -0
- package/.harness/rules/project/java-backend.md +460 -0
- package/.harness/rules/project/web-specific.md +231 -0
- package/.harness/skills/build.md +192 -0
- package/.harness/skills/code-review.md +251 -0
- package/.harness/skills/docker-build.md +227 -0
- package/.harness/skills/docs-update.md +164 -0
- package/.harness/skills/java-build.md +261 -0
- package/.harness/skills/lint-check.md +482 -0
- package/.harness/skills/task-board-maintenance.md +105 -0
- package/.harness/skills/test-api.md +461 -0
- package/.harness/skills/test-e2e.md +431 -0
- package/.harness/skills/test-unit.md +649 -0
- package/.harness/skills/vue-frontend-build.md +344 -0
- package/.harness/specs/quality-feedback/implementation-guide.md +350 -0
- package/.harness/task-board.md +121 -0
- package/.harness/workflow/definition.yaml +504 -0
- package/.harness/workflow/validate.js +320 -0
- package/.harness/workflow/variants.yaml +253 -0
- package/README.md +237 -0
- package/bin/jsharness.js +53 -0
- package/lib/index.mjs +778 -0
- 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
|
+
```
|