← 返回蜂巢洞察

在旧系统迁移过程中如何使用差异测试方法

在旧系统迁移过程中,最危险的时刻并不一定是在开始编写新实现代码的时候,而是在新实现看起来已经完成的时候。 代码编译通过了,测试也通过了,架构也更加清晰了,新的服务响应速度也更快了…… 然后所有人都会开始问同一个问题: 我们现在可以把流量切换过来吗? 这时,信心就会变得难以建立。 一个新的实现虽然能够通过自己的测试套件,但其行为仍可能与它所要替代的系统有所不同。 也许数值的舍入规则发生了变化,或者对空值的处理方式不同了,又或许某个错误现在被当作成功的响应来处理了…… 也许记录的排序方式改变了,某些副作用出现的顺序也变了,又或许在迁移过程中,一些从未被记录下来的业务规则丢失了…… 正因如此,在进行

在旧系统迁移过程中,最危险的时刻并不一定是在开始编写新实现代码的时候,而是在新实现看起来已经完成的时候。

代码编译通过了,测试也通过了,架构也更加清晰了,新的服务响应速度也更快了……

然后所有人都会开始问同一个问题:

我们现在可以把流量切换过来吗?

这时,信心就会变得难以建立。

一个新的实现虽然能够通过自己的测试套件,但其行为仍可能与它所要替代的系统有所不同。

也许数值的舍入规则发生了变化,或者对空值的处理方式不同了,又或许某个错误现在被当作成功的响应来处理了……

也许记录的排序方式改变了,某些副作用出现的顺序也变了,又或许在迁移过程中,一些从未被记录下来的业务规则丢失了……

正因如此,在进行旧系统迁移时,我建议使用另一种验证方法:使用相同的输入来运行旧系统和新系统,然后比较它们的行为差异。

这就是差异测试的基本思想。我们不仅仅要检查新系统是否通过了所有的测试,还要看看:在相同的输入条件下,新系统的行为与旧系统有哪些不同之处。

这些差异其实就是重要的证据。

其中一些差异可能是漏洞,有些可能是有意为之的改进,有些可能只是表示方式上的细微差别,而还有一些差异则会揭示出人们之前根本不知道的存在的行为。

在本教程中,我将向您展示如何在旧系统迁移过程中使用差异测试来:

  • 比较旧系统和新系统的实现结果

  • 明确哪些方面应该被视为是等价的

  • 在对比输出结果之前先对它们进行标准化处理

  • 正确处理时间戳以及其他具有不确定性的数据

  • 比较错误处理机制和副作用的表现

  • 自动执行差异测试

  • 在不需要完全一致的情况下设定容差范围

  • 分析那些不一致的地方

  • 在生产环境中安全地使用模拟流量进行测试

  • 利用人工智能来识别这些差异,但不要让人工智能来判断这些差异的正确性

  • 确定新系统何时可以正式投入使用

本教程中使用的示例代码是TypeScript和Vitest,但这种测试方法适用于大多数编程语言和迁移场景。

我们的目标并不是要证明两个实现版本在内部是完全相同的,而是要获得证据,证明它们在那些真正重要的方面表现出等价的行为

先决条件

要想顺利跟随本教程的学习,您需要具备以下知识:

  • TypeScript或类似的编程语言

  • 单元测试和集成测试的相关知识

  • 异步代码的编写技巧

  • API接口与服务边界的相关概念

  • 旧系统现代化改造的相关经验

  • 基本的可观测性分析方法

此外,您还应该对正在被迁移的系统本身的功能有了一定的了解。

理想情况下,你应该清楚系统的输入数据、输出结果、重要的业务规则、外部合同条款、可能产生的副作用,以及那些存在不确定性的环节。

在为你想要迁移的功能划定边界之后,差异测试才会发挥出最大的作用。

目录

差异测试实际上能告诉你什么

假设你的旧版应用程序用于计算订单的最终价格。

