首页 > Java > java教程 > 正文

如何在单元测试中有效模拟方法返回的对象:解耦与依赖注入实践

心靈之曲
发布: 2025-11-13 10:42:16
原创
111人浏览过

如何在单元测试中有效模拟方法返回的对象:解耦与依赖注入实践

当被测类内部直接实例化依赖对象时,传统的模拟方法难以奏效。本文将探讨导致此问题的紧密耦合现象,并提供一种通过引入 `supplier` 接口进行依赖注入的重构策略。通过解耦对象的创建过程,我们能够有效地在单元测试中模拟依赖行为,从而提高代码的可测试性和维护性。

引言:理解测试中的模拟挑战

在单元测试中,我们经常需要模拟依赖对象的行为,以隔离被测单元并确保测试的专注性。然而,当被测类在内部直接创建其依赖对象的实例时,这种传统的模拟方法会遇到障碍。考虑以下 Java 代码示例:

class A {
    public void foo() {
        System.out.println("A's foo called");
    }
}

class B {
    public A foo() {
        System.out.println("B's foo called");
        return new A(); // B's foo returns a new A
    }
}

class SomeClass {
    public void doSomeThing() {
        B b = new B(); // SomeClass internally creates B
        A a = b.foo();
        a.foo();
    }
}
登录后复制

假设我们希望测试 SomeClass 的 doSomeThing 方法,并模拟 B.foo() 返回的 A 对象。直观的尝试可能是使用 @Mock 注解来模拟 A,但这种方法通常会失败:

import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;

public class SomeClassTest {

    @Mock
    A aMock; // 尝试模拟 A

    @InjectMocks
    SomeClass someClass;

    @Test
    void testDoSomeThingFails() {
        // 尝试配置 aMock 的行为,但这个 aMock 实例并不会被 SomeClass 使用
        Mockito.when(aMock.foo()).thenReturn(/* 某些值或行为 */ null); 

        // 这里的测试会失败,因为 SomeClass 内部创建了 B 和 A 的实例
        // 它并不知道我们创建的 aMock
        assertDoesNotThrow(() -> someClass.doSomeThing());
    }
}
登录后复制

上述测试失败的原因在于 SomeClass 与 B 之间存在紧密的耦合。SomeClass 在 doSomeThing() 方法内部通过 new B() 直接创建了 B 的实例,进而调用 b.foo() 获取 A 的实例。测试框架无法拦截或替换这些在方法内部创建的具体实例,因此我们外部创建的 aMock 和 bMock 都不会被 SomeClass 所使用。

解决方案:通过依赖注入解耦

要解决这种紧密耦合带来的测试难题,核心思想是将对依赖对象的创建控制权从被测类内部转移到外部。这正是依赖注入(Dependency Injection, DI)模式所倡导的。通过允许外部在构造时或运行时提供依赖,我们可以轻松地在测试中注入模拟对象。

一种简洁有效的解耦策略是引入 java.util.function.Supplier 接口。Supplier 是一个函数式接口,它不接受任何参数并返回一个结果,非常适合用来“供应”或“提供”一个对象实例。

我们将重构 SomeClass,使其不再直接创建 B 的实例,而是通过一个 Supplier 来获取 B 的实例:

有道小P
有道小P

有道小P,新一代AI全科学习助手,在学习中遇到任何问题都可以问我。

有道小P 64
查看详情 有道小P
import java.util.function.Supplier;

class SomeClass {
  private final Supplier<? extends B> bFactory;

  // 构造函数:允许外部注入如何创建 B 的逻辑
  public SomeClass(final Supplier<? extends B> bFactory) {
    this.bFactory = bFactory;
  }

  // 无参构造函数:为了向后兼容性或生产环境的便利
  // 在生产代码中,它会使用默认的 B::new 来创建 B 的实例
  public SomeClass() {
    this(B::new); 
  }

  public void doSomeThing() {
    // 通过注入的 Supplier 获取 B 的实例
    B b = this.bFactory.get(); 
    A a = b.foo();
    a.foo();
  }
}
登录后复制

在重构后的 SomeClass 中,B 对象的创建逻辑被抽象为 bFactory。在生产环境中,可以通过 new SomeClass(B::new) 来保持原有行为;而在测试中,我们可以注入一个返回模拟 B 对象的 Supplier。

测试重构后的代码

