如何在较长调用链末端 stub 外部依赖?
结论
将外部命令封装成一个窄接口,并在组装对象图时注入 B。测试时无须从 CUT 开始沿调用链逐层传递 stub。直接用 stub 构造 B,再依次构造 A 和 CUT 即可。
依赖关系如下:
CUT -> A -> B -> CommandExecutor
^
|
ShellCommandExecutor
StubCommandExecutor
注入的是对象的构造依赖,stub 不会成为业务方法中逐层传递的参数。生产环境和测试环境各自在自己的 composition root 中组装对象。
如果 CUT、A 和 B 都在内部通过 new 创建下一层对象,测试就很难替换最深层的依赖。若不调整对象的创建方式,只能借助 static field、反射、字节码 mock 或全局 ServiceLoader 等隐式机制,维护起来通常更麻烦,也更容易出问题。
为外部命令定义边界接口
不建议直接 mock ProcessBuilder 或 Runtime.exec()。这些 API 较宽,属于底层实现细节。可以定义一个仅包含业务所需操作的接口:
public interface CommandExecutor {
CommandResult execute(CommandRequest request) throws Exception;
}
请求和结果也应使用明确的类型,以免业务代码到处处理 shell 字符串:
import java.util.List;
public final class CommandRequest {
private final List<String> arguments;
public CommandRequest(List<String> arguments) {
this.arguments = List.copyOf(arguments);
}
public List<String> getArguments() {
return arguments;
}
}
public final class CommandResult {
private final int exitCode;
private final String stdout;
private final String stderr;
public CommandResult(int exitCode, String stdout, String stderr) {
this.exitCode = exitCode;
this.stdout = stdout;
this.stderr = stderr;
}
public int getExitCode() {
return exitCode;
}
public String getStdout() {
return stdout;
}
public String getStderr() {
return stderr;
}
}
生产实现负责启动实际进程:
import java.io.IOException;
public final class ShellCommandExecutor implements CommandExecutor {
@Override
public CommandResult execute(CommandRequest request)
throws IOException, InterruptedException {
Process process = new ProcessBuilder(request.getArguments()).start();
String stdout = new String(process.getInputStream().readAllBytes());
String stderr = new String(process.getErrorStream().readAllBytes());
int exitCode = process.waitFor();
return new CommandResult(exitCode, stdout, stderr);
}
}
这段示例使用了 readAllBytes() 和 List.copyOf()。较早版本的 Java 可能不支持这两个方法,需要换成相应版本可用的读取和复制方式。正式实现还要处理超时、字符集和输出量,并同时读取 stdout 与 stderr,避免子进程因缓冲区写满而阻塞。
在 B 中使用构造器注入
真正执行外部命令的是 B,因此应由它声明这项依赖:
import java.util.List;
import java.util.Objects;
public final class B {
private final CommandExecutor commandExecutor;
public B(CommandExecutor commandExecutor) {
this.commandExecutor = Objects.requireNonNull(commandExecutor);
}
public String performOperation(String input) throws Exception {
CommandRequest request = new CommandRequest(
List.of("/opt/example/bin/tool", input)
);
CommandResult result = commandExecutor.execute(request);
if (result.getExitCode() != 0) {
throw new IllegalStateException(
"External command failed: " + result.getStderr()
);
}
return result.getStdout();
}
}
A 只依赖已经组装完成的 B:
import java.util.Objects;
public final class A {
private final B b;
public A(B b) {
this.b = Objects.requireNonNull(b);
}
public String process(String input) throws Exception {
return b.performOperation(input);
}
}
CUT 也只接收自己的直接依赖:
import java.util.Objects;
public class CUT {
private final A a;
public CUT(A a) {
this.a = Objects.requireNonNull(a);
}
public String execute(String input) throws Exception {
return a.process(input);
}
}
这样一来,A 和 CUT 无须了解 CommandExecutor,业务方法也不用增加专门传递 stub 的参数。
分别组装生产对象和测试对象
生产环境可以集中创建对象图:
public final class ProductionComponents {
private ProductionComponents() {
}
public static CUT createCUT() {
CommandExecutor executor = new ShellCommandExecutor();
B b = new B(executor);
A a = new A(b);
return new CUT(a);
}
}
测试环境则使用 stub 组装另一套对象图:
import java.util.ArrayList;
import java.util.List;
public final class StubCommandExecutor implements CommandExecutor {
private final List<CommandRequest> requests = new ArrayList<>();
private CommandResult nextResult =
new CommandResult(0, "stub output", "");
@Override
public CommandResult execute(CommandRequest request) {
requests.add(request);
return nextResult;
}
public void setNextResult(CommandResult nextResult) {
this.nextResult = nextResult;
}
public List<CommandRequest> getRequests() {
return List.copyOf(requests);
}
}
JUnit 测试可以直接设置返回结果,并检查外部调用的参数:
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class MyIntegrationTest {
@Test
void processesDataWithoutStartingExternalCommand() throws Exception {
StubCommandExecutor stub = new StubCommandExecutor();
stub.setNextResult(new CommandResult(0, "expected output", ""));
B b = new B(stub);
A a = new A(b);
CUT cut = new CUT(a);
String result = cut.execute("input-value");
assertEquals("expected output", result);
assertEquals(
List.of("/opt/example/bin/tool", "input-value"),
stub.getRequests().get(0).getArguments()
);
}
}
这类测试保留了 CUT -> A -> B 之间的真实协作,仅在系统边界处用 stub 替换外部进程。
NiFi processor 由框架创建时怎么处理
NiFi processor 通常由框架或 test runner 实例化,不能假设所有版本都支持带参数的构造器。TestRunner 具体支持哪些创建方式,应以项目所用 NiFi 版本的 API 为准。
如果当前 API 支持传入 processor 实例,可以先组装依赖,再将实例交给 test runner。如果框架要求使用无参构造器,可以把对象创建集中到一个可覆盖的工厂方法中:
public class MyProcessor extends AbstractProcessor {
private A a;
@Override
protected void init(ProcessorInitializationContext context) {
this.a = createA();
}
protected A createA() {
return new A(new B(new ShellCommandExecutor()));
}
// onTrigger() 使用 a
}
测试只需覆盖对象的创建点:
StubCommandExecutor stub = new StubCommandExecutor();
MyProcessor processor = new MyProcessor() {
@Override
protected A createA() {
return new A(new B(stub));
}
};
这个工厂方法只负责组装对象,不包含测试判断,也不会根据「是否正在测试」改变行为。依赖的创建时机仍须符合 NiFi processor 的生命周期,不能单纯为了测试方便就随意放进 init()。如果依赖需要读取 processor properties,通常应在相应的生命周期回调中创建或刷新。
如果外部连接需要配置、验证、共享或统一管理生命周期,也可以考虑将其建模为 NiFi ControllerService。测试时注册一个测试实现,processor 只依赖对应接口。对于仅在该 processor 内部执行一次本地命令的情况,使用普通接口配合构造器或工厂注入会更简单。
为什么不建议使用其他方案
ServiceLoader
ServiceLoader 适用于发现可插拔实现。单纯为了在测试中替换依赖,没必要引入这套机制。存在多个 provider 时,除非应用已经实现了确定的选择逻辑,否则不能把「第一个 provider」当作稳定规则。
为 provider 增加 priority 虽然能确定选择结果,却会把测试替换需求变成生产运行时的服务发现机制。产品本身没有插件需求时,这层复杂度并无必要。
static stub
static field 会引入全局可变状态,容易让测试互相影响,并行执行时风险更明显。它还会隐藏 B 的实际依赖,使对象构造完成后仍然处于依赖不完整的状态。
反射或 private field 注入
反射可以在不修改生产代码的情况下改写深层字段,但测试会因此绑定字段名、可见性和内部对象结构。即使是正常重构,也可能导致测试失败,所以不适合作为长期方案。
mock 整条调用链
如果把 A、B 和外部命令全部 mock 掉,测试只能证明 CUT 调用了某个 mock,无法检查这些类之间的真实协作。这里更适合只替换无法使用的系统边界。
测试边界仍要区分
使用 StubCommandExecutor 的测试可以覆盖 processor、A、B 和命令参数生成逻辑,但无法验证真实可执行文件、权限、环境变量、shell 行为及操作系统差异。
因此,最好保留两类测试:
- 日常运行的 component test,使用 stub 覆盖完整的 Java 调用链。
- 条件允许时运行外部集成测试,使用真实的
ShellCommandExecutor验证部署环境。
即使后一类测试暂时无法在 Jenkins 上执行,前一类测试仍然有用。不过,不能据此认为真实外部服务已经通过验证。
备注:内容仅供参考。