旧版本的实现方式如下:

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("订单总额计算功能的迁移", () => {
  it("新实现版本的结果应与旧版本相同", () => {
    const order: Order = {
      subtotal: 10000,
      customerType: "PREMIUM",
      country: "AR",
    };

    const legacyResult = legacyCalculateTotal(order);
    const newResult = newCalculateTotal(order);

    expect(newResult).toBe(legacyResult);
  });
});

对于这样的输入数据,两个实现版本都应该返回相同的结果:

旧版本 → 8500
新版本 → 8500

虽然只有一个例子能证明它们结果一致,但这还不够。我们需要进行更多的测试。

只有通过多次测试,才能确保新的实现版本确实能够替代旧的版本。

从某个可观察的边界开始测试

在开始测试之前,不要直接比较整个应用程序。首先,应该选择某一项具体功能来进行测试。

计算订单总额
生成发票
审核客户信息
续订服务
计算佣金
创建发货单

假设迁移的边界是这样一个接口:

interface OrderProcessor { process(order: Order): Promise; }

现在你有两个实现版本:

旧版订单处理模块 新版订单处理模块

这个边界非常有用,因为这两个模块接收的是相同的输入数据,而且都会产生相同的结果。

因此,即使它们的内部架构不同,也可以直接进行比较。

实际上,在迁移过程中,系统的结构往往会被有意地修改。

旧版实现流程:控制器 → 服务层 → SQL数据库 → 提供商SDK
新版实现流程可能有所不同
用例
→ 仓库
→ 网关
→ 事件

差异测试不应关注这些细节,而应该关注可观察的行为。

使用相同的输入运行旧版本和新版本的实现

假设这两种实现都提供了以下接口:

interface OrderProcessor {
  process(order: Order): Promise;
}

你可以创建如下对象:

const legacyProcessor =
  new LegacyOrderProcessor();

const newProcessor =
  new NewOrderProcessor();

然后进行如下测试:

it("产生的处理结果应该相同", async () => {
  const input: Order = {
    id: "order-1",
    subtotal: 10000,
    customerType: "PREMIUM",
    country: "US",
  };

  const legacyResult =
    await legacyProcessor.process(
      structuredClone(input)
    );

  const newResult =
    await newProcessor.process(
      structuredClone(input)
    );

  expect(newResult).toEqual(legacyResult);
});

注意这里使用了:

structuredClone(input)

如果其中任何一种实现修改了输入数据,这一点就非常重要。

如果没有独立的副本,第一次执行的结果可能会影响第二次执行的结果。

我们需要确保:

初始状态相同

而不是:

新版本使用旧版本修改后的数据

否则,这种“数据污染”会导致错误的测试结果。

不要盲目比较原始输出结果

差异测试的初始版本通常会这样写:

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不同
generatedAt时间不同

但实际上,这两种系统的业务行为可能是相同的。

你需要确定哪些字段是评估系统功能时真正重要的因素。

也许:

id、total和status这些字段

才是关键。

而:

在比较之前先对数值进行标准化处理

规范化意味着在比较数据之前,将其转换成一种统一的表示形式。

这样做的目的并不是改变数据的业务意义,而是消除那些在比较中属于无关紧要的差异——比如生成的请求ID或时间戳。这样一来,测试就可以专注于那些真正能够反映你所关注的行为的字段。

在实际操作中,这通常意味着需要创建一种规范的表示形式:一种规模较小、结构稳定的格式,其中只包含那些需要进行比较的有意义的字段。

举个例子:

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()函数会保留idtotalstatus这些字段,而忽略generatedAtrequestId。这样一来,即使两个结果是在不同的时间生成的,或者使用了不同的请求标识符,它们仍然可以被视为等价的。

现在来比较一下:

expect(
  normalizeOrder(migrated)
).toEqual(
  normalizeOrder(legacy)
);

这样就能让你的等价判断规则变得清晰明了。

你实际上是在表达:

这些字段才是进行比较时需要考虑的关键因素。

