← 返回蜂巢洞察

如何在重构旧代码之前设计相应的特性测试用例

许多工程师在继承遗留代码后,首先想要做的就是改进这些代码。 你会遇到一些难以理解的函数,或者看到重复的逻辑、深度嵌套的条件语句、混合了业务规则的数据库调用,以及那些使得测试几乎无法进行的依赖关系。 你知道这些代码本可以做得更好,于是开始对其进行优化。 然而,随后就会出现问题。并不是因为新的实现方式存在明显的错误,而是因为旧的实现方式在背后做了某些人们之前根本不知道它在做的事情。 这就是在遗留代码现代化过程中最常出现的风险之一。 在修改代码之前,你需要有一种方法来回答一个简单的问题: 我是否保留了那些原本就重要的功能行为? 这时,特性测试就派上了用场。 特性测试并不是从“软件应该做什么”这个角度

许多工程师在继承遗留代码后,首先想要做的就是改进这些代码。

你会遇到一些难以理解的函数,或者看到重复的逻辑、深度嵌套的条件语句、混合了业务规则的数据库调用,以及那些使得测试几乎无法进行的依赖关系。

你知道这些代码本可以做得更好,于是开始对其进行优化。

然而,随后就会出现问题。并不是因为新的实现方式存在明显的错误,而是因为旧的实现方式在背后做了某些人们之前根本不知道它在做的事情。

这就是在遗留代码现代化过程中最常出现的风险之一。

在修改代码之前,你需要有一种方法来回答一个简单的问题:

我是否保留了那些原本就重要的功能行为?

这时,特性测试就派上了用场。

特性测试并不是从“软件应该做什么”这个角度出发的,而是首先记录下“软件目前实际在做什么”。

这种区别非常重要。

对于全新的应用程序来说,测试通常用于验证预期的行为;但对于遗留代码而言,你可能首先需要一些测试来确认现有的功能表现,这样在修改代码时才能确保不会意外地改变其输出结果。

在本教程中,我将向你展示如何在重构遗留代码之前,利用特性测试作为一道安全保障。

我们将学习如何:

  • 识别出那些值得保护的功能行为;

  • 确定合适的测试边界;

  • 记录软件当前的输出结果;

  • 处理可能产生的副作用;

    正确地管理与数据库及外部系统的交互;

  • 利用人工智能来加速测试过程的进行;

  • 避免过度固定代码实现的细节;

  • 判断哪些部分不需要进行特性测试;

  • 让特性测试成为安全重构的基础。

这些示例中使用了TypeScript和Vitest,但这种方法适用于大多数编程语言和测试框架。

我们的目标并不是永远保留每一行遗留代码的功能,而是要在开始修改产生这些功能的代码之前,先弄清楚这些功能的具体表现。

先决条件

