天天看點

Spring Cloud 接口契約測試

在微服務體系中,開發者要進行接口測試,一般有以下幾種方法:

1. 搭建完整的微服務環境,将所有依賴的微服務全部運作起來,然後針對要測試的微服務寫測試用例;

2. 使用 Mock 來模拟依賴的微服務以及資料庫的讀寫;

3. 契約測試,服務的提供者和消費者按照同樣的契約編寫自己的測試用例。

這其中,方法1的工作量比較大,維護這麼一個環境也是一個麻煩的事情,但是能真實模拟請求的完整流程;方法2能讓測試集中于自己的微服務中,但是一旦依賴的接口有變化,Mock并不能及時的反映出來,要到內建測試的時候才可能發現,這是個隐患;方法3在微服務架構中是一個比較好的方法,服務的提供者和消費者同時按照同一個版本的契約進行各自獨立的開發和測試,又不用完整的運作整套微服務體系,在便捷性和準确性上都有一定的保證。

本文介紹在 Spring Cloud 微服務中,如何優雅的編寫接口測試用例,這其中依賴到了 Spring Cloud Contract(契約測試架構),DbUnit(資料庫工具,用來模拟資料庫的讀寫)。一個好的測試用例,應該在測試接口邏輯的完整性的條件下,不會對資料庫造成破壞(這就要使用DbUnit工具),運作測試用例時不會依賴其他的微服務(這就要使用契約測試)。

首先介紹下示例項目依賴的版本:

Spring Cloud:  Greenwich.RELEASE

DbUnit: 2.6.0