规范化还可以处理以下情况:

  • 排序问题

  • 大小写差异

  • 可选字段的处理

  • 时间戳的处理

  • 生成的标识符的问题

  • 数值格式的统一

  • 不同提供者生成的元数据

但是,规范化必须是有意识地进行的。如果去除的内容太多,就可能会掩盖一些真正的迁移错误。

处理时间戳及其他不确定性值

旧系统中含有许多具有不确定性的数值。

例如:

时间戳
UUID
随机生成的令牌
请求ID
跟踪ID
数据库自动生成的标识符
无序集合
由特定提供者生成的引用值

如果对这些数值进行完全相同的比较,那么你的测试套件很可能会一直出现失败结果。

一种解决办法是实施依赖控制。

依赖控制指的是将那些具有不确定性的数据源——比如当前时间或ID生成器——放在一个可以在测试过程中被替换的接口之后。

这样,所有的实现都不会独立地读取真实的时间信息,而是都会使用同一个受控的时间源。这样一来,所有系统就能得到相同的时间值,从而避免因时间差异而导致的错误结果。

假设某段代码使用了以下代码:

new Date()

你可以用一个时钟来替代这种依赖关系:

interface Clock {
  now(): Date;
}

这样一来,这两种实现方式都会收到如下代码中的对象:

const clock = {
  now: () =>
    new Date(
      "2026-09-09T10:00:00.000Z"
    ),
};

现在,时间的获取方式就变得确定下来了。

这种技术也可以用于生成唯一标识符:

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(operation: () => Promise) {
  try {
    return {
      type: "success",
      value: await operation(),
    };
  } catch (error) {
    return {
      type: "error",
      error: error instanceof Error ? error.message : String(error),
    };
  }
}

这个辅助函数会封装异步操作,并将两种可能的结果都转换成数据形式。

if the operation succeeds, it returns an object with `type: "success"` and the resulting value; if an exception is thrown, the `catch` block converts it into an object with `type: "error"` and a descriptive error message.

这样,两种实现就能被放在相同的比较框架下进行评估了,测试也就能够明确地区分成功与失败的情况,而不会因为异常的出现就提前终止测试。

const legacy = await captureResult(() => legacyProcessor.process(input));
const migrated = await captureResult(() => newProcessor.process(input));
expect(migrated.type).toBe(legacy.type);

如果错误在功能契约中属于关键因素,那么就必须进行对比分析:

Error category, HTTP status, error code, whether it can be retried, validation details...

除非客户对具体的表述有特殊要求,否则不必刻意去比较这些表述是否完全一致。

也要对比副作用

在迁移过程中,最常见的错误之一就是保留了返回值,却忽略了某些副作用。

{
  "status": "PROCESSED"
}
The old version also persists the original order, publishes an event, creates a payment record, and writes an audit entry.
However, the new version forgets to write the audit entry.

仅通过响应层面的差异测试是无法发现这种差异的,因此必须记录这些副作用。

type Effect = |
  | { type: "payment"; orderId: string; amount: number; }
  | { type: "event"; name: string; orderId: string; };
A test adapter can be used to record these side effects.
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, }); } }

应用程序仍然会像往常一样调用其支付相关功能以及事件处理逻辑。这些测试替身只是将这些调用以结构化数据的形式记录下来,而并不会实际执行任何外部操作。

在运行了使用原有适配器和迁移后新适配器的实现代码之后,你可以对比这两组记录下来的操作结果,从而确认两个系统确实尝试产生了相同的效果。

expect(newEffects).toEqual(legacyEffects);

需要强调的是,只有当顺序确实重要时,才需要确保这些操作的执行顺序完全一致。

在无法实现精确匹配时使用容差值

有些场景并不适合使用精确相等性来进行判断。

legacy → 34.333333333 new → 34.333333334

这种情况是否属于迁移过程中的错误呢?未必如此。

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);

