許式偉談七牛如何做HTTP服務測試
4月26日,為期兩天的開發者盛會Gopher China完美畢幕。在此次會議上,開發者們除了見到Go語言創始人Robert Griesemer,穿上了神氣的“中國版土撥鼠T-shirt”外,還收獲到了滿滿的技術干貨。作為Go語言在國內的踐行者,七牛云CEO許式偉在 Gopher China上為開發者們分享了七牛如何做HTTP服務測試的經驗。
基于HTTP協議來提供服務的好處是顯然的。除了HTTP服務有很多現成的客戶端、服務端框架可以直接使用外,在HTTP服務的調試、測試等工程領域都有現成的相關工具支撐。七牛大量的服務都基于HTTP,所以需要思考如何更有效地進行HTTP服務的測試。
七牛早期HTTP服務的測試方法是先寫好服務端,然后寫一個客戶端SDK,再基于這 個客戶端SDK寫測試案例。這種方法多多少少會遇到一些問題。首先,客戶端SDK的修改可能會導致測試案例編不過。其次,客戶端SDK通常是使用方友好, 而不是測試方友好。服務端開發過程和客戶端SDK耦合容易過早地陷入“客戶端SDK如何抽象更合理”的細節,而不能專注于測試服務邏輯本身。我的核心訴求 是服務端開發過程和客戶端開發過程解耦,因為網絡協議定好了以后,整個系統原則上就可以編寫測試案例,而不用等客戶端SDK的完成。
不寫客戶端SDK而直接做HTTP測試,一個直觀的思路是直接基于 http.Client類來寫測試案例。這種方式的問題是代碼比較冗長,而且它的業務邏輯表達不直觀,很難一眼看出這句話想干什么。雖然可以寫一些輔助函 數來改觀,但是會逐漸有寫測試專用SDK的傾向。這種寫法看起來也不是很可取,畢竟為測試寫一個專門的SDK,看起來成本有些高了。
七牛當前的做法是引入一種httptestDSL文法。這是七牛為測試寫的領域專用 語言。這個語言的文法大概在2012年就已經被加入到七牛的代碼庫,后來有個同事根據這個DSL文法寫了第一版本qiniutest程序。在決定推廣用這 個DSL來進行測試的過程中,我們對DSL不斷地進行了調整和加強。雖然總體思路沒有變化,但最終定稿的DSL與最初版本有較大的差異。目前來說,我已經 可以十分確定地說,這個DSL可以滿足90%以上的測試需求。現在所有新寫的模塊全部基于這套測試案例進行測試,它被推薦做為七牛內部的首選測試方案。
上圖是這套DSL的“hello world”程序。執行預期:下載www.qiniu.com首頁,要求返回的HTTP狀態碼為200。如果返回非200,測試失敗;否則則測試通過,打 印返回包的正文內容(resp.body變量)。打印resp.body通常是調試需要,而不是測試需要。自動化測試是不需要去打印什么的。
我們再看該DSL的一個“quick start(快速入門)”樣例。以#開始的內容是程序的注釋部分。這里有一個很長很長的注釋,描述了一個基本的HTTP請求測試的構成。后面我們會對這部 分內容進行詳細展開,這里暫時跳過。這段代碼的第一句話是定義了一個auth別名叫qiniutest,這只是為了讓后面具體的HTTP請求中授權語句更 簡短。緊接著是發起一個POST請求,創建一個內容為 {“a”: “value1″,”b”: 1}的對象,并將返回的對象id賦值給一個名為id1的變量。后面我們會詳細解釋這個賦值過程是如何進行的。接著我們發起一個獲取對象內容的GET請求, 需要注意的是GET的URL中引用了id1的值,這意味著我們不是要取別的對象的內容,而是取剛剛創建成功的對象的內容,并且我們期望返回的對象內容和剛 才POST上去的一樣,也是{“a”:”value1″, “b”: 1}。這就是一個最基礎的HTTP測試,它創建一個對象,確認創建成功,并且嘗試去取回這個對象,確認內容與預期的一致。這里上下兩個請求是通過一個變量 來關聯的。
對這套DSL文法有了一個大概的印象后,我們開始來解剖它。先來看看它的語法結構。首先這套httptest DSL基于命令行文法:
command switch1 switch2 …arg1 arg2…
整個命令行先是一個命令,然后緊接著是一個個開關(可選),最后是一個個的命令參 數。和大家熟悉的命令行比如LinuxShell一樣,它也會有一些參數需要轉義,如果參數包含空格或其他特殊字符,則可以用’\’轉義,如’\ ‘表示’ ‘,’\t’表示TAB等。另外,我們也支持用 ‘…’ 或者 “…” 去引用一個比較復雜的文本作為參數,比如json格式的多行文本。同 LinuxShell類似,’…’ 里面的內容沒有轉義,’\’ 就是 ‘\’,’\t’ 就是 ‘\t’,而不是 TAB。而 “…” 則支持轉義。
和Linux Shell 不同的是,我們的httptest DSL雖然基于命令行文法,但是它的每一個參數都是有類型的,也就是說這個語言有類型系統,而不像Linux Shell只有字符串。我們的httptest DSL支持切僅支持所有json支持的數據類型,包括:
- string (如:”a”、application/json),在不引起歧義的情況下,可以省略雙引號
- number (如:3.14159)
- boolean (如:true、false)
- array (如:[“a”, 200, {“b”: 2}])
- dictionary/object (如:{“a”: 1, “b”: 2}) </ul>
- 無授權的GET請求: </ul>
- 帶授權的Post請求: </ul>
- 無授權的GET請求: </ul>
- 帶授權的Post請求: </ul>
- 與match不同,<expected>、<source>中都不允許出現未綁定的變量
- 與match不同equal要求<expected>、<source>的值精確相等 </ul>
- SET是指集合
- 與equal不同,equalSet要求<expected>、<source>都是array,并且對array的元素進行排序后兩者精確相等
- equalSet的典型使用場景是list類的API,比如列出一個目錄下的所有文件,你可能預期這個目錄下有哪些文件,但是不能預期他們會以什么樣的次序返回 </ul>
- 測試 stage 環境: </ul>
- 測試 product 環境: </ul>
另外,我們的httptestDSL也有子命令的概念,它相當于一個函數,可以返回任意類型的數據。比如 `qiniu f2weae23e6c9f jg35fae526kbce` 返回一個 auth object,這用字符串無法表達。
理解了httptestDSL后,我們來看看如何表達一個http請求。它的基本形式如下:
req <http-method> <url>
header <key1> <val11> <val12>
header <key2> <val21> <val22>
auth <authorization>
body <content-type> <body-data>
第一句是req指令,帶兩個參數: 一個是http method,即http請求的方法,另一個是要請求的URL。接著是一個個自定義的header(可選),每個header指令后面跟一個key(鍵) 和一個或多個value(值)。然后是一個可選的auth指令,用來指示這個請求的授權方式。如果沒有auth語句,那么這個http請求是匿名的,否則 這就是一個帶授權的請求。最后一句是body指令,顧名思義它用來指定http請求的正文。body指令也有兩個參數,一個是content- type(內容格式),另一個是body-data(請求正文)。
這樣說比較抽象,我們看下實際的例子:
req GET http://www.qiniu.com/
req POST http://foo.com/objects
auth ‘qiniu f2weae23e6c9fjg35fae526kbce’
body application/json ‘{“a”:”hello1″, “b”:2}’
也可以簡寫成:
get http://www.qiniu.com/
post http://foo.com/objects
auth ‘qiniu f2weae23e6c9fjg35fae526kbce’
json ‘{“a”: “hello1″,”b”:2}’
發起了http請求后,我們就可以收到http返回包并匹配。http返回包匹配的基本形式如下:
ret <expected-status-code>
header <key1> <expected-val11><expected-val12>
header <key2> <expected-val21><expected-val22>
body <expected-content-type><expected-body-data>
我們先看ret指令。實際上,請求發出去的時間是在ret指令執行的時候。前面req、header、auth、body指令僅僅表達了http請求,但是如果沒有調用ret指令,那么系統什么也沒有發生。
ret指令可以不帶參數。不帶參數的ret指令,其含義是發起http請求,并將返回的http返回包解析并存儲到resp的變量中。而對于帶參數的ret指令:
ret <expected-status-code>
它等價于:
ret
match <expected-status-code> $(resp.code)
這里我們引入了一個新的指令 —— match指令
七牛所有http返回包匹配的匹配文法,都可以用這個match來表達:
所以本質上來說,我們只需要一個不帶參數的ret,加上match指令,就可以搞定所有的返回包匹配過程。這也是我們為什么說match指令是這套DSL中最核心的概念的原因。
和其他自動化測試框架類似,這套DSL也提供了斷言文法。它類似于CppUnit或JUnit之類的測試框架提供assertEqual。具體如下:
equal<expected> <source>
equalSet<expected> <source>
以上介紹基本上就是這套DSL最核心的內容了。內容非常精簡,但滿足了絕大部分測試場景的需求。下面我們談談最后一個話題:測試環境的參數化。
為了讓測試案例更加通用,我們需要對測試依賴的環境進行參數化。比如,為了讓測試腳 本能夠同時用于stage環境和product環境,我們需要把服務的Host信息參數化。另外,為了方便測試腳本入口,我們通常還需要把用戶名/密碼、 AK/SK(公私鑰對)等敏感性信息參數化,避免直接硬編碼到測試案例中。
為了把服務器的Host信息(也就是服務器的位置)參數化,我們引入了host指令。例如:
host foo.com 127.0.0.1:8888
get http://foo.com/objects/a325gea2kgfd
auth qiniutest
ret 200
json ‘{“a”: “hello1″,”b”: 2}’
這樣,后文所有出現請求foo.com地方,都會把請求發送到127.0.0.1:8888這樣一個服務器地址。要想讓腳本測試另一個測試服務器,我們只需要調整host語句,將127.0.0.1:8888調整成其他即可。
除了服務器Host需要參數化外,其他常見的參數化需求是Username/Password、AK/SK(公私鑰對)等。AK/SK這樣的信息非常敏感,如果在測試腳本里面硬編碼這些信息,將非常不方便測試腳本的入庫。一個典型的測試環境參數化后的測試腳本樣例如下:
其中,env指令用于取環境變量對應的值(返回值類型是 string),envdecode指令則是先取得環境變量對應的值,然后對值進行json decode得到相應的object/dictionary。有了$(env)這個對象(object),就可以通過它獲得各種測試環境參數,比如 $(env.FooHost)、$(env.AK)、$(env.SK) 等。
寫好了測試腳本后,在執行測試腳本之前,我們需要先配置測試環境:
export QiniuTestEnv_stage='{
"FooHost":"192.168.1.10:8888",
"AK":"…",
"SK":"…"
}’
export QiniuTestEnv_product='{
"FooHost":"foo.com",
"AK":"…",
"SK":"…"
}’
這樣我們就可以執行測試腳本了:
QiniuTestEnv=stage qiniutest ./testfoo.qtf
QiniuTestEnv=product qiniutest ./testfoo.qtf
測試是軟件質量保障至關重要的一環。一個好的測試工具對提高開發效率的作用巨大。如 果能夠讓開發人員的開發時間從一小時減少到半小時,那么日積月累就會得到驚人的效果。七牛非常樂意去關注開發人員日常工作過程中的不爽和低效率,我們認 為,任何開發效率提升相關的工作,其收益都是指數級的。這也是七牛團隊所推崇的做事風格。
</div> </div> 來自:http://blog.qiniu.com/archives/2541