iT邦幫忙

2021 iThome 鐵人賽

DAY 18
0
Modern Web

基於 Kotlin Ktor 建構支援模組化開發的 Web 框架系列 第 18

[Day 18] 轉換 OpenAPI 文件為 Postman Collection 做 Web API 自動化測試

Web API 測試可以是後端工程師使用測試框架撰寫白箱測試,也可以是 QA 使用測試工具進行黑箱測試。對於後端工程師來說,如果 API 有異動,很容易找出對應的測試程式碼進行調整,畢竟程式都是自己寫的,也有 IDE 工具輔助。但如果是 QA 的黑箱測試,就需要後端工程師通知 QA,然後 QA 再去修改測試腳本,這時候就有了雙方如何溝通同步的議題。

實作目標

前幾天我們已實作 OpenAPI Generator,能自動產生 OpenAPI 文件,讓實際運行的程式碼與 API 文件儘量保持一致。那麼測試程式也能像文件一樣自動產生嗎? 再進一步延伸,我們希望達到以下目標

  • 跨後端技術架構,是可以廣泛適用的解決方案
  • 能自動產生測試程式,而且與 API 規格一致。減少 QA 撰寫及維護測試程式的成本,也避免人工漏改而引發不一致的錯誤
  • QA 只要專心撰寫測試腳本,準備對應的測試資料即可,不必考慮底層 Http Request 的實作細節,這可以降低對 QA 的技術門檻要求,畢竟會寫程式的 QA 不好找
  • 不必依賴測試工具的 GUI 介面,所有的測試相關檔案都是文字檔格式,可以直接編輯,也能納入 Git 版本控制
  • 可以放到 CI/CD Server 上自動執行,並且產出測試報告

我的想法是整合現有的測試工具,以此為基礎進行修改調整。我選擇 Postman 這個著名的測試工具,因為 Postman 提供 openapi-to-postman 套件,先轉換 OpenAPI json 為 Postman Collection,然後再使用 Postman Newman 執行測試。這個解決方案可以符合上述目標

  • 使用 OpenAPI 文件當作輸入,就可以跨後端技術架構
  • 轉換產生的 Postman Collection 就是測試程式,所以 QA 只要準備測試資料當作輸入即可
  • Postman 的函式庫全都是 NodeJS 套件,所以 Postman Collection 本身及其相關檔案都是 json 檔案,方便納入 Git 做版本控制
  • Postman Newman 是 command-line collection runner,只要準備好 NodeJS 執行環境就能在本機或 Jenkins 上面執行,不需安裝 Postman GUI 工具
  • Postman 提供 newman-reporter-htmlextra 套件產生美觀的 HTML 測試報告

接下來就開始動手實作 (點我連結到完整程式碼)

安裝 Gradle NodeJS Plugin 建立 NodeJS 執行環境

Postman 函式庫都是 NodeJS 套件,所以是可以跟本專案的 Kotlin Ktor 專案分開獨立存在,不過為了開發方便,我想放在同一個專案就好,所以要先安裝 gradle-node-plugin 建立 NodeJS 執行環境。

先設定 openApiSchemaUrl 指定 openapi json 網址,然後再依以下順序執行 gradle task downloadOpenApiJsonopenApiToPostmanCollectionrunPostmanTest

plugins {
    id("com.github.node-gradle.node") version "3.1.0"
}

// OpenAPI json 檔案網址
val openApiProjectName = "ops"
val openApiSchemaUrl = "http://localhost:8080/apidocs/schema/$openApiProjectName.json"
val postmanEnvironment = "localhost"
// 如果 OpenAPI json 需要帳密才能下載
val swaggerUserName = "swagger"
val swaggerPassword = "123456"

// 串接 Postman Cloud 的 API Key
val postmanApiKey: String = System.getenv("POSTMAN_API_KEY") ?: ""

val downloadOpenApiJson by tasks.register<NodeTask>("downloadOpenApiJson") {
    group = "postman"
    script.set(file("postman/scripts/openapi-json-provider.js"))
    args.set(listOf(openApiProjectName, openApiSchemaUrl, swaggerUserName, swaggerPassword))
}

val openApiToPostmanCollection by tasks.register<NodeTask>("openApiToPostmanCollection") {
    group = "postman"
    script.set(file("postman/scripts/openapi-to-postman-collection.js"))
    args.set(listOf(openApiProjectName))
}

val generatePostmanCollection by tasks.register("generatePostmanCollection") {
    dependsOn(downloadOpenApiJson, openApiToPostmanCollection)
    group = "postman"
}

val runPostmanTest by tasks.register<NodeTask>("runPostmanTest") {
    group = "postman"
    script.set(file("postman/scripts/postman-test-runner.js"))
    args.set(listOf(openApiProjectName, postmanEnvironment)) // envName(required), folderName(optional)
}

val uploadToPostmanCloud by tasks.register<NodeTask>("uploadToPostmanCloud") {
    group = "postman"
    environment.set(mapOf("X-Api-Key" to postmanApiKey))
    script.set(file("postman/scripts/postman-api.js"))
    args.set(listOf("uploadAll", openApiProjectName)) // function => uploadAll, uploadEnvironments, uploadCollection
}