不过,是否使用容差值应该取决于具体的业务需求。不要仅仅为了让测试结果看起来正常而随意使用容差值。

对于金融系统而言,一分的差异可能就非常重要;而对于科学计算来说,数值上微小的差别也可能具有决定性意义。

是否认为两个结果相等,实际上是一个商业和工程决策问题。

构建可重用的差分测试框架

当你需要比较多个案例时,就可以创建一个可重用的测试框架。

type DifferentialResult = { input: T; equivalent: boolean; legacy: unknown; migrated: unknown; }; async function compareImplementations< TInput, TOutput >( input: TInput, legacy: ( input: TInput ) => Promise, migrated: ( input: TInput ) => Promise, normalize: ( output: TOutput ) => unknown ): Promise>; { 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, }; }

这个测试框架具有四个主要功能。

首先,它会使用相同的输入数据分别运行旧版本和升级后的实现代码,这样一次执行过程就不会影响到另一次执行的结果。

其次,它会将两种处理结果的输出都传递给同一个`normalize()`函数进行处理。这样就可以在统一的位置应用等价性判断规则,而无需在每个测试中重复这些操作。

第三,它会比较经过规范化处理后的结果,并记录它们是否相等。

最后,它会将输入数据以及两种处理结果一起返回出来。这样一来,当比较结果不同时,就更容易进行分析了,因为测试报告会明确指出是哪个案例出现了差异,以及每种实现方式产生了什么结果。

const result =
  await compareImplementations(
    input,
    legacyProcessor.process.bind(
      legacyProcessor
    ),
    newProcessor.process.bind(
      newProcessor
    ),
    normalizeOrder
  );

expect(result.equivalent).toBe(true);

对于实际应用系统来说,我通常会避免将`JSON.stringify()`作为最终的等价性判断机制。

这个示例只是为了让测试框架更容易理解而已。在实际的生产级工具中,应该使用专门设计的结构化比较器或领域特定的比较算法。

关键在于,要将所有的比较逻辑集中起来进行管理。

根据真实行为生成测试用例

手工编写的测试用例确实很有用,但在进行系统升级时,那些没人想到需要手动编写测试用例的情况往往会导致测试失败。

现有的测试用例
历史上的异常记录
可用于生产环境的请求样本
数据库中的数据
边界值
之前的错误报告
已知的客户使用场景

假设在生产环境中出现了以下这样的订单数据结构:

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)
    );
  }
);

真实的例子有助于揭示那些合成测试数据常常会忽略的假设。但处理生产数据时必须格外小心。

需要删除或对以下信息进行匿名化处理:

个人数据
凭证信息
令牌
财务识别信息
机密商业数据

我们的目标是保留那些有用的行为差异,而不是将敏感的生产信息复制到测试用例中。

区分每一个细微差异

出现差异性故障,并不意味着新的实现方式就是错误的。

假设你发现了200处不一致之处,那么就需要对这些差异进行分类。

我喜欢的分类方式包括:

迁移缺陷
故意保留的旧有代码缺陷
有意为之的行为变更
表现形式上的差异
非确定性差异
测试/对比用例中的缺陷
未知原因

例如:

输入数据:
小计 = 5000

旧版本代码:
折扣 = 0

新版本代码:
折扣 = 500

分类结果:
未知原因

经过调查后发现,新版本中的代码将条件修改为:

金额 > 5000

改为:

金额 >= 5000

现在你需要做出一个决定:

这是由于:

偶然的代码迁移错误

还是:

如何利用人工智能来分析差异性故障

大规模的代码迁移可能会产生成百上千处差异,而人工智能可以帮助我们对这些差异进行筛选和分类。

假设你有如下数据:

{
  "输入数据": {
    "小计": 5000,
    "国家": "阿根廷"
  },
  "旧版本代码": {
    "总金额": 4500
  },
  "新版本代码": {
    "总金额": 4000
  }
}

