[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\": \"[email protected]\",\"password\": \"123456\",\"enabled\": true,\"role\": \"OpsTeam\",\"name\": \"tester\",\"email\": \"[email protected]\",\"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\": \"[email protected]\",\"enabled\": true,\"role\": \"Admin\",\"name\": \"tester\",\"gender\": \"Male\",\"birthday\": 2000,\"email\": \"[email protected]\",\"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\": \"[email protected]\",\"password\": \"123456\",\"enabled\": true,\"role\": \"Admin\",\"name\": \"tester\",\"gender\": \"Male\",\"birthday\": 2000,\"email\": \"[email protected]\",\"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\": \"[email protected]\",\"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 = [email protected]]",
    "_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


<<:  成为工具人应有的工具包-08 IECacheView

>>:  故事的层次与元素

集合推理与欧拉图

上一篇介绍了文氏图与 SQL 的集合的应用,这一篇要介绍的是欧拉图,欧拉图与文氏图不同之处,在於它主...

第7车厢-讨厌~叫人家开要干嘛?触发check事件应用篇

本篇延续第六篇按钮开关样板,来触发之後的行为 昨天做了一个开关,那要怎麽透过科好的按钮,来判断之後...

你要的是Entity Framework吗?

很多初学Entity Framework( Core)(以下简称EF)的新手,刚开始使用EF时都会有...

Kneron - Kneron Toolchain 转档操作参考笔记

Kneron - Kneron Toolchain 转档操作参考笔记 参考资料 onnx 档案来源:...

从 JavaScript 角度学 Python(19) - JSON

前言 前面我们了解基本的档案处理之後,接下来当然就是试着实作读取一些不同格式的档案,因此这一篇将会介...