
当被测类内部直接实例化依赖对象时,传统的模拟方法难以奏效。本文将探讨导致此问题的紧密耦合现象,并提供一种通过引入 `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 的实例:
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 对象的行为。
避免“模拟返回模拟”(Mocks Returning Mocks): 尽管上述解决方案有效,但值得注意的是,让一个模拟对象返回另一个模拟对象(即 bMock.foo() 返回 aMock)通常被认为是不良实践。这种设置会使测试变得脆弱、复杂,并与实现细节过度耦合。
理想情况下,我们应该尽量模拟那些直接与被测单元交互的依赖。如果 A 是一个简单的数据对象(POJO),或者其行为不复杂,可以考虑返回一个真实的 A 实例,或者一个行为非常简单的 A 模拟。如果 A 自身具有复杂的行为且需要被模拟,那么可能需要重新评估 SomeClass、B 和 A 之间的职责划分。
设计可测试的代码: 本教程的核心在于强调“设计可测试性”。依赖注入是实现这一目标的关键模式之一。通过将依赖对象的创建和管理外部化,我们不仅方便了测试,还降低了模块间的耦合度,提高了代码的灵活性和可维护性。在设计之初就考虑依赖注入,可以避免后期为了测试而进行大规模重构。
其他依赖注入方式: 除了 Supplier 模式,还有其他实现依赖注入的方式,例如:
当被测类内部直接实例化其依赖对象时,传统的模拟方法会因紧密耦合而失效。通过引入 java.util.function.Supplier 并采用依赖注入模式,我们可以有效地解耦对象的创建过程。这种重构策略允许我们在单元测试中注入模拟的依赖对象,从而实现对被测单元行为的精确控制。尽管“模拟返回模拟”可能带来一些复杂性,但通过仔细设计和权衡,依赖注入是构建可测试、可维护和高弹性代码的重要实践。
以上就是如何在单元测试中有效模拟方法返回的对象:解耦与依赖注入实践的详细内容,更多请关注php中文网其它相关文章!
每个人都需要一台速度更快、更稳定的 PC。随着时间的推移,垃圾文件、旧注册表数据和不必要的后台进程会占用资源并降低性能。幸运的是,许多工具可以让 Windows 保持平稳运行。
Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号