你可以向人工智能模型提供以下信息:

  • 输入数据

  • 旧版本和新版本的输出结果

  • 相关的旧版本代码

  • 相关的新版本代码

  • 用于比较的规则

然后让模型进行分析,询问它:

分析这个差异性故障。
找出最有可能导致这种不一致的行为差异。
对比旧版本和新版本的实现方式。
返回以下结果:
1. 观察到的差异;
2>相关的旧版本代码分支;
3>相关的新版本代码分支;
4>可能的原因;
5>支持这一原因的证据;
6>可以用来验证这一结论的额外测试用例。
请注意,不要自行判断哪种行为是正确的,也暂时不要修改任何代码。

最后这条指令非常重要。人工智能在帮助我们找出两种实现方式之间差异的原因方面确实非常有用,但它不应该默默地将这种分析结果转化为商业决策。

不要让人工智能来决定哪种行为是正确的

假设旧系统是这样运作的:

客户年龄为65岁 → 不享受折扣
客户年龄为66岁 → 可享受折扣

而新系统的运作方式则是:

客户年龄为65岁 → 可享受折扣
客户年龄为66岁 → 仍可享受折扣

人工智能看到这样的代码后,可能会认为:

新的实现方式看起来更合理,因为通常老年人折扣政策是从65岁开始的。

但这种看法其实并不重要。

业务规则可能是这样的:

年龄 > 65岁

制定这样的规则肯定是有原因的;或者,旧系统的这种行为可能隐藏着某种错误。

你需要确凿的证据来支持你的判断。

你可以利用以下这些信息来进行分析:

需求文档
现有的测试用例
系统在生产环境中的实际表现
业务负责人提供的意见
历史故障记录
代码提交历史
相关合同文件

人工智能可以帮助你收集并整理这些证据,但它不应该自行创造规则或假设。

差异测试非常有用,因为它能让你在错误地将某些差异带入生产环境之前,就发现它们存在的真实性。

如何安全地使用影子流量测试

当离线差异测试的结果看起来可靠时,你有时可以将系统的实际运行情况与真实流量进行对比。这种测试方法通常被称为“影子测试”或“流量镜像测试”。

真实请求      │
│              └────────────→ 旧系统
│                          │
│                          ↓
│             真实响应     │
│
└────────────→ 新系统       │
                       │
                       ↓
                  影子测试结果

在这种情况下,用户仍然会收到旧系统的响应,而新系统则会处理这个请求的副本。

旧系统的输出    vs.
影子测试的输出

通过这种对比,你可以发现那些原本被测试用例遗漏的问题。

意外的空值组合
罕见的客户状态
不常见的国际数据格式
过时的记录
超大数值
异常的数据序列模式

不过,进行影子测试时需要特别小心设计,尤其是当某些操作会产生副作用时。

如何防止影子测试产生重复的副作用

假设我们正在进行影子测试:

POST /payments
如果两个系统都会真正执行这笔支付操作,那就会带来严重的后果。
发送邮件
创建货运订单
扣款
修改库存信息
发布事件
写入外部数据库记录
除非这些操作被妥善隔离,否则影子测试系统不应该产生任何破坏性效果或可被外部观察到的结果。
class ShadowPaymentGateway
  implements PaymentGateway {
  calls: PaymentRequest[] = [];

  async charge(
    request: PaymentRequest
  ) {
    this.calls.push(request);

    return {
      paymentId: "shadow",
    };
  }
}

新的实现仍然会尝试执行以下操作:

支付相关操作

但 instead of 使用真实的银行卡进行收费,这个“影子适配器”只会记录下本应被发送的数据:

对比它们的行为差异

并不意味着:

通过测量差异来判断是否准备就绪,而不是等待一切完美无缺

当进行成千上万的对比测试时,一个简单的“通过/失败”结果:

对比次数:100,000次
结果一致的情况:99,620次
存在差异的情况:380次
差异占比:0.38%

然后对这些差异进行分类:

最终剩余且原因不明的差异仅为5次,
占比为0.005%

