##引言
當一個新人剛加入公司的時候,我們通常告訴新人怎麼去寫一個自動化用例:從工程配置到如何新增介面、如何使用斷言,最後到如何將一個用例執行起來。
而在實際工作和業務場景中,我們常常面臨著需要編寫和組織一堆用例的情況:我們需要編寫一個業務下的一系列的自動化介面用例,再把用例放到持續整合中不斷執行。面臨的問題比單純讓一個用例執行起來複雜的多。
本人加入有贊不到一年,從寫下第 1 個 case 開始,持續編寫和執行了 1000 多個 case ,在這過程中有了一些思考。在本文中,和大家探論下如何編寫大量自動化介面用例以及保持結果穩定。
##一、執行效率
目前使用的測試框架是基於 spring ,被測介面是 dubbo 的服務。 dubbo 的架構如圖(源自官網)
服務使用方的初始化需要經歷以下這幾個步驟:
- 監聽註冊中心
- 連線服務提供端
- 建立消費端服務代理
本地除錯用例時,發現速度非常慢,執行一個用例需要 30s,而實際執行用例邏輯的時間大概在 1s 左右,主要時耗在服務消費者的初始化階段。
測試工程中,各服務的 test 類繼承了同一個基類,基類裡面做了各服務的初始化的步驟。在對接的服務數目較少時,需要初始化的物件較少,對用例執行的影響並不大,但隨著業務的增多,服務數目也增多,導致跑 A 服務介面的用例時把大量未用到的 B 服務、C 服務也一起初始化了,導致整體時耗大大增加。
解決辦法:在執行用例時只初始化需要的服務使用方,減少不必要的初始化開銷。
##二、用例編寫和維護
###一個用例示例
以一個簡單的業務場景為例:商家可以在後臺建立會員卡給店鋪的會員領取,商家可以對會員卡進行更新操作,這裡需要有一個自動化用例去覆蓋這個場景。
用例編寫的基本步驟為:
- step 1 :準備資料構造新建會員卡和更新會員卡的物件
- step 2 :執行建立會員卡
- step 3 :執行更新會員卡
- step 4 :檢查更新結果
- step 5 :清理建立的會員卡
轉換成程式碼為:
@Test
public void testUpdate() {
try {
/*
* 建立新建和更新的卡物件
*/
CardCreateDescriptionDTO descCreate = new CardCreateDescriptionDTO();
descCreate.setName(xxxx);
//此處省略若干引數設定過程....
CardUpdateDescriptionDTO descUpdate = new CardUpdateDescriptionDTO();
descUpdate.setName(xxxxx);
//此處省略若干引數設定過程....
/*
* 新建會員卡
*/
cardAlias = cardService.create((int) kdtId, descCreate,operator).getCardAlias();
/*
* 更新會員卡
*/
cardService.update(kdtId, cardAlias, descUpdate, operator);
/*
* 校驗編輯是否生效
*/
CardDTO cardDTO = cardService.getByCardAlias(cardAlias);
Assert.assertEquals(cardDTO.getName(), xxxx, "會員卡更新失敗");
//此處省略若干引數校驗過程....
} catch (Exception e) {
Assert.assertNull(e);
} finally {
try {
if(cardAlias!=null) {
cardService.deleteByCardAlias((int) kdtId, cardAlias, operator);
}
} catch (Exception e) {
Assert.assertNull(e, e.getMessage());
}
}
}
複製程式碼
按照預期的步驟去寫這個 case ,可以滿足要求,但是如果需要擴充套件一下,編寫諸如:更新某種型別的會員卡、只更新會員卡的有效期這樣用例的時候,就會覺得按這個模式寫 case 實在太長太囉嗦了,痛點在以下幾個地方:
- 資料準備比較麻煩,需要逐一設值
- 資料檢查部分逐欄位檢查,心好累
- 每個建立相關的用例都需要清理資源,每次都需要做一次,太重複了
用例本身關注的是更新這個操作,卻花了太多時間和精力在其他地方,很多是重複勞動。程式碼編寫裡有一個重要原則,DRY(Don`t Repeat Yourself),即所有重複的地方都可以考慮抽象提煉出來。
###三段式用例
可以將大部分用例的執行過程簡化為三個部分:
- 資料準備
- 執行操作
- 結果檢查
用簡單的三個部分來完成上述用例的改寫:
資料準備:
@DataProvider(name="dataTestUpdate")
public Object[][] dataTestUpdate() {
return new Object[][]{
{cardFactory.genRuleNoCreate(...),cardFactory.genRuleNoUpdate(...)},
{cardFactory.genRuleCreate(...),cardFactory.genRuleUpdate(...)},
{cardFactory.genPayCreate(...),cardFactory.genPayUpdate(...)}
};
}
複製程式碼
執行操作+結果檢查
Test(dataProvider = "dataTestUpdate")
public void testUpdate(CardCreateDescriptionDTO desc,CardUpdateDescriptionDTO updateDesc){
try {
/*
* 執行操作:建立+更新
*/
//建立會員卡
CardDTO cardBaseDTO = createCard(kdtId,desc,operatorDTO);
cardAlias=cardBaseDTO.getCardAlias();
recycleCardAlias.add(cardAlias); //將卡的標識放入垃圾桶後續進行回收
CardDTO ori = getCard(kdtId,cardAlias);
//更新會員卡
updateCard(kdtId,cardAlias,updateDesc,operatorDTO);
CardDTO updated = getCard(kdtId,cardAlias);
/*
* 結果檢查
*/
checkUpdateCardResult(ori,updated,updateDesc,kdtId);
} catch (Exception e) {
Assert.assertNull(e);
}
複製程式碼
其中可行的優化點將在下面娓娓道來。
###測試資料的優化
在這個用例中,資料準備的部分使用了 dataProvider
來複用執行過程,這樣不同引數但同一過程的資料可以放在一個 case 裡進行執行和維護。
資料生成使用了工廠方法 CardFactory
,好處是簡化了引數,避免了大量 set 操作(本身包裝的就是 set 方法);另一方面,根據實際的業務場景,可以考慮提供多個粒度的構造方法,比如以下兩個構造方法需要提供的引數差別很大:
- 第一個主要用在驗證建立介面的場景,檢查各個傳入的引數是否生效。
public CardCreateDescriptionDTO genRuleCreate(Boolean isPost,Integer discount,Long rate,Long pointsDef,
String couponIds, Long num, Long growth,Long termToCardId,Long amount,Long points,Long trade){
複製程式碼
- 第二個用在如刪除的場景,所以只需要一個建立好的會員卡物件,並不是很關注建立的內容是什麼。
public CardCreateDescriptionDTO genRuleSimpleCreate(String name){
複製程式碼
在上面的優化過的用例中,能夠執行更新操作的前置條件是需要有一個已經建立的會員卡,在實際用例編寫的時候通過直接建立一個會員卡,然後執行更新完成後再回收刪除這張會員卡來滿足這個條件。另一種提供滿足操作所需前置資料的方式是預置資料(預先生成資料)。
以下情況可以考慮預置資料的方式:
- 提高用例穩定性,解依賴,加快執行速度
- 需要對特定的型別、狀態的物件進行查詢
- 建立或者構造比較麻煩
典型的場景:比如編寫查詢的用例時預先建立滿足條件的物件供查詢用例使用。
談到預置資料,不得不談的一個問題是資料管理。在編寫用例的時候,“我們往往需要一個____的資源”,框框裡面的即是對資料的描述和要求,比如我需要一個全新的賬號,一個支付過的訂單號,一張免費的會員卡,來完成我們的用例。所以需要對資料進行標記而不是簡單硬編碼的方式在用例中使用。
如:通過特定名字的變數名和資料進行關聯。
/**只做查詢卡,不做領卡刪卡*/
public Long queryCardUid = DataMocker.MOCK_YZUID.get(1);
/**使用者卡類操作,領卡刪卡*/
public Long takeCardUid = DataMocker.MOCK_YZUID.get(6);
/**退款用*/
public Long refundCardUid =DataMocker.MOCK_YZUID.get(4);
複製程式碼
對資料進行標記後,會發現有一部分資料是用來驗證寫操作(如建立、更新),有一部分資料是查詢使用。如果資料又要被寫操作的 case 使用,又要被讀操作的 case 使用,那麼寫操作的問題和異常就會影響讀操作 case 的執行結果。所以,在程式碼工程中,可以進行約定,將讀寫用到的資源進行分離來降低資料的耦合:
- 查詢 case 用的賬號不做更改物件的操作
- 查詢 case 用的物件不做修改、刪除的操作
- 驗證增、刪、改行為的資源使用特定賬號,且資源最後做回收刪除處理(因為資源總數有限)
最後,用例執行完成後需要清理資源。這裡的清理資源採用的是一個全域性的 list 的方式儲存需要清理的資源資訊,在用例執行過程中往裡增加資料:(recycleCardAlias.add(cardBaseDTO.getCardAlias());
),
然後用對應的方法取其中的資料進行刪除,類似垃圾桶。與原有執行完就執行清理動作相比,使用垃圾桶更加靈活,可以選擇控制下清理頻率。
比如每次在 AfterMethod
或 AfterClass
中去清理。
//統一回收
@AfterMethod
public void tearDownMethod() {
for(int i =0;i<recycleCardAlias.size();++i) {
try {
deleteCard(kdtId, recycleCardAlias.get(i), cardOperatorDTO);
} catch (Exception e) {
logger.error("clear card fail: " + recycleCardAlias.get(i));
}
}
recycleCardAlias.clear();
}
複製程式碼
###對方法的適度封裝
在實際編寫用例的時候,有兩個地方可以考慮進行方法封裝,從來簡化呼叫,方便維護:
-
封裝基本操作。如果刪除操作依賴建立操作,查詢操作依賴建立操作,那麼建立操作可以看作是個基本操作,可以對建立操作包裝一下,將注意力關注於實際需要執行和驗證的地方。可以封裝的東西很多,有引數封裝、異常處理的封裝、一些輪訓、重新邏輯的封裝。
createCard()
、getCard()
、deleteCard
方法就是將介面、引數組裝、檢查等封裝好的方法。 -
封裝檢查方法。上述用例中的檢查採用了一個檢查方法代替了以往的多個assert:
checkUpdateCardResult(ori,updated,updateDesc,kdtId);
,在方法裡包裝了一些關鍵欄位的比較,包括兩個物件之間成員是否一致的比較。所有的更新操作的結果都需要滿足:有變更的欄位值變成新的值,未發生變更的值和原有一致。該方法實現了這種檢查邏輯,所以寫更新操作用例的同學不需要關注如何校驗,而是關心如何更新,因為檢查邏輯是現成的、通用的。將來檢查邏輯發生變更,也只需要維護這一個方法即可。
##穩定性
當大批量用例進行執行時,用例集的失敗率會變得較高,幾個微小的瑕疵都會造成用例的失敗,此時我們需要更加關注用例的穩定性。一些實踐中比較好的措施和方式:
-
減少外部依賴。如果執行過程需要依賴其他系統的介面的話,那麼其他系統發生了變更或故障就會影響自身用例的進行。可以考慮通過預先生成的資料來替代呼叫外部介面生成資料在用例中使用。
-
預置資料代替建立過程。由於操作越多穩定性越低,使用預置資料而不是實時生成它,速度更快,穩定性更高。
-
使用不同賬號等進行隔離。通過隔離,用例執行失敗的髒資料就不會影響其他用例。
-
調優:超時、等待時間。線上超時時間設定的比較短,測試環境的機器配置不如線上,需要適時調大超時和等待時間來保證介面呼叫不會超時。
-
防禦式程式設計。編寫測試程式碼時不能假設資料已存在或者沒有髒資料殘留,所以預先的判斷和清理很重要,比如檢查到資料缺失就實時修復、用例執行之前考慮清除臨時資料。
-
定位並解決不穩定的問題。有時候偶現用例失敗,可以考慮給被測應用增加日誌,同時持續多次執行用例多次(如 testNg 裡增加
threadPoolSize=1
,invocationCount=50
)來複現問題,最終解決問題。
##總結
對於大規模用例的編寫、組織和執行的問題,文中從三個方面給出了有贊測試的實踐和思考:精簡初始化來提高執行速度、優化用例編寫降低編寫和維護成本、多種方式提高用例穩定性,希望能給大家一些啟發。