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:
hjhan
2026-07-03 09:05:37 +08:00
parent e4a92c93ee
commit 178f1d7db8
@@ -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: 横向扩展到其他 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 不是 PositionId**DealFloatPositions 用 `posi.id` 匹配 eodPosition 和 flowEvent