这样,讨论就变得具体多了。

与其说:“我认为迁移工作已经准备就绪了”,

不如这样说:

我们对比了100,000次具有代表性的测试结果,发现还有5处行为差异尚未得到解决。

这些差异是否可以接受,取决于它们的具体性质。例如,一次错误的财务交易可能比100次无害的格式差异更为严重。

因此,评估时不要只看比例,还要考虑问题的严重性。

如何判断是否已经准备好进行切换

差异测试并不能给出一个通用的标准,但它可以为你提供有力的证据。

在切换之前,我需要回答以下问题:
不仅是要检查正常运行情况下的结果,
这些差异是否都得到了分类?
关键性的差异是否都已经得到解决?
那些人为故意造成的差异是否都已经被记录下来?

如果新的行为确实是人为故意设计的,那么这一点就必须明确记录在案。

副作用是否相同?

不仅仅是响应结果而已。

类似生产环境的测试案例是否已经过测试?

仅使用合成测试环境可能还不够。

迁移操作可以撤销吗?

差异化的评估方法虽然能降低风险,但并不能完全消除进行撤销操作的必要性。

如果你能够回答这些问题,那么你就离实现可控的切换流程更近了一步。

实用的差异化测试工作流程

以下是我会采用的工作流程。

1. 选择一项功能进行测试

例如:

处理顺序:
计算发票金额
审批客户请求

不要一次性比较整个平台的所有功能。

2. 明确可观察的测试结果指标

列出需要关注的关键要素:

返回值
状态
错误信息
数据库状态
发生的事件
外部调用情况

3>创建旧版本和新版本的适配器

通过相同的接口来暴露这两种实现方式。

4>制定标准化规则

确定如何处理以下情况:

5>对比已知的测试案例

首先从以下这些案例开始测试:

6>记录副作用
必要时可以使用录制功能或模拟适配器来收集数据。

7>自动化测试流程

对于每一处不匹配的情况,都要生成结构化的输出结果。

例如:

{
  "caseId": "case-493",
  "equivalent": false,
  "legacy": {},
  "migrated": {},
  "difference": {}
}

8>对差异进行分类

可以使用以下分类标准:

9>添加具有代表性的真实世界案例

可以使用匿名化处理过的或经过安全重建的生产环境数据来进行测试。

10>在适当的情况下模拟真实业务流量

只有在对副作用和隐私风险进行了有效控制之后,才能进行这种模拟测试。

11>衡量差异程度

需要跟踪以下两个方面:

12>在切换之前解决所有未知问题

最危险的情况往往并不是:

系统确实存在差异,但没有人知道原因何在

差分测试无法证明什么

差分测试有一个重要的局限性:它只是将新系统与旧系统进行比较。

这意味着旧系统成为了行为参考标准,但这个旧系统本身可能就已经存在错误了。

旧系统的输出结果是错误的
新系统的输出结果也是相同的错误结果

虽然差分测试通过了,但这并不意味着新系统的行为就是正确的。

正因如此,差分测试应该与其他测试方法相结合使用:

差分测试将迁移风险转化为可验证的证据

我之所以喜欢这种技术,还有另一个原因:如果没有差分测试,关于系统迁移的讨论就很容易变得主观化。

另一个人则说:“我还不太信任它。”

这两个人的观点都可能有道理,但它们的论据都不容易被量化或验证。

差分测试能够改变这种讨论方式。

结论

仅仅因为新系统的实现通过了自身的测试,并不意味着迁移工作就已经完成。

真正重要的是:新系统是否保留了被替换旧系统中那些重要的行为特征。

差分测试为回答这个问题提供了另一种方法。

只有那些真正无关紧要的差异才需要被忽略,
其他所有问题都应当被仔细研究。
了解现状 → 分析系统特性 → 优化代码结构 → 进行系统迁移 → 对结果进行对比 → 最后完成切换

人工智能也可以帮助加速这一过程。

