PAYATHON 2026

如何在较长调用链末端 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 行为及操作系统差异。

因此,最好保留两类测试:

  1. 日常运行的 component test,使用 stub 覆盖完整的 Java 调用链。
  2. 条件允许时运行外部集成测试,使用真实的 ShellCommandExecutor 验证部署环境。

即使后一类测试暂时无法在 Jenkins 上执行,前一类测试仍然有用。不过,不能据此认为真实外部服务已经通过验证。

备注:内容仅供参考。