微服务的单元测试,这样做就对了

前段时间写了一篇《这才是单元测试,也许我们之前都理解错了》,文章主要是从概念出发,重新定义了“单元测试”,获得了不少朋友的共鸣。本文可以当着上一遍的姊妹篇来看,如下图所示,在上一篇中,我提出粗粒度单元测试(CUT,Coarse-grained Unit Test)和细粒度单元测试(FUT,Fine-grained Unit Test)两个新概念。

9e8972569158abfe28f0d086401f0a73.png

对于微服务而言,我们提倡CUT优先,FUT补充的策略。因为CUT是从adaptor为入口,一个测试可以贯穿整个应用,测试效率非常高。而FUT是对核心的业务逻辑,进行细粒度的测试,虽然覆盖范围小,但核心业务逻辑是应用的关键,值得多花时间去覆盖更多的场景。关于如何做FUT,我在《这才是单元测试,也许我们之前都理解错了》中已经有比较详细的阐述,本文主要介绍如何实施CUT,以及如何解决CUT的依赖和测试效率问题。

一、实施CUT粗粒度单元测试

对于CUT而言,最棘手的问题是依赖问题,即我们的微服务对各种中间件(数据库、缓存、消息等)以及周边服务会有依赖。因为我们毕竟是单元测试,没有真实的依赖环境。所以解决依赖问题,只能靠“伪造”,这在上一篇中也有提及,“伪造”的方式主要有两种:一个是Mock,另一个是Embedded Server。相比较而言,Embedded Server的测试编写效率更高,所以接下来我们重点介绍此方法。


注意:后续内容中和SpringBoot相关的依赖都没有版本号,是因为我统一import了SpringBoot BOM依赖管理,因为现有大部分项目用的是jdk11,所以SpringBoot用的还是2.x版本。

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>2.7.17</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

1. 基于数据库的UT

使用Java有一个好处就是它的生态,基本上我们碰到的问题都会有对应的开源解决方案。这个数据库依赖也不例外,Embedded Database我们可以选用h2,数据库里面的数据准备我们可以用dbunit,这些功能我们只需要加入以下依赖即可。

<!--this is for embedded database unit test-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
        </dependency>
        <dependency>
            <groupId>com.github.ppodgorsek</groupId>
            <artifactId>spring-test-dbunit-core</artifactId>
            <version>5.2.0</version>
        </dependency>
        <dependency>
            <groupId>org.dbunit</groupId>
            <artifactId>dbunit</artifactId>
            <version>2.7.0</version>
        </dependency>

因为SpringBoot集成了h2,因此在启动Spring容器的同时也会启动h2数据库,使用dbunit是因为我们可能要对进行的测试准备一些数据,我们可以用如下的方式去写我们的dbunit:

@SpringBootTest
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, DbUnitTestExecutionListener.class })
public class DBSetupTest {

    @Autowired
    private PersonRepository personRepository;

    @Test
    @DatabaseSetup("/fixture/db/sampleData.xml")
    public void testFind() throws Exception {
        List<Person> personList = personRepository.find("hil");
        System.out.println(personList);
        assertEquals(1, personList.size());
        assertEquals("Phillip", personList.get(0).getFirstName());
    }
}

示例的完整代码可以在COLA开源中找到。关于dbunit的更多官方内容可以查看spring-test-dbunit开源主页。

2. 基于Kafka的UT

在embedded方案中,Spring对kafka的支持最好,我们只需要加入SpringBoot BOM中的以下依赖即可:

<!--this is for embedded kafka unit test-->
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka-test</artifactId>
</dependency>

不过spring-kafka-test并没有提供和dbunit类似的@DatabaseSetup数据准备工具,不过,我们不难通过Junit5提供的Extension能力,自己构建一个,如下所示,我们可以实现一个自己的Extension:

public class KafkaExtension implements BeforeAllCallback, BeforeEachCallback {

    private EmbeddedKafkaBroker embeddedKafkaBroker;
    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        try {
            EmbeddedKafkaBroker embeddedKafkaBroker = (EmbeddedKafkaBroker) SpringExtension.getApplicationContext(context).getBean(EmbeddedKafkaBroker.class);
            this.embeddedKafkaBroker = embeddedKafkaBroker;
        } catch (NoSuchBeanDefinitionException e) {
            log.error("Please add @EmbeddedKafka for your test", e);
            throw e;
        }
    }

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        ProduceMessage produceMessage = context.getElement().get().getAnnotation(ProduceMessage.class);
        if (Objects.nonNull(produceMessage)) {
            log.info("begin produce message for kafka");
            String location = produceMessage.value();
            MessageData messageData = objectMapper.readValue(FixtureLoader.loadResource(location), MessageData.class);
            KafkaTemplate<String, JsonNode> producer = this.createProducer();

            List<ObjectNode> messages = messageData.getMessages();
            int count = 0;
            for (ObjectNode message : messages) {
                producer.send(messageData.getTopic(), message);
                log.info("produce message[{}:{}]: {}", new Object[]{messageData.getTopic(), ++count, message});
            }
        }
    }

这样我们就可以通过ProduceMessage这个annotation做到和dbunit的DatabaseSetup类似的功能,接下来如果我们想测试一个从kafka message触发的测试,就可以按照如下的方式写:

@SpringBootTest
@EmbeddedKafka(partitions = 1, brokerProperties = { "listeners=PLAINTEXT://localhost:9092", "port=9092" })
@ExtendWith(KafkaExtension.class)
public class KafkaExtensionTest {

    @Autowired
    private KafkaConsumer consumer;

