重构与可测试性:重构代码使其更易于单元测试
说实话,我见过太多团队把单元测试当成「政治任务」来写。代码写完,测试补上,覆盖率达标,然后大家心照不宣地忽略那些脆弱的、跑一次要五分钟的测试用例。为什么会这样?
根源往往不在测试本身,而在代码的设计。代码如果天生就「难测」,你再怎么努力写测试,也只是在给烂代码披上一层虚假的安全感。今天我们就聊聊,怎么通过重构,让代码变得「天生好测」。
1. 为什么你的代码难测?
先看一个我经常遇到的场景。假设有个订单处理函数:
public class OrderService {
public void processOrder(Order order) {
// 直接调用数据库
Database.save(order);
// 直接发送邮件
EmailService.send(order.getCustomerEmail(), "您的订单已处理");
// 直接调用外部支付
PaymentGateway.charge(order.getTotal());
}
}
这个函数要测,你得准备数据库、启动邮件服务器、还得有个真实的支付网关。一次单元测试跑下来,比集成测试还重。说白了,它把三个外部依赖硬编码在函数体里,你没法替换,没法隔离。
我个人习惯把这种代码称为「水泥代码」——浇铸成型,敲不动,拆不开。
2. 重构的核心思路:依赖注入与接口隔离
要让代码可测,核心就一句话:把「怎么干」和「谁来干」分开。
怎么干?业务逻辑。谁来干?外部依赖。业务逻辑应该只关心「我要保存订单」,而不关心「你是存MySQL还是存文件」。外部依赖应该可以被替换,测试时换成假的,上线时换成真的。
具体怎么做?我总结了三步:
- 识别硬编码依赖——找到代码里所有 new、static 调用、直接访问外部资源的地方。
- 抽象成接口——把每个依赖的行为提炼成接口。
- 通过构造函数注入——让调用方把依赖「递」进来,而不是自己「拿」。
重构后的代码长这样:
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.Now、File.ReadAllText、Guid.NewGuid()。这些也是硬编码依赖。我的做法是:把时间、随机数、文件系统都抽象成接口,测试时注入可控的假实现。
4. 知识体系:可测试性重构的四个层次
下面这张图是我自己总结的,每次重构前我都会对照着看一遍:
这四个层次,我建议从 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 服务器。
重构步骤:
- 提取数据查询接口:
DataFetcher,测试时返回假数据。 - 提取文件上传接口:
FileUploader,测试时只记录调用。 - 把 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