docs(swap): 借鉴testable分支的Seam实践指南(团队规范沉淀)
从 origin/glms/feature/refactor-swap-event-testable 的 docs/seams-pattern-practice-guide.md 借鉴。 内容:Protected Virtual Seams 模式方法论、依赖类型提取策略表、可测试子类模式、 三层测试体系(合成+黄金+端到端)、命名约定、渐进节奏、6条经验教训。 与当前分支已采用的 seam 模式(SwapDealService/SwapEodPositionService)一致,作团队规范。
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
# Seams 模式实践指南 — 遗留代码可测试化方法论
|
||||
|
||||
## 背景
|
||||
|
||||
本指南基于互换合约交易系统(OTC衍生品后台)的实际改造经验提炼。目标是在不改变生产行为的前提下,让 LLM 能够验证核心业务逻辑的正确性。
|
||||
|
||||
## 核心模式:Protected Virtual Seams
|
||||
|
||||
### 原理
|
||||
|
||||
遗留代码的典型问题:业务逻辑与基础设施(数据库、缓存、外部服务)紧耦合。直接单元测试需要真实数据库,成本极高。
|
||||
|
||||
**Seams 模式**的解法:
|
||||
1. 识别方法中的外部依赖点
|
||||
2. 提取 `protected virtual` 方法作为"接缝"
|
||||
3. 默认实现保持原有行为
|
||||
4. 测试时创建子类 override,注入内存数据
|
||||
|
||||
```csharp
|
||||
// 生产代码:添加虚方法
|
||||
protected virtual trade FindTrade(int swapTradeId)
|
||||
=> DbContext.trade.Find(swapTradeId);
|
||||
|
||||
// 测试代码:override 返回内存数据
|
||||
protected override trade FindTrade(int swapTradeId)
|
||||
=> _trades.TryGetValue(swapTradeId, out var t) ? t : null;
|
||||
```
|
||||
|
||||
**关键原则:最小侵入**
|
||||
- 不改变方法签名
|
||||
- 不改变访问修饰符(除了 private → protected)
|
||||
- 不引入新接口或依赖注入框架
|
||||
- 默认实现完全等价于原有行为
|
||||
|
||||
### 依赖类型与提取策略
|
||||
|
||||
| 依赖类型 | 示例 | 提取策略 |
|
||||
|----------|------|---------|
|
||||
| DbContext 查询 | `DbContext.trade.Where(predicate)` | 提取为 `FindXxx()` 虚方法 |
|
||||
| DbContext 写入 | `DbContext.eod_swap_position.Add(entity)` | 提取为 `PersistXxx()` 虚方法 |
|
||||
| DbContext 事务 | `DbContext.Database.BeginTransaction()` | 提取为 `ExecuteInTransaction(Action)` |
|
||||
| DbContext 保存 | `DbContext.SaveChanges()` | 提取为 `SaveAllChanges()` |
|
||||
| new XxxService() | `new EodCurrencyRateService(UserInfo).GetRate()` | 提取为业务语义虚方法如 `GetCurrencyRate()` |
|
||||
| 静态方法 | `EodPriceQueryService.TryGetPrice()` | 提取为虚方法包装 |
|
||||
| DataCacheProvider | `DataCacheProvider.GetUnderlyingDataSource()` | 提取为 `GetUnderlyingData()` |
|
||||
| 基类非虚方法 | `base.InitInterestDate()` | 提取为 Wrapper 虚方法 |
|
||||
|
||||
## 可测试子类模式
|
||||
|
||||
### 构造函数注入
|
||||
|
||||
```csharp
|
||||
public class TestableSwapEodPositionService : SwapEodPositionService
|
||||
{
|
||||
private readonly Func<string, DateTime, decimal> _priceProvider;
|
||||
private readonly Func<decimal> _currencyRateProvider;
|
||||
|
||||
public TestableSwapEodPositionService(
|
||||
OptUserInfo optUser,
|
||||
Func<string, DateTime, decimal> priceProvider = null,
|
||||
Func<decimal> currencyRateProvider = null
|
||||
) : base(optUser)
|
||||
{
|
||||
_priceProvider = priceProvider ?? ((code, date) => 100m);
|
||||
_currencyRateProvider = currencyRateProvider ?? (() => 1m);
|
||||
}
|
||||
|
||||
protected override decimal GetUnderlyingPrice(string code, DateTime date)
|
||||
=> _priceProvider(code, date);
|
||||
}
|
||||
```
|
||||
|
||||
### 输出捕获
|
||||
|
||||
```csharp
|
||||
public List<eod_swap_position> CreatedEodPositions { get; } = new();
|
||||
|
||||
protected override void PersistEodSwapPosition(eod_swap_position position)
|
||||
=> CreatedEodPositions.Add(position);
|
||||
```
|
||||
|
||||
### 事务 No-op
|
||||
|
||||
```csharp
|
||||
protected override void ExecuteInTransaction(Action action)
|
||||
=> action(); // 测试中跳过事务
|
||||
```
|
||||
|
||||
### 委托参数过多时用自定义委托
|
||||
|
||||
```csharp
|
||||
// Func 最多16个类型参数,超出时用 delegate
|
||||
public delegate bool InterestDateDelegate(
|
||||
DateTime? preSettleDate, DateTime valueDate, trade td,
|
||||
bool tdClose, bool calcLastNew,
|
||||
out DateTime interestStart, out DateTime interestEnd);
|
||||
```
|
||||
|
||||
## 测试体系设计
|
||||
|
||||
### 三层测试
|
||||
|
||||
| 层级 | 目的 | 特点 |
|
||||
|------|------|------|
|
||||
| 合成测试 | 验证业务逻辑正确性 | 程序化构造输入,断言输出字段 |
|
||||
| 黄金文件 | 回归保护 | JSON 存储 input/output,回放对比 |
|
||||
| 端到端测试 | 验证方法间协作 | 调用主入口方法,验证全流程 |
|
||||
|
||||
### 合成测试模式
|
||||
|
||||
```csharp
|
||||
[TestMethod]
|
||||
public void Scenario3_HasPrevEod_HasCloseEvent_ShouldUpdatePosition()
|
||||
{
|
||||
// Arrange
|
||||
var prevEod = CreateEodPosition(positionId: 1, qty: 1000, grossPrice: 1.0020m);
|
||||
var service = CreateService();
|
||||
var positions = new List<swap_position> { CreatePosition(id: 1, qty: 1000) };
|
||||
var flowEvents = new List<swap_flow_event>
|
||||
{
|
||||
CreateCloseFlowEvent(positionId: 1, qty: 400, markClosePnl: 500m)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = service.ExecuteDealFloatPositions(
|
||||
positions, new List<swap_position>(),
|
||||
new List<eod_swap_position> { prevEod }, new List<eod_swap_position>(),
|
||||
SettleDate, CreateTrade(), PreSettleDate, flowEvents);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual(600, result[0].PosiQuantity, "1000-400=600");
|
||||
Assert.AreEqual(400, result[0].TdCloseQty);
|
||||
}
|
||||
```
|
||||
|
||||
### 黄金文件格式
|
||||
|
||||
```json
|
||||
{
|
||||
"Scenario": "有平仓事件→更新",
|
||||
"ScenarioId": 3,
|
||||
"TradeDate": "2025-04-24T00:00:00",
|
||||
"Description": "...",
|
||||
"SwapTradeId": 100,
|
||||
"Positions": [...],
|
||||
"PrevEodPositions": [...],
|
||||
"FlowEvents": [...],
|
||||
"ExpectedCreatedPositions": [...],
|
||||
"ExpectError": false
|
||||
}
|
||||
```
|
||||
|
||||
### 回放测试框架
|
||||
|
||||
```csharp
|
||||
[TestMethod]
|
||||
public void ReplayAllGoldenFiles()
|
||||
{
|
||||
var files = Directory.GetFiles(GoldenDir, "*.json");
|
||||
foreach (var file in files)
|
||||
{
|
||||
var golden = Deserialize(file);
|
||||
var service = CreateService(golden);
|
||||
var result = Execute(service, golden);
|
||||
AssertResults(result, golden.ExpectedCreatedPositions);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 渐进式改造节奏
|
||||
|
||||
### 推荐:由内而外,逐层深入
|
||||
|
||||
```
|
||||
Week 1: 子方法可测试化(ComposePage, DealFloatPositions, DealInterests)
|
||||
Week 2: 主入口方法可测试化(SwapPositionCompose 端到端)
|
||||
Week 3: 横向扩展到其他 Service(SwapDealService, SwapTradeService)
|
||||
```
|
||||
|
||||
### 每个 Service 的改造步骤
|
||||
|
||||
1. **分析**:列出方法清单,统计硬依赖密度(deps/100行),选择 ROI 最高的方法
|
||||
2. **提取**:添加 `#region 可测试化`,逐一提取虚方法
|
||||
3. **重构**:将硬依赖调用替换为虚方法调用
|
||||
4. **子类**:创建 TestableXxxService,override 虚方法
|
||||
5. **测试**:编写合成测试 → 黄金文件 → 回放测试
|
||||
6. **验证**:全量测试通过,无回归
|
||||
|
||||
### 虚方法命名约定
|
||||
|
||||
| 类型 | 命名 | 示例 |
|
||||
|------|------|------|
|
||||
| 查询 | `FindXxx` | `FindTrade`, `FindEodSwapPositions` |
|
||||
| 持久化 | `PersistXxx` | `PersistEodSwapPosition`, `PersistFlowEvent` |
|
||||
| 计算 | `CalcXxx` / `GetXxx` | `CalcBondPayment`, `GetCurrencyRate` |
|
||||
| 操作 | `ExecuteXxx` / `SaveXxx` | `ExecuteInTransaction`, `SaveAllChanges` |
|
||||
| 包装 | `XxxWrapper` | `InitInterestDateWrapper` |
|
||||
|
||||
## 实际成果
|
||||
|
||||
### SwapEodPositionService(Day 5a-5d)
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 虚方法数 | 29 |
|
||||
| 重构方法数 | 7 |
|
||||
| 合成测试 | 24 |
|
||||
| 黄金文件 | 18 |
|
||||
| 端到端测试 | 6 |
|
||||
| 总测试数 | 65(56 passed + 9 skipped recording) |
|
||||
|
||||
### SwapDealService(Day 6a)
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 虚方法数 | 2 |
|
||||
| 合成测试 | 6 |
|
||||
| 总测试数 | 71(62 passed + 9 skipped) |
|
||||
|
||||
## 经验教训
|
||||
|
||||
1. **先识别路径再提取**:DealFloatPositions 有 3 条路径、DealInterests 有 5 条路径,理解路由逻辑后才知道哪些虚方法可以复用
|
||||
2. **黄金文件需要 rebuild 才生效**:JSON 资源文件通过 csproj 的 CopyToOutputDirectory 复制,修改后需要 rebuild
|
||||
3. **枚举值别靠记忆**:平仓=2、互换=3、自动互换=4 — 写黄金文件时查枚举定义
|
||||
4. **Func 参数上限**:C# Func 最多 16 个类型参数,超出用自定义 delegate
|
||||
5. **swap_position.SwapIntervalList 是只读**:从 InterestSwapInterval JSON 反序列化,不能直接赋值
|
||||
6. **匹配键是 id 不是 PositionId**:DealFloatPositions 用 `posi.id` 匹配 eodPosition 和 flowEvent
|
||||
Reference in New Issue
Block a user