Web API 測試可以是後端工程師使用測試框架撰寫白箱測試,也可以是 QA 使用測試工具進行黑箱測試。對於後端工程師來說,如果 API 有異動,很容易找出對應的測試程式碼進行調整,畢竟程式都是自己寫的,也有 IDE 工具輔助。但如果是 QA 的黑箱測試,就需要後端工程師通知 QA,然後 QA 再去修改測試腳本,這時候就有了雙方如何溝通同步的議題。
前幾天我們已實作 OpenAPI Generator,能自動產生 OpenAPI 文件,讓實際運行的程式碼與 API 文件儘量保持一致。那麼測試程式也能像文件一樣自動產生嗎? 再進一步延伸,我們希望達到以下目標
我的想法是整合現有的測試工具,以此為基礎進行修改調整。我選擇 Postman 這個著名的測試工具,因為 Postman 提供 openapi-to-postman
套件,先轉換 OpenAPI json 為 Postman Collection,然後再使用 Postman Newman
執行測試。這個解決方案可以符合上述目標
接下來就開始動手實作 (點我連結到完整程式碼)
Postman 函式庫都是 NodeJS 套件,所以是可以跟本專案的 Kotlin Ktor 專案分開獨立存在,不過為了開發方便,我想放在同一個專案就好,所以要先安裝 gradle-node-plugin 建立 NodeJS 執行環境。
先設定 openApiSchemaUrl
指定 openapi json 網址,然後再依以下順序執行 gradle task downloadOpenApiJson
→openApiToPostmanCollection
→ runPostmanTest
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-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 裡面,執行順序應該是在執行期所決定的,但是這要怎麼做?
我的解決方式是
folder
裡面,建立 Dummy Request
而且要放在第一個位置,然後在 prerequest event
塞入 postman.setNextRequest(pm.iterationData.get('_requestName'));
程式碼,其中_requestName
是來自於測試資料,這樣子每一筆測試資料就會先執行 Dummy Request,然後再執行指定的 _requestName。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);});"
}
]
我們再繼續來看更複雜的測試案例,下面有4筆測試資料
pm.environment.set('sid', pm.response.json().data.sid);
把 response body 的 session id 儲存至 postman 的環境變數 sid
既使這種由多個 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 網站申請 API Key 進行串接,把本地端產生的 collection 檔案,還有 environment json 檔案上傳至個人或團隊的 workspace,畢竟有時透過 Postman GUI 會比較方便瀏覽。下圖是我上傳的2個子專案的 collection