|
|
|
@ -325,9 +325,8 @@ public class LogRepository : BaseRepository { ... } // → cnc_log
|
|
|
|
### 6.1 覆盖率要求
|
|
|
|
### 6.1 覆盖率要求
|
|
|
|
|
|
|
|
|
|
|
|
- **每个 public 方法必须有至少一个测试用例**
|
|
|
|
- **每个 public 方法必须有至少一个测试用例**
|
|
|
|
- **分支覆盖**:if/else 每个分支都要测到
|
|
|
|
- 目标:**100% 方法覆盖,≥95% 分支覆盖**
|
|
|
|
- **异常路径**:参数校验失败、数据不存在等异常场景必须覆盖
|
|
|
|
- 内部 private 方法通过 public 方法间接覆盖,不单独测试
|
|
|
|
- 目标:**100% 方法覆盖,≥90% 分支覆盖**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 6.2 测试命名
|
|
|
|
### 6.2 测试命名
|
|
|
|
|
|
|
|
|
|
|
|
@ -368,7 +367,7 @@ public void GetList_WithKeywordFilter_ReturnsFilteredMachines()
|
|
|
|
|----|---------|---------|
|
|
|
|
|----|---------|---------|
|
|
|
|
| **Controller** | 测试路由匹配、参数校验、响应格式 | Mock Service |
|
|
|
|
| **Controller** | 测试路由匹配、参数校验、响应格式 | Mock Service |
|
|
|
|
| **Service** | 测试业务逻辑、数据转换、异常抛出 | Mock Repository |
|
|
|
|
| **Service** | 测试业务逻辑、数据转换、异常抛出 | Mock Repository |
|
|
|
|
| **Repository** | 测试SQL正确性、数据映射 | 使用内存数据库或测试库 |
|
|
|
|
| **Repository** | 测试SQL正确性、数据映射 | 使用测试数据库(真实MariaDB) |
|
|
|
|
| **Models** | 测试属性默认值、枚举值、验证逻辑 | 无依赖 |
|
|
|
|
| **Models** | 测试属性默认值、枚举值、验证逻辑 | 无依赖 |
|
|
|
|
|
|
|
|
|
|
|
|
### 6.5 测试项目配置
|
|
|
|
### 6.5 测试项目配置
|
|
|
|
@ -382,6 +381,240 @@ public void GetList_WithKeywordFilter_ReturnsFilteredMachines()
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 6.6 必测场景清单(每个方法逐项检查)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
以下场景**适用于所有层的每个 public 方法**,开发者编写测试时必须逐项对照,确认是否需要覆盖:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#### A. 正常路径(Happy Path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 场景 | 说明 | 示例 |
|
|
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
|
|
| 标准输入 | 常规参数,期望正常返回 | `Create(validRequest)` → 成功 |
|
|
|
|
|
|
|
|
| 边界值-最小 | 数值型参数传最小合法值 | `pageSize=1`、`sortOrder=0` |
|
|
|
|
|
|
|
|
| 边界值-最大 | 数值型参数传最大合法值 | `pageSize=100`、`sortOrder=99` |
|
|
|
|
|
|
|
|
| 边界值-零 | 数值型参数传0 | `collectInterval=0`(如果业务允许) |
|
|
|
|
|
|
|
|
| 边界值-空集合 | 查询结果为空列表 | `GetList(不存在的关键词)` → `items=[], total=0` |
|
|
|
|
|
|
|
|
| 边界值-单条数据 | 查询结果恰好1条 | `GetList(精确匹配)` → `items=[1条]` |
|
|
|
|
|
|
|
|
| 边界值-满页 | 查询结果恰好等于pageSize | 分页边界不丢数据 |
|
|
|
|
|
|
|
|
| 边界值-跨页 | 查询结果超过pageSize,翻页正确 | 第1页20条、第2页从第21条开始 |
|
|
|
|
|
|
|
|
| 字符串-最大长度 | 字符串参数传maxlength边界 | `name` 传100字符 |
|
|
|
|
|
|
|
|
| 字符串-含特殊字符 | 中文、括号、横杠、空格 | `"A栋(二期)"`、`"西-1.8"` |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#### B. 参数校验异常(输入不合法)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 场景 | 说明 | 示例 |
|
|
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
|
|
| null 参数 | 必填参数传 null | `Create(null)` → 抛出 ArgumentNullException |
|
|
|
|
|
|
|
|
| 空字符串 | 必填字符串传 `""` | `name=""` → 校验失败 |
|
|
|
|
|
|
|
|
| 纯空格 | 字符串参数传 `" "` | `name=" "` → 视为空 |
|
|
|
|
|
|
|
|
| 超长字符串 | 超过 maxlength | `name` 传201字符 → 校验失败 |
|
|
|
|
|
|
|
|
| 负数 | 应为正整数的参数传负数 | `collectInterval=-1` → 校验失败 |
|
|
|
|
|
|
|
|
| 超范围数值 | 超出业务允许范围 | `sortOrder=100` → 校验失败 |
|
|
|
|
|
|
|
|
| 非法格式 | IP地址、URL格式不正确 | `ipAddress="abc"` → 校验失败 |
|
|
|
|
|
|
|
|
| 缺失必填字段 | 请求体缺少必填字段 | 只有 `name` 没有 `deviceCode` |
|
|
|
|
|
|
|
|
| 类型不匹配 | 数字字段传字符串等 | pageSize="abc"(API层拦截) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#### C. 业务规则异常(输入合法但违反业务约束)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 场景 | 说明 | 示例 |
|
|
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
|
|
| 唯一性冲突 | 唯一键重复 | `Create(已存在的deviceCode)` → 40003 |
|
|
|
|
|
|
|
|
| 外键不存在 | 关联实体不存在 | `Create(workshopId=999)` → 外键校验失败 |
|
|
|
|
|
|
|
|
| 状态不允许操作 | 当前状态下不能执行该操作 | 删除有关联机床的车间 → 40001 |
|
|
|
|
|
|
|
|
| 资源不存在 | 操作的目标ID不存在 | `Update(id=99999)` → 40002 |
|
|
|
|
|
|
|
|
| 资源已停用 | 操作已停用的资源 | 编辑已停用的品牌(如果业务限制) |
|
|
|
|
|
|
|
|
| 跨天日期逻辑 | 开始日期 > 结束日期 | `startDate=4月30日, endDate=4月28日` → 校验失败 |
|
|
|
|
|
|
|
|
| 密码校验 | 旧密码不正确 | `changePassword(错误旧密码)` → 40001 |
|
|
|
|
|
|
|
|
| 自引用冲突 | 不能删除自己 | 不能删除正在使用的Token(如果适用) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#### D. 数据一致性(并发和状态)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 场景 | 说明 | 示例 |
|
|
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
|
|
| 并发修改 | 两人同时编辑同一条记录 | 以最后一次为准,不报错 |
|
|
|
|
|
|
|
|
| 先查后改一致性 | 查出来时有数据,改的时候数据已被删 | 返回40002而非抛空引用异常 |
|
|
|
|
|
|
|
|
| 事务完整性 | 多表操作部分失败时全部回滚 | 创建机床+绑定工人,工人绑定失败时机床也回滚 |
|
|
|
|
|
|
|
|
| 批量操作部分失败 | 批量处理中部分成功部分失败 | 全部失败或全部成功,不允许部分成功 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#### E. 边界日期和时间
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 场景 | 说明 | 示例 |
|
|
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
|
|
| 今日 | `startDate=endDate=今天` | 单天查询 |
|
|
|
|
|
|
|
|
| 昨日 | 查询昨天的数据 | 跨天边界正确 |
|
|
|
|
|
|
|
|
| 大范围 | `startDate` 和 `endDate` 差90天 | 不报错,SQL性能可接受 |
|
|
|
|
|
|
|
|
| 跨月 | 查询范围跨越月份分区 | 分区表查询正确(4月30日~5月2日) |
|
|
|
|
|
|
|
|
| 日期为null | 可选日期参数不传 | 使用默认值(今日) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#### F. 安全性
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 场景 | 说明 | 示例 |
|
|
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
|
|
| SQL注入 | 参数含SQL片段 | `keyword="' OR 1=1 --"` → 不影响查询 |
|
|
|
|
|
|
|
|
| XSS | 参数含脚本标签 | `name="<script>alert(1)</script>"` → 原样存储不执行 |
|
|
|
|
|
|
|
|
| 敏感字段脱敏 | Token/密码字段返回时遮蔽 | `api_token` 返回 `"********"` |
|
|
|
|
|
|
|
|
| 无Token访问 | 未登录访问管理后台API | 返回 40101 |
|
|
|
|
|
|
|
|
| 无效Token | Token过期或伪造 | 返回 40101 |
|
|
|
|
|
|
|
|
| 大屏免认证 | `/api/screen/**` 不需要Token | 正常返回数据 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#### G. 分页参数
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 场景 | 说明 | 示例 |
|
|
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
|
|
| 默认分页 | 不传page/pageSize | 使用默认值 page=1, pageSize=20 |
|
|
|
|
|
|
|
|
| 超大页码 | `page=99999` 无数据 | 返回 `items=[], total=原始总数` |
|
|
|
|
|
|
|
|
| page=0 或负数 | 非法页码 | 自动修正为 page=1 |
|
|
|
|
|
|
|
|
| pageSize超大 | `pageSize=9999` | 限制最大100 |
|
|
|
|
|
|
|
|
| pageSize=0 或负数 | 非法pageSize | 自动修正为默认20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 6.7 测试场景检查模板
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
每个方法编写测试前,填写此检查清单,标注 ✅已覆盖 / ❌不适用:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
方法:MachineService.Create(CreateMachineRequest request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
A. 正常路径
|
|
|
|
|
|
|
|
✅ 标准输入 → 成功返回新建ID
|
|
|
|
|
|
|
|
✅ 字符串含中文和特殊字符 → 正常存储
|
|
|
|
|
|
|
|
❌ 边界值-最大长度(在DTO校验测试中覆盖)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
B. 参数校验异常
|
|
|
|
|
|
|
|
✅ request为null → ArgumentNullException
|
|
|
|
|
|
|
|
✅ deviceCode为空 → 校验失败
|
|
|
|
|
|
|
|
✅ name为纯空格 → 校验失败
|
|
|
|
|
|
|
|
✅ ipAddress格式错误 → 校验失败
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
C. 业务规则异常
|
|
|
|
|
|
|
|
✅ deviceCode已存在 → BusinessException 40003
|
|
|
|
|
|
|
|
✅ workshopId不存在 → BusinessException 40002
|
|
|
|
|
|
|
|
✅ collectAddressId不存在 → BusinessException 40002
|
|
|
|
|
|
|
|
✅ brandId不存在 → BusinessException 40002
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
D. 数据一致性
|
|
|
|
|
|
|
|
✅ 事务回滚(机床创建成功但后续操作失败)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
E. 日期时间
|
|
|
|
|
|
|
|
❌ 不涉及
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
F. 安全性
|
|
|
|
|
|
|
|
✅ deviceCode含SQL注入片段 → 不影响
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
G. 分页
|
|
|
|
|
|
|
|
❌ 不涉及(非查询方法)
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 6.8 参数化测试(减少重复代码)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
对于同一方法的多种输入组合,使用 `[Theory]` + `[InlineData]` 合并:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
|
|
/// 参数校验:必填字段为空或空白时必须抛出异常
|
|
|
|
|
|
|
|
/// 覆盖场景:null、空字符串、纯空格
|
|
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
|
|
[Theory]
|
|
|
|
|
|
|
|
[InlineData(null)]
|
|
|
|
|
|
|
|
[InlineData("")]
|
|
|
|
|
|
|
|
[InlineData(" ")]
|
|
|
|
|
|
|
|
public void Create_WithBlankName_ThrowsArgumentException(string name)
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
var request = new CreateWorkshopRequest { Name = name, SortOrder = 1 };
|
|
|
|
|
|
|
|
var ex = Assert.Throws<ArgumentException>(() => _service.Create(request));
|
|
|
|
|
|
|
|
Assert.Contains("车间名称", ex.Message);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
|
|
/// 唯一性校验:不同车间名称各创建一次均成功,重复名称抛40003
|
|
|
|
|
|
|
|
/// 覆盖场景:正常创建、重复名称
|
|
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
|
|
public void Create_WithDuplicateName_Throws40003()
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
_mockRepo.Setup(r => r.GetByName("A栋")).Returns((Workshop)null);
|
|
|
|
|
|
|
|
_mockRepo.Setup(r => r.GetByName("B栋")).Returns((Workshop)null);
|
|
|
|
|
|
|
|
_mockRepo.Setup(r => r.GetByName("A栋")).Returns(new Workshop { Name = "A栋" });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 第一次创建成功
|
|
|
|
|
|
|
|
_service.Create(new CreateWorkshopRequest { Name = "A栋", SortOrder = 1 });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 第二次创建同名 → 异常
|
|
|
|
|
|
|
|
var ex = Assert.Throws<BusinessException>(() =>
|
|
|
|
|
|
|
|
_service.Create(new CreateWorkshopRequest { Name = "A栋", SortOrder = 2 }));
|
|
|
|
|
|
|
|
Assert.Equal(40003, ex.Code);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 6.9 Repository 层测试(真实数据库)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Repository 测试使用**真实 MariaDB 测试库**,不走 Mock:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
测试库:cnc_business_test / cnc_log_test
|
|
|
|
|
|
|
|
原则:每个测试方法前 TRUNCATE 相关表,插入已知数据,执行测试,验证结果
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
|
|
|
public class MachineRepositoryTests : IDisposable
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
private readonly MySqlConnection _conn;
|
|
|
|
|
|
|
|
private readonly MachineRepository _repo;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public MachineRepositoryTests()
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
_conn = new MySqlConnection("Server=localhost;Database=cnc_business_test;Uid=root;Pwd=root;");
|
|
|
|
|
|
|
|
_conn.Open();
|
|
|
|
|
|
|
|
_repo = new MachineRepository(_conn);
|
|
|
|
|
|
|
|
// 清空测试表,插入基础数据
|
|
|
|
|
|
|
|
CleanTestData();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
|
|
public void GetById_ExistingMachine_ReturnsCorrectEntity()
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
// Arrange
|
|
|
|
|
|
|
|
_conn.Execute("INSERT INTO cnc_machine (id, device_code, name, ...) VALUES (1, 'test_001', '测试机床', ...)");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
|
|
|
var machine = _repo.GetById(1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
|
|
|
Assert.NotNull(machine);
|
|
|
|
|
|
|
|
Assert.Equal("test_001", machine.DeviceCode);
|
|
|
|
|
|
|
|
Assert.Equal("测试机床", machine.Name);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
|
|
public void GetById_NotExists_ReturnsNull()
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
var machine = _repo.GetById(99999);
|
|
|
|
|
|
|
|
Assert.Null(machine);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public void Dispose()
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
CleanTestData();
|
|
|
|
|
|
|
|
_conn?.Close();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 6.10 禁止的测试行为
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 禁止 | 原因 |
|
|
|
|
|
|
|
|
|------|------|
|
|
|
|
|
|
|
|
| `Assert.True(true)` | 无意义的断言,自欺欺人 |
|
|
|
|
|
|
|
|
| 只测正常路径不测异常 | 隐藏生产环境Bug |
|
|
|
|
|
|
|
|
| 测试方法间有执行顺序依赖 | 并行执行时全部失败 |
|
|
|
|
|
|
|
|
| Mock了被测方法本身 | 测了个寂寞 |
|
|
|
|
|
|
|
|
| 用 `// TODO: 补充测试` 跳过 | 不允许提交未完成的测试 |
|
|
|
|
|
|
|
|
| 删除测试来"通过"构建 | 禁止,必须修复 |
|
|
|
|
|
|
|
|
| 忽略 `[Fact(Skip="...")]` | 禁止跳过测试 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 七、依赖注入
|
|
|
|
## 七、依赖注入
|
|
|
|
|
|
|
|
|
|
|
|
使用 Unity 或手动实现简易 DI 容器(VS2017 默认不支持 .NET Core 风格的内置DI)。
|
|
|
|
使用 Unity 或手动实现简易 DI 容器(VS2017 默认不支持 .NET Core 风格的内置DI)。
|
|
|
|
|