导读

阅读此文需要有基础的 maven、docker、GithubAction 使用经验

作为一个开源项目,不比公司项目迭代会有专门的测试童鞋跟进,想要保证代码质量,更多的是依靠编码的人员的技术水平和自动化测试,Dubbo 也不例外。如果准备成为一个 Dubbo Contributor,花点时间了解下 Dubbo 的自动化测试体系,也是很有必要的。

整体来看,Dubbo 的测试代码分为两部分,一部分是在 apache/dubbo 的单元测试,另一部分是在 apache/dubbo-samples 的集成测试,从功能层面来看,前者关注单个函数功能是否正常,后者关注 Provider/Consumer 两端交互是否符合预期。

接下来,我们将讨论下常见的测试用例写法注意点,以及介绍部分背后的技术点。

单元测试

Dubbo 的单元测试使用的是 Junit 5,具体测试语法并无特殊,读者可自行参阅相关使用文档。

相比于集成测试,单元测试是从开发的角度在函数粒度对功能进行测试,针对函数的上下文依赖,通常通过 mock 的方式进行注入。下面将介绍几个在 Dubbo 中编写单元测试的注意点,大多是通用共识。

测试用例的编写需要始终牢记一个原则:在满足必要功能覆盖的情况下,尽量克制地占用执行时间和硬件资源。
因为当一个项目的测试用例运行时间达到小时级别的时候,开发人员由于得不到及时的反馈,很有可能最后选择绕过。

慎用 *Each 注解

特指 @BeforeEach@AfterEach,被该注解标记的方法,会在同一测试类内所有 @Test, @RepeatedTest, @ParameterizedTest, @TestFactory 标记的方法前后重复执行。
所以在日常实践中,只会在 Each 方法里放置轻量短耗时操作,再则一些公用的初始化和销毁操作可以使用@BeforeAll@AfterAll 替代,注意 All 系列方法一般要求是静态方法,虽然在 per-class 模式下可以是实例方法,但该模式过于消耗性能,不建议轻易使用。

举个例子,我们需要测试 Zookeeper 的一些功能方法,可以复用同一个 zkServer 来避免频繁创建销毁带来的性能损耗,在每个 testMethod 里面,通过测试不同的路径,来防止不同方法之间互相干扰,真实案例可见源码的 Curator5ZookeeperClientTest 类。

  1. public SampleTest {
  2. private static TestingServer zkServer;
  3. @BeforeAll
  4. public static void setUp() {
  5. // 创建 zkSever 是一个重资源操作,所以我们在整个测试类里只创建一次
  6. zkServer = new TestingServer(2181, true);
  7. }
  8. @Test
  9. public void testMethod1() {
  10. // 依赖 zkServer 进行一些交互
  11. }
  12. @Test
  13. public void testMethod2() {
  14. // 依赖 zkServer 进行一些交互
  15. }
  16. @AfterAll
  17. public static void cleanUp() {
  18. zkServer.stop();
  19. }
  20. }

可能阻塞的操作请加 Timeout

列觉几个常见的场景:

  • 特别重资源的初始化操作,可能由于资源不够等原因,导致假死
  • 需要连接外部服务,外部服务不受自己控制
  • 存在加锁等待的情况,由于收不到释放信号量,导致死锁阻塞

同样举个小例子:

public SampleTest {
    @Test
    @Timeout(value = 2, unit = TimeUnit.SECONDS)
    public void testMaybeTimeout() {
        final CountDownLatch countDownLatch = new CountDownLatch(1);

        oneClient.addListener(() -> {
            // 依赖外部事件,进行减数
            countDownLatch.countDown();
        });

        // 等待计数归零
        countDownLatch.await();
    }
}

适度使用多实例特性

Dubbo 3 中引入了多实例特性,以替换之前的大单例模型,划分了 3 类 ScopeModel:

定义 中文 备注
FrameworkModel 框架层 将需要全局缓存的进行复用(端口、序列化等)
ApplicationModel 应用层 隔离应用之间的信息,包括注册中心、配置中心、元数据中心
ModuleModel 模块层 提供热加载能力,可以按 ClassLoader、Spring Context 进行隔离上下文

截止目前,详细的多实例官方文档暂未完成编写,后续视情况会进行专题介绍。

但利用多实例带来的资源隔离性,可以帮助我们写出更加健壮的单元测试来,举一个之前笔者遇到的例子,详见源码 ServiceInstanceHostPortCustomizerTest 类:

class ServiceInstanceHostPortCustomizerTest {
    @Test
    void customizePreferredProtocol() {
        // FrameworkModel 层级共享
        ApplicationModel applicationModel= new ApplicationModel(FrameworkModel.defaultModel());
        applicationModel.getApplicationConfigManager().setApplication(new ApplicationConfig("service-preferredProtocol"));

        // 在 ApplicationModel 层级进行资源隔离
         WritableMetadataService writableMetadataService = WritableMetadataService.getDefaultExtension(applicationModel);


        // 以此防止 writableMetadataService 中出现不受期望的 ServiceURL
        writableMetadataService.exportURL(
            URL.valueOf("tri://127.1.1.1:50052/org.apache.dubbo.demo.GreetingService")
        );
    }
}

该单元测试单独运行是没有问题的,但一旦和其他用例一起运行,由于 writableMetadataService 会出现不在期望内的 ServiceURL,有一定几率该单元测试用例会执行失败。在使用 ApplicationModel 级隔离之后,我们就可以保证用例运行的确定性。