    @Test
    @ProduceMessage("/fixture/kafka/produce-message.json")
    public void testProduceMessage(){
        log.info("test produce message");

        // 等待消息业务处理,每100毫秒poll一下,最长等待10秒
        await().atMost(10, TimeUnit.SECONDS).pollInterval(100, TimeUnit.MILLISECONDS)
                .until(() -> consumer.isFinished);

        log.info("consume message finished");
    }
}

其中produce-message.json中的内容下:

{
  "topic": "embedded-test-topic",
  "messages": [
    {
      "job_id": "10000000-0000-0000-0000-000000000001",
      "version": "v1",
      "action": "create",
      "resource_type": "test_resource",
      "request": [
        {
          "id": "30000000-0000-0000-0000-000000000001",
          "name": "test-01",
          "project_id": "7a9941d34fc1497d8d0797429ecfd354"
        }
      ]
    }
  ]
}

其中topic代表我们要发送的kafka消息topic,messages中是要发送的消息payload。因为消息处理是异步的,所以在测试的Assert中,我们通常要使用await等待consumer真正处理完消息再进行断言判断。

3. 基于Redis的UT

类似的,如果要使用Redis的embedded方案,我们需要加入以下依赖:

<!--this is for embedded redis unit test-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>it.ozimov</groupId>
    <artifactId>embedded-redis</artifactId>
    <version>0.7.3</version>
    <exclusions>
        <!-- Exclude slf4j-simple -->
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>5.1.0</version>
</dependency>

Spring没有对redis有类似的像kafka那样的原生支持,这就需要我们在使用的时候自己启动RedisServer,因为RedisServer只需要启动一次,因此我们可以通过在Extension中设置一个static变量来实现RedisServer只启动一次的目的,具体做法如下。

public class RedisExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback {
    //default port is 6397
    private static RedisServer redisServer;
    public static Jedis jedis;
    private static boolean isStarted;

    //省略其它代码,源码在COLA开源:https://github.com/alibaba/COLA

@Override
public void beforeAll(ExtensionContext context) {
    try {
        if (redisServer == null && !isStarted) {
            redisServer = new RedisServer(); //default port is 6379
            redisServer.start();
            log.debug("Redis server started");
        }
    } catch (Exception e) {
        isStarted = true;
        log.warn("Redis Server may already started, just ignore this exception:" + e.getMessage());
    }
    if (jedis == null) {
        jedis = new Jedis("localhost", 6379);
    }
}

其数据准备的方式和dbunit,kafka类似,就不再展示了。

4. 基于微服务依赖的UT

现在的微服务大部分都是REST的API,所以我们可以用WireMock来代替依赖的服务,WireMock本质上是一个http server,通过对http response打桩,从而起到mock的效果。为了使用WireMock,我们要引入以下依赖:

<!--this is for microservice unit test, to avoid conflict, we'd better use standalone -->
<dependency>
    <groupId>org.wiremock</groupId>
    <artifactId>wiremock-standalone</artifactId>
    <version>3.0.1</version>
</dependency>
<!--if WebTestClient is used -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

我们可以用如下的方式启动WireMock服务,并对HTTP的请求和响应进行mock,从而解决微服务依赖的问题:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@WireMockTest(httpPort = 8080)//WireMock 
public class WireMockBasicTest {
    @Autowired
    protected WebTestClient webClient;

    @Test
    public void testWireMockAccount(WireMockRuntimeInfo wmRuntimeInfo) {
        WireMockRegister.registerStub(wmRuntimeInfo.getWireMock(), "/fixture/wiremock/stub-account.json");

        webClient.get()
                .uri("localhost:8080/v1/api/account/"+123456789)
                .exchange()
                .expectStatus()
                .isEqualTo(200)
                .expectHeader()
                .contentType(MediaType.APPLICATION_JSON)
                .returnResult(Account.class)
                .getResponseBody()
                .map(account -> {
                    log.info(account.toString());
                    Assertions.assertEquals("frank", account.getName());
                    Assertions.assertEquals(123456789, account.getPhoneNo());
                    return account;
                })
                .subscribe();

        log.info("wire mock serer port : " + wmRuntimeInfo.getHttpPort());
    }

}

二、cola的unittest组件

以上提到的这些依赖和测试方法,我已经把它们打包在新的cola组件中,叫cola-component-unittest。如果你需要用这些功能,只需要在项目中加入这一个依赖即可:

<dependency>
    <groupId>com.huawei.cola</groupId>
    <artifactId>cola-component-unittest</artifactId>
    <version>1.0.3</version>
    <scope>test</scope>
</dependency>

具体的使用方式,你可以参考cola开源的cola-component-unittest相关的代码。

三、使用DCEVM进一步提效

因为引入了很多外部依赖,又启动了一些embedded服务,我们必须要面对一个启动时间问题,当上面提到的这些embedded服务都启动的时候,随着业务代码规模的变大,启动时间从十几秒到几十秒不等。对于单元测试来说,这个时间太过于漫长了,达不到quick feedback的目的。所以,我之前开发了TestsContainer,该工具在一定程度上可以消减等待应用启动的烦恼。

然而,标准的JDK的hot swap只支持方法体修改,这就意味着我增加新的method,增加新的field等等都需要重新启动jvm,这就使得TestsContainer的效用大打折扣。因此,我们需要DCEVM技术来协助我们,DCEVM是Dynamic Code Evolution Virtual Machine的简称,它可以帮助我们实现unlimited redefinition of loaded classes at runtime。这正是我们想要的,为此,我们需要做下面两件事:

  1. 安装dcevm,目前能用的最高版本是Dcevm-openjdk11。如果你是jdk17或者更高版本的话,直接使用JetBrainsRuntime就可以。

  2. 安装好dcevm之后,我们还需要安装HotSwapAgent,这样我们就能在debugger的时候,任意的修改class并且热生效了。再配合使用TestsContainer就能极大地提升我们测试和研发效率了。

  • 29
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值