如何為復雜的 Java 應用編寫集成測試,你學會了嗎?
今后甚至可以提供一個 jar 包就可以把后端服務全部啟動起來用于體驗,此時就可以使用一個簡單的基于內存的注冊中心。
除此之外做的更多的就是新增了一個集成測試的模塊,沒有完善的集成測試功能在合并代碼的時候都要小心翼翼,基本的功能需求都沒法保證。
加上這幾年我也接觸了不少優秀的開源項目(比如 Pulsar、OpenTelemetry、HertzBeat 等),他們都有完整的代碼合并流程;首先第一點就得把測試流水線跑通過。
這一點在 OpenTelemetry 社區更為嚴格:
圖片
他們的構建測試流程非常多,包括單元測試、集成測試、代碼風格、多版本兼容等。
所以在結合了這些優秀項目的經驗后我也為 cim 項目新增相關的模塊 cim-integration-test,同時也在 github 上配置了相關的 action,最終的效果如下:
圖片
圖片
在 “Build with Maven” 階段觸發單元測試和集成測試,最終會把測試結果上傳到 Codecov,然后會在 PR 的評論區輸出測試報告。
圖片
相關的 action 配置如下:
圖片
就是配置了幾個 Job,重點是這里的:
mvn -B package --file pom.xml
它會編譯并運行項目下面的所有 test 代碼。
cim-integration-test 模塊
為了方便進行集成測試,我新增了 cim-integration-test 這個模塊,這里面沒有任何源碼,只有測試相關的代碼。
圖片
類的繼承關系圖如下:
圖片
因為我們做集成測試需要把 cim 所依賴的服務都啟動起來,目前主要由以下幾個服務:
- cim-server: cim 的服務端
- cim-route: 路由服務
- cim-client: 客戶端
而 route 服務是依賴于 server 服務,所以 route 繼承了 server,client 則是需要 route 和 server 都啟動,所以它需要繼承 route。
集成 test container
先來看看 server 的測試實現:
public abstract class AbstractServerBaseTest {
private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName
.parse("zookeeper")
.withTag("3.9.2");
private static final Duration DEFAULT_STARTUP_TIMEOUT = Duration.ofSeconds(60);
@Container
public final ZooKeeperContainer
zooKeeperContainer = new ZooKeeperContainer(DEFAULT_IMAGE_NAME, DEFAULT_STARTUP_TIMEOUT);
@Getter
private String zookeeperAddr;
public void startServer() {
zooKeeperContainer.start();
zookeeperAddr = String.format("%s:%d", zooKeeperContainer.getHost(), zooKeeperContainer.getMappedPort(ZooKeeperContainer.DEFAULT_CLIENT_PORT));
SpringApplication server = new SpringApplication(CIMServerApplication.class);
server.run("--app.zk.addr=" + zookeeperAddr);
}
}
因為 server 是需要依賴 zookeeper 作為元數據中心,所以在啟動之前需要先把 zookeeper 啟動起來。
此時就需要使用 testcontainer 來做支持了,使用它可以在單測的過程中使用 docker 啟動任意一個服務,這樣在 CI 中做集成測試就很簡單了。
我們日常使用的大部分中間件都是支持的,使用起來也很簡單。
先添加相關的依賴:
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.6</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
然后在選擇我們需要依賴的服務,比如是 PostgreSQL:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.8</version>
<scope>test</scope>
</dependency>
然后在測試代碼中啟動相關的服務
class CustomerServiceTest {
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
"postgres:16-alpine"
);
CustomerService customerService;
@BeforeAll
static void beforeAll() {
postgres.start();
}
@AfterAll
static void afterAll() {
postgres.stop();
}
@BeforeEach
void setUp() {
DBConnectionProvider connectionProvider = new DBConnectionProvider(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword()
);
customerService = new CustomerService(connectionProvider);
}
通常情況下我們都是需要獲取這些中間件的鏈接,比如 IP 端口啥的。
org.testcontainers.containers.ContainerState#getHost
org.testcontainers.containers.ContainerState#getMappedPort
通常是通過這兩個函數來獲取對應的 IP 和端口。
集成
@Container
RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:7.4.0"));
public void startRoute() {
redis.start();
SpringApplication route = new SpringApplication(RouteApplication.class);
String[] args = new String[]{
"--spring.data.redis.host=" + redis.getHost(),
"--spring.data.redis.port=" + redis.getMappedPort(6379),
"--app.zk.addr=" + super.getZookeeperAddr(),
};
route.setAdditionalProfiles("route");
route.run(args);
}
對于 route 來說不但需要 zookeeper 還需要 Redis 來存放用戶的路由關系,此時就還需要運行一個 Redis 的容器,使用方法同理。
最后就需要以 springboot 的方式將這兩個應用啟動起來,我們直接創建一個 SpringApplication 對象,然后將需要修改的參數通過 --varname=value 的形式將數據傳遞進去。
還可以通過 setAdditionalProfiles() 函數指定當前應用運行的 profile,這樣我們就可以在測試目錄使用對應的配置文件了。
圖片
route.setAdditionalProfiles("route");
比如我們這里設置為 route 就可以使用 application-route.yaml 作為 route 的配置文件啟動,就不用每個參數都通過 -- 傳遞了。
private void login(String userName, int port) throws Exception {
Long userId = super.registerAccount(userName);
SpringApplication client = new SpringApplication(CIMClientApplication.class);
client.setAdditionalProfiles("client");
String[] args = new String[]{
"--server.port=" + port,
"--cim.user.id=" + userId,
"--cim.user.userName=" + userName
};
client.run(args);
}
@Test
public void olu() throws Exception {
super.startServer();
super.startRoute();
this.login("crossoverJie", 8082);
this.login("cj", 8182);
MsgHandle msgHandle = SpringBeanFactory.getBean(MsgHandle.class);
msgHandle.innerCommand(":olu");
msgHandle.sendMsg("hello");
}
我們真正要測試的其實是客戶端的功能,只要客戶端功能正常,說明 server 和 route 也是正常的。
比如這里的 olu(oline user) 的測試流程是:
- 啟動 server 和 route
- 登錄注冊兩個賬號
- 查詢出所有用戶
- 發送消息
最終的測試結果如下,符合預期。
圖片
碰到的問題
應用分層
不知道大家注意到剛才測試代碼存在的問題沒有,主要就是沒法斷言。
因為客戶端、route、server 都是以一個應用的維度去運行的,沒法獲取到一些關鍵指標。
比如輸出在線用戶,當客戶端作為一個應用時,在線用戶就是直接打印在了終端,而沒有直接暴露一個接口返回在線數據;收發消息也是同理。
其實在應用內部這些都是有接口的,但是作為一個整體的 springboot 應用就沒有提供這些能力了。
本質上的問題就是這里應該有一個 client-sdk 的模塊,client 也是基于這個 sdk 實現的,這樣就可以更好的測試相關的功能了。
之后就準備把 sdk 單獨抽離一個模塊,這樣可以方便基于這個 sdk 實現不同的交互,甚至做一個 UI 界面都是可以的。
編譯失敗
還有一個問題就是我是直接將 client/route/server 的依賴集成到 integration-test 模塊中:
<dependency>
<groupId>com.crossoverjie.netty</groupId>
<artifactId>cim-server</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.crossoverjie.netty</groupId>
<artifactId>cim-forward-route</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.crossoverjie.netty</groupId>
<artifactId>cim-client</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
在 IDEA 里直接點擊測試按鈕是可以直接運行這里的測試用例的,但是想通過 mvn test 時就遇到了問題。
圖片
會在編譯期間就是失敗了,我排查了很久最終發現是因為這三個模塊應用使用了springboot 的構建插件:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
這幾個模塊最終會被打包成一個 springboot 的 jar 包,從而導致 integration-test 在編譯時無法加載進來從而使用里面的類。
暫時沒有找到好的解決辦法,我就只有把這幾個插件先去掉,需要打包時再手動指定插件。
mvn clean package spring-boot:repackage -DskipTests=true
其實這里的本質問題也是沒有分層的結果,最好還是依賴 route 和 server 的 SDK 進行測試。
現在因為有了測試的 CI 也歡迎大家來做貢獻,可以看看這里的 help want,有一些簡單易上手可以先搞起來。
圖片
https://github.com/crossoverJie/cim/issues/135
參考鏈接:
- https://github.com/crossoverJie/cim/pull/140
- https://github.com/crossoverJie/cim/pull/144