如何在遗留系统迁移中运用差异化测试(Differential Testing)
遗留系统迁移中最危险的时刻,往往不是开始编写新实现方案时,而是当新实现方案看起来已经完工之际。
代码编译通过,测试全部通过,架构更加清晰,新服务的响应速度也更快了。
这时,所有人都会问起同一个问题:
我们现在可以切换流量了吗?
正是这一刻,让人难以产生信心。
新实现方案虽然通过了自己的测试套件,但仍可能与被其替代的旧系统表现出不同的行为。
也许舍入方式发生了变化,或者空值处理方式不同,或者原本报错的情况现在变成了成功响应。
也许记录排序方式变了,或者副作用的执行顺序不同,又或许是某条从未被文档记录的规则在迁移过程中丢失了。
因此,在遗留系统迁移过程中,我倾向于寻找另一个证据来源:使用相同的输入分别运行新旧实现方案,并比较它们的行为。
这正是差异化测试的基本思想。除了询问新系统是否通过测试外,你还需要追问:在给定相同输入的情况下,新系统在哪里与旧系统表现不同?
这些差异就成了证据。
有些差异是 Bug,有些是有意为之的改进,有些是无害的表达差异,还有些揭示了连当事人都未曾意识到的行为。
在本教程中,我将展示如何在遗留系统迁移中运用差异化测试,以实现:
比较新旧实现方案
定义何为等价
在比较前对输出进行归一化
处理时间戳及其他非确定性值
比较错误和副作用
自动运行差异化测试
在精确相等无意义的地方引入容差机制
分析不匹配项
在生产环境中安全地使用影子流量
利用 AI 对分歧进行分类,但不让 AI 决定正确性
判断新实现方案何时具备切换条件
示例基于 TypeScript 和 Vitest,但该思路适用于大多数语言和迁移策略。
目标不是证明两套实现内部完全一致,而是获取证据,证明它们在重要的地方行为等价。
前置要求
要跟上本文内容,你需要熟悉:
TypeScript 或类似语言
单元测试和集成测试
异步代码
API 和服务边界
遗留系统现代化
基本的可观测性概念
你还应该对要迁移的功能有一定了解。
理想情况下,你清楚它的输入、输出、关键业务规则、外部契约、副作用,以及已知的不确定环节。
差异化测试(Differential Testing)最好是在你已经为待迁移功能划出边界之后再进行。
目录
差分测试实际提供了什么信息
假设你的遗留应用程序负责计算订单的最终价格。
遗留实现如下:
type Order = {
subtotal: number;
customerType: "STANDARD" | "PREMIUM";
country: string;
};
function legacyCalculateTotal(order: Order): number {
let total = order.subtotal;
if (order.customerType === "PREMIUM") {
total *= 0.9;
}
if (order.country === "AR") {
total -= 500;
}
return Math.max(total, 0);
}
迁移过程中,你编写了一个新实现:
function newCalculateTotal(order: Order): number {
const premiumDiscount =
order.customerType === "PREMIUM"
? order.subtotal * 0.1
: 0;
const countryAdjustment =
order.country === "AR"
? 500
: 0;
return Math.max(
order.subtotal -
premiumDiscount -
countryAdjustment,
0
);
}
两种实现的代码写法不同。这没关系。关键在于它们的行为是否等效。
一个简单的差分测试可以运行这两个版本:
import { describe, expect, it } from "vitest";
describe("order total migration", () => {
it("matches the legacy implementation", () => {
const order: Order = {
subtotal: 10000,
customerType: "PREMIUM",
country: "AR",
};
const legacy =
legacyCalculateTotal(order);
const migrated =
newCalculateTotal(order);
expect(migrated).toBe(legacy);
});
});
对于这个输入:
legacy → 8500
new → 8500
很好。但仅仅一个匹配的示例证明不了什么。
其价值在于系统地追问:
相同输入
↓
旧实现 ──→ 结果 A
相同输入
↓
新实现 ──→ 结果 B
比较 A 和 B
每一次不匹配都提供了值得排查的线索。
从单一可观测边界入手
不要一开始就对比整个应用。开始时,选择一个能力即可。
例如:
计算订单总额
生成发票
审批客户
续订订阅
计算佣金
创建发货单
假设迁移边界如下:
interface OrderProcessor {
process(order: Order): Promise<ProcessedOrder>;
}
现在你有了两个实现:
LegacyOrderProcessor
NewOrderProcessor
这是一个有用的差异化边界,因为两者接收相同的概念输入,并产生相同的概念输出。
你可以比较它们,而无需要求内部架构一致。
这一点很重要,因为迁移通常会有意改变结构。
旧实现可能是:
controller
→ service
→ SQL
→ provider SDK
而新实现可能是:
use case
→ repository
→ gateway
→ events
差异化测试不应关心这些。它只关心可观测的行为。
使用相同输入运行旧实现和新实现
假设两个实现都暴露:
interface OrderProcessor {
process(order: Order): Promise<ProcessedOrder>;
}
你可以创建:
const legacyProcessor =
new LegacyOrderProcessor();
const newProcessor =
new NewOrderProcessor();
然后:
it("produces the same processed order", async () => {
const input: Order = {
id: "order-1",
subtotal: 10000,
customerType: "PREMIUM",
country: "US",
};
const legacy =
await legacyProcessor.process(
structuredClone(input)
);
const migrated =
await newProcessor.process(
structuredClone(input)
);
expect(migrated).toEqual(legacy);
});
注意这里使用了:
structuredClone(input)
如果任何一方的实现会修改输入对象,这一步就很关键。
如果不分别创建副本,第一次执行可能会影响第二次执行的结果。
你需要的是:
same initial state
而不是:
new implementation receives state modified by legacy implementation
这种数据污染会导致测试结果产生误导。
不要盲目比较原始输出
差分测试的初版往往就是这样写的:
expect(newResult).toEqual(legacyResult);
有时这正是对的,但有时却是错的。
假设旧系统返回:
{
"id": "order-1",
"total": 9000,
"status": "PROCESSED",
"generatedAt": "2026-09-09T10:00:01.231Z",
"requestId": "legacy-f93a"
}
新系统返回:
{
"requestId": "new-b517",
"status": "PROCESSED",
"generatedAt": "2026-09-09T10:00:01.416Z",
"total": 9000,
"id": "order-1"
}
直接比较对象可能会失败,因为:
requestId differs
timestamp differs
但从业务角度看,两者的行为可能是等价的。
你需要判断哪些字段属于真正有意义的契约。
也许:
id
total
status
这些字段是关键。
而:
generatedAt
requestId
这些字段不需要完全一致。
这就引出了归一化。
比较前先对值做归一化
归一化是指在比较之前,先把两边的输出转换成统一的表示形式。
目的不是改变数据的业务含义,而是去除那些预期存在、且与比较无关的差异——比如自动生成的 requestId 或时间戳——让测试聚焦于真正定义你所关心行为的字段。
在实际操作中,这通常意味着创建一个规范表示形式:一个更小、更稳定的结构,仅包含你希望比较的核心字段。
例如:
type ProcessedOrder = {
id: string;
total: number;
status: string;
generatedAt: string;
requestId: string;
};
function normalizeOrder(
order: ProcessedOrder
) {
return {
id: order.id,
total: order.total,
status: order.status,
};
}
这里,ProcessedOrder 既包含与业务相关的字段,也包含可能在不同执行间合理存在差异的值。
normalizeOrder() 函数保留 id、total 和 status,而忽略 generatedAt 和 requestId。这意味着,即使两个结果生成的时间略有不同或使用了不同的请求标识符,它们仍可被视为等价。
现在进行对比:
expect(
normalizeOrder(migrated)
).toEqual(
normalizeOrder(legacy)
);
这让等价规则变得明确。
你实际上是在说:
这些字段定义了此次比较中相关的行为。
规范化还可以处理:
排序
大小写
可选字段
时间戳
生成的标识符
数字格式化
特定提供商的元数据
但规范化必须是审慎的。移除过多内容可能会掩盖真正的迁移缺陷。
处理时间戳及其他非确定性值
遗留系统包含许多非确定性值。
例如:
timestamps
UUIDs
random tokens
request IDs
trace IDs
database-generated IDs
unordered collections
provider-generated references
如果精确比较这些值,你的差分测试套件可能会持续失败。
一种选择是依赖控制。
依赖控制意味着将非确定性源(如当前时间或 ID 生成器)移到一个接口背后,以便在测试期间替换。
不要让每个实现独立读取真实时钟,而是向两者注入同一个受控时钟。这样它们就会得到相同的值,从而消除时间本身作为无意义差异来源的影响。
假设代码使用的是:
new Date()
你可以用时钟接口替换该依赖:
interface Clock {
now(): Date;
}
随后,两个实现都接收同一个时钟实例:
const clock = {
now: () =>
new Date(
"2026-09-09T10:00:00.000Z"
),
};
这样一来,时间就是确定的了。
同样的技术也适用于 ID 生成:
interface IdGenerator {
next(): string;
}
测试代码可以提供固定的生成器:
const ids = {
next: () => "fixed-id",
};
如果控制非确定性行为不切实际,仅当它不属于你需要保护的行为时,再将其归一化消除。
对比业务含义,而非仅仅是 JSON
两个系统可以返回不同的表示形式,但表达相同的业务状态。
假设你的遗留系统中有这样的数据:
{
"status": 2
}
而新系统中是这样的:
{
"status": "APPROVED"
}
直接对比结果是:
不同
但业务对比结果可能是:
等价
你可以创建一个语义归一化器:
function normalizeStatus(
status: number | string
) {
if (status === 2) {
return "APPROVED";
}
return status;
}
这里,归一化器将遗留系统中的数字值 2 翻译为新实现所使用的业务含义:"APPROVED"。
这并不意味着所有数字和字符串都可以互换。它只是编码了一条你已确认为该迁移有效的显式等价规则。
随后使用:
expect(
normalizeStatus(newResult.status)
).toBe(
normalizeStatus(legacyResult.status)
);
当迁移过程中有意改变了以下内容时,这种方法特别有用:
数据库结构
API 表示形式
枚举类型
供应商特定格式
内部标识符
关键问题变成了:
可观察的业务含义是否保持等价?
而不是:
字节是否完全一致?
将错误视为契约的一部分进行对比
成功响应并不代表完整的行为,错误同样重要。
假设遗留实现在客户缺失时抛出异常:
throw new Error("Customer not found");
新实现错误地返回了:
return null;
面对同样的非法输入,这两个实现的行为截然不同。
旧版本会显式失败,而新版本会静默返回一个值,调用方可能把它当成成功的结果。
如果你的差分测试只覆盖存在有效客户的场景,两个实现看起来就是等价的,这种契约变更也就无从暴露。
所以失败行为也必须纳入对比。
写一些捕获错误的用例:
async function captureResult<T>(
operation: () => Promise<T>
) {
try {
return {
type: "success" as const,
value: await operation(),
};
} catch (error) {
return {
type: "error" as const,
error:
error instanceof Error
? error.message
: String(error),
};
}
}
这个工具函数包装一个异步操作,把两种可能的结果都转化为数据。
操作成功时,返回带 type: "success" 和返回值的对象;操作抛异常时,catch 块会把异常转化为带 type: "error" 和可读错误信息的对象。
这样两个实现就有了统一的对比结构,测试可以显式比较成功与失败,而不是让异常在两种行为还没来得及评估时就中断测试。
然后:
const legacy =
await captureResult(() =>
legacyProcessor.process(input)
);
const migrated =
await captureResult(() =>
newProcessor.process(input)
);
expect(migrated.type).toBe(legacy.type);
如果错误在契约上很重要,还应比较:
错误类别
HTTP 状态码
错误码
是否可重试
校验详情
除非客户端依赖具体文案,否则不必逐字比较错误信息。
副作用也要比较
迁移中最容易犯的错误之一,就是保住了返回值却丢掉了副作用。
假设两个实现都返回:
{
"status": "PROCESSED"
}
但旧版本还会:
持久化订单
发布事件
创建支付
写入审计日志
而新版本漏掉了写审计日志。
只看响应的差分测试发现不了这个问题,所以你还需要捕获副作用。
举例来说:
type Effect =
| {
type: "payment";
orderId: string;
amount: number;
}
| {
type: "event";
name: string;
orderId: string;
};
测试适配器可以记录它们:
class RecordingPaymentGateway {
effects: Effect[] = [];
async charge(
orderId: string,
amount: number
) {
this.effects.push({
type: "payment",
orderId,
amount,
});
}
}
该适配器不会发送真实的支付请求,而是将应用尝试执行的操作记录在 effects 数组中。
同样的思路可以应用于事件发布:
class RecordingEvents {
effects: Effect[] = [];
async publish(
name: string,
orderId: string
) {
this.effects.push({
type: "event",
name,
orderId,
});
}
}
应用仍像往常一样调用支付和事件依赖。测试替身只是将这些调用捕获为结构化数据,而非执行真实的对外部系统的操作。
使用各自的记录适配器分别运行旧版和新版实现后,可以对比两份记录的 effect 列表,验证两个系统是否尝试了相同的可观测副作用。
此时,差异测试可以比较:
expect(newEffects).toEqual(legacyEffects);
再次强调,只有在顺序确实重要时,才要求严格的顺序一致。
当精确相等不适用时,使用容差
某些领域不应使用精确相等判断。
假设迁移后的计算结果为:
legacy → 34.333333333
new → 34.333333334
这是迁移引入的 bug 吗?未必。
浮点数计算通常允许一定容差。
例如:
expect(newResult).toBeCloseTo(
legacyResult,
6
);
或者定义一个显式的比较器:
function withinTolerance(
a: number,
b: number,
tolerance: number
) {
return Math.abs(a - b) <= tolerance;
}
然后:
expect(
withinTolerance(
migrated.total,
legacy.total,
0.01
)
).toBe(true);
但容差标准应源自领域需求,不能为了掩盖失败测试而随意设定。
在金融系统中,一美分可能至关重要;而在科学计算中,更小的数值差异也可能关键。
等价性是业务和工程层面的决策。
构建可复用的差分测试框架
当需要对比的案例超过少数几个时,就可以构建一个可复用的测试框架(harness)。
示例如下:
type DifferentialResult<T> = {
input: T;
equivalent: boolean;
legacy: unknown;
migrated: unknown;
};
async function compareImplementations<
TInput,
TOutput
>(
input: TInput,
legacy: (
input: TInput
) => Promise<TOutput>,
migrated: (
input: TInput
) => Promise<TOutput>,
normalize: (
output: TOutput
) => unknown
): Promise<
DifferentialResult<TInput>
> {
const legacyResult =
await legacy(
structuredClone(input)
);
const migratedResult =
await migrated(
structuredClone(input)
);
const normalizedLegacy =
normalize(legacyResult);
const normalizedMigrated =
normalize(migratedResult);
return {
input,
equivalent:
JSON.stringify(
normalizedLegacy
) ===
JSON.stringify(
normalizedMigrated
),
legacy: normalizedLegacy,
migrated: normalizedMigrated,
};
}
该框架执行四项操作。
首先,它对 legacy 和迁移后的实现分别使用同一输入的独立克隆副本,确保单次执行不会改变另一执行所看到的数据。
其次,它将两种输出通过同一个 normalize() 函数处理。这样可以将等价规则集中在一处应用,而无需在每个测试中重复。
再次,它比较归一化后的结果,并记录它们是否等价。
最后,它返回输入以及两个归一化后的输出。由于测试报告可以精确展示哪个案例出现了差异以及各实现产生的具体结果,这使得检查失败案例变得更加容易。
随后:
const result =
await compareImplementations(
input,
legacyProcessor.process.bind(
legacyProcessor
),
newProcessor.process.bind(
newProcessor
),
normalizeOrder
);
expect(result.equivalent).toBe(true);
在实际系统中,我通常建议避免将 JSON.stringify() 作为最终的一致性判断机制。
示例代码是为了保持框架的可读性。
在生产级工具中,应使用合适的结构化或领域特定比较器。
关键的一点是,比较逻辑实现了集中化管理。
从真实行为生成测试用例
手写的测试示例有用,但迁移往往在那些没人想到要手写的场景上出问题。
实用的输入来源包括:
existing test fixtures
historical incidents
production-safe request samples
database records
boundary values
previous bug reports
known customer scenarios
假设生产环境出现过这些订单形态:
const cases: Order[] = [
{
subtotal: 0,
customerType: "STANDARD",
country: "US",
},
{
subtotal: 500,
customerType: "PREMIUM",
country: "AR",
},
{
subtotal: 10000,
customerType: "STANDARD",
country: "AR",
},
];
第一段代码是测试数据,它记录了一小组有代表性的输入形态,这些形态要么来自真实使用场景的观察,要么是从生产行为中安全地还原出来的。
第二段代码是测试本身。it.each(cases) 告诉 Vitest,对数组里的每个输入都跑一遍同样的差分对比。
这样就分离了两件事:定义贴近真实的用例,以及定义每个用例如何被评估。
接下来:
it.each(cases)(
"matches legacy behavior",
async (input) => {
const legacy =
await legacyProcessor.process(
structuredClone(input)
);
const migrated =
await newProcessor.process(
structuredClone(input)
);
expect(
normalizeOrder(migrated)
).toEqual(
normalizeOrder(legacy)
);
}
);
真实样例能暴露出人工构造的测试数据容易漏掉的隐含假设,但生产数据必须谨慎处理。
要移除或匿名化的内容包括:
personal data
credentials
tokens
financial identifiers
confidential business data
目标是保留有价值的行为形态,而不是把敏感的生产信息复制进测试夹具。
给每个差异分类
差分测试出现失败,并不自动意味着新实现有问题。
假设你发现 200 处不一致,先给它们分类。
我喜欢用这样的类别:
migration defect
legacy defect intentionally preserved
intentional behavior change
representation difference
nondeterministic difference
test/comparator defect
unknown
举个例子:
Input:
subtotal = 5000
Legacy:
discount = 0
New:
discount = 500
Classification:
unknown
调查表明,新实现方式做出了如下变更:
amount > 5000
改为:
amount >= 5000
现在,你需要做出判断:
这是:
意外引发的迁移差异
还是:
有意为之的缺陷修复
差异测试只能暴露决策依据,替你做出判断。
这正是它最大的优势之一。
如何利用 AI 调查差异测试失败
大型迁移项目往往会产生数百甚至数千处差异。AI 可以辅助对这些差异进行分类和分级。
假设你拥有如下数据:
{
"input": {
"subtotal": 5000,
"country": "AR"
},
"legacy": {
"total": 4500
},
"new": {
"total": 4000
}
}
你可以向模型提供:
输入数据
新旧两套输出结果
相关的遗留代码
相关的迁移后代码
比较规则
随后询问:
Analyze this differential test failure.
Identify the smallest behavioral difference that could
explain the mismatch.
Compare the legacy and migrated implementations.
Return:
1. observed difference,
2. relevant legacy branch,
3. relevant migrated branch,
4. likely cause,
5. evidence supporting the cause,
6. additional test cases that could confirm it.
Do not decide which behavior is correct.
Do not modify the code yet.
上述最后一条指令至关重要。AI 在定位两个实现产生分异的原因方面非常有用,但不应悄然将诊断结果转化为业务决策。
不要让 AI 决定哪种行为是正确的
假设遗留系统的逻辑如下:
Customer age 65 → no discount
Customer age 66 → discount
新系统的逻辑为:
Customer age 65 → discount
Customer age 66 → discount
AI 可能会阅读代码后表示:
新实现看起来更合逻辑,因为老年折扣通常从 65 岁开始。
这与实际无关。
业务规则可能是:
age > 65
这是有原因的,或者遗留行为本身就是一个 bug。
你需要的是证据。
请使用:
requirements
existing tests
production behavior
business owners
historical tickets
commit history
contracts
AI 可以帮助收集和汇总这些证据,但不应凭空臆造规则。
差异测试的价值在于,它能让你发现差异,避免不小心把这种差异固化到生产环境中。
如何安全地利用影子流量
当离线差异测试结果良好时,你可以尝试用真实流量对比行为。这通常被称为影子流量或流量镜像。
其模式如下:
real request
│
├────────────→ legacy system
│ │
│ ↓
│ real response
│
└────────────→ new system
│
↓
shadow result
用户仍然收到:
legacy response
同时新系统处理请求的副本。
然后对比:
legacy output
vs.
shadow output
这可以发现测试套件从未覆盖的场景。
例如:
unexpected null combinations
rare customer states
unusual international data
old records
large values
unusual sequence patterns
但影子执行需要精心设计,尤其是当操作具有副作用时。
如何防止影子执行产生重复副作用
假设我们要对以下操作进行影子处理:
POST /payments
如果两个系统都实际执行了付款,那就是严重问题。
同样的情况也适用于:
send email
create shipment
charge card
modify inventory
publish event
write external record
除非安全隔离,否则影子实现不应执行破坏性或对外可见的操作。
一种方法是使用录制适配器替换真实网关:
class ShadowPaymentGateway
implements PaymentGateway {
calls: PaymentRequest[] = [];
async charge(
request: PaymentRequest
) {
this.calls.push(request);
return {
paymentId: "shadow",
};
}
}
新实现仍会尝试执行:
payment
但影子适配器不会真正扣款,而是记录:
what would have been sent
然后你可以将该意图与遗留系统的副作用进行对比。
这一区别很重要:
compare behavior
并不意味着:
复现生产环境的副作用
衡量差异率,而不是追求完美
当对比规模达到数千次时,简单的二元结果:
通过 / 失败
可能说明不了全部问题。
你可以去衡量差异率。
例如:
Requests compared: 100,000
Equivalent: 99,620
Different: 380
Divergence rate: 0.38%
然后对这 380 个差异进行分类:
250 timestamp differences
80 known intentional changes
30 comparator problems
15 migration defects fixed
5 still unexplained
归一化之后:
meaningful unresolved divergence:
5 / 100,000
= 0.005%
这样讨论起来就具体多了。
与其说:
我觉得迁移可以上线了。
不如说:
我们对比了 10 万次有代表性的执行,还有 5 个未解决的行为差异。
这是否可以接受,取决于这 5 个案例具体是什么。
一笔错误的金融交易,可能比 100 处无害的格式差异更致命。
所以不要只看百分比,还要评估严重程度。
如何判断可以切换上线
差异测试不会给你一个万能的阈值,但它能给你证据。
切换之前,我希望回答清楚这些问题:
重要的输入类别都对比过吗?
不能只覆盖正常路径。
还要包括:
边界情况
错误处理
历史 bug
超大值
缺失值
罕见状态
有意义的差异都分类了吗?
要避免出现:
we have 47 unexplained mismatches
关键差异都解决了吗?
尤其是:
金钱
鉴权
状态流转
数据完整性
对外契约
幂等性
有意为之的差异都记录在案了吗?
如果新行为是有意不同,就应该明确说明。
副作用等价吗?
不能只看响应结果。
类似生产的场景测试过吗?
只有人工构造的测试数据可能不够。
迁移能回滚吗?
差异测试带来的信心能降低风险,但不能取代回滚方案。
如果你能回答这些问题,就离一次可控的切换不远了。
一套实用的差异测试工作流
下面是我推荐的工作流程。
1. 选定一项能力
比如:
Process Order(处理订单)
Calculate Invoice(计算发票)
Approve Customer(审批客户)
不要一上来就对比整个平台。
2. 定义可观测契约
列出需要关注的内容:
返回值
状态
错误
数据库状态
事件
外部调用
3. 编写新旧两套适配器
让两套实现通过同一个概念接口暴露出来。
4. 制定归一化规则
提前决定如何处理:
时间戳
生成的 ID
排序
表示形式差异
可选值
要在看到大量失败之前就定好。否则你可能会为了让结果通过而不断放宽比较器的标准。
5. 对比已知用例
从这些开始:
现有测试
特征化用例
边界用例
历史 bug
6. 捕获副作用
必要时使用录制或 fake 适配器。
7. 自动化测试框架
为每个不一致的用例输出结构化结果。
比如:
{
"caseId": "case-493",
"equivalent": false,
"legacy": {},
"migrated": {},
"difference": {}
}
8. 对差异进行分类
使用这些类别:
缺陷
有意变更
归一化问题
非确定性
未知
9. 补充有代表性的真实用例
使用匿名化或安全重构后的生产数据模式。
10. 适时进行真实流量影子测试
前提是副作用和隐私风险都已受控。
11. 度量差异
同时追踪:
频率
严重程度
12. 切换前解决所有未知项
最危险的类别往往不是:
有差异
而是:
有差异,而且没人知道为什么
差异测试无法证明什么
差异测试有一个重要局限:它是拿新系统和旧系统做对比。
这意味着旧系统成了行为基准,但旧系统本身可能就是错的。
假设:
legacy output = 错误
new output = 同样的错误结果
差异测试通过了,但这并不代表行为是正确的。
正因如此,差异测试应该与以下手段配合使用:
```html规格测试
特征测试
业务需求
安全测试
性能测试
契约测试
领域评审
它回答的是:
行为是否发生了改变?
它不会自动回答:
这是正确的行为吗?
这一区别至关重要。遗留应用只是证据,而非绝对真理。
差分测试将迁移风险转化为证据
还有另一个理由让我青睐这种技术。没有差分测试,迁移讨论很容易陷入主观臆断。
一个人说:
新实现看起来已经准备好了。
另一个人说:
我目前还不敢信任它。
两人的直觉可能都有道理,但这些说法很难量化。
差分测试改变了这种对话方式。
现在你可以这样说:
已对比 12,000 个案例
发现 47 处差异
其中 31 处属于表现层差异
9 处为有意的行为变更
已修复 6 处迁移缺陷
仍有 1 处未解决
这才像一场高质量的工程讨论。你正在将不确定性转化为可观察的差异,随后再决定如何处理这些差异。
结语
仅因新实现通过了自身测试,并不意味着遗留迁移已经完成。
更难的问题在于:它是否保留了被替换系统中那些关键的行为?
差分测试为你回答这一问题提供了另一种途径。
使用相同的输入运行两种实现。
对比输出。
对比错误。
对比副作用。
仅对那些真正无关紧要的差异进行归一化处理。
其余所有差异都需深入调查。
尽可能使用真实的线上生产行为来发现测试套件未曾预料到的案例。
这样一来,迁移流程就变成了:
理解
↓
特征化
↓
重构
↓
迁移
↓
对比
↓
切换
AI 也能加速这一过程。
它可以协助构建对比器、分析失败原因、归类相似的分歧、检查代码路径,并建议额外的测试用例。
但它不应决定哪一版实现才是正确的。
这仍然需要证据、领域知识以及工程判断。
差分测试的目的并非彻底消除不确定性。
```目的是在切换生产流量之前,让不确定性变得可见。
因为在迁移过程中,发现新系统行为与旧系统存在差异,是有价值的。
而在旧系统下线之后才发现问题,代价则要大得多。