Junit5 + YAML 輕鬆實現引數化和資料驅動,讓 App 自動化測試更高效(二)
本文為霍格沃茲測試學院優秀學員課程學習筆記,想一起系統進階的同學文末加群交流。
上篇文章提到了資料驅動可以在幾個方面進行:
-
測試資料的資料驅動
-
測試步驟的資料驅動
-
定位符
-
行為流
-
-
斷言的資料驅動
下面將詳細解說如何進行資料驅動。
** 5. 資料驅動**
5.1 測試資料的資料驅動
5.1.1 Junit5的 引數化
說到測試資料的資料驅動,就必然離不開測試框架的引數化,畢竟測試資料是傳給用例的,用例是由框架來管理的,這裡以目前最推薦使用的Junit5框架為例,介紹引數化的使用
@ParameterizedTest+@ValueSource引數化
在Junit5中,提供了@ParameterizedTest
@ValueSource
註解用來存放資料,寫法如下:
@ParameterizedTest@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })void palindromes(String candidate) { assertTrue(StringUtils.isPalindrome(candidate));}
@ParameterizedTest+@CsvSource引數化
Junit5還提供了@CsvSource
註解來實現csv
格式的引數傳遞,寫法如下:
@ParameterizedTest@CsvSource({ "滴滴,滴滴出行", "alibaba,阿里巴巴", "sougou,搜狗"})public void searchStocks(String searchInfo,String exceptName) { String name = searchpage.inputSearchInfo(searchInfo).getAll().get(0); assertThat(name,equalTo(exceptName));}
@ParameterizedTest+@CsvFileSourc資料驅動
最終,Junit5提供了@CsvFileSourc
註解來實現csv資料格式的資料驅動,可以傳遞csv
檔案路徑來讀取資料,寫法如下:
- csv資料檔案:
pddxiaomipdd
- 用例實現:
@ParameterizedTest@CsvFileSource(resources = "/data/SearchTest.csv")void choose(String keyword){ArrayList<String> arrayList = searchPage.inputSearchInfo(keyword).addSelected();}
對於簡單的資料結構,可以使用CSV,上面也說過,較為複雜的資料結構,推薦使用yaml,接下來看如何用yaml檔案完成測試資料驅動。
@ParameterizedTest+@MethodSource引數化
- 先來看Junit5提供的另一個註解——
@MethodSource
,此註解提供的方法是我們做測試資料驅動的核心,它可以讓方法接收指定方法的返回值作為引數化的入參,用法是在註解的括號中填入資料來源的方法名,具體用法如下:
@ParameterizedTest@MethodSource("stringProvider")void testWithExplicitLocalMethodSource(String argument) { assertNotNull(argument);}
static Stream<String> stringProvider() { return Stream.of("apple", "banana");}
@ParameterizedTest+@MethodSource引數化 + jackson yaml資料驅動
有了@MethodSource
的引數化支援,我們就可以在方法中利用jackson
庫對yaml
檔案進行資料讀取,從而完成資料驅動了
- 現有如下yaml資料檔案,我需要取出testdata中的測試資料
username: 888 password: 666 testdata: 滴滴: 滴滴出行 alibaba: 阿里巴巴 sougou: 搜狗
- 建立
Config
類:
import java.util.HashMap;
public class Config { public String username; public String password; public HashMap<String,String> testdata = new HashMap<>();}
- 建立
Config
物件,與yaml
檔案建立對映關係,讀取資料,通過@MethodSource
完成資料的引數化傳遞
public class TestSteps {
@ParameterizedTest @MethodSource("YamlData") public void search(String searchInfo,String exceptName) { String name = searchpage.inputSearchInfo(searchInfo).getAll().get(0); assertThat(name,equalTo(exceptName)); }
static Stream<Arguments> YamlData() throws IOException { ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); Config data = mapper.readValue(Config.class.getResourceAsStream("/demo2.yaml"), Config.class); List<Arguments> list = new ArrayList<>(); Arguments arguments = null; for (String key : data.testdata.keySet()) { Object value = data.testdata.get(key); arguments = arguments(key, value); list.add(arguments); } return Stream.of(list.get(0),list.get(1),list.get(2)); }
為了保證執行通過,可以先簡單列印驗證一下:
5.2 測試步驟的資料驅動
對於測試步驟的資料驅動主要針對兩點:
- 定位符:
我們做App自動化的時候可以把定位符合定位器直接寫在PO中,也可以將其剝離出來,寫在類似yaml的檔案中,定義好格式個物件的對映關係即可完成定位符的資料驅動。
- 行為流:
與定位符的剝離思想一致,行為流原本也是寫在PO中的各個方法,這些行為流和定位符是緊密關聯的,因此也可以剝離出來,和定位符在一起組成測試步驟的資料驅動。
好比下面這樣的,以雪球App的搜尋場景為例:
public class SearchPage extends BasePage{ //定位符 private By inputBox = By.id("search_input_text"); private By clickStock = By.id("name"); private By cancel = By.id("action_close"); //行為流 //搜尋股票 public SearchPage search(String sendText){ sendKeys(inputBox,sendText); click(clickStock); return this; } //取消返回 public App cancel(){ click(cancel); return new App(); }}
注:測試步驟的資料驅動是指把PO中變化的量剝離出來,不是對用例裡的呼叫步驟進行封裝。在上面已經提到過不要在測試用例內完成大量的資料驅動:用例通過PO的呼叫是能夠非常清晰展現出業務執行場景的,業務才是用例的核心;
一旦在用例裡使用了大量資料驅動,如呼叫各種 yaml、csv 等資料檔案,會造成用例可讀性變差,維護複雜度變高;
5.2.1 設計思路
首先來考慮我們的剝離到yaml
中的資料結構
- 做測試步驟的資料局驅動我們希望可以將一個用例中的步驟方法清晰的展示出來,在對應的方法中包括了方法對應的定位符和行為流,這樣能和PO中的結構保持一致,更易讀易維護;如下:
search: steps: - id: search_input_text send: pdd - id: namecancel: steps: - id: action_close
- 另外我們還要考慮擴充套件性,之前提到了還有測試斷言的資料驅動,另外還有一點沒提到的是,框架的健壯程度還要考慮被測系統(Android,IOS)的通用性、版本變更、元素定位符的多樣性等。這樣考慮的話就應該有多個分類,一個分類中包含了PO中的所有方法,一個分類中包含了版本、系統等資訊等,如下(
SearchPage.yaml
):
#方法methods: search: steps: - id: search_input_text send: pdd - id: name cancel: steps: - id: action_close
#定位符對應系統、版本資訊elements: search_input_text: element: ...
#斷言asserts: search: assert: ... cancel: assert: ...
- 按照上述的思路,以搜尋步驟為例,我們需要一個
Model
類,用來對映不同的資料模組(方法、版本、斷言),對不同的模組需要一一對應的類,類的成員變數結構與yaml檔案中的結構保持一致:
1)建立PageObjectModel
類
import java.util.HashMap;public class PageObjectModel { public HashMap<String, PageObjectMethod> methods = new HashMap<>(); public HashMap<String, PageObjectElement> elements = new HashMap<>(); public HashMap<String, PageObjectAssert> asserts = new HashMap<>();}
2)建立對應資料模組的類PageObjectMethod
public class PageObjectMethod { public List<HashMap<String, String>> getSteps() { return steps; }
public void setSteps(List<HashMap<String, String>> steps) { this.steps = steps; }
public List<HashMap<String,String>> steps = new ArrayList<>();}
3)實現解析yaml
資料的方法,完成PO
中行為流的封裝;
- 首先按照之前介紹過的通過
jackson
來解析yaml
資料,我們需要檔案的地址
,另外我們還需要知道當前執行的方法
,用來去yaml
中取方法對應的定位符
和行為流
,所以初步設想應該有method
和path
兩個引數:
public void parseSteps(String method,String path){ ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); try { PageObjectModel model = mapper.readValue(BasePage.class.getResourceAsStream(path),PageObjectModel.class); parseStepsFromYaml(model.methods.get(method)); }catch (IOException e) { e.printStackTrace(); } }
- 上面的方法中可以看到呼叫了一個
parseStepsFromYaml
方法,這個方法是將從yaml
中獲取到的資料進行處理,拿到對應方法
的定位符
再拿到定位符緊跟的行為流
完成對應的操作步驟
(點選、輸入、獲取屬性等);之所以將這個方法單獨抽離出來,是因為後面會對parseSteps
過載,方便複用,後面會介紹到。
如下:我們要通過methods
裡的search
方法拿到對應的步驟steps
裡的id
,在根據id
下的send
值進行輸入操作
methods: search: steps: - id: search_input_text send: pdd - id: name
private void parseStepsFromYaml(PageObjectMethod steps){ //獲取方法名method steps.getSteps().forEach(step ->{ WebElement element = null; if (step.get("id") != null){ element = findElement(By.id(id)); }else if (step.get("xpath") != null){ element = findElement(By.id(step.get("xpath"))); }else if (step.get("aid") != null){ element = findElement(MobileBy.AccessibilityId(step.get("aid"))); if (step.get("send") != null){ element.sendKeys(step.get("send")); }else if (step.get("get") != null){ findElement(by).getAttribute(get); } else { element.click(); //預設操作是點選 } }); }
4)這個時候再回到我們的PO裡,就變成了這個樣子,看一下PO是不是一下子變得簡潔了許多:
public class SearchPage extends BasePage{ //行為流 //搜尋股票 public SearchPage search(String sendText){ parseSteps("search","/com.xueqiu.app/page/SearchPage.yaml"); return this; } //取消返回 public App cancel(){ parseSteps("cancel","/com.xueqiu.app/page/SearchPage.yaml"); return new App(); }}
到這裡,測試步驟的資料驅動算是完成了一個基本模板,還有很多可以優化的地方,比如上面的SearchPage
的PO
中,parseSteps
的兩個引數method
和path
都是有規律可循的:
-
method
和當前執行的方法名是定義好保持一致的 -
當前
PO
所對應的yaml
檔案的path
是固定的
下面針對這個點做個小優化
5.2.2 框架優化
這裡將會對上一節中的 parseSteps 方法進行優化,減少重複性工作。
- 先來解決方法名
method
的問題,來看Thread的一個方法:Thread.currentThread().getStackTrace()
利用這個方法可以打印出當前方法執行的全部過程,寫單測來驗證,將每一步的方法名都打印出來:
void testMethod(){ Arrays.stream(Thread.currentThread().getStackTrace()).forEach(stack ->{ System.out.println(stack.getMethodName()); }); System.out.println("當前呼叫我的方法是:"+Thread.currentThread().getStackTrace()[2].getMethodName()); }
@Testvoid getMethodName(){ testMethod(); }
執行結果:
getStackTracetestMethod //當前執行的方法getMethodName //呼叫testMethod的方法invoke0invokeinvokeinvokeinvokeMethodproceed//...這裡省略中間很多不重要的部分executeexecutestartRunnerWithArgsstartRunnerWithArgsprepareStreamsAndStartmain當前執行的方法是:getMethodName
從結果中可以看到,當方法被呼叫時,呼叫它的方法名會在輸出結果的索引2位置,因此通過此方法就可以成功的拿到我們所需的method
引數
- 再來解決
yaml
檔案路徑的path
引數,這裡可以藉助java.lang.Class.getCanonicalName()
方法,此方法可以返回當前類名,包括類所在的包名,如下:
@Testvoid getPath(){ System.out.println(this.getClass().getCanonicalName());}
//列印結果com.xueqiu.app.testcase.TestSteps
- 稍加改造就可以變成地址資訊:
@Testvoid getPath(){ System.out.println(this.getClass().getCanonicalName()); String path = "/com.xueqiu.app" + this.getClass().getCanonicalName().split("app")[1].replace(".", "/") + ".yaml"; System.out.println(path);}
列印結果:
com.xueqiu.app.testcase.TestSteps/com.xueqiu.app/testcase/TestSteps.yaml
這樣我們就將當前類的資訊轉變成了一個地址資訊,後面我們只需要將對應的yaml
檔案以和類相同的命名
,相同路徑結構
存放在resources
目錄下即可
- 現在
method
和path
引數的問題都解決了,在來看現在的parseSteps
方法:
//解析步驟public void parseSteps(String method) { ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); String path = "/com.xueqiu.app" + this.getClass().getCanonicalName().split("app")[1].replace(".", "/") + ".yaml"; try { PageObjectModel model = mapper.readValue(this.getClass().getResourceAsStream(path),PageObjectModel.class); parseStepsFromYaml(model.methods.get(method)); }catch (IOException e) { e.printStackTrace(); }}
public void parseSteps(){ String method = Thread.currentThread().getStackTrace()[2].getMethodName(); parseSteps(method);}
- 此時再次回到
SearchPage
的PO中,可以看到更加的簡潔了,甚至變成了“傻瓜操作”:
public class SearchPage extends BasePage{
public SearchPage search(){ parseSteps(); return this; }
public App cancel(){ parseSteps(); return new App(); }}
send引數化處理
- 看似好像大功告成,又出現了新的問題,不知道大家注意到沒有,
search
方法其實是需要send
值的,而現在我們的send
值是寫死在yaml
中的,這反而違背了我們引數化和資料驅動的原則:
methods: search: steps: - id: search_input_text send: pdd #send的內容被寫死在了這裡 - id: name
- 所以我們需要繼續解決這個問題,將
send
的值進行引數化
1) 既然是引數化,那就要把send的值變成引數,這裡用$sendText
來表示是引數
methods: search: steps: - id: search_input_text # send: pdd send: $sendText #表示引數化 - id: name
2)在search
方法中使用HashMap
將用例傳遞過來的測試資料儲存至其中,用來傳遞到parseSteps
步驟解析方法中。
public SearchPage search(String sendText){ HashMap<String,Object> map = new HashMap<>(); map.put("sendText",sendText); setParams(map); parseSteps(); return this;}
3)再在parseSteps
方法所處的類中新增HashMap
型別的params
變數,用來接收PO傳過來的sendText
測試資料
private static HashMap<String,Object> params = new HashMap<>();
public HashMap<String, Object> getParams() { return params;}//測試步驟引數化public void setParams(HashMap<String, Object> params) { this.params = params;}
4)最後修改parseStepsFromYaml
方法中的send
值獲取方式,將佔位的引數$sendText
替換成實際傳遞過來的測試資料sendText
if (step.get("send") != null){ String send = step.get("send").replace("$sendText",params.get("sendText").toString()); element.sendKeys(send);}
getAttribute實現
在文章前面提到過獲取元素屬性,在自動化測試過程中,經常要獲取元素屬性來作為方法的返回值,以供我們進行其他操作或斷言,其中text是我們最常獲取的屬性,下面來實現此方法的資料驅動
在上面的搜尋股票場景下,加上一步獲取股票的價格資訊
- 先看一下思路,按照之前的設計,在
yaml
中的定位符後面跟著的就是行為流,假定有一個getCurrentPrice
方法,通過get
text
來獲取text
屬性,寫法如下:
getCurrentPrice: steps: - id: current_price get: text
- 這個時候就可以在
parseStepsFromYaml
方法中加入屬性獲取的解析邏輯,通過get
來傳遞要獲取的屬性
if (step.get("send") != null){ String send = step.get("send").replace("$sendText",params.get("sendText").toString()); element.sendKeys(send);}else if (step.get("get") != null){ String attribute = element.getAttribute(step.get("get")); }
- 接著我們到
SearchPage
的PO
中實現getCurrentPrice
方法,這個時候就會發現一個問題:
public Double getCurrentPrice(){ parseSteps(); // return ???; }
沒錯,text
屬性獲取到了,可以沒有回傳出來,getCurrentPrice
方法沒有return值;我們要將parseStepsFromYaml
獲取到的屬性值通過一個“中間商
"給傳遞到getCurrentPrice
方法中,然後再return
到用例中供我們斷言使用
- 語言描述比較晦澀,下面我以一個市場供需買賣的場景來說明整個設計流程:
1)產生市場需求
,yaml
中定義好資料結構
methods: search: steps: - id: search_input_text send: $sendText - id: name
getCurrentPrice: steps: - id: current_price get: text dump: price
cancel: steps: - id: action_close
2) 實現“中間商”
,這個“中間商”
就是一個HashMap
,將它取名為result
private static HashMap<String,Object> result = new HashMap<>();
//測試步驟結果讀取public static HashMap<String, Object> getResult() { return result;}
3)供應商
根據市場需求
產生產品
並提供給中間商
,獲取屬性
並將屬性值
存入result
if (step.get("send") != null){ String send = step.get("send").replace("$sendText",params.get("sendText").toString()); element.sendKeys(send);}else if (step.get("get") != null){ String attribute = element.getAttribute(step.get("get")); result.put(step.get("dump"),attribute); }
4)消費者
根據自己的需求
去中間商
那裡拿到商品
,從result
中get
到price
的值
public Double getCurrentPrice(){ parseSteps(); return Double.valueOf(getResult().get("price").toString());}
這樣就成功完成了這個交易場景的閉環,股票價格price
被成功返回至用例中
5.3 斷言的資料驅動
有了上面的鋪墊,斷言的資料驅動就顯得簡單多了,我個人有時候也簡單的把它歸為測試資料的驅動中
- 因為每個測試資料在傳入用例跑完後,都會對應有斷言來進行結構判定,因此將測試資料對應的斷言資料在一個
yaml
檔案中,寫入一個數組裡,再同測試資料一起獲取傳入到用例中
- - didi - 100d- - alibaba - 120d- - sougou - 80d
- 回到最初的測試資料資料驅動,把資料獲取傳入
@ParameterizedTest@MethodSource("searchYamlData")void search(String searchInfo,String exceptPrice ){ Double currentPrice = searchPage.search(searchInfo).getCurrentPrice(); assertThat(currentPrice,greaterThanOrEqualTo(Double.parseDouble(exceptPrice)));}
static Stream<Arguments> searchYamlData() throws IOException { Arguments arguments = null; List<Arguments> list = new ArrayList<>(); ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
String path1 = "/com.xueqiu.app" + TestSearch.class.getCanonicalName().split("app")[1].replace(".","/") + ".yaml"; Object[][] searchData = mapper.readValue(TestSearch.class.getResourceAsStream(path1), Object[][].class); for (Object[] entrySet : Arrays.asList(searchData)){ String key = Arrays.asList(entrySet).get(0).toString(); String value = Arrays.asList(entrySet).get(1).toString(); arguments = arguments(key,value); list.add(arguments); } return Stream.of(list.get(0),list.get(1),list.get(2));}
注:
其實這裡應該說還是測試資料驅動,並不能算是斷言的驅動,真正想做成斷言的驅動還需要封裝類似測試步驟驅動的形式。目前沒有做這層封裝,因為在使用中發現斷言的型別很多,直接在用例裡面寫也很方便易讀,加上目前時間精力也有限,待後續需要的時候再繼續補充~
** 6. 執行效果**
說的再多,不如實際跑一下,檢驗一下框架封裝後的實際執行效果
- 用例執行結果:
折騰了這麼久,總算是“大功告成”了,之所以加個引號,是因為這個僅僅是個開始,只能算是初具雛形,像文章中提到的被測系統切換、版本切換、多元素查詢等都還未實現,後續會持續學習更新。有很多錯誤或表述不恰當的地方,請大家多指正!
** _
來霍格沃茲測試開發學社,學習更多軟體測試與測試開發的進階技術,知識點涵蓋web自動化測試 app自動化測試、介面自動化測試、測試框架、效能測試、安全測試、持續整合/持續交付/DevOps,測試左移、測試右移、精準測試、測試平臺開發、測試管理等內容,課程技術涵蓋bash、pytest、junit、selenium、appium、postman、requests、httprunner、jmeter、jenkins、docker、k8s、elk、sonarqube、jacoco、jvm-sandbox等相關技術,全面提升測試開發工程師的技術實力