HOOOS

遗留代码难测?用依赖注入给它“开个刀”!

0 4 码农老王 遗留代码依赖注入单元测试
Apple

“遗留代码”,这四个字一听就让人头大,尤其是当它还难以测试时,那简直是噩梦。每次改动都小心翼翼,生怕“一不小心”就埋下了隐形炸弹。你是不是也有过这样的经历?想给老代码加测试,却发现它像个紧密耦合的铁疙瘩,牵一发而动全身?别担心,这几乎是每个程序员都逃不过的“劫”。今天,我就来聊聊如何驯服这些“野马”,让它们变得温顺可测。

为什么遗留代码这么难测?

我们先搞清楚病根在哪里。通常,遗留代码的难测性主要来源于以下几点:

  1. 紧耦合: 代码模块之间像藤蔓一样缠绕,一个类可能直接依赖并创建了大量外部资源(数据库连接、文件系统、网络服务等),导致无法独立测试。
  2. 全局状态和副作用: 滥用静态变量、单例模式,或者函数在执行时会修改外部状态,使得测试结果难以预测和隔离。
  3. 缺乏抽象: 具体实现与业务逻辑混杂,没有清晰的接口或抽象层,导致难以替换依赖。
  4. 与外部环境强绑定: 代码直接与文件系统、数据库、网络、时间等外部资源交互,而这些在单元测试中是需要被隔离的。

核心思想:找到“接缝”(Seams),然后“注入”!

要让代码可测,核心就是要隔离。我们需要在代码中找到或创造出一些“接缝”(Seams),这些接缝允许我们在测试时,用我们控制的“假”对象来替换掉那些难以控制的“真”依赖。而“依赖注入”(Dependency Injection,简称DI)正是制造和利用这些接缝的强大工具。

什么是依赖注入?

简单来说,依赖注入就是不在类内部自己创建它所依赖的对象,而是由外部将这些依赖“喂”给它。 想象一下,一个厨师需要一把刀。如果他每次做菜都自己去铁匠铺打一把刀,那效率可想而知。更好的方式是,厨房管理者(外部)给他准备好各种刀具,他需要哪把就用哪把。这里的“厨房管理者”就是实现依赖注入的“容器”或“机制”。

依赖注入如何让测试变简单?

当一个类不再自己创建依赖,而是通过构造函数、方法参数或属性接收依赖时,我们在测试时就可以:

  1. 替换真实依赖: 将数据库连接、网络请求、文件操作等耗时、不确定、有副作用的真实依赖,替换成受我们控制的“测试替身”(Test Double),比如桩(Stub)模拟对象(Mock Object)
  2. 隔离测试单元: 确保我们只测试当前被测类(Unit Under Test,UUT)的逻辑,而不会受到其依赖对象行为的影响。
  3. 提高测试速度和稳定性: 测试替身通常只是内存中的简单对象,执行速度快,且行为稳定可预测。

实战:给遗留代码做“依赖注入手术”

下面是一个循序渐进的思路,你可以尝试将它应用到你的遗留代码中:

第一步:识别不可测的代码块

找到那些让你头疼、不敢改动、或每次改动都容易出问题的代码。通常它们都拥有长长的函数、复杂的逻辑、直接与外部系统交互。

第二步:找出外部依赖

仔细审视这些不可测的代码块,列出它们所直接或间接依赖的所有外部资源或服务,例如:

  • new DatabaseConnection()
  • File.ReadAllText()
  • HttpClient.Send()
  • new SmtpClient()
  • DateTime.Now (是的,时间也是一个外部依赖,因为它不可预测)
  • 以及其他业务模块中,你无法控制其行为的复杂对象。

第三步:提取接口或抽象类

这是关键一步。对于每个找出的外部依赖,尝试为其定义一个接口(或抽象类)。例如,如果你的代码直接调用了Logger的静态方法,或者直接new了一个FileProcessor

  • 糟糕的遗留代码示例:

    public class LegacyService {
        public void processData(String data) {
            // 直接创建并使用具体实现
            DatabaseUtil db = new DatabaseUtil(); 
            db.save(data);
            // ...
            LogManager.log("Data processed"); // 静态调用
        }
    }
    
  • 改造思路:

    1. DatabaseUtil定义一个IDatabaseRepository接口。
    2. LogManager定义一个ILogger接口。