spring-test-dbunit-core: 5.2.0 (注意這個元件不能用 https://github.com/springtestdbunit/spring-test-dbunit 這裡面的,這個是比較舊的版本,已經無人維護,Spring boot 1.X 可以使用,Spring Boot2 就不行了,需要用  https://github.com/ppodgorsek/spring-test-dbunit 這個,這是對舊項目的 fork ,進行長期維護的版本)

具體的依賴還需要根據實際的 Spring Cloud 版本進行更換。

一、使用 DbUnit 完成對資料庫層面的Mock

DnUnit工具具體使用方法請自行百度,它的實作邏輯是根據你提供的資料庫連接配接資訊,将對應的資料庫進行備份,然後将你準備的測試資料寫入到資料庫中,之後執行測試用例,所有測試用例執行完畢之後,再将備份資訊還原到資料庫中,這樣就避免了對資料庫的破壞。

首先準備測試資料,在 src/test/resources 下面建立 testData.xml 檔案,按照如下格式寫入測試資料

<?xml version='1.0' encoding='UTF-8'?>
<dataset>
    <user_ user_uuid="11111111" account="zhangsan" user_name="張三"/>
    <user_ user_uuid="22222222" account="lisi" user_name="李四"/>
</dataset>
           

假設我們有一個接口 http://localhost:8080/user/${userUuid} 根據 userUuid 擷取使用者資訊,具體的實作不列出了,這不是這篇文章的重點,我們隻要有這個接口存在就行,它會傳回如下格式的json資料

{
	"errorCode": 0,
	"errorMsg": "SUCCESS",
	"data": {
		"userUuid": "11111111",
		"account": "zhangsan",
		"userName": "張三"
	}
}
           

然後編寫測試類:

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@Transactional(transactionManager = "transactionManager")
@Rollback(value = true)
@TestExecutionListeners({ 
    DependencyInjectionTestExecutionListener.class,
    DirtiesContextTestExecutionListener.class,
    TransactionDbUnitTestExecutionListener.class,
    DbUnitTestExecutionListener.class })
@DatabaseSetup("/testData.xml")
public class UserControllerTest {

    private static final Logger LOGGER = LoggerFactory.getLogger(UserControllerTest.class);

    private ObjectMapper mapper;
    @Autowired
    public MockMvc mvc;

    @Before
    public void setUp() {
        LOGGER.info("UserControllerTest init");
        RestAssuredMockMvc.mockMvc(mvc);
        
        this.mapper = new ObjectMapper();
    }

    @Test
    public void testCreateUser() throws Exception {
        this.mvc.perform(MockMvcRequestBuilders.get("/user/11111111")
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("errorCode").value(0))
                .andReturn();
    }
}
           

編譯運作,測試用例通過,可以檢視下實際的資料庫是不是還是原來的狀态,如果是則表示 DbUnit 工具引入成功。當然這過程中編寫類似建立使用者的測試用例,更能看出來的 DbUnit 是否生效。

這中間過程中你可能會碰到一個問題: org.dbunit.database.AmbiguousTableNameException: EVALUATE ,這是一個很坑的問題,我在這個問題上糾結了兩天,各種百度 google 無果,最後發現是 Spring Cloud Greenwich.RELEASE 版本使用的 mysql-connector-java 是 8.0的版本,需要将其改成 5.X的版本才能使得 DbUnit 正常運作。

DbUnit 完美運作之後,接下來就是契約測試了。

二、Spring Cloud Contract

文檔:https://cloud.spring.io/spring-cloud-static/Greenwich.RELEASE/single/spring-cloud.html#_spring_cloud_contract

具體如何使用請自學。

先說下契約這個東西,對于服務提供者而言,契約可以用來限制其單元測試用例,服務提供者編寫的測試用例,必須符合這個契約,才能保證服務提供者提供的接口确實是符合這個契約的。對于服務消費者而言,契約可以模拟其調用這個微服務時,會得到什麼樣的結果。編寫契約可以使用 groovy 或者 yml,Spring Cloud Contract 可以根據這個契約生成 測試用例,我們可以有效利用這一點,簡化服務提供者的單元測試用例的編寫工作。

服務提供者:

引入依賴

<dependencies>
    ...
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-contract-verifier</artifactId>
        <scope>test</scope>
    </dependency>
    ...
</dependencies>
<build>
    <plugins>
        ...
        <plugin>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-contract-maven-plugin</artifactId>                
            <version>2.1.0.RELEASE</version>
            <!--Don't forget about this value !!--> 
            <extensions>true</extensions>
            <configuration>
                <!--MvcMockTest為生成本地測試案例的基類-->  
                <baseClassForTests>com.walli.user.service.test.UserControllerTest</baseClassForTests>
            </configuration>
        </plugin>
        ...
    </plugins>
</build>
           

這裡說明下 baseClassForTests 這個屬性配置,這裡聲明 Spring Cloud Contract 自動生成測試用例的時候的基類,在這個基類中,你需要注入 MockMvc 的上下文(上面的測試類示例代碼中的@Before  RestAssuredMockMvc.mockMvc(mvc) 這一行)。

然後編寫契約,我采用的是 yml 方式, groovy 不是太熟,但是使用 groovy 肯定靈活性更高。

Spring Cloud Contract 預設會去 src/test/resources/contracts 目錄下去加載契約檔案,這裡簡單一點我們就不改目錄了, 直接在這個目錄下建立契約檔案 getUser.yml(契約檔案的具體内容,還需要根據你實際的接口規則去編寫,此處傳回的狀态等都隻适合我的測試代碼,你可以組織各種各樣不同的參數送出來模拟各種複雜情況,以提高測試的代碼覆寫率)

## 此檔案為 get user by userUuid 接口的契約

## 測試使用者不存在
request:
    method: GET
    url: /user/33333333
    headers:
        Content-Type: application/json
response:
    status: 500
    body:
        errorCode: 990004
    headers:
        Content-Type: application/json;charset=UTF-8
---
## 測試使用者正常擷取
request:
    method: GET
    url: /user/11111111
    headers:
        Content-Type: application/json
response:
    status: 200
    body:
        errorCode: 0
        errorMsg: SUCCESS
        data:
            userUuid: 11111111
            userName: 張三
            account: zhangsan
    headers:
        Content-Type: application/json;charset=UTF-8
           

契約編寫完成之後,直接 mvn clean install 編譯,如果成功,你可以在 代碼目錄的 target 目錄下看到一個叫做 XXXX-stub.jar 的檔案,這個 stub 檔案就是你可以交給服務消費者使用的檔案,你可以把它放到你們自己的 maven 倉庫中,供别人下載下傳。

然後,你可以在 target\generated-test-sources 找到一個 ContractVerifierTest 的類,它 extends 你寫的 UserControllerTest 類,這裡面,就是根據契約自動生成的測試用例。

服務消費方:

服務消費方關鍵就是要引入服務提供方給出的 stub 檔案,有遠端和本地兩種引入方式。

首先需要引入依賴:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
    <scope>test</scope>
</dependency>
           

本地引入 stub 時,需要先擷取 服務提供方的代碼然後編譯完成,即保證本地的 maven 倉庫中有對應的 stub 檔案。

然後編寫消費方的測試代碼:

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureStubRunner(ids = {"com.walli:cloud-user-service-server:1.0.1:stubs:9900"},
		stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public class SsoControllerTest {

    private static final Logger LOGGER = LoggerFactory.getLogger(SsoControllerTest.class);

    private ObjectMapper mapper;
    @Autowired
    public MockMvc mvc;
    
    @Before
    public void setUp() {
        LOGGER.info("SsoControllerTest init");
        
        this.mapper = new ObjectMapper();
    }
    
    @Test
    public void testLogin() throws Exception {
        this.mvc.perform(MockMvcRequestBuilders.get("/user/111111")
                .contentType(MediaType.APPLICATION_JSON)
                .content(this.mapper.writeValueAsString(param))
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("errorCode").value(0))
                .andReturn();
    }
}
           

其中 

@AutoConfigureStubRunner(ids = {"com.walli:cloud-user-service-server:1.0.1:stubs:9900"},
		stubsMode = StubRunnerProperties.StubsMode.LOCAL)
           

這一段即為本地使用 stub 檔案,如果是遠端調用,需要按照如下方式進行:

首先在 application.yml 檔案中聲明stub依賴方式:

stubrunner:
  ids: 'com.walli:cloud-user-service-server:1.0.1:stubs:9900'
  repositoryRoot: http://repo.spring.io/libs-snapshot
           

repositoryRoot 改成自己的,然後把測試代碼上的 @AutoConfigureStubRunner 中StubsMode 改成 REMOTE即可。

編譯運作測試用例通過,即表示消費方契約測試成功,因為你并沒有啟動 cloud-user-service-server,但是你的測試用例還是通過了,并且調用的接口傳回值是契約中約定的值。

關鍵概念&知識點

1. priority 數值越小,優先級越高

    在契約定義中,我們有時候不僅要定義接口的正常傳回值,也有可能要定義異常的傳回值(即消費者按照此規則傳遞參數之後獲得的是一個錯誤),這種情況我們可以利用 priority,将錯誤傳回值的契約設定較高優先級,将正常傳回值的契約優先級降低,這樣有利于消費者需求錯誤響應時能拿到相應的錯誤,而不是比對上正确的響應結果。

2. request 中的一些概念

    request 中的參數,不管是 queryParameters 還是 body 中的參數,如果你不寫相應的 matcher 的話,契約中就是預設生成參數必須嚴格相等的契約。

    request.matchers 是用來定義請求參數的規則的,即消費者必須按照此規則來送出參數;并且,matchers 中最好隻定義必傳參數的規則,否則就會面臨不必傳的參數,消費者使用契約時必須填寫該參數才行;契約中不存在的參數,預設都是可傳可不傳的。

    request matchers queryParameters 的 type 可用如下值:

    equal_to_json, equal_to, not_matching, matching, containing, absent, equal_to_xml

3. response 中的一些概念

    response.body 的傳回值,是用來模拟給消費者的傳回值的,同時也用來校驗spring cloud contract 自動生成測試用例的傳回結果是不是跟這個值比對

    response.matchers 的用處是當 spring cloud contract 自動生成測試用例得到的傳回結果與 response.body 中定義的值不一樣時,比如建立使用者生成 uuid,這個 uuid 必然是随機的,response.body 必然無法自定義,是以這時候需要寫 response.matchers 定義 uuid 規則,隻要實際的傳回值符合這個規則,測試用例就認為可以通過。matchers 不特殊定義比對規則的字段,就是嚴格等于 response.body 中定義的值

    response.body 中最好寫調用此接口必然會傳回的值,同時 response.matchers 中需要對應 response.body 傳回的字段有動态值的字段寫好相應的 matcher,否則自動生成的測試用例無法通過

    response matchers  type 可用如下值:

    by_type, by_command, by_time, by_date, by_timestamp, by_null, by_equality, by_regex

    response matchers type=by_regex時,使用 predefined 可用如下預定義的正規表達式:

    non_blank, iso_date_time, iso_8601_with_offset, iso_time, iso_date, only_alpha_unicode, url, hostname, any_boolean, uuid, ip_address, any_double, number, non_empty, email

4. matchers 中,url 無法用正常的正規表達式(關鍵是不能用 ^ 和 $,包括 groovy 中也是如此),YML可以

局限性

1. 在類似 新增/更新 這種參數不确定的請求中,request 的參數隻能寫必傳值,response 也隻能寫必然會傳回的資料(更新可以所有有效字段)。

2. reuqest.queryParameters 無法編寫數組類型的參數契約,隻能預設全都允許(即 request matchers 中判斷其不為空即可)

3. request.body 中的參數對象如果每個字段都可為空,這種契約是沒法編寫的,契約的 request.body 不寫的話代碼會報 body 不能為空的錯誤,寫的話又強制了定下契約body的字段必須傳遞,這是個沖突點,是以隻能挑一個絕大多數情況下都會傳遞的參數寫到 request.body 裡面,消費者調用的時候傳遞一下這個參數。

實踐過程中遇到的問題

1. 單元測試在 jenkins 腳本中編譯可以,但是測試用例完全無法運作,出錯的現象是無法通路其他微服務,比如 config-server。

解決辦法:

這個問題困擾了很久,最後發現是編譯時使用的maven鏡像并沒有加入整個Spring Cloud 的網絡環境中。整體的環境是,Docker中啟動Jenkins,Jenkins 建立多流水線任務調用項目代碼中的 Jenkinsfile,Jenkinsfile 中定義 Pipeline,最開始,pipeline 中使用的 agent 如下:

pipeline {
    agent {
        docker {
            image 'maven:3.6.0-jdk-8'
            args '-v /root/.m2:/root/.m2'
        }
    }
    。。。。。。
}
           

可以看出來使用的官方的 maven 鏡像來進行編譯工作,但是這個鏡像是官方的,并沒有加入我們自定義的SpringCloud使用的網絡中,于是在 args 中增加網絡參數,即可解決

pipeline {
    agent {
        docker {
            image 'maven:3.6.0-jdk-8'
            args '-v /root/.m2:/root/.m2 --net=servicenet'
        }
    }
    。。。。。。
}
           

2. DBUnit 解析的時間不對,xml 資料中,以“yyyy-MM-dd HH:MM:SS” 的格式寫入時間,代碼中會自動将其轉換成UTC時間格式,發現在本地開發的時候,正常轉換沒有問題,UTC字元比實際的時間少8小時,但是在Jenkins中編譯時,UTC字元跟xml裡面時間是一樣的,導緻了測試用例失敗。

解決辦法:

其實就是個時區的問題,jenkins 通過 Docker 容器啟動,Docker 容器沒有設定為正确的時區的話,openjdk8預設是從系統的 /etc/timezone 檔案中讀取時區的,是以造成了 xml 中的時間沒有被正确的解析,預設的 timezone 就是0時區。解決辦法是啟動jenkins時,把相應的時間、時區都設定進去,比如 docker-compose.yml:

version: '3'

services:
 jenkins:
    image: wx.ankoninc.com.cn/jenkins:2.222.4
    user: root
    privileged: true
    container_name: jenkins
    environment:
        # 這一行也很重要,告訴 JVM 時區
        JAVA_OPTS: -Duser.timezone=Asia/Shanghai
    volumes:
      # 容器同步主控端時間
      - /etc/localtime:/etc/localtime
      # 容器同步主控端時區
      - /etc/timezone:/etc/timezone
      - jenkins-data:/var/jenkins_home
      - /var/run/docker.sock:/var/run/docker.sock
      - /usr/bin/docker:/usr/bin/docker
      - /root/.docker:/root/.docker
    ports:
      - 8080:8080
      - 50000:50000
    networks:
      - servicenet


volumes:
  jenkins-data:
    driver: local

networks:
  servicenet:
    external: true
           

如果主控端中 timezone 檔案不存在,自己建立一個即可

echo "Asia/Shanghai" >> /etc/timezone
           

繼續閱讀