你需要具备以下技能:

  • TypeScript或类似的编程语言;

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

  • 依赖注入机制的理解;

  • mock对象与测试替身的使用方法;

  • 基本的代码重构技巧;

  • 阅读不熟悉的代码库的能力。

  • 如果你已经了解了想要修改的功能的具体实现方式,那会更有帮助。

    在编写特性测试之前,你应该先清楚以下内容:

    • 这些功能行为是从哪里开始的;

    • 它们会改变系统的状态到什么程度;

    • 它们会影响到哪些外部系统;

    • 有哪些输出结果可能会被其他部分使用。

    目录

    特性测试究竟能保护什么

    假设你继承了这样一个函数:

    type Customer = {
      id: string;
      type: "STANDARD" | "PREMIUM";
    };
    
    type Order = {
      id: string;
      customer: Customer;
      subtotal: number;
      country: string;
      paymentMethod: "CARD" | "TRANSFER";
    };
    
    async function processOrder(order: Order) {
      let total = order.subtotal;
    
      if (order.customer.type === "PREMIUM") {
        total = total * 0.9;
      }
    
      if (order.country === "AR" && order.paymentMethod === "TRANSFER") {
        total = total - 500;
      }
    
      if (total < 0) {
        total = 0;
      }
    
      await ordersRepository.save({
        ...order,
        total,
        status: "PROCESSED",
      });
    
      await eventBus.publish("order.processed", {
        orderId: order.id,
        total,
      });
    
      return total;
    }
    

    在这里,有几处内容可能是你需要进行重构的。

    例如,定价规则可以被移放到另一个模块中;数据持久化功能也可以被单独处理;事件发布机制可以通过一个接口来实现;而且该函数应该返回一个对象,而不是基本类型的数据。

    这些可能都是合理的决策,但在做出决定之前,你应该先问自己:目前哪些行为是真正重要的?

    对于这个函数来说,其关键行为至少包括以下几点:

    • 高级客户可以享受10%的折扣

    • 阿根廷地区的转账需要进行额外的处理

    • 计算出的总金额不能为负数

    • 订单信息会被以特定的状态保存下来

    • 系统会发布相应的事件

    • 事件中应该包含计算出的总金额

    • 函数最终需要返回这个总金额

    通过编写特征测试,你可以为这些行为设定一个基准。

    举个例子:

    import { describe, expect, it, vi } from "vitest";
    
    describe("processOrder", () => {
      it("applies the existing premium customer behavior", async () => {
        const save = vi.spyOn(ordersRepository, "save");
        const publish = vispyOn(eventBus, "publish");
    
        const order: Order = {
          id: "order-1",
          customer: {
            id: "customer-1",
            type: "PREMIUM",
          },
          subtotal: 10000,
          country: "US",
          paymentMethod: "CARD",
        };
    
        const result = await processOrder(order);
    
        expect(result).toBe(9000);
    
        expect(save).toHaveBeenCalledWith(
          expect.objectContaining({
            id: "order-1",
            total: 9000,
            status: "PROCESSED",
          })
        );
    
        expect(publish).BeenCalled("order.processed", {
          orderId: "order-1",
          total: 9000,
        });
      });
    });
    

    这个测试并不是在说明10%的折扣就是最好的定价模式。

    它只是在说明:

    这就是当前系统的实际运行方式。

    在对其进行修改之前,你需要先了解这一现状。

    从行为入手,而非实现细节

    一个常见的错误就是根据你计划创建的结构来编写测试用例。

    假设你想将之前的代码重构为以下结构:

    OrderProcessor
    PricingPolicy
    OrdersRepository
    OrderEventPublisher
    

    你可能会想要先为这些未来的类编写测试用例。

    但这些类描述的是你提出的设计方案,并不是当前系统的实际运行方式。

    特征测试应该从当前系统已有的功能边界开始进行。

    不要问:

    `PricingPolicy`应该如何工作?

    而应该问:

    给定这样的输入,`processOrder()`函数目前会返回什么结果?

    这样就能确保你的新架构不会无意中改变系统的现有行为。

    正确的操作顺序应该是:

    观察现有系统的行为
    ↓
    记录这些行为
    ↓
    重构相关代码实现
    ↓
    运行特性测试
    ↓
    确认系统行为依然稳定

    而不是这样:

    设计新的系统架构
    ↓
    为新架构编写测试用例
    ↓>假设新架构与旧系统能够正常兼容

    第二种工作流程用于验证你的设计是否可行,而第一种工作流程则能确保迁移过程的安全性。

    在编写测试用例之前,先选择一项功能进行测试

    不要一开始就试图测试整个旧系统,而是先选取某一项具体的业务功能来进行测试。

    例如:

    批准订单
    生成发票
    续订服务
    注册客户
    计算佣金
    取消预订

    然后追踪这一功能在系统中的实现路径。

    假设你选择了“生成发票”这项功能,那么你会发现其实现流程如下:

    POST /orders/:id/invoice
            ↓
    InvoiceController.generate()
            ↓
    InvoiceService.generate()
            ↓
    TaxCalculator.calculate()
            ↓
    InvoiceRepository.save()
            ↓
    PdfGenerator.create()
            ↓
    EmailService.send()
    

    这就是你需要重点测试的范围。

    接下来要思考的是:如果重构这一功能,哪些行为是需要特别关注的?

    例如:

    税费计算
    发票编号生成
    数据库状态变化
    PDF文件的生成内容
    收件人的电子邮件地址
    附件内容
    错误处理机制

    这些都是需要通过测试来验证的环节。

    这种做法比盲目提高整个代码库的测试覆盖率要有效得多。

    测试覆盖率并不是最终目标,我们的目标是确保系统行为的稳定性。

    找到最合适的测试边界

    特性测试可以针对不同的层次进行设计。

    你可能会选择测试以下这些层面:

    函数
    服务模块
    API接口
    后台作业流程
    整个业务流程

    但正确的测试边界通常是那些仍能覆盖关键行为的最小范围。

    假设你需要重构的逻辑位于如下代码中:

    class InvoiceService {
      async generate(orderId: string) {
        // 包含300行的旧代码实现
      }
    }

    如果`generate()`方法负责处理税费计算、数据持久化、发票编号生成以及外部接口调用等功能,那么仅仅测试这个方法内部的小部分逻辑可能无法覆盖所有关键行为。

    而测试整个生产环境下的系统流程可能会显得效率过低且难度太大。

    因此,服务层级的特性测试可能是一种更为合适的解决方案。

    例如:

    describe("InvoiceService.generate", () => {
      it("能够正确生成发票", async () => {
        const service = createInvoiceService();
    
        const invoice = await servicegenerate("order-123");
    
        expect(invoice.subtotal).toBe(10000);
        expect(invoice.tax).toBe(2100);
        expect(invoice.total).toBe(12100);
      });
    });
    

    你并不想去探究“能够进行测试的最小单位是什么”;你真正想要知道的是:在本次代码重构过程中,哪个边界值能让你获得足够的信心。

    这两者并不总是相同的。

    在改进之前,先记录现有的行为表现

    旧代码中往往包含一些看起来可疑的行为逻辑。

    举个例子:

    function calculateDiscount(amount: number) {
      if (amount > 10000) {
        return amount * 0.15;
      }
    
      if (amount > 5000) {
        return amount * 0.1;
      }
    
      return 0;
    }
    

    你运行了几组测试后,发现以下结果:

    5000 → 0
    5001 → 500.1
    10000 → 1000
    10001 → 1500.15
    

    你可能会想:

    5000这个数值应该享受10%的折扣才对。

    也许吧……但当前的代码并非如此设计的。

    通过编写测试用例,可以记录下这种行为特征:

    describe("calculateDiscount", () => {
      it.each([
        [5000, 0],
        [5001, 500.1],
        [10000, 1000],
        [10001, 1500.15],
      ])(
        "该函数应该为金额%d返回相应的折扣值",
        (amount, expected) => {
          expect(calculateDiscount(amount)).toBe(expected);
        }
      );
    });
    

    这样就能明确界定当前代码实现中的行为边界了。

    后来,如果业务方确认5000确实应该享受折扣,你就可以有针对性地修改代码:

    if (amount > 5000)
    

    改为:

    if (amount >= 5000)
    

    然后更新相关的测试用例。

    关键在于:这样的修改必须被清晰地记录下来。

    如果没有这些测试用例,这种改动很可能会在另一次与当前代码无关的重构过程中被无意中引入。

    梳理那些你尚未完全理解的边缘情况

    那些显而易见的案例并不总是最危险的;旧系统往往会在一些边界条件下出现故障。

    你需要关注以下这些值:

    0
    -1
    null
    空字符串
    最大值
    最小值
    精确的临界值
    未知状态
    重复的标识符
    缺失的相关记录
    

    假设你发现了这样的代码:

    function normalizeBalance(balance?: number) {
      if (!balance) {
        return 0;
      }
    
      return Math.round(balance * 100) / 100;
    }
    

    这意味着:

    undefined → 0
    0 → 0
    

    但同时也可能产生这样的结果:

    NaN → 0
    

    因为NaN在编程中被视为假值。

    这种设计是故意的吗?你现在可能还不知道。

    你可以通过编写测试用例来验证这一点:

    describe("normalizeBalance", () => {
      it("当输入为undefined时,函数应返回0", () => {
        expect(normalizeBalance(undefined)).toBe(0);
      });
    
      it("当输入为0时,函数应返回0", () => {
        expect(normalizeBalance(0)).toBe(0);
      });
    
      it("在当前的实现中,当输入为NaN时,函数也应返回0", () => {
        expect(normalizeBalance(Number.NaN)).toBe(0);
      });
    });
    

    名称确实很重要。

    请注意,我是这样写的:

    在当前的实现中

    我并不是在声称这种行为是正确的,只是在记录它而已。

    当某个测试描述的是一些有问题的行为时,这种区分就显得非常重要了。

    测试副作用,而不仅仅是返回值

    返回值只是代码行为的一种表现形式罢了。

    许多旧版本的函数通常都会产生副作用。

    举个例子:

    async function cancelOrder(order: Order) {
      order.status = "CANCELLED";
    
      await orders.save(order);
      await inventory.release(order.id);
      await audit.log("ORDER_CANCELLED", order.id);
    
      return order;
    }
    

    一个简单的测试可能只会检查:

    expect(result.status).toBe("CANCELLED");
    

    但在进行代码重构时,很可能会不小心删除某些代码:

    inventory.release()
    audit.log()
    

    而这样的测试仍然会通过。

    一个更完善的测试则能够捕捉到那些可观察到的副作用:

    it("能保留取消操作产生的副作用", async () => {
      const save = vispyOn(orders, "save");
      const release = vi.spyOn(inventory, "release");
      const log = vispyOn(audit, "log");
    
      const order = {
        id: "order-1",
        status: "APPROVED",
      } as Order;
    
      await cancelOrder(order);
    
      expect(save).BeenCalled();
    
      expect(release).BeenCalled("order-1");
    
      expect(log).BeenCalled(
        "ORDER_CANCELLED",
        "order-1"
      );
    });
    

    这并不意味着每一个内部调用都值得进行测试验证。

    关键在于,这个调用是否会产生在代码实现之外也重要的、可观察到的行为。

    如何测试那些依赖数据库的代码

    那些与数据库打交道的旧版本代码往往很难进行测试。

    假设你有这样的代码:

    async function activateCustomer(customerId: string) {
      const customer = await db.customers.findById(customerId);
    
      if (!customer) {
        throw new Error("未找到该客户");
      }
    
      await db.customers.update(customerId, {
        status: "ACTIVE",
        activatedAt: new Date(),
      });
    
      return db-customers.findById(customerId);
    }
    

    你有几种选择。

    使用集成测试

    如果数据库本身的行为也很重要,那么就可以在临时搭建的测试数据库上运行这些测试。

    例如:

    it("能够激活现有的客户", async () => {
      await seedCustomer({
        id: "customer-1",
        status: "PENDING",
      });
    
      const result = await activateCustomer("customer-1");
    
      expect(result?.status).toBe("ACTIVE");
      expect(result?.activatedAt).toBeTruthy();
    });
    
    <这种方式能够提供较高的可靠性,但测试过程可能会比较缓慢。>

    引入“接口分离”机制

    如果数据库访问导致测试变得不切实际,那么在进行功能测试之前,你可能需要对代码结构进行非常小的修改。

    例如:

    type CustomerRepository = {
      findById(id: string): Promise;
      update(
        id: string,
        data: Partial,
      ): Promise;
    };
    

    然后可以进行如下修改:

    async function activateCustomer(
      customerId: string,
      customers: CustomerRepository
    ) {
      // 保持原有功能不变
    }
    

    这种做法源自于对遗留代码的处理经验:创建一个接口分离点,这样就可以在不重新编写整个系统代码的情况下观察或替换某些功能。

    关键在于要确保这种准备工作仅仅是机械性的、不涉及业务逻辑的修改。

    在划定测试边界时,千万不要对业务逻辑进行重新设计。

    首先确保该功能可以被测试;然后对其进行功能验证;最后再进行代码重构。

    如何处理外部服务

    遗留代码通常会直接与以下外部系统进行交互:

    支付提供商
    电子邮件服务
    企业资源规划系统
    客户关系管理软件
    消息中间件
    云存储服务
    第三方API

    你通常不希望功能测试反复调用这些外部系统。

    因此,应该在外部系统与应用程序之间的接口处捕获这些交互行为。

    假设我们有如下代码:

    async function chargeOrder(order: Order) {
      const response = await stripe.charge({
        amount: order.total,
        currency: "usd",
        customerId: order.customerId,
      });
    
      await orders.markPaid(order.id, response.id);
    
      return response.id;
    }
    

    我们可以这样对这段代码进行功能测试:

    it("能够正确发送支付请求数据", async () => {
      const charge = vi
        .spyOn(stripe, "charge")
        .mockResolvedValue({
          id: "payment-123",
        });
    
      const markPaid = vi.spyOn(orders, "markPaid");
    
      const order = {
        id: "order-1",
        total: 5000,
        customerId: "customer-1",
      } as Order;
    
      await chargeOrder(order);
    
      expect(charge).toHaveBeenCalledWith({
        amount: 5000,
        currency: "usd",
        customerId: "customer-1",
      });
    
      expect(markPaid).toHaveBeenCalledWith(
        "order-1",
        "payment-123"
      );
    });
    

    这样的测试方式可以在不直接访问外部系统的情况下,验证应用程序与外部服务之间的交互是否正常。

    但需要注意的是:如果外部服务提供的功能本身非常重要,那么仅仅使用模拟对象可能还不够。

    你可能还需要进行以下类型的测试:

    • 提供者的沙盒测试

    • 合同规范测试

    • 集成测试

    • 数据结构验证测试

    功能测试并不能替代这些其他类型的测试。

    如何利用人工智能来发现需要进行的功能测试

    当你面对庞大的遗留代码时,人工智能可以帮助你判断哪些部分确实需要进行测试。

    假设你有一个包含400行代码的服务。

    不要这样问:

    为这个类编写单元测试。

    而应该用这样一个更具指导性的问题来询问:

    在不修改这个类的情况下分析它。
    
    找出在重构过程中可能会发生变化的可观察行为,并将这些行为分类为:
    
    1. 返回值;
    2> 状态变化;
    3> 持久化效果;
    4> 外部调用;
    5> 发生的事件;
    6> 异常情况;
    7> 边界条件。
    
    对于每一个拟定的测试用例:
    
    - 请引用相关的源代码;
    - 解释这个测试用例旨在验证什么行为;
    - 区分实际观察到的行为与推测出的行为。
    
    不要自己编造预期结果。

    最后这条指令非常重要:你希望人工智能帮助你确定应该观察哪些行为,而不是让它去决定软件应该做什么。

    另一个有用的提示是:

    检查现有的测试用例是否涵盖了这一功能。
    
    将测试所覆盖的行为与实现中的可观察行为进行对比,
    
    列出那些似乎没有得到测试覆盖的行为。
    
    现在还不要开始编写新的测试用例。

    这样往往比直接要求编写测试代码更有意义。

    首先找出现有的漏洞,然后再判断哪些漏洞是真正需要关注的。

    不要让人工智能编造预期行为

    当将人工智能应用于特性测试时,这条规则可能是最重要的,因此值得进一步探讨。

    假设人工智能读取了这样的代码:

    if (customer.age > 65) {
      discount = 0.2;
    }
    

    它可能会生成这样的测试用例:

    expect(calculateDiscount(65)).toBe(0.2);

    因为人工智能认为这里的业务规则应该是:

    年龄在65岁及以上的顾客可以享受折扣。

    但实际情况并非如此。

    实际的代码逻辑是:

    年龄为65岁时不享受折扣;
    年龄为66岁以上时可以享受折扣。

    特性测试中的预期结果应该基于实际存在的证据来确定。

    有用的证据包括:

    • 当前系统的运行情况

    • 现有的测试用例

    • 用于测试的辅助工具

    • 在生产环境中观察到的结果

    • 有文档记录的示例

    • 数据库中的数据状态

    • 系统过去的行为表现

    不要仅仅根据看起来合理的假设来推断预期结果。

    一个更好的指令应该是:

    对于每一个拟定的测试用例,请告诉我如何从当前的代码实现中得到预期的结果。
    
    除非预期结果可以直接从可执行的行为或现有的测试用例中得出,否则不要自己提出这个预期结果。

    这样,人工智能就能成为帮助设计测试的助手,而不是判断业务规则的对错权威。

    避免测试实现细节

    如果特性测试使现有的代码结构固定下来,那么这些测试反而可能带来负面影响。

    假设当前的实现如下:

    async function processOrder(order: Order) {
      validateOrder(order);
      calculatePrice(order);
      reserveInventory(order);
      saveOrder(order);
    }
    

    一种过于僵化的测试可能会要求这样进行验证:

    expect(validateOrder).toHaveBeenCalledBefore(calculatePrice);
    expect(calculatePrice).BeenCalledBefore(reserveInventory);
    expect(reserveInventory).BeenCalledBefore(saveOrder);
    

    不过,这种顺序是否真的重要呢?也许并不重要。

    如果用户只关心以下几点:

    订单的总金额是否正确
    库存是否已被预留
    订单信息是否被成功保存

    那么强制要求按照特定的顺序进行测试,反而会限制代码的重构工作。

    相比之下,我们应该更注重保护那些对外部用户具有实际意义的行为。

    例如:

    expectsavedOrder.total).toBe(9000);
    expect(inventory.reserve).BeenCalled(
      "product-1",
      2
    );
    expect(repository.save).toHaveBeenCalled();
    

    特性测试应该起到“安全网”的作用,而不是把原有的实现变成对所有内部决策的详细规定。

    当特性测试发现错误时

    最终,你肯定会遇到一些明显存在问题的行为。

    例如:

    function calculateFee(amount: number) {
      if (amount === 0) {
        return 100;
      }
    
      return amount * 0.02;
    }
    

    你会发现,对于零金额的交易,系统会收取固定的费用。显然,这种设计很不合理。

    那么,特性测试应该怎么做呢?

    首先,需要区分两个问题:

    1. 当前系统的实际行为是什么?

    2. 系统应该具备什么样的正确行为?

    特性测试用于验证第一个问题。

    it("currently charges 100 for a zero-value transaction", () => {
      expect(calculateFee(0)).toBe(100);
    });
    

    接下来,需要进一步调查这种行为是出于商业考虑、是一种历史遗留的临时解决方案,还是真正的缺陷。

    如果业务部门确认这确实是一个错误,就应该为此创建一个单独的修改任务。

    例如:

    it("does not charge a fee for a zero-value transaction", () => {
      expect(calculateFee(0)).toBe(0);
    });
    

    最后,再对生产环境中的代码进行相应的修改。

    对于这样一个简单的错误来说,这样的处理方式可能显得有些过于正式。但实际上,这种方式能够清晰地区分以下两种情况:

    在重构过程中发现的行为

    与:

    你应该测试多少种系统行为?

    你并不需要对所有内容都进行详细描述。试图保留每一个观察到的细节反而可能会造成另一种形式的障碍。

    应优先处理那些变化风险较高或对企业业务影响较大的功能。

    我通常会首先关注以下方面:

    • 财务计算

    • 状态变迁

    • 认证与授权机制

    • 外部合同条款

    • 队列处理及事件数据

    • 数据转换过程

    • 重试逻辑

    • 功能的幂等性

    • 相关法规要求

    • 错误处理机制

    而以下这些方面则可以暂时忽略:

    • 内部辅助函数的命名规则

    • 私有方法的实现结构

    • 没人会查看的日志信息

    • 临时对象的形态设计

    • 特定于实现的调用顺序

    一个有用的问题是:如果在重构过程中某个功能的实现方式发生了变化,外部人员能否察觉到这种变化?

    如果答案是肯定的,那么这个功能很可能值得被重点考虑。

    在重构过程中使用特性测试

    一旦建立了相应的特性测试套件,就应该尽量保持重构的范围较小。

    假设你从以下代码开始重构:
    async function processOrder(order: Order) {
      // 验证数据
      // 计算价格
      // 检查库存情况
      // 数据持久化处理
      // 发布相关事件
    }
    

    你可以先提取出价格计算相关的代码:

    function calculateOrderTotal(order: Order) {
      let total = order.subtotal;
    
      if (order.customer.type === "PREMIUM") {
        total *= 0.9;
      }
    
      if (
        order.country === "AR" && order.paymentMethod === "TRANSFER"
      ) {
        total -= 500;
      }
    
      return Math.max(total, 0);
    }
    

    运行特性测试套件,如果所有测试都通过,就可以继续下一步重构。

    接下来再单独测试库存处理相关功能,然后再进行数据持久化相关的测试。

    这样就能确保重构过程有条不紊地进行。

    进行小的结构修改
    ↓
    运行测试
    ↓
    观察测试结果
    ↓
    继续下一步重构

    如果某个测试失败了,那么出错的原因通常比较明显,排查范围也会较小。

    相比之下,如果直接重写2000行代码,之后才发现有47个测试出现了问题,那就麻烦多了。

    小的修改能将错误转化为有用的反馈信息,而大的改动则可能会让问题变得复杂不堪、难以解决。

    实用的特性测试工作流程

    以下是我在处理不熟悉的旧有功能时会采用的工作流程:

    1. 明确该功能的构成要素

    需要确定以下内容:

    入口点
    业务逻辑
    状态变迁
    副作用
    外部合同条款
    输出结果

    此时还不需要进行重构。

    2. 查找现有的测试用例

    寻找那些已经能够描述该功能的测试用例。

    需要关注的内容包括:

    正常运行路径  
    边界情况  
    错误现象  
    历史遗留的漏洞  
    集成测试时的表现

    不要不必要地重复那些有用的测试。

    3. 列出可观察的行为

    创建如下表格:

    。 > > >
    行为 依据是否受到保护?
    高级折扣功能 代码示例 + 实际运行结果 未受保护
    订单处理逻辑 代码 受保护
    转账调整机制 代码未受保护
    总额限制功能 代码未受保护
    保存状态逻辑 已有集成测试受保护

    现在你知道风险存在于哪些地方了。

    4. 确定测试边界

    判断哪些功能属于有用的测试边界:

    函数  
    服务  
    模块  
    端点  
    作业  
    工作流

    选择测试边界时应基于实际信心,而非某种测试理念。

    5. 捕捉当前系统行为

    运行现有的系统。

    尽可能使用真实的、可观察的输出结果。

    不要主观猜测系统的预期行为。

    6>添加极端边界情况

    针对以下内容进行测试:

    阈值  
    空值  
    null值  
    错误情况  
    重复操作  
    重试场景
    尤其是那些你打算修改的逻辑部分。

    7>捕捉副作用

    需要重点保护的包括:

    写操作  
    事件触发  
    消息传递  
    外部调用  
    状态变化
    而不仅仅是函数的返回值。

    8>标记不确定的行为

    使用明确的测试名称或文档来区分:

    已确认的业务规则
    与:
    当前观察到的系统行为

    9>逐步进行代码重构

    每次只进行一次结构上的修改。

    然后运行测试套件。 重复这个过程。

    10>在适当的情况下用功能意图取代描述性测试

    随着对系统理解的深入,一些描述性测试可以逐渐演变成真正的规范测试。

    例如,原本的测试可能是:
    对于这种输入,当前系统会返回0
    但后来你可以改写为:
    当金额低于高级折扣阈值时,该功能不会应用折扣
    这样的转变很有意义,因为它说明你对系统的理解正在加深,而不仅仅是简单地维持现有代码。
    

    描述性测试无法告诉你的信息

    描述性测试非常强大,但它们也存在一个重要的局限性:

    它们只能告诉你在观察到的那些情况下系统发生了什么,却不能自动解释其中的原因。 假设某个测试结果显示:
    阿根廷境内的转账订单会自动进行500单位的金额调整。

    这种测试能够保护这种现有行为不受影响。

    然而,它无法告诉你这种行为的变化究竟是由于以下哪些原因造成的:

    • 某种税收规定

    • 银行收取的手续费

    • 之前实施的促销活动

    • 针对特定客户制定的临时解决方案

    • 某些至今仍未被修复的程序漏洞

    因此,你仍然需要其他证据来验证这一结论:

    • 相关文档记录

    • Git代码历史记录

    • 生产环境中的数据监测结果

    • 领域专家的意见

    • 故障处理记录

    • 外部系统签订的合同内容

    正因为如此,特性测试才应该在充分了解代码结构之后进行,而不是取代这一过程。

    首先你需要发现这种行为的存在,然后采取措施保护它,最后再深入探究它的具体含义。

    特性测试是一种临时性的知识基础设施

    我认为还可以从另一个角度来理解这些测试的作用。

    旧有系统中包含了许多信息,但这些信息往往隐藏在具体的实现细节之中。而特性测试能够将这些信息转化为可执行的形式,从而使人们更容易理解它们的含义。

    在进行测试之前:

    没有人知道改变这个条件会导致什么问题发生。

    经过测试之后:

    当前代码的实际功能

    与:

    结论

    人工智能使得对旧有代码进行重构的工作变得更快了。

    它能够解释代码的功能、生成抽象概念、提取接口信息、建议模块的划分方式,并且能在几秒钟内重写大量代码。

    正因为如此,特性测试反而变得更加重要了,而不是相反。

    当开发新实现方案的成本降低后,人们需要关注的重点就变成了验证新方案是否仍然能够保持那些关键的功能。

    在考虑如何重构代码之前,先问问自己:

    这段代码目前具体起到了什么作用?

    接着再思考:

    如何证明修改之后这些功能仍然能够正常工作?

    而特性测试正好能够帮助你完成这些任务。

    他们并不会告诉你那些传统的行为方式是正确的,他们只会向你提供证明这些行为确实存在的证据而已。

    一旦这些证据具备了可执行性,你就可以更加放心地进行代码重构了。

    整个工作流程的顺序变为:

    理解问题 → 对问题进行特征分析 → 进行代码重构 → 最后验证结果

    人工智能可以加速这一工作流程中的每一个步骤。但最终是否决定某种行为应该被保留、哪种行为需要被修改,以及何时拥有足够的证据来做出这样的判断,这些仍然需要人类工程师来决策。