集成测试

Dubbo 的集成测试使用的是 Junit 4,两者的差别可见 JUnit 4 vs Jupiter

相比于单元测试,集成测试是从用户的角度在功能粒度对使用流程进行测试,流程的上下文依赖,通常也是真实而非 Mock 的。同样需要牢记一个原则:在满足必要功能覆盖的情况下,尽量克制地占用执行时间和硬件资源。

编写集成测试

可之间参见 dubbo-samples/README.md,同时参考末尾 4 个示例可快速上手
注意:在常见的目录拆分中,Provier 端在 main 目录,Consumer 端在 test 目录,更多细节不做展开。

集成配置

基础用法可直接参见:Dubbo Integration Test Quick Start

这里展开说下:如何组合复用多个子模块实现多份不同的集成配置?

首先我们需要构建 maven 多模块项目,为了保证子模块的组装灵活性:

  • 父 POM 的 packaging 类型建议为 pom 类型,通过 modules 标签包含多个子模块,不做依赖管理等事情
  • 子模块不依赖父 POM,各自依赖配置独立,可独立运行

这样我们只需要新建一个只包含 pom.xmlcase-configuration.ymlcase-versions.conf maven 项目,就可以实现一份新的测试用例配置,实现多模块的复用,快速构建测试场景。

示例可见:dubbo-samples-migration/dubbo-samples-migration-case-default

特例:兼容性测试

在当前设计架构下,一份 case-configuration.yml 可以同时包含多个 service 服务,最常见的搭配的是:

  1. 一个 type=app 的 Provider,用来提供服务
  2. 一个 type=test 的 Consumer,用来消费服务,并执行断言逻辑,一般基于 Junit4 执行

接下来,一份 case-configuration.yml 绑定一份 case-versions.conf 配置,也就是说所有 service 都是运行同一个 Dubbo 版本,这就导致了一些问题:

如果我想测试 Dubbo 3 对 Dubbo 2 的兼容性有没有达到设计期望 或者 我想验证 Dubbo 官方提供的 Dubbo 2 向 Dubbo 3 的迁移方案是否靠谱,该如何操作?

而这些兼容性测试,对于用户的业务稳定性来说,是必须要重点关注的事项,也是一个负责的的顶级开源软件,需要重点保障的事项。

Dubbo 的集成测试核心代码在 apache/dubbo-samples/test 目录下,接下来我们将对其进行升级,以支撑上述需求。

集成测试整体流程

  1. 源码编译 dubbo 项目,生成 snapshot 版本,会作为第二步的 candidateVersions(候选版本)入参。
  2. VersionMatcher 读取 case-versions.conf 文件,生成 -Ddubbo.version=、-Dspring.version 参数,作为 Maven 编译入参。
  3. 通过 maven 进行测试项目构建,此时项目中 properties 定义的版本信息会被命令行入参替换 ,同时构建时候会带上项目依赖。
  4. ScenarioBuilderMain 会基于 case-configuration.yml 去构建项目的 docker-compose.yml 文件,以最后提交 GithubAction 进行执行,在 docker-compose.yml 文件中,会直接挂载第 3 步中项目编译的 target 目录。
  5. Github workflow 触发执行。

下面列举几个使用到的核心命令:

# 打包命令
mvn --batch-mode --no-snapshot-updates --no-transfer-progress clean package dependency:copy-dependencies -DskipTests -Ddubbo.version=3.0.3-SNAPSHOT -Dspring.version=4.3.16.RELEASE

# 启动 Provider
java -classpath $(echo ./dependency/*.jar | tr ' ' ':'):./dubbo-samples-migration-default-1.0-SNAPSHOT.jar org.apache.dubbo.migration.provider.ApiProvider

# 启动单元测试
java -cp 'dubbo-test-runner.jar:./test-classes:./classes:./dependency/*' org.apache.dubbo.test.runner.TestRunnerMain ./test-classes ./classes ./dependency ./test-reports '**/*IT.class'

我们改了什么?

走了一些弯路,最后总结下兼容性测试最重要的特点:两端不能是同一个 POM,因为你不能保证你写的所有代码 Dubbo 2.0 和 3.0 都同时支持,所以必须是两个独立项目。这在日常业务的兼容性测试中,也同样适用。

我们在原有的的 case-versions.conf 配置内,新增如下配置支持:

# 老版通用配置,可以作为缺省配置,
dubbo.version=2.7*, 3.*
spring.version=4.*, 5.*

# 支持不同的 servcie 应用,配置不同的 dubbo 版本依赖,与 dubbo.version 二选一
# 为防止构建用例倍级增长,不支持配置多个版本
dubbo.provider.version=3.*
dubbo.consumer.version=2.7.*

我们主要更改了 maven 构建项目时的命令行入参以支持上述语法,主要更改在 VersionMatcher.java 文件内。
具体用法见:dubbo-samples-migration/README.md,此处不做重复描述。

总结

面对中小项目的时候,我们总是追求覆盖率,而对于大型项目来说,测试用例性能也变得重要起来,它直接影响到开发者的 DEBUG 效率,以及是否会因为过长的等待时间而选择绕过。

在 Dubbo 中,很多测试用例和功能的开发者不是同一个人,这造成了大量测试用例存在隔靴搔痒的问题,这时候尝试进行 TDD(测试驱动开发)的模式,也是个可行的可选项。

参考资料