轉換 OpenAPI Json 為 Postman Collection

使用 openapi-to-postman 套件把 openapi.json 轉換為 collection

const postmanConverter = require('openapi-to-postmanv2');

async function convert(json) {
    let input = json ? {type: 'json', data: json} : {
        type: 'file',
        data: `postman/${projectName}/${projectName}-openapi.json`
    };
    let options = {
        requestNameSource: "Fallback",
        folderStrategy: "Tags"
    }
    let collection = await postmanConverter.convert(input, options, (err, conversionResult) => {
        if (!conversionResult.result) {
            throw err
        } else {
            console.log("openapiToPostmanCollection success");
            return conversionResult.output[0].data;
        }
    });
}

參數化測試程式與測試資料分離

在產生 collection 檔案之前,要把 request 的 header, path, query…等欄位值,修改為 {{fieldA}} 字串,使其參數化,這樣子就可以在測試資料填入 fieldA 的值進行替換,把測試資料與測試程式 collection 完全分離。

一旦產生 collection 檔案之後就不要再對它進行修改了,這是因為 collection 是自動產生的檔案,如果再對它進行修改,下一次產生時就又被覆蓋掉了。

function setRequest(request) {
    // 參數化 request body
    if (request.body) {
        request.body = {mode: "raw", raw: "{{_requestBody}}"};
    }
    // 參數化 API Key
    if (request.auth) {
        if (request.auth.apikey) {
            request.auth.apikey.find(it => it.key === "value").value = "{{X-API-KEY}}";
        }
    }
    // 參數化 header
    if (request.header) {
        request.header.forEach(it => it.value = `{{${it.key}}}`)
    }
    // 參數化 query
    if (request.url.query) {
        request.url.query.forEach(it => it.value = `{{${it.key}}}`)
    }
    // 參數化 path
    if (request.url.variable) {
        request.url.variable.forEach(it => it.value = `{{${it.key}}}`)
    }
}

把 collection 檔案匯入到 postman,就可以明顯看到 value 已經被參數化了

使用測試資料驅動測試腳本

postman collection runner 可以針對每一筆測試資料,逐一執行每一個 request。現在 postman 新版本還可以讓你勾選只要執行那些 request 就好,甚至可以調整執行順序,比我以前使用的舊版本只能執行全部的 request 好很多。雖然 runner 已有所改進,但仍然不夠有彈性,因為每一筆測試資料都會被勾選的 request 所執行,然而每個 request 需要的測試資料怎麼可能都一樣。

針對此問題,Postman 提出 request workflows 解決方法,我們可以在某個 request 的 Tests 寫下 postman.setNextRequest("request_name"); 就可以指定下一個被執行的 request,如果要停止,則呼叫 postman.setNextRequest(null); 所以我們可以針對 requestA 的測試資料先呼叫 postman.setNextRequest("requestA"); 然後再呼叫 postman.setNextRequest(null);,這樣子就只會執行 requestA 而已了。

不過問題來了,由於我們的 collection 是自動產生的,所以不可能再去編輯 collection 內容,為每一個 request 加上 postman.setNextRequest("request_name"); 而且這種方式等於直接寫死執行 request 的順序在 collection 裡面,執行順序應該是在執行期所決定的,但是這要怎麼做?

我的解決方式是

  1. 在 collection 的每一個 folder 裡面,建立 Dummy Request 而且要放在第一個位置,然後在 prerequest event 塞入 postman.setNextRequest(pm.iterationData.get('_requestName'));程式碼,其中_requestName是來自於測試資料,這樣子每一筆測試資料就會先執行 Dummy Request,然後再執行指定的 _requestName。
  2. 在每一個 request 的 test event 塞入 let script = pm.iterationData.get('_test');eval(script);postman.setNextRequest(null) 程式碼,其中 _test 是寫在測試資料的 javascript 程式碼,透過呼叫 eval 函式,我們可以在不修改 collection 檔案的情況下,做 assertion 及控制 postman 的行為。最後呼叫 postman.setNextRequest(null); 結束執行這一筆測試資料
function addDummyRequestAtFirstPosition(collection) {
    collection.item.forEach(folder => {
        if (folder.item) {
            folder.item.splice(0, 0, {
                id: uuidv4(),
                name: 'Dummy Request',
                request: {
                    url: {
                        host: ['{{dummyRequestUrl}}']
                    },
                    method: 'GET'
                },
                event: [
                    {
                        listen: "prerequest",
                        script: {
                            type: "text/javascript",
                            exec: [
                                "postman.setNextRequest(pm.iterationData.get('_requestName'));"
                            ]
                        }
                    }
                ]
            });
        }
    });
}

function addEvent(event) {
    event.push(
        {
            listen: "test",
            script: {
                type: "text/javascript",
                exec: [
                    "let script = pm.iterationData.get('_test');eval(script);postman.setNextRequest(null);"
                ]
            }
        }
    );
}

