封裝指南:告別Pre-request Script重復(fù)代碼)
做接口聯(lián)調(diào)這幾年我在 Postman 里最煩的事不是接口長時間無響應(yīng)而是同一個簽名算法在十幾個請求的 Pre-request Script 里各放了一份。每次后端改一點邏輯我都要打開每個請求、找到那段一模一樣的代碼、逐個替換還得提心吊膽怕漏改某一個。后來痛定思痛決定把公共函數(shù)統(tǒng)一抽出來。這篇文章就把我折騰 Postman 定義公共函數(shù)的完整經(jīng)驗寫出來包括三套不同的實現(xiàn)方案、每一套背后的原理以及實測中踩過的各種坑。如果你也經(jīng)常被復(fù)制粘貼腳本折磨這篇應(yīng)該能幫你省下大量時間。1. 為什么接口測試?yán)锟偸侵貜?fù)造輪子1.1 一個再常見不過的崩潰瞬間Postman 腳本的重復(fù)和業(yè)務(wù)代碼里的重復(fù)不太一樣。業(yè)務(wù)代碼你還能抽一個 utils 文件到處 importPostman 早期版本沒有一個真正意義上的公共模塊入口。于是很多人的做法是某個請求腳本里寫好了一段簽名邏輯測試通過了下一個請求直接復(fù)制再下一個請求繼續(xù)復(fù)制。等到整個 Collection 里鋪滿了同一段代碼噩夢才開始。我之前接手過一套接口測試集合三十多個請求每個請求的 Pre-request Script 里都有一段 HMAC 簽名代碼代碼內(nèi)容一模一樣只是復(fù)制時有人多粘貼了一次、有人少了一行。后端在一次安全升級中把簽名算法從 HMAC-SHA1 換成了 HMAC-SHA256我花了一個晚上把三十多個請求逐個打開、逐段替換中間還漏掉了三個導(dǎo)致第二天聯(lián)調(diào)時同一個錯誤被連續(xù)報了四五次。那之后我意識到在 Postman 里定義公共函數(shù)不是代碼潔癖而是接口測試工程化的基本前提。只要你的接口數(shù)量超過五個、或者同一段腳本被粘貼超過兩次就應(yīng)該考慮這個問題了。1.2 先理解 Postman 腳本的執(zhí)行順序和變量體系要選對公共函數(shù)的方案得先弄清楚 Postman 的腳本執(zhí)行鏈路。一個請求從發(fā)送到結(jié)束腳本的執(zhí)行順序大致如下執(zhí)行階段腳本位置用途第一階段Collection 級 Pre-request Script集合內(nèi)所有請求都會先執(zhí)行適合放全局公共邏輯第二階段Folder 級 Pre-request Script當(dāng)前文件夾內(nèi)的請求先執(zhí)行適合放文件夾級公共邏輯第三階段Request 級 Pre-request Script當(dāng)前請求自身攜帶的腳本第四階段發(fā)送請求本身無腳本執(zhí)行第五階段Request 級 Tests當(dāng)前請求收到響應(yīng)后執(zhí)行的斷言第六階段Folder 級 Tests文件夾級公共斷言第七階段Collection 級 Tests集合級公共斷言也就是說Collection 級 Pre-request Script 天然適合作為公共代碼區(qū)它位于所有子請求之前定義在這里的函數(shù)和變量后續(xù)的請求腳本、Tests 腳本都能訪問到。這一點是 Postman 定義公共函數(shù)最核心的基礎(chǔ)。除了執(zhí)行順序Postman 的變量體系也很關(guān)鍵。它至少包含五層變量全局變量、環(huán)境變量、集合變量、數(shù)據(jù)變量、局部變量。我們在做公共函數(shù)時主要會用到前三種因為公共函數(shù)需要讀取配置的值而配置往往存在于環(huán)境變量或集合變量中。理解這些變量怎么傳遞后面幾套方案才不會選錯。2. 方案一把函數(shù)字符串化存進(jìn)全局變量再用 eval 喚起2.1 最樸素也最普及的操作步驟這是網(wǎng)上流傳最廣、也是老版本 Postman 時代就存在的方案。核心思路是把一段 JavaScript 代碼當(dāng)作字符串存進(jìn)全局變量或環(huán)境變量在腳本頂部用eval()執(zhí)行這段字符串于是函數(shù)就活了。先看第一步。在 Postman 左側(cè)菜單進(jìn)入 Environments選 Globals新建一個變量名字建議叫utils值填一段字符串化的 JavaScript 代碼。比如function genTimestamp() { return Math.floor(Date.now() / 1000); } function genNonce(len) { var chars abcdefghijklmnopqrstuvwxyz0123456789; var nonce ; for (var i 0; i len; i) { nonce chars.charAt(Math.floor(Math.random() * chars.length)); } return nonce; }然后在任意請求的 Pre-request Script 里寫eval(pm.globals.get(utils)); // 這句之后genTimestamp 和 genNonce 就能直接用了 var timestamp genTimestamp(); var nonce genNonce(16); console.log(timestamp, nonce);這樣凡是想用這兩個函數(shù)的請求都只需要一行eval再加調(diào)用不用再復(fù)制一整套函數(shù)體。要改邏輯只需要改全局變量utils里的字符串所有引用了eval的請求下次執(zhí)行時都會拿到新版本。2.2 eval 方案背后的作用域原理很多人擔(dān)心eval的性能和安全性。這里要說明一下Postman 的腳本運行在一個獨立的沙箱環(huán)境中每次請求執(zhí)行時Collection 腳本、Pre-request 腳本、Tests 腳本共享同一個全局作用域。eval執(zhí)行的代碼本質(zhì)上就是在當(dāng)前全局作用域中解釋運行。所以eval(pm.globals.get(utils))執(zhí)行完函數(shù)聲明genTimestamp就會掛到全局對象上后面再調(diào)用自然找得到。也正因為共享全局作用域eval方案有一個容易翻車的細(xì)節(jié)函數(shù)聲明和var聲明會暴露到全局但const和let不會。換句話說如果你的全局變量里寫的是const genTimestamp () Math.floor(Date.now() / 1000);那么eval之后你直接調(diào)genTimestamp()大概率會報genTimestamp is not defined。我在實測里就栽過這個跟頭。要穩(wěn)妥公共函數(shù)的字符串里請盡量使用function聲明式寫法或者用命名空間對象掛載var ApiUtils {}; // 用 var ApiUtils.genTimestamp function() { ... }; ApiUtils.genNonce function(len) { ... };執(zhí)行完eval后通過ApiUtils.genTimestamp()調(diào)用只要ApiUtils是var或全局對象上已有的屬性就不會有作用域丟失的問題。2.3 eval 方案的短板字符串轉(zhuǎn)義和代碼管理這個方案雖然簡單但它的短板也很明顯。第一是字符串轉(zhuǎn)義。如果你把上面那段函數(shù)體原樣復(fù)制到 Globals 變量的輸入框里可能因為引號、換行、反引號等各種問題導(dǎo)致解析出錯。我的經(jīng)驗是別直接在變量值里反復(fù)改可以先在一個臨時請求的腳本里寫好函數(shù)源碼然后利用JSON.stringify把它變成字符串輸出再復(fù)制進(jìn) Globalsfunction genNonce(len) { var chars abcdefghijklmnopqrstuvwxyz0123456789; var nonce ; for (var i 0; i len; i) { nonce chars.charAt(Math.floor(Math.random() * chars.length)); } return nonce; } console.log(JSON.stringify(genNonce.toString()));把控制臺輸出的長字符串復(fù)制到全局變量里比手動轉(zhuǎn)義靠譜得多。第二是代碼管理。全局變量里存著一大段代碼字符串沒有語法高亮沒有版本控制多人協(xié)作時你會分不清這套變量值是哪一次的版本。Eval 方案適合臨時頂一頂或者跨 Collection 少量復(fù)用不建議作為長期唯一的公共函數(shù)方案。3. 方案二Collection 級 Pre-request Script公共函數(shù)的官方主場3.1 配置步驟與代碼組織方式如果你只在一個 Collection 內(nèi)部共享公共函數(shù)我會毫不猶豫推薦 Collection 級 Pre-request Script。這個方案不需要把代碼字符串化不需要eval代碼編輯器里有完整的語法高亮和自動補全改起來舒服得多。操作非常簡單在 Collection 上右鍵選 Edit進(jìn)入 Pre-request Script 標(biāo)簽頁把公共函數(shù)的代碼直接寫進(jìn)去。例如var ApiUtils { genTimestamp: function() { return Math.floor(Date.now() / 1000).toString(); }, genNonce: function(len) { var chars abcdefghijklmnopqrstuvwxyz0123456789; var nonce ; for (var i 0; i len; i) { nonce chars.charAt(Math.floor(Math.random() * chars.length)); } return nonce; } };保存之后該 Collection 下所有請求的 Pre-request Script 里都可以直接寫ApiUtils.genTimestamp()不需要再引入任何變量也不需要eval。用這套方案時我還有個習(xí)慣集合腳本只放函數(shù)定義不放具體的業(yè)務(wù)邏輯。業(yè)務(wù)邏輯永遠(yuǎn)留在請求腳本里請求腳本只負(fù)責(zé)取參數(shù)、調(diào)函數(shù)、塞進(jìn)請求頭。這樣公共函數(shù)和具體用例就徹底解耦了以后換簽名算法只需要改集合腳本一個地方。3.2 作用域細(xì)節(jié)為什么集合腳本里的函數(shù)在請求腳本里能用這是我在實際使用中確認(rèn)過的一個重點。Collection 級 Pre-request Script 執(zhí)行時確實是在當(dāng)前沙箱的全局作用域里跑由于腳本按照集合級 - 文件夾級 - 請求級的層級順序執(zhí)行集合級腳本里通過function聲明或var聲明的對象在請求級腳本里是可以直接訪問的。但這里有一個非常容易踩的坑const和let聲明不會掛載到全局對象上。我見過同事把集合腳本寫成const ApiUtils { ... };然后在請求腳本里調(diào)用ApiUtils.xxx()在部分 Postman 版本里會穩(wěn)定報錯在另一些版本里時好時壞非常磨人。所以我在集合腳本里統(tǒng)一用var 對象名 {}配合函數(shù)屬性的方式從根源上避開作用域不確定性。還有一點函數(shù)內(nèi)部訪問pm對象時也要注意時機。比如你寫了一個getBaseUrl()里面返回pm.environment.get(baseUrl)。這個函數(shù)在集合腳本定義時只是被聲明真正執(zhí)行是請求發(fā)出前一刻。因此函數(shù)體內(nèi)訪問環(huán)境變量完全沒有問題它會讀取到最新切換過來的環(huán)境。反過來如果你在集合腳本頂層直接寫var baseUrl pm.environment.get(baseUrl);這行代碼集合腳本一執(zhí)行就取值快照了之后再切換環(huán)境、改變量baseUrl這個值都不會刷新。公共函數(shù)里請避免定義時取值盡量讓函數(shù)在調(diào)用時才去讀取變量。3.3 公共函數(shù)在 Tests 階段也能復(fù)用很多人以為集合級 Pre-request Script 只在請求前有效其實它的作用域貫穿整個沙箱生命周期。請求發(fā)送結(jié)束之后Tests 腳本執(zhí)行時同樣可以訪問到集合腳本里定義的函數(shù)。這意味著你可以把公共斷言也放進(jìn)集合腳本里而不是在每個請求的 Tests 里復(fù)制一遍。我通常會在集合腳本里放一個assertSuccess風(fēng)格的公共方法ApiUtils.assertSuccess function(res) { var json res.json(); if (json.code ! 0) { console.log(業(yè)務(wù)返回碼非0 JSON.stringify(json)); throw new Error(業(yè)務(wù)異常code json.code msg json.msg); } return json; };請求的 Tests 腳本里就只寫一行var body ApiUtils.assertSuccess(pm.response); pm.test(返回數(shù)據(jù)包含list字段, function() { pm.expect(body.data).to.have.property(list); });這樣一來公共的返回碼校驗邏輯只維護(hù)一份斷言的可讀性也高了不少。3.4 這套方案的邊界Collection 級腳本也不是萬能的。它的作用域嚴(yán)格被限制在同一個 Collection 內(nèi)如果你有多個 Collection 需要共享同一套公共函數(shù)就得在每個 Collection 里各貼一份。對這種跨 Collection 的場景我會結(jié)合全局變量方案把是否穩(wěn)定、是否跨集合作為選型標(biāo)準(zhǔn)。另外集合腳本里的代碼如果過于龐大每次請求都要解析執(zhí)行一遍雖然真實影響不大但確實沒必要把幾百行重型工具代碼全部塞進(jìn)去。4. 方案三require() 內(nèi)置庫公共函數(shù)也能模塊化4.1 Postman 沙箱自帶的能力比你想得多很多新手不知道Postman 腳本沙箱其實是支持 CommonJS 風(fēng)格的require()的只不過它不能像 Node.js 那樣加載你本地的任意文件只能加載沙箱預(yù)置的庫。我在日常項目里最常用的幾個內(nèi)置庫包括庫名用途示例crypto-js各種哈希、HMAC、AES 加解密moment時間格式化、日期計算lodash數(shù)組、對象、字符串處理cheerio在響應(yīng) HTML 中解析元素postman-collection操作 Collection 結(jié)構(gòu)數(shù)據(jù)使用方式非常直接var CryptoJS require(crypto-js); var moment require(moment); var time moment().format(YYYY-MM-DD HH:mm:ss); var sign CryptoJS.HmacSHA256(data, secret).toString();這條思路和公共函數(shù)的結(jié)合點在哪里在于你的公共函數(shù)不一定都是自己手寫的純函數(shù)很多簽名、加密、解析邏輯需要依賴成熟庫。不要自己去實現(xiàn)一個 MD5直接在集合公共腳本里require就好。4.2 用 require() 編寫公共函數(shù)的完整形態(tài)以簽名場景為例。我們可以在 Collection 級 Pre-request Script 里寫var CryptoJS require(crypto-js); var AuthUtils { genTimestamp: function() { return Math.floor(Date.now() / 1000).toString(); }, genNonce: function(len) { return CryptoJS.lib.WordArray.random(len / 2).toString(); }, buildSign: function(appId, timestamp, nonce, secret) { var raw appId timestamp nonce secret; return CryptoJS.SHA256(raw).toString(); }, attachAuth: function(request) { var appId pm.environment.get(appId); var secret pm.environment.get(secret); var timestamp this.genTimestamp(); var nonce this.genNonce(16); var sign this.buildSign(appId, timestamp, nonce, secret); var headers request.headers; headers.add({ key: X-App-Id, value: appId }); headers.add({ key: X-Timestamp, value: timestamp }); headers.add({ key: X-Nonce, value: nonce }); headers.add({ key: X-Sign, value: sign }); } };請求腳本里只需要AuthUtils.attachAuth(pm.request);require的庫在集合公共腳本里加載一次整個集合所有請求都能用。這比每個請求里重復(fù)寫CryptoJS.HmacSHA256(...)要干凈太多。4.3 require 方案的兩個認(rèn)識誤區(qū)第一個誤區(qū)是Postman 能直接 require 我自己寫的本地文件。至少到目前Postman 腳本沙箱不能像 Node.js 那樣require一個相對路徑的 js 文件它只能加載內(nèi)置庫和通過配置引入的外部庫。所以如果你希望團(tuán)隊共享一套自定義公共模塊要么用前面講的變量字符串方案要么把公共代碼放進(jìn)集合腳本要么借助新版本支持的外部庫配置把第三方依賴 URL 引進(jìn)來。自定義的那段邏輯終究還是需要通過集合腳本或全局變量來承載。第二個誤區(qū)是require 模塊和 eval 函數(shù)不能共存。事實上我經(jīng)常在集合腳本里先寫好公共函數(shù)再在公共函數(shù)內(nèi)部用 require 加載依賴兩個方案完全不沖突。公共函數(shù)的骨架由集合腳本提供依賴能力由 require 解決。這也是我最推薦的組合方式。5. 實戰(zhàn)把簽名、鑒權(quán)、斷言統(tǒng)一成一套公共函數(shù)5.1 業(yè)務(wù)需求拆解為了把前面的方案串起來我用一個實際場景走一遍完整流程。假設(shè)公司接口要求每個請求的 Header 里帶四個參數(shù)appId、timestamp、nonce、sign簽名規(guī)則是SHA256(appId timestamp nonce secret)。secret 不直接暴露在 Header 中只在服務(wù)端和測試配置里存在。這個需求如果不做公共函數(shù)每個請求的 Pre-request Script 都要寫一遍時間戳生成、隨機數(shù)生成、字符串拼接、SHA256 簽名、Header 設(shè)置大約二十多行重復(fù)代碼。有了公共函數(shù)之后所有請求統(tǒng)一收斂為一行調(diào)用。5.2 完整實現(xiàn)集合腳本 請求腳本我按方案二加方案三的組合來做先在 Collection 的 Pre-request Script 里寫入完整工具對象var CryptoJS require(crypto-js); var AuthUtils { genTimestamp: function() { return Math.floor(Date.now() / 1000).toString(); }, genNonce: function(len) { return CryptoJS.lib.WordArray.random(len ? len / 2 : 8).toString(); }, buildSign: function(appId, timestamp, nonce) { var secret pm.environment.get(secret); return CryptoJS.SHA256(appId timestamp nonce secret).toString(); }, attachAuth: function() { var req pm.request; var appId pm.environment.get(appId); var timestamp this.genTimestamp(); var nonce this.genNonce(16); var sign this.buildSign(appId, timestamp, nonce); var headers req.headers; headers.add({ key: X-App-Id, value: appId }); headers.add({ key: X-Timestamp, value: timestamp }); headers.add({ key: X-Nonce, value: nonce }); headers.add({ key: X-Sign, value: sign }); }, assertSuccess: function() { var json pm.response.json(); if (json.code ! 0) { throw new Error(業(yè)務(wù)異常 JSON.stringify(json)); } return json; } };請求的 Pre-request Script 寫AuthUtils.attachAuth();請求的 Tests 寫var body AuthUtils.assertSuccess(); pm.test(list 字段存在, function() { pm.expect(body.data).to.have.property(list); });這里有一個細(xì)節(jié)需要提醒attachAuth內(nèi)部我用了pm.request。在較新的 Postman 沙箱里這沒有問題如果你用的是某些老版本pm.request可能取不到完整的 header 操作接口這時可以改用request全局變量效果是一樣的。執(zhí)行后打開控制臺查看請求頭確認(rèn)四個X-開頭的 Header 都已經(jīng)加上即可。5.3 為什么這套設(shè)計比每個請求各寫各的強從表面看公共函數(shù)只是省了復(fù)制粘貼。但更深層的好處是當(dāng)后端的簽名算法從 SHA256 升級到 HMAC-SHA256或者要求增加一個鹽值你只需要改AuthUtils.buildSign這一個函數(shù)所有請求在下一次執(zhí)行時自動生效不需要再去逐個檢查哪幾個請求漏改了。我后來多次體會過這個優(yōu)勢接口協(xié)議一變更整個集合的維護(hù)成本幾乎為零。公共函數(shù)還可以繼續(xù)擴展。比如把genNonce改成更安全的隨機源、把簽名規(guī)則改成先排序再拼接、把assertSuccess擴展出特定的錯誤碼處理邏輯都是在公共腳本里一個函數(shù)的事。接口測試集合的穩(wěn)定性和可維護(hù)性往往就是這么一點點撐起來的。6. 幾個我實測翻過車的細(xì)節(jié)6.1 全局變量更新了但公共函數(shù)還是舊的用全局變量存公共代碼時最容易出這種問題你更新了 Globals 里的utils字符串代碼看起來也保存了但某個請求執(zhí)行出來還是老邏輯。原因通常是你在另一個環(huán)境里做的修改卻沒有把utils變量同步到當(dāng)前環(huán)境。全局變量跨環(huán)境同步有一定滯后性在多人協(xié)作的 Workspace 里更是如此。后來我把公共函數(shù)的最終版本放到了 Collection 級腳本里全局變量只存跨 Collection 的通用字符串哪個集合沒同步刪掉重新拉取一次就好。6.2 字符串轉(zhuǎn)義和換行地獄用 eval 方案時最大的敵人是轉(zhuǎn)義。函數(shù)體一旦稍長引號、換行、正則表達(dá)式混在一起很難在變量輸入框里保持原樣。上面提到過用JSON.stringify(fn.toString())來生成字符串但這里還要提醒一個補充不要試圖把整個對象字面量直接JSON.stringify后丟進(jìn)變量因為JSON.stringify會丟棄函數(shù)屬性。你要處理的是獨立的函數(shù)或者把函數(shù)轉(zhuǎn)換成可執(zhí)行的源碼段再拼接成整體。如果是團(tuán)隊里經(jīng)常更新公共代碼我會建議直接放棄手動編輯全局變量字符串這個動作改成在某個專門的請求腳本里維護(hù)源碼執(zhí)行一次后自動寫入全局變量。這樣至少代碼有語法高亮改起來不容易出錯。6.3 公共函數(shù)不要吞掉環(huán)境變量的最新值我在 3.2 提到過定義時取值和調(diào)用時取值的區(qū)別這里再強調(diào)一次。公共函數(shù)內(nèi)部凡是涉及環(huán)境變量的都應(yīng)該寫成函數(shù)體內(nèi)的取值邏輯// 錯誤示范 var appId pm.environment.get(appId); function buildSign() { return appId ... ; } // 正確示范 function buildSign() { var appId pm.environment.get(appId); return appId ...; }Postman 的環(huán)境變量是可以在多個環(huán)境之間切換的。如果公共函數(shù)在定義階段就把 appId 取出來存成了快照那么你從 dev 環(huán)境切到 uat 環(huán)境函數(shù)仍然在用 dev 的那份值簽名就會一直通不過。這類問題非常隱蔽因為在本地一次跑通時根本發(fā)現(xiàn)不了。6.4 公共腳本別寫成一個巨型倉庫最后一條經(jīng)驗公共函數(shù)的抽象層級要控制好。把簽名、時間戳、隨機數(shù)這類高頻穩(wěn)定的邏輯抽出來是值得的但如果有人把幾十個接口各自特有的業(yè)務(wù)分支也塞進(jìn)集合腳本這個集合腳本就會變成誰都看不懂、誰都不敢改的垃圾堆。我一般只把工具類和斷言類邏輯放公共層業(yè)務(wù)分支判斷留在請求腳本里。公共代碼行數(shù)控制在兩百行以內(nèi)超過這個量級說明應(yīng)該考慮拆分 Collection 或者把復(fù)雜邏輯挪到接口層之后的輔助服務(wù)里。我在實際項目里現(xiàn)在的標(biāo)準(zhǔn)做法是Collection 內(nèi)共享的函數(shù)全部放在 Collection 級 Pre-request Script用一個var 命名空間對象暴露內(nèi)部用require加載 crypto-js 等依賴只有真正需要跨 Collection 復(fù)用、且變更頻率很低的純函數(shù)才會塞進(jìn)全局變量配合 eval 使用。這套組合已經(jīng)在多套接口測試集合里穩(wěn)定跑了一年多期間經(jīng)歷過三四次簽名規(guī)則調(diào)整每次都是改一個函數(shù)完事。建議你也早點把這些重復(fù)代碼收攏起來省下的時間足夠多做不少真正有價值的測試設(shè)計。