Files
zszq-trs/项目文档/互换模块可测性改造Seam实践指南.md
hjhan 178f1d7db8 docs(swap): 借鉴testable分支的Seam实践指南(团队规范沉淀)
从 origin/glms/feature/refactor-swap-event-testable 的 docs/seams-pattern-practice-guide.md 借鉴。
内容:Protected Virtual Seams 模式方法论、依赖类型提取策略表、可测试子类模式、
三层测试体系(合成+黄金+端到端)、命名约定、渐进节奏、6条经验教训。
与当前分支已采用的 seam 模式(SwapDealService/SwapEodPositionService)一致,作团队规范。
2026-07-03 09:05:37 +08:00

7.6 KiB
Raw Permalink Blame History

Seams 模式实践指南 — 遗留代码可测试化方法论

背景

本指南基于互换合约交易系统(OTC衍生品后台)的实际改造经验提炼。目标是在不改变生产行为的前提下,让 LLM 能够验证核心业务逻辑的正确性。

核心模式:Protected Virtual Seams

原理

遗留代码的典型问题:业务逻辑与基础设施(数据库、缓存、外部服务)紧耦合。直接单元测试需要真实数据库,成本极高。

Seams 模式的解法:

  1. 识别方法中的外部依赖点
  2. 提取 protected virtual 方法作为"接缝"
  3. 默认实现保持原有行为
  4. 测试时创建子类 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: 横向扩展到其他 ServiceSwapDealService, SwapTradeService

每个 Service 的改造步骤

  1. 分析:列出方法清单,统计硬依赖密度(deps/100行),选择 ROI 最高的方法
  2. 提取:添加 #region 可测试化,逐一提取虚方法
  3. 重构:将硬依赖调用替换为虚方法调用
  4. 子类:创建 TestableXxxServiceoverride 虚方法
  5. 测试:编写合成测试 → 黄金文件 → 回放测试
  6. 验证:全量测试通过,无回归

虚方法命名约定

类型 命名 示例
查询 FindXxx FindTrade, FindEodSwapPositions
持久化 PersistXxx PersistEodSwapPosition, PersistFlowEvent
计算 CalcXxx / GetXxx CalcBondPayment, GetCurrencyRate
操作 ExecuteXxx / SaveXxx ExecuteInTransaction, SaveAllChanges
包装 XxxWrapper InitInterestDateWrapper

实际成果

SwapEodPositionServiceDay 5a-5d

指标 数值
虚方法数 29
重构方法数 7
合成测试 24
黄金文件 18
端到端测试 6
总测试数 6556 passed + 9 skipped recording

SwapDealServiceDay 6a

指标 数值
虚方法数 2
合成测试 6
总测试数 7162 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 不是 PositionIdDealFloatPositions 用 posi.id 匹配 eodPosition 和 flowEvent