以下面這筆測試資料為例,一開始先執行 Dummy Request,然後根據 _requestName 的值,執行 CreateUser request,然後透過 eval 函式執行 _test 的 assertion code,最後呼叫 postman.setNextRequest(null); 結束,然後再繼續執行下一筆測試資料

[
  {
    "_requestName": "CreateUser",
    "X-API-KEY": "ops_root",
    "_requestBody": "{\"account\": \"tester@abcde.com\",\"password\": \"123456\",\"enabled\": true,\"role\": \"OpsTeam\",\"name\": \"tester\",\"email\": \"tester@abcde.com\",\"lang\": \"zh-TW\",\"mobile\": \"0987654321\"}",
    "_test": "pm.test('200 ok', function(){pm.response.to.have.status(200);});"
  }
]

撰寫多個 Request 組合而成的測試腳本

我們再繼續來看更複雜的測試案例,下面有4筆測試資料

  1. [CreateUser] 故意少填密碼,驗證 http status code 是否為 400 bad request
  2. [CreateUser] 填入正確的資料,驗證 http status code 是否為 200 OK,此時資料庫存在一筆資料
  3. [Login] 登入成功後,執行 pm.environment.set('sid', pm.response.json().data.sid); 把 response body 的 session id 儲存至 postman 的環境變數 sid
  4. [FindUsers] 這個 API 需要先登入才能呼叫,不過我們不必在這裡指定 sid 欄位值,因為剛才執行 Login 後已經把 sid 儲存在環境變數裡面,postman 會自動帶入。所以一旦登入之後,後續的 request 都不必再填入 sid,方便許多。另外在 q_filter 欄位填入 account 查詢條件,最後驗證 response body 是否回傳一筆剛才 CreateUser 建立的資料

既使這種由多個 request 組合而成的複雜測試腳本,我們都不必在 collection 檔案撰寫任何程式碼。另一方面,QA 只要會寫 postman 的 assert 就好,技術要求門檻低

[
  {
    "_requestName": "CreateUser",
    "X-API-KEY": "club_root",
    "_requestBody": "{\"account\": \"tester@abcde.com\",\"enabled\": true,\"role\": \"Admin\",\"name\": \"tester\",\"gender\": \"Male\",\"birthday\": 2000,\"email\": \"tester@abcde.com\",\"lang\": \"zh-TW\",\"mobile\": \"0987654321\"}",
    "_test": "pm.test('check password required', function(){pm.response.to.have.status(400);});"
  },
  {
    "_requestName": "CreateUser",
    "X-API-KEY": "club_root",
    "_requestBody": "{\"account\": \"tester@abcde.com\",\"password\": \"123456\",\"enabled\": true,\"role\": \"Admin\",\"name\": \"tester\",\"gender\": \"Male\",\"birthday\": 2000,\"email\": \"tester@abcde.com\",\"lang\": \"zh-TW\",\"mobile\": \"0987654321\"}",
    "_test": "pm.test('200 ok', function(){pm.response.to.have.status(200);});"
  },
  {
    "_requestName": "Login",
    "X-API-KEY": "club_android",
    "_requestBody": "{\"account\": \"tester@abcde.com\",\"password\": \"123456\",\"deviceId\": \"623b4a70-64fc-401a-978b-8d63dfaacddc\",\"devicePushToken\": \"abcdefghijklmnopqrstuvwxyz\",\"deviceOsVersion\": \"Android 9.0\"}",
    "_test": "pm.test('200 ok', function(){pm.response.to.have.status(200);}); pm.environment.set('sid', pm.response.json().data.sid);"
  },
  {
    "_requestName": "FindUsers",
    "X-API-KEY": "club_android",
    "q_filter": "[account = tester@abcde.com]",
    "_test": "pm.test('200 ok', function(){pm.response.to.have.status(200);});pm.test('check user count', function(){pm.expect(pm.response.json().data).to.be.lengthOf(1)});"
  }
]

執行測試及產生測試報告

Postman Newman runner 可以指定 folder,只執行 folder 裡面的 request,或是不指定 folder,所有 request 都可以執行。至於 folder 是對應到 OpenAPI 的 tag,方便我們組織分類 request。

下圖是我規劃的檔案編排方式,我先根據 ops, club 2個子專案建立第一層資料夾,然後 data 資料夾裡面就是測試資料,至於 environment 資料夾是放置 postman 的 environment json 設定檔。

我會在 folder 資料夾放置基本的 request 測試資料,可以視為 API 層級的單元測試,然後在 suite 資料夾放置複雜的測試案例,可以視為 API 層級的整合測試。執行 Newman 的時候,可以傳入參數決定是要測試 folder 或是 suite

執行完畢後,會產出 HTML 格式的測試報告

串接 Postman Cloud 與其它團隊成員同步資料

最後我們可以在 Postman 網站申請 API Key 進行串接,把本地端產生的 collection 檔案,還有 environment json 檔案上傳至個人或團隊的 workspace,畢竟有時透過 Postman GUI 會比較方便瀏覽。下圖是我上傳的2個子專案的 collection


上一篇
[Day 17] 實作 Ktor OpenAPI Generator
下一篇
[Day 19] 實作 Ktor Request Logging
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30

尚未有邦友留言

立即登入留言