16、测试驱动重构:测试金字塔实战、单元测试编写策略(JUnit/pytest)、测试替身(Mock/Stub/Fake)、重构中的测试保护网

说实话,很多团队不敢重构,不是因为代码烂,而是因为没测试。代码烂可以忍,但改出 bug 没人扛得住。我见过太多项目,重构到一半,测试全红,然后回滚,然后继续烂下去。

所以这一章,咱们聊聊怎么用测试给重构铺一条安全的路。说白了,就是给你的代码装个安全气囊。

16.1 测试金字塔:别把鸡蛋放在一个篮子里

先说说测试金字塔。这个概念你可能听过,但真正用好的团队不多。我见过不少项目,单元测试没几个,集成测试倒写了一堆。跑一次要半小时,谁还愿意跑?

测试金字塔长这样:

单元测试 (数量多,速度快) 集成测试 (数量适中) 端到端测试 (数量少,速度慢) 测试金字塔:越往上越少,越往下越多

我个人习惯的比例是:70% 单元测试,20% 集成测试,10% 端到端测试。为什么?因为单元测试跑得快,几秒钟就能跑完。你想想看,每次改完代码,几秒钟就知道有没有搞坏东西,这感觉多爽。

核心原则:重构时,单元测试是你的第一道防线。集成测试是第二道。端到端测试是最后一道。别指望端到端测试帮你抓 bug,它太慢了,跑一次够你喝杯咖啡。

16.2 单元测试编写策略:JUnit 和 pytest

单元测试怎么写?我总结了一个口诀:「一个方法,一个行为,一个断言」。别在一个测试里测一堆东西,那样出了问题你都不知道是哪个环节挂了。

16.2.1 Java 版:JUnit 5

先看个 Java 的例子。假设我们有个订单服务,要计算折扣:

public class OrderService {
    public double calculateDiscount(Order order) {
        if (order.getTotalAmount() > 1000) {
            return order.getTotalAmount() * 0.1;
        }
        return 0;
    }
}

对应的单元测试:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class OrderServiceTest {

    @Test
    void shouldGive10PercentDiscountWhenAmountExceeds1000() {
        OrderService service = new OrderService();
        Order order = new Order(1500);

        double discount = service.calculateDiscount(order);

        assertEquals(150.0, discount, 0.01);
    }

    @Test
    void shouldGiveNoDiscountWhenAmountIsBelow1000() {
        OrderService service = new OrderService();
        Order order = new Order(500);

        double discount = service.calculateDiscount(order);

        assertEquals(0.0, discount, 0.01);
    }
}

注意看,每个测试方法的名字都写得很清楚。我见过有人写 test1test2,跑挂了还得去翻代码才知道测的啥。别这样,名字就是文档。

16.2.2 Python 版:pytest

Python 这边,pytest 用起来更简洁。同样的逻辑:

# order_service.py
class OrderService:
    def calculate_discount(self, order):
        if order.total_amount > 1000:
            return order.total_amount * 0.1
        return 0

# test_order_service.py
import pytest

def test_should_give_10_percent_discount_when_amount_exceeds_1000():
    service = OrderService()
    order = Order(total_amount=1500)

    discount = service.calculate_discount(order)

    assert discount == 150.0

def test_should_give_no_discount_when_amount_is_below_1000():
    service = OrderService()
    order = Order(total_amount=500)

    discount = service.calculate_discount(order)

    assert discount == 0.0

小技巧:pytest 支持参数化测试,可以少写很多重复代码。比如测边界值,用 @pytest.mark.parametrize 一行搞定。

16.3 测试替身:Mock、Stub、Fake 怎么选?

重构时最头疼的是什么?外部依赖。数据库、第三方 API、文件系统……这些东西在测试里不好搞。这时候就需要测试替身了。

我简单说说三者的区别:

类型 作用 什么时候用
Stub 提供预设的返回值 你只关心输入输出,不关心方法有没有被调用
Mock 验证行为(比如方法被调用了多少次) 你需要确保某个方法确实被调用了
Fake 轻量级的真实实现(比如内存数据库) 你需要一个能正常工作的替代品

举个例子。假设你的订单服务要调用支付网关:

public class OrderService {
    private PaymentGateway paymentGateway;

    public OrderService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    public boolean processOrder(Order order) {
        // 业务逻辑...
        return paymentGateway.charge(order.getAmount());
    }
}

用 Mockito 写个 Mock:

@Test
void shouldChargePaymentWhenProcessingOrder() {
    PaymentGateway mockGateway = mock(PaymentGateway.class);
    when(mockGateway.charge(anyDouble())).thenReturn(true);

    OrderService service = new OrderService(mockGateway);
    Order order = new Order(500);

    boolean result = service.processOrder(order);

    assertTrue(result);
    verify(mockGateway).charge(500);  // 验证确实调用了 charge 方法
}

避坑指南:我曾经在一个项目里过度使用 Mock,结果测试全绿,但一上线就挂。为什么?因为 Mock 的行为和真实实现不一致。记住:Mock 只验证交互,不验证逻辑。关键的业务逻辑,该用集成测试还是得用。

16.4 重构中的测试保护网

好了,前面铺垫了这么多,终于到正题了。重构时怎么用测试保护自己?我总结了一个三步法:

  1. 先写测试,再重构 —— 这是铁律。没有测试保护,别动代码。
  2. 小步提交,频繁运行 —— 改一行代码,跑一次测试。别攒一堆再跑,到时候都不知道哪出问题了。
  3. 测试也重构 —— 代码重构了,测试也得跟着改。别让测试变成技术债务。

我举个例子。假设你要把一个大的 if-else 改成策略模式。步骤是这样的:

// 重构前
public double calculatePrice(String userType, double amount) {
    if ("VIP".equals(userType)) {
        return amount * 0.8;
    } else if ("NORMAL".equals(userType)) {
        return amount * 0.9;
    } else {
        return amount;
    }
}

先写测试:

@Test
void shouldApplyVIPDiscount() {
    PriceCalculator calculator = new PriceCalculator();
    assertEquals(80, calculator.calculatePrice("VIP", 100));
}

@Test
void shouldApplyNormalDiscount() {
    PriceCalculator calculator = new PriceCalculator();
    assertEquals(90, calculator.calculatePrice("NORMAL", 100));
}

@Test
void shouldNotApplyDiscountForGuest() {
    PriceCalculator calculator = new PriceCalculator();
    assertEquals(100, calculator.calculatePrice("GUEST", 100));
}

然后开始重构。每次只改一点点,改完就跑测试。直到所有测试都通过,重构完成。

记住:测试保护网不是一次性的。它需要维护。每次重构完,检查一下测试覆盖率。如果发现有些分支没测到,补上。我习惯在重构前后各跑一次覆盖率报告,确保没有遗漏。

最后说一句。测试驱动重构,说白了就是「先有安全,再求效率」。没有测试的重构,就像走钢丝没有安全网。你技术再好,也难免失手。所以,别偷懒,把测试写好。你的代码会感谢你的。


公众号:蓝海资料掘金营,微信deep3321