第四步:修改构造函数或方法参数,引入依赖

现在,将这些接口作为参数,通过构造函数方法参数传入你的LegacyService中。

  • 改造后的代码示例:
    // 定义接口
    public interface IDatabaseRepository {
        void save(String data);
    }
    
    public interface ILogger {
        void log(String message);
    }
    
    // 遗留服务通过构造函数接收依赖
    public class LegacyService {
        private IDatabaseRepository dbRepository;
        private ILogger logger;
    
        // 依赖通过构造函数注入
        public LegacyService(IDatabaseRepository dbRepository, ILogger logger) {
            this.dbRepository = dbRepository;
            this.logger = logger;
        }
    
        public void processData(String data) {
            dbRepository.save(data);
            // ...
            logger.log("Data processed");
        }
    }
    

现在,你的LegacyService不再直接创建DatabaseUtil或调用LogManager的静态方法了。它只是“知道”自己需要一个IDatabaseRepository和一个ILogger,而具体是谁提供、怎么提供,它不关心。这就是解耦

第五步:编写单元测试

有了接口和依赖注入,编写单元测试就变得轻而易举。你可以创建IDatabaseRepositoryILogger测试替身(通常使用模拟框架,如Mockito、NSubstitute等,或手动实现)。

  • 单元测试示例:
    // 假设使用一个测试框架
    public class LegacyServiceTest {
        @Test
        public void processData_shouldSaveDataAndLogMessage() {
            // 1. 创建模拟对象 (Test Doubles)
            IDatabaseRepository mockDbRepo = mock(IDatabaseRepository.class); // 使用Mock框架
            ILogger mockLogger = mock(ILogger.class);
    
            // 2. 注入模拟对象到被测服务
            LegacyService service = new LegacyService(mockDbRepo, mockLogger);
    
            // 3. 执行被测方法
            service.processData("test data");
    
            // 4. 验证行为 (确保方法被调用,且参数正确)
            verify(mockDbRepo).save("test data"); // 验证mockDbRepo的save方法被调用
            verify(mockLogger).log("Data processed"); // 验证mockLogger的log方法被调用
        }
    }
    

看,现在你可以完全控制LegacyService的依赖行为了!在测试中,IDatabaseRepository不会真的去碰数据库,ILogger也不会真的写日志文件。它们只是按照你定义好的模拟行为来响应,使得你的测试快速、独立、可靠

其他提升遗留代码可测性的策略:

除了依赖注入,还有一些辅助策略可以帮助你:

  • 参数化构造函数: 如果一个类有太多的参数,考虑使用建造者模式或者参数对象。
  • 提取方法: 将一个大方法拆分成多个小方法,每个方法只做一件事。小方法更容易测试,也更容易被依赖注入。
  • 包装外部API: 如果某个类直接调用了无法控制的第三方库或系统API,可以为其编写一个薄薄的包装类(Wrapper),然后将这个包装类的接口注入到你的业务逻辑中。
  • 引入“测试保护”(Characterization Tests): 在你开始重构之前,为现有代码编写一些高层次的集成测试或端到端测试,确保你在重构过程中没有改变代码的外部行为。这就像给老代码拍了一张“快照”,确保你重构后的代码能通过这张“快照”的验证。

小结

处理难以测试的遗留代码是一个漫长而需要耐心的过程,但每一步的改进都会降低未来的风险和维护成本。依赖注入是其中一个极其有效的工具,它能帮助你解耦代码,让独立的单元测试成为可能。

记住,小步快跑,持续改进。不要试图一次性重构整个系统,而是从最痛、最常修改、最核心的模块开始,一点点地引入接缝,一点点地增加测试覆盖率。当你发现自己能信心十足地修改老代码时,那种成就感,是无与伦比的!祝你好运!

点评评价

captcha
健康