# 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