重构与可测试性:重构代码使其更易于单元测试

说实话,我见过太多团队把单元测试当成「政治任务」来写。代码写完,测试补上,覆盖率达标,然后大家心照不宣地忽略那些脆弱的、跑一次要五分钟的测试用例。为什么会这样?

根源往往不在测试本身,而在代码的设计。代码如果天生就「难测」,你再怎么努力写测试,也只是在给烂代码披上一层虚假的安全感。今天我们就聊聊,怎么通过重构,让代码变得「天生好测」。

1. 为什么你的代码难测?

先看一个我经常遇到的场景。假设有个订单处理函数:

public class OrderService {
    public void processOrder(Order order) {
        // 直接调用数据库
        Database.save(order);
        // 直接发送邮件
        EmailService.send(order.getCustomerEmail(), "您的订单已处理");
        // 直接调用外部支付
        PaymentGateway.charge(order.getTotal());
    }
}

这个函数要测,你得准备数据库、启动邮件服务器、还得有个真实的支付网关。一次单元测试跑下来,比集成测试还重。说白了,它把三个外部依赖硬编码在函数体里,你没法替换,没法隔离。

我个人习惯把这种代码称为「水泥代码」——浇铸成型,敲不动,拆不开。

2. 重构的核心思路:依赖注入与接口隔离

要让代码可测,核心就一句话:把「怎么干」和「谁来干」分开

怎么干?业务逻辑。谁来干?外部依赖。业务逻辑应该只关心「我要保存订单」,而不关心「你是存MySQL还是存文件」。外部依赖应该可以被替换,测试时换成假的,上线时换成真的。

具体怎么做?我总结了三步:

  1. 识别硬编码依赖——找到代码里所有 new、static 调用、直接访问外部资源的地方。
  2. 抽象成接口——把每个依赖的行为提炼成接口。
  3. 通过构造函数注入——让调用方把依赖「递」进来,而不是自己「拿」。

重构后的代码长这样:

public class OrderService {
    private final OrderRepository repository;
    private final NotificationService notifier;
    private final PaymentProcessor processor;

    // 依赖通过构造函数注入
    public OrderService(OrderRepository repo, NotificationService notif, PaymentProcessor proc) {
        this.repository = repo;
        this.notifier = notif;
        this.processor = proc;
    }

    public void processOrder(Order order) {
        repository.save(order);
        notifier.send(order.getCustomerEmail(), "您的订单已处理");
        processor.charge(order.getTotal());
    }
}

现在测试时,我可以传假的实现进去:

public class OrderServiceTest {
    @Test
    void testProcessOrder() {
        // 用假依赖替换真实依赖
        OrderRepository fakeRepo = new FakeOrderRepository();
        NotificationService fakeNotif = new FakeNotificationService();
        PaymentProcessor fakeProc = new FakePaymentProcessor();

        OrderService service = new OrderService(fakeRepo, fakeNotif, fakeProc);
        Order order = new Order(100.0, "test@example.com");

        service.processOrder(order);

        // 验证假仓库里有没有保存订单
        assertTrue(fakeRepo.contains(order));
        // 验证假通知有没有发送
        assertTrue(fakeNotif.wasSentTo("test@example.com"));
    }
}

你看,测试不再需要数据库、邮件服务器、支付网关。跑一次只要几毫秒。这才是单元测试该有的样子。

3. 避坑指南:我曾经踩过的三个坑

坑一:过度抽象

我曾经把一个只有两个方法的类拆出五个接口,结果代码量翻了三倍,可读性直线下降。抽象不是越多越好,够用就行。一个依赖一个接口,别搞「接口的接口」。

坑二:注入泛滥

有个同事把每个基本类型都通过构造函数注入,比如注入一个 int 类型的「超时时间」。结果测试时传了 0,线上传了 3000,代码里到处都是 if (timeout > 0) 的判断。基本类型不需要注入,配置类才需要。

坑三:忽略静态方法

很多人重构时只关注 new 和直接调用,忘了静态方法。比如 DateTime.NowFile.ReadAllTextGuid.NewGuid()。这些也是硬编码依赖。我的做法是:把时间、随机数、文件系统都抽象成接口,测试时注入可控的假实现。

注意: 静态方法不是不能用,但如果你要测的代码里调用了静态方法,而这个静态方法又依赖外部资源(网络、磁盘、时间),那它就是个测试杀手。重构时优先处理这类调用。

4. 知识体系:可测试性重构的四个层次

