前段时间写了一篇《这才是单元测试,也许我们之前都理解错了》,文章主要是从概念出发,重新定义了“单元测试”,获得了不少朋友的共鸣。本文可以当着上一遍的姊妹篇来看,如下图所示,在上一篇中,我提出粗粒度单元测试(CUT,Coarse-grained Unit Test)和细粒度单元测试(FUT,Fine-grained Unit Test)两个新概念。
对于微服务而言,我们提倡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。这正是我们想要的,为此,我们需要做下面两件事:
- 安装dcevm,目前能用的最高版本是Dcevm-openjdk11。如果你是jdk17或者更高版本的话,直接使用JetBrainsRuntime就可以。
- 安装好dcevm之后,我们还需要安装HotSwapAgent,这样我们就能在debugger的时候,任意的修改class并且热生效了。再配合使用TestsContainer就能极大地提升我们测试和研发效率了。
版权归原作者 张建飞(Frank) 所有, 如有侵权,请联系我们删除。