它能够帮助构建比较工具、分析故障、将相似的差异归类到一起、检查代码执行路径,并建议添加额外的测试用例。

但是,它不应该去判定哪种实现方式是正确的。

这样的判断仍然需要证据、领域知识以及工程判断力。

差异测试的目的并不是要完全消除不确定性。

它的作用是在你将生产环境中的流量切换到新系统之前,让这些不确定性变得显而易见。

因为在系统迁移的过程中,如果能够及时发现新系统的行为与旧系统不同,那会非常有用;

而如果在旧系统已经被停用之后才发现问题,那么所造成的损失将会大得多。

相关文章

技术实践

iOS NFC使用指南:如何使用React Native读取、写入NFC标签以及锁定这些标签

将iPhone靠近贴纸,就会发生一些奇妙的事情:名片会自动添加到联系人列表中,某个聚焦操作会结束,或者某扇门会自动打开。这种芯片的成本大约为20便士,其存储容量约为130字节。 读取一条NFC信息需要执行两次函数调用;而要获得执行这些调用的权限,则需要花费更长的时间。之后,CoreNFC还会要求你再次完成这个流程。 第一个障碍来自苹果公司:你需要拥有一个付费开发者账户,在某个网站平台上注册应用ID,勾选相关选项,并重新生成配置文件。如果其中任何一步出错,构建过程就会因为代码签名错误而失败,而这些错误信息中根本不会提到“NFC”这个词。 第二个障碍则来自CoreNFC本身,而且没有人会提醒你注意

阅读全文
技术实践

如何逐步迁移传统的单体应用系统,而无需进行大规模的重新开发

大多数传统的迁移项目在最终切换完成之前就会失败。 这种失败通常源于将迁移过程视为一个单一的步骤来执行:迁移应用程序、数据库,转移所有用户数据,调整流量分配,然后关闭旧系统。 这种做法隐含了一个危险的假设:即旧系统和新系统必须同时被替换掉。 但实际上,这种情况很少会发生。 如果你已经了解了旧系统的运行机制,可以通过编写测试用例来保护这些现有功能,设定适合迁移的边界条件,并对比新旧系统的实现方式,那么你就有了另一种选择。 你可以一次只迁移一个功能模块。这样就能彻底改变原有的迁移方案。 旧式单体系统 ↓ 全面重构 ↓ 一次性切换完成 而你现在可以选择的做法是: 旧式单体系统 ↓ 提取出一个功能模块进

阅读全文
技术实践

文章:构建安全且可扩展的面部识别系统

当有三千名员工同时进行身份验证时,同步API调用会导致系统崩溃。本文介绍了一种适用于大规模人脸识别系统的四层架构:客户端过滤功能可使云服务成本降低30%;检测与验证过程的分离使得系统扩展能力提升10倍;基于风险的动态阈值设置有助于更好地控制系统性能;而零信任隐私保护机制,结合用户同意机制及自动化数据清理功能,能够有效遵守GDPR和HIPAA等法规要求。 作者:Praveen Kumar Gopalakrishnan

阅读全文
技术实践

如何利用功能标志来实现安全、渐进式的功能推出

功能开关是团队在部署过程中最强大的工具之一。它们将 部署 与 发布 分离开来,这意味着你的持续集成/持续交付流程可以在每次代码合并时都将更新推送到生产服务器上,但只有当你明确启用这些功能开关时,用户才会看到新的变化。 “部署”是一个技术性操作,而“发布”则是一项产品决策。正是这种分离机制,使得本文中讨论的诸多内容成为可能。 然而,如果实现不当,功能开关反而会带来技术债务、测试难题以及运行时的复杂性。在这篇文章中,你将学习到实施功能开关的核心方法——从简单的布尔值切换,到基于百分比的比例化部署方案,再到针对不同用户群体的功能启用策略,同时还会了解相关的生命周期管理方法及应避免的错误做法。以下就是

阅读全文