下面这张图是我自己总结的,每次重构前我都会对照着看一遍:

可测试性重构四层次 L1: 依赖注入 将 new、静态调用、直接外部访问替换为接口 + 构造函数注入 L2: 接口粒度控制 每个接口只做一件事,避免「上帝接口」。一个依赖一个接口,不搞接口继承 L3: 副作用隔离 把「有副作用的操作」(写库、发消息、写文件)推到边界,核心逻辑保持纯函数 L4: 测试替身友好 接口设计时考虑 Mock/Stub 的易用性,避免 final 类、私有方法、静态工厂

这四个层次,我建议从 L1 开始,逐步往上。别想着一步到位,重构是个渐进的过程。你想想看,一个几千行的类,你不可能一天之内把所有依赖都抽干净。先挑最痛的地方下手——那些每次改代码都要跑十分钟测试的模块。

5. 实战:一个完整的重构案例

假设我们有一个报表生成器,它从数据库取数据,生成 CSV 文件,然后上传到 FTP:

public class ReportGenerator {
    public void generateAndUpload(String reportName) {
        // 1. 查数据库
        Connection conn = DriverManager.getConnection("jdbc:mysql://...");
        ResultSet rs = conn.createStatement().executeQuery("SELECT * FROM " + reportName);
        
        // 2. 生成 CSV
        StringBuilder csv = new StringBuilder();
        while (rs.next()) {
            csv.append(rs.getString(1)).append(",").append(rs.getString(2)).append("\n");
        }
        
        // 3. 上传 FTP
        FTPClient client = new FTPClient();
        client.connect("ftp.example.com");
        client.storeFile(reportName + ".csv", new ByteArrayInputStream(csv.toString().getBytes()));
    }
}

这个类有三个问题:数据库连接硬编码、CSV 生成和数据库查询耦合、FTP 上传硬编码。测试它?你得有数据库和 FTP 服务器。

重构步骤:

  1. 提取数据查询接口DataFetcher,测试时返回假数据。
  2. 提取文件上传接口FileUploader,测试时只记录调用。
  3. 把 CSV 生成逻辑独立出来:变成一个纯函数,只依赖输入数据,不依赖任何外部资源。

重构后:

public class ReportGenerator {
    private final DataFetcher fetcher;
    private final FileUploader uploader;

    public ReportGenerator(DataFetcher fetcher, FileUploader uploader) {
        this.fetcher = fetcher;
        this.uploader = uploader;
    }

    public void generateAndUpload(String reportName) {
        List<String[]> data = fetcher.fetch(reportName);
        String csv = CsvFormatter.format(data);  // 纯函数,极易测试
        uploader.upload(reportName + ".csv", csv.getBytes());
    }
}

// 纯函数,不需要任何 Mock
public class CsvFormatter {
    public static String format(List<String[]> rows) {
        StringBuilder sb = new StringBuilder();
        for (String[] row : rows) {
            sb.append(String.join(",", row)).append("\n");
        }
        return sb.toString();
    }
}

现在测试 CsvFormatter 不需要任何依赖,直接传数据就能测。测试 ReportGenerator 只需要两个简单的假对象。整个测试套件跑完,不超过 0.1 秒。

核心原则: 把「业务逻辑」和「外部世界」彻底分开。业务逻辑用纯函数表达,外部世界用接口抽象。测试时,你只需要测纯函数和接口的交互,不需要测外部世界。

6. 一些实用技巧

  • 用工厂方法代替 new:如果实在不想引入依赖注入框架,可以用工厂方法把 new 包起来,测试时重写工厂方法。
  • 避免在构造函数中做实际工作:构造函数只做赋值,不做数据库查询、文件读取等操作。否则你连对象都创建不了。
  • 善用 Optional 和 Result 类型:把可能失败的操作返回成 Optional 或 Result,而不是抛异常。这样测试时更容易模拟各种场景。
  • 小步重构,频繁测试:每次只改一个依赖,改完就跑测试。别一次性改完再跑,那样出了问题你都不知道是哪一步引入的。
我的个人习惯: 每次重构前,先写一个「测试清单」,列出当前代码中所有硬编码依赖。然后按依赖的「测试成本」从高到低排序,逐个击破。成本最高的往往是数据库和网络调用,优先处理它们。

嗯,关于可测试性重构,今天就聊这么多。记住一句话:好代码天生好测,烂代码怎么测都烂。重构不是为了测试而测试,是为了让代码本身更健康。测试只是健康的副产品。

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