从 origin/glms/feature/refactor-swap-event-testable 的 docs/seams-pattern-practice-guide.md 借鉴。 内容:Protected Virtual Seams 模式方法论、依赖类型提取策略表、可测试子类模式、 三层测试体系(合成+黄金+端到端)、命名约定、渐进节奏、6条经验教训。 与当前分支已采用的 seam 模式(SwapDealService/SwapEodPositionService)一致,作团队规范。
7.6 KiB
7.6 KiB
Seams 模式实践指南 — 遗留代码可测试化方法论
背景
本指南基于互换合约交易系统(OTC衍生品后台)的实际改造经验提炼。目标是在不改变生产行为的前提下,让 LLM 能够验证核心业务逻辑的正确性。
核心模式:Protected Virtual Seams
原理
遗留代码的典型问题:业务逻辑与基础设施(数据库、缓存、外部服务)紧耦合。直接单元测试需要真实数据库,成本极高。
Seams 模式的解法:
- 识别方法中的外部依赖点
- 提取
protected virtual方法作为"接缝" - 默认实现保持原有行为
- 测试时创建子类 override,注入内存数据
// 生产代码:添加虚方法
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 虚方法 |
可测试子类模式
构造函数注入
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);
}
输出捕获
public List<eod_swap_position> CreatedEodPositions { get; } = new();
protected override void PersistEodSwapPosition(eod_swap_position position)
=> CreatedEodPositions.Add(position);
事务 No-op
protected override void ExecuteInTransaction(Action action)
=> action(); // 测试中跳过事务
委托参数过多时用自定义委托
// 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,回放对比 |
| 端到端测试 | 验证方法间协作 | 调用主入口方法,验证全流程 |
合成测试模式
[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);
}
黄金文件格式
{
"Scenario": "有平仓事件→更新",
"ScenarioId": 3,
"TradeDate": "2025-04-24T00:00:00",
"Description": "...",
"SwapTradeId": 100,
"Positions": [...],
"PrevEodPositions": [...],
"FlowEvents": [...],
"ExpectedCreatedPositions": [...],
"ExpectError": false
}
回放测试框架
[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 的改造步骤
- 分析:列出方法清单,统计硬依赖密度(deps/100行),选择 ROI 最高的方法
- 提取:添加
#region 可测试化,逐一提取虚方法 - 重构:将硬依赖调用替换为虚方法调用
- 子类:创建 TestableXxxService,override 虚方法
- 测试:编写合成测试 → 黄金文件 → 回放测试
- 验证:全量测试通过,无回归
虚方法命名约定
| 类型 | 命名 | 示例 |
|---|---|---|
| 查询 | 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) |
经验教训
- 先识别路径再提取:DealFloatPositions 有 3 条路径、DealInterests 有 5 条路径,理解路由逻辑后才知道哪些虚方法可以复用
- 黄金文件需要 rebuild 才生效:JSON 资源文件通过 csproj 的 CopyToOutputDirectory 复制,修改后需要 rebuild
- 枚举值别靠记忆:平仓=2、互换=3、自动互换=4 — 写黄金文件时查枚举定义
- Func 参数上限:C# Func 最多 16 个类型参数,超出用自定义 delegate
- swap_position.SwapIntervalList 是只读:从 InterestSwapInterval JSON 反序列化,不能直接赋值
- 匹配键是 id 不是 PositionId:DealFloatPositions 用
posi.id匹配 eodPosition 和 flowEvent