有了这种解耦,我们现在可以轻松地在单元测试中模拟 B 和 A 的行为:

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class SomeClassRefactoredTest {

    @Test
    void testDoSomeThingWithMocks() {
        // 1. 创建 A 的模拟对象
        final A aMock = mock(A.class);
        // 2. 配置 A 模拟对象的行为 (如果需要)
        // 例如:当 aMock.foo() 被调用时,不抛出异常
        when(aMock.foo()).thenAnswer(invocation -> {
            System.out.println("Mocked A's foo called");
            return null; // 或者返回其他期望值
        });

        // 3. 创建 B 的模拟对象
        final B bMock = mock(B.class);
        // 4. 配置 B 模拟对象的 foo() 方法,使其返回 aMock
        when(bMock.foo()).thenReturn(aMock);

        // 5. 实例化 SomeClass,注入一个返回 bMock 的 Supplier
        final SomeClass someClass = new SomeClass(() -> bMock);

        // 6. 执行测试并断言
        assertDoesNotThrow(() -> someClass.doSomeThing());

        // 验证 mock 对象是否被正确调用 (可选)
        Mockito.verify(bMock).foo();
        Mockito.verify(aMock).foo();
    }
}
登录后复制

通过这种方式,我们成功地控制了 SomeClass 内部对 B 实例的获取过程,从而能够注入一个模拟的 B 对象,并进一步控制 B 返回的 A 对象的行为。

最佳实践与注意事项

  1. 避免“模拟返回模拟”(Mocks Returning Mocks): 尽管上述解决方案有效,但值得注意的是,让一个模拟对象返回另一个模拟对象(即 bMock.foo() 返回 aMock)通常被认为是不良实践。这种设置会使测试变得脆弱、复杂,并与实现细节过度耦合。

    • 脆弱性: 如果 B.foo() 的实际实现发生变化(例如,它开始返回 C 而不是 A 的子类),即使功能不变,测试也可能中断。
    • 复杂性: 增加了测试的理解难度,需要跟踪多个模拟对象的配置。
    • 耦合性: 测试不仅依赖于 SomeClass 的行为,还依赖于 B 和 A 之间的具体交互模式。

    理想情况下,我们应该尽量模拟那些直接与被测单元交互的依赖。如果 A 是一个简单的数据对象(POJO),或者其行为不复杂,可以考虑返回一个真实的 A 实例,或者一个行为非常简单的 A 模拟。如果 A 自身具有复杂的行为且需要被模拟,那么可能需要重新评估 SomeClass、B 和 A 之间的职责划分。

  2. 设计可测试的代码: 本教程的核心在于强调“设计可测试性”。依赖注入是实现这一目标的关键模式之一。通过将依赖对象的创建和管理外部化,我们不仅方便了测试,还降低了模块间的耦合度,提高了代码的灵活性和可维护性。在设计之初就考虑依赖注入,可以避免后期为了测试而进行大规模重构。

  3. 其他依赖注入方式: 除了 Supplier 模式,还有其他实现依赖注入的方式,例如:

    • 构造函数注入: 直接在构造函数中传入依赖对象实例(适用于依赖是具体实例而非创建逻辑)。
    • Setter 注入: 通过公共的 setter 方法设置依赖对象。
    • 接口注入: 依赖对象实现特定接口,被测类通过该接口获取依赖。
    • 依赖注入框架: 使用 Spring、Guice 等框架自动化依赖的创建和注入过程,尤其适用于大型复杂应用。

总结

当被测类内部直接实例化其依赖对象时,传统的模拟方法会因紧密耦合而失效。通过引入 java.util.function.Supplier 并采用依赖注入模式,我们可以有效地解耦对象的创建过程。这种重构策略允许我们在单元测试中注入模拟的依赖对象,从而实现对被测单元行为的精确控制。尽管“模拟返回模拟”可能带来一些复杂性,但通过仔细设计和权衡,依赖注入是构建可测试、可维护和高弹性代码的重要实践。

以上就是如何在单元测试中有效模拟方法返回的对象:解耦与依赖注入实践的详细内容,更多请关注php中文网其它相关文章!

最佳 Windows 性能的顶级免费优化软件
最佳 Windows 性能的顶级免费优化软件

每个人都需要一台速度更快、更稳定的 PC。随着时间的推移,垃圾文件、旧注册表数据和不必要的后台进程会占用资源并降低性能。幸运的是,许多工具可以让 Windows 保持平稳运行。

下载
来源:php中文网
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
最新问题
热门推荐
开源免费商场系统广告
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板
关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新 English
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送
PHP中文网APP
随时随地碎片化学习

Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号