Kafka、Redis、RDBMS結合Docker Testcontainers進行整合測試
整合測試在本地端進行往往會與實際部署環境有很大的落差,然而Docker容器化的技術使得環境建立的門檻降低,這篇文章將從整合測試在實務上會遇到的問題,並根據現今軟體開發的趨勢,說明如何運用容器化的技術解決實務上測試的困難點。
本篇文章中提及之各種測試名詞說明
-
單元測試 → 開發人員撰寫的單元測試
-
整合測試 → 開發人員撰寫的整合測試
-
手動整合測試 → 部署到共享環境中的使用者測試
若想暸解更多關於整合測試/單元測試,提供以下昕力大學資源參考
談談整合測試實務上的問題
如上圖可看到廣為人知的測試金字塔(此概念源自於Mike Cohn ,可查閱這篇BLOG文章),描述了各個層級的測試比重應該要是單元測試>整合測試>手動整合測試,然而實務上的狀況則通常相反,手動整合集成測試的比重最高 → 從金字塔變成了冰淇淋 ?
整合測試遇到的問題大致如下:
-
整合測試相較於單元測試,運行的時間和成本都較高。
-
環境建置複雜,需要在開發環境預先安裝建置好所需工具,例如: 資料庫等外部依賴。
藉由上述的問題,環境建置複雜度的成本是更主要的原因,因此可以理解為何許多情況下會優先考慮直接部署到SIT/UAT環境進行手動整合測試。
所以就繼續手動整合測試吧!?
我們先把目光轉移到幾個面向
-
微服務。
-
分散式。
在上述主流的架構當中,服務和存儲裝置不再只是集中在一個地方,而是能夠分散到在各地運作,以達到靈活、延展、高可用、資料隔離等等優點,但也衍生了一些問題
-
多個服務之間的介接測試成本高,需要部署多個服務後才能進行完整測試。
-
資料又該如何共享。
為了因應上述的架構,往往會需要搭配一些解決方案
-
例如你需要部署的服務變多了,需要導入CI/CD自動化你的部署流程,節省開發人員的工作,但相對自動部署頻率的提高,也使得測試人員可能更無法即時的去測試每次更新的正確性。
-
例如你可能還會需要搭建外部緩存機制、消息代理等等,也就是外部依賴數量增加,需要測試的環節也變多了,每次為了驗證這些外部依賴,又只依靠手動整合測試的情況下,可能只能增加部署的頻率。
尚未實施上述架構的團隊,可能先不用擔心這些問題,但相信一定有遇過下列情境
-
多團隊協作,在共享環境中部署、更新了共享資源,例如:資料庫、第三方套件等等,造成其中一方的功能無法正確被驗證,而程式可能根本就沒有異動。
-
開發測試共用資料庫,因為多人開發,資料或數據庫被修改,造成測試結果失敗或不準確。
-
搭配H2撰寫整合測試時,即使通過測試,當部署到線上環境時依然會出錯,主要原因和問題如下
-
SQL語法不兼容。
-
需維護多個版本的SQL語法。
-
環境資料表落差。
-
不論哪種情境,都顯示出整合測試在開發者的環境中進行是有必要的,但如同前面提到的,環境建置的成本是個大問題。
容器化整合測試方案選擇
既然有容器化的技術,使用Container來進行整合測試會是一個選擇。
-
將多個服務建構成Images以docker-compose up的方式執行,但有一些侷限性。
-
每個服務的PORT是固定的,在環境上的配置造成了限制,且無法進行並行測試。
-
多個測試可能無法同時進行,因為也許在A情境下的測試資料不應留到B情境。
-
當有多個不同服務或版本需要被測試時,配置將會是個問題。
-
每次測試前後都需要自行啟動/關閉Container。
-
-
Maven Plugin - docker-maven-plugin
若想達到自動化測試,Maven的配置方案也許可以考慮,但也有上述提到的部分問題。
-
Docker提供了REST API,因此若是可以自行使用這些API,將可使得整合測試的設計更靈活。
接著進入本篇重點,結合JVM與Container來實現整合測試
介紹 TestContainers
擷取開源專案上針對TestContainers的說明
”Testcontainers is a Java 8 library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.”
初步可以得知,Testcontainers是Java8的類庫,採用容器化的技術,能夠各自獨立運行並使用與Production相同版本的外部依賴,且Container能運行在各個不同平台的特性,減少了環境搭建的複雜度。
TestContainer能夠使用任何具有Docker Image的外部依賴項目,例如: 資料庫、Web瀏覽器工具、消息代理、網頁伺服器等等,同時也支援JVM的測試框架,例如: JUnit,另外還支持各種語言的版本,目前Java的版本是比較完整的(點擊查閱支援項目清單)。
應用場景
-
資料庫數據存取層的整合測試
Example: 任何容器化的資料庫類型,MySQL、Postgrest...
-
外部依賴項目的整合測試
Example: LDAP、Redis、Kafka、Micro Service、Nginx...
-
自動化UI整合測試
Example: Selenium browser
-
自動化整合測試
Example: dind-drone-plugin
導入團隊前的建議事項
-
具有Docker的概念及操作經驗將可幫助理解其運作。
-
確認並列出需要被測試的外部依賴項目,一開始先聚焦確認好測試的目的和方向會省下許多時間。
實際測試範例說明
▶︎ 專案結構簡述
分別以Kafka、Redis、RDBMS(MSSQL)進行,使用Spring Boot 2及Junit 5,並引用前述依賴(Kafka、Redis、MSSQL)撰寫Production Code或配置,再引用Testcontainers Spring Boot的依賴撰寫測試。
• Maven依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- test - junit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.4.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.4.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.4.2</version>
<scope>test</scope>
</dependency>
▶︎ 實際演練-Kafka
建置Spring Boot 2 專案,並引用Kafka依賴,建立Producer發送消息、Consumer訂閱Topic取得並回應訊息,這裡在本地環境不去安裝及建立Kafka,也不使用Embedded Kafka,而是使用TestContainers協助建立Kafka Container進行整合測試。
• Maven依賴
<!-- kafka streams -->
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-streams</artifactId>
</dependency>
<!-- kafka spring -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- test - testcontainers - kafka -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>kafka</artifactId>
<version>1.14.3</version>
<scope>test</scope>
</dependency>
<!-- test - kafka test util -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
說明:
spring-kafka-test依賴,可以讓我們輕易的去驗證Producer&Consumer的資料狀態。
• Kafka Producer實作
@Component
@ConditionalOnProperty(name = "enable.kafka", havingValue="true")
public class KafkaSenderService {
private static Logger logger = LoggerFactory.getLogger(DatabaseInitialService.class);
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@EventListener(ApplicationStartedEvent.class)
public void send() {
kafkaTemplate.send("testcontainers", "kafka", "TestcontainersKafka").addCallback(result -> {
if (result != null) {
RecordMetadata recordMetadata = result.getRecordMetadata();
logger.info("producer send data to {}, {}, {}", recordMetadata.topic(), recordMetadata.partition(),
recordMetadata.offset());
}
}, ex -> {
logger.error("something wrong...", ex);
});
kafkaTemplate.flush();
}
}
說明:
利用KafkaTemplate來幫助我們更容易的建立Topic以及欲寫入的資料
10: 寫入key/value "kafka"/"TestcontainersKafka" 給Kafka Topic "testcontainers",並log印出資訊。
• 建立測試
@SpringBootTest
class KafkaTest {
private static Logger logger = LoggerFactory.getLogger(KafkaTest.class);
static KafkaContainer kafkaContainer = new KafkaContainer();
@DynamicPropertySource
static void kafkaProperties(DynamicPropertyRegistry registry) {
kafkaContainer.start();
registry.add("spring.kafka.properties.bootstrap.servers", kafkaContainer::getBootstrapServers);
registry.add("spring.kafka.consumer.group-id", () -> "testcontainersapp");
registry.add("spring.kafka.consumer.properties.auto.offset.reset", () -> "earliest");
}
@Autowired
private KafkaProperties properties;
@Test
public void testKafkaProducerSendDataAndConsumerReceiveData() {
final Consumer<String, String>[] consumer = new Consumer[]{createConsumer("testcontainers")};
String actual = "";
while (true) {
ConsumerRecords<String, String> records = KafkaTestUtils.getRecords(consumer[0], 10000);
if (records.isEmpty()) {
break;
}
for (ConsumerRecord<String, String> record : records) {
actual = record.value();
}
}
assertEquals("TestcontainersKafka", actual);
}
private Consumer<String, String> createConsumer(String topicName) {
Consumer<String, String>
consumer = new DefaultKafkaConsumerFactory<>(properties.buildConsumerProperties(), StringDeserializer::new,
StringDeserializer::new).createConsumer();
consumer.subscribe(Collections.singletonList(topicName));
return consumer;
}
}
說明:
5: 實例化Kafka Container。
7-13: 啟動Kafka Container並動態指定設定檔參數。
18-34: 利用Kafka Consumer API 取得訂閱的Topic "testcontainers"資料。
36-43: 實例化Kafka Consumer並訂閱Topic "testcontainers。
• 執行測試
[啟動容器]
[測試結果]
▶︎ 實際演練-RDBMS(MSSQL)
建置Spring Boot 2 專案,並引用MSSQL依賴,這裡在本地環境不去安裝及建立MSSQL,而是使用TestContainers協助建立MSSQL Container進行數據層的整合測試。
• Maven依賴
<!-- mssql server -->
<dependency>
<groupId>com.microsoft.sqlserver</groupId>
<artifactId>mssql-jdbc</artifactId>
<scope>runtime</scope>
</dependency>
<!-- test - testcontainers - mssql server -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mssqlserver</artifactId>
<version>1.14.3</version>
<scope>test</scope>
</dependency>
• Model & Repository (使用Spring Data JPA)
/** Model */
@Entity
@Table(name = "book_category", schema = "dbo")
public class BookCategory {
private int bookSeq;
private String categoryName;
private int sort;
private String suspend;
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
@Column(name = "book_seq")
public int getBookSeq() {
return bookSeq;
}
public void setBookSeq(int bookSeq) {
this.bookSeq = bookSeq;
}
@Basic
@Column(name = "category_name")
public String getCategoryName() {
return categoryName;
}
public void setCategoryName(String categoryName) {
this.categoryName = categoryName;
}
@Basic
@Column(name = "sort")
public int getSort() {
return sort;
}
public void setSort(int sort) {
this.sort = sort;
}
@Basic
@Column(name = "suspend")
public String getSuspend() {
return suspend;
}
public void setSuspend(String suspend) {
this.suspend = suspend;
}
public static BookCategory createBookCategory(String categoryName, int sort) {
BookCategory bookCategory = new BookCategory();
bookCategory.setCategoryName(categoryName);
bookCategory.setSort(sort);
bookCategory.setSuspend("N");
return bookCategory;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
BookCategory that = (BookCategory) o;
return bookSeq == that.bookSeq &&
sort == that.sort &&
Objects.equals(categoryName, that.categoryName) &&
Objects.equals(suspend, that.suspend);
}
@Override
public int hashCode() {
return Objects.hash(bookSeq, categoryName, sort, suspend);
}
}
/** Repository */
public interface BookCategoryRepository extends JpaRepository<BookCategory, Integer> {
}
說明:
建立Entity物件及Spring Data JPA Repository。
• 初始化資料表
SET ANSI_NULLS ON
SET QUOTED_IDENTIFIER ON
SET ANSI_PADDING ON
CREATE TABLE book_category (
book_seq int NOT NULL IDENTITY(1,1),
category_name nvarchar(100) NOT NULL,
sort int NOT NULL,
suspend char(1) NOT NULL CONSTRAINT DF_book_category_suspend DEFAULT ('N'),
CONSTRAINT PK_book_category PRIMARY KEY CLUSTERED (
book_seq ASC
),
);
SET ANSI_PADDING OFF
EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'分類', @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'book_category'
EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流水號', @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'book_category', @level2type=N'COLUMN', @level2name=N'book_seq'
EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'排序(1-99)', @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'book_category', @level2type=N'COLUMN', @level2name=N'sort'
EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'停用否', @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'book_category', @level2type=N'COLUMN', @level2name=N'suspend'
INSERT INTO dbo.book_category (category_name, sort, suspend) VALUES (N'文學小說', 1, 'N');
INSERT INTO dbo.book_category (category_name, sort, suspend) VALUES (N'商業理財', 2, 'N');
INSERT INTO dbo.book_category (category_name, sort, suspend) VALUES (N'藝術設計', 3, 'N');
INSERT INTO dbo.book_category (category_name, sort, suspend) VALUES (N'人文史地', 4, 'N');
INSERT INTO dbo.book_category (category_name, sort, suspend) VALUES (N'社會科學', 5, 'N');
INSERT INTO dbo.book_category (category_name, sort, suspend) VALUES (N'自然科普', 6, 'N');
INSERT INTO dbo.book_category (category_name, sort, suspend) VALUES (N'心理勵志', 7, 'N');
INSERT INTO dbo.book_category (category_name, sort, suspend) VALUES (N'醫療保健', 8, 'N');
INSERT INTO dbo.book_category (category_name, sort, suspend) VALUES (N'飲食', 9, 'N');
INSERT INTO dbo.book_category (category_name, sort, suspend) VALUES (N'生活風格', 10, 'N');
說明:
本範例之檔案放置於src/main/resources/....
• 建立測試
@SpringBootTest
public class DatabaseTest {
private static Logger logger = LoggerFactory.getLogger(DatabaseTest.class);
static MSSQLServerContainer mssqlserver = (MSSQLServerContainer) new MSSQLServerContainer()
.withInitScript("doc/ddl.sql");
@DynamicPropertySource
static void mssqlProperties(DynamicPropertyRegistry registry) {
mssqlserver.start();
registry.add("spring.datasource.driver-class-name", mssqlserver::getDriverClassName);
registry.add("spring.datasource.url", () -> mssqlserver.getJdbcUrl());
registry.add("spring.datasource.username", mssqlserver::getUsername);
registry.add("spring.datasource.password", mssqlserver::getPassword);
}
@Autowired
private BookCategoryRepository bookCategoryRepository;
@Test
void testBookCategoryListSizeIs10() {
List<BookCategory> bookCategoryList = bookCategoryRepository.findAll();
assertEquals(10, bookCategoryList.size());
}
}
說明:
5: 實例化MSSQL SERVER Containers並指定初始化資料庫的Script檔案,本範例會建立book_category資料表並寫入10筆資料。
8-15: 啟動MSSQL SERVER Container,並動態指定設定檔參數。
20-24: 驗證可否從book_category資料表取出10筆資料。
• 執行測試
[啟動容器]
[測試結果]
▶︎ 實際演練-Redis
建置Spring Boot 2 專案,並引用Redis依賴,這裡在本地環境不去安裝及建立Redis,而是使用TestContainers協助建立Redis Container進行整合測試。
• Maven依賴
<!-- spring data redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- test - testcontainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.14.3</version>
<scope>test</scope>
</dependency>
• Redis配置
@Configuration
public class RedisConfiguration {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate redisTemplate = new RedisTemplate();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(connectionFactory);
return redisTemplate;
}
}
說明:
配置RedisTemplate並指定實作之Serializer(也可依據需求使用客製化的Serializer),當使用RedisTemplate將資料寫入Redis時,會根據指定Serializer進行處理,如此一來則無需在每個物件中實作序列化。
• 建立測試
@SpringBootTest
public class RedisTest {
private static Logger logger = LoggerFactory.getLogger(RedisTest.class);
static GenericContainer redis = new GenericContainer("redis:5.0.5")
.withExposedPorts(6379);
@DynamicPropertySource
static void redisProperties(DynamicPropertyRegistry registry) {
redis.start();
registry.add("spring.redis.host", redis::getContainerIpAddress);
registry.add("spring.redis.port", redis::getFirstMappedPort);
}
@Autowired
private RedisTemplate redisTemplate;
@Test
public void testSetAndGetWithString() {
redisTemplate.opsForValue().set("k1", "v1");
assertEquals("v1", redisTemplate.opsForValue().get("k1"));
}
@Test
public void testSetAndGetWithObject() {
BookCategory bookCategory = BookCategory.createBookCategory("生活風格", 10);
redisTemplate.opsForValue().set("k2", bookCategory);
String categoryName = ((BookCategory) redisTemplate.opsForValue().get("k2")).getCategoryName();
assertEquals("生活風格", categoryName);
}
@After
public void destory() {
redis.stop();
}
}
說明:
5: 實例化Redis Container,這裡使用的是GenericContainer,可以透過這個物件傳入客製化的Image Name,增加測試容器的彈性。
8-13: 啟動Redis Container,並動態指定設定檔參數。
18-22: 驗證從Redis寫入/取出字串資料的正確性。
24-30: 驗證從Redis寫入/取出物件資料的正確性。
• 執行測試
[啟動容器]
[測試結果-1]
[測試結果-2]
實際演練心得
單單就Kafka、Redis、RDBMS的情境分別實作的過程來說,透過TestContainers簡化了許多步驟,也無端口衝突的問題,當然可以想像在較為複雜的專案情境中一定會遇到一些不可預期的狀況。
容器化的整合測試方案並非完全沒有缺點,實際操作發現以下問題:
1. 每個測試都會啟用Docker Container,也因此花費時間較長(自動部署運行自動化測試花費時間亦會有此問題),這部分則可以採用並行化測試來解決。
2. 需要進行額外的配置工作,像是針對初始化資料庫,本篇文章使用的是MSSQL,而Testcontainer的MSSQL的解析實作上會擋掉某些語句,因此就必須要花費一些時間測試資料初始化的Script。
然而整體來說,相較於每次都要部署到測試環境進行驗證,即便是使用自動化部署,部署上去版本難免還是可能發生不可預期狀況(例如推送失敗、合併問題等等),因此容器化整合測試我覺得是值得嘗試的一個解決方案。
有任何問題,歡迎聯繫討論,謝謝~!