diff --git a/项目文档/互换模块可测性改造Seam实践指南.md b/项目文档/互换模块可测性改造Seam实践指南.md new file mode 100644 index 00000000..4140d0e4 --- /dev/null +++ b/项目文档/互换模块可测性改造Seam实践指南.md @@ -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 _priceProvider; + private readonly Func _currencyRateProvider; + + public TestableSwapEodPositionService( + OptUserInfo optUser, + Func priceProvider = null, + Func 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 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 { CreatePosition(id: 1, qty: 1000) }; + var flowEvents = new List + { + CreateCloseFlowEvent(positionId: 1, qty: 400, markClosePnl: 500m) + }; + + // Act + var result = service.ExecuteDealFloatPositions( + positions, new List(), + new List { prevEod }, new List(), + 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