相关文章

技术实践

如何测试Flutter应用程序:单元测试、组件测试、黄金标准测试以及集成测试详解

第一次在技术面试中被问到“你的测试覆盖范围是多少?”时,我并没有一个令人满意的答案。 那时我已经发布了几款真正的Flutter应用程序,它们可以正常运行,用户也在使用它们。但我的测试工作其实非常有限——仅仅是为某个定价功能编写了少量的单元测试而已,并没有其他测试内容。 几个月后,我对其中一个任务完成流程进行了重构,这个修改在代码差异对比中看起来完全没问题,但却破坏了用户真正关心的一个功能:当用户将某项任务标记为已完成时,该任务并不会从错误的列表中移除。虽然没有任何程序崩溃,也没有任何错误日志被记录下来,但用户却开始不再信任这款应用程序。而我直到有用户把这个问题告诉了一位也是测试人员的朋友,才发

阅读全文
技术实践

如何防止人工智能代理伪造自己的测试结果

我确认,那些被标记为“已验证”的结果实际上都是虚假的。这样一来,原本五个伪造的响应中现在一个也没有了,测试过程进行得非常顺利,任务已经完成。 随后我在更严格的条件下再次进行了测试,得到的准确率为66.7%。然而,这个数值并不符合我的预期;我本来还打算在它旁边标注“人工验证”呢。出现这种结果的原因是:仅仅一次成功的测试并不能证明其结果就是正确的,“已验证”这个标签其实并不代表这一点。而我之前开发的那个工具,本来就是为了防止人们忽视这种区别,但这次我却差点忽略了它。 这种讽刺意味确实值得专门加以说明。 我开发的这个工具叫做“spec-verify”:它属于Claude Code系列技能之一。该工具

阅读全文
技术实践

如何使测试工作更具可持续性

通过采用可持续的测试策略,你可以跳过那些不必要的测试,确保问题能够尽快被发现,并且只运行那些会受到代码变更影响的测试。记录每次测试所消耗的能量,同时利用静态代码分析工具,可以帮助你找出效率低下的地方,从而指导优化工作。 作者:本·林德斯

阅读全文
技术实践

《消毒器使用手册:内存管理、初始化机制以及竞态条件问题》

一些最为危险的“原生故障”其实是由那些看似运行正常的程序所引发的。 加密操作会生成正确的密文,解析器也会拒绝处理格式错误的输入数据,缓存机制也能通过基准测试,候选发布版本也能通过所有的单元测试和集成测试。 然而,在这些看似正确的结果背后,某些组件仍然认为自己拥有已经被转移的对象控制权;某些成功路径在运行过程中并未初始化输出字段;有些终结器还在等待释放那些实际上已经由其他运行时机制控制的对象;还有两个线程会修改相同的状态,但调度器并没有选择那种会导致竞争条件出现的执行顺序。 然而,预期的输出结果中根本没有任何迹象能够揭示这些隐藏的问题。 第一次出现可观察到的故障可能要几个小时后才会发生:比如分配

阅读全文