軟件測(cè)試 | UI 自動(dòng)化常用設(shè)計(jì)模式(二)
狀態(tài)模式
狀態(tài)模式之所以常用是因?yàn)樵谖覀兊暮芏鄻I(yè)務(wù)邏輯中都會(huì)有不同狀態(tài)的出現(xiàn),比如訂單的狀態(tài),任務(wù)的狀態(tài)。而不同的狀態(tài)下 UI 上會(huì)有不同的行為。 比如不同的控件的展示, 不同的報(bào)錯(cuò)信息等。 我們往往需要驗(yàn)證不同狀態(tài)下的邏輯。 但是我們的狀態(tài)往往比較多 (一般怎么都會(huì)有個(gè) 5,6 種吧)。 所以我們需要一種合適的方法來(lái)組織和管理這些狀態(tài)下的行為。
舉個(gè)例子, 在我們的產(chǎn)品中,每一個(gè)算子都有:未配置,配置成功,等待運(yùn)行,運(yùn)行中,運(yùn)行成功,運(yùn)行失敗和終止這 6 種狀態(tài)。算子在每種狀態(tài)下顯示的控件和能操作的邏輯是不一樣的。我們一個(gè)最簡(jiǎn)單的需求就是,在 case 中驗(yàn)證每一種狀態(tài)下,UI 控件的展示是符合需求的。 比如處于未配置狀態(tài)的算子是不能運(yùn)行和停止的, 運(yùn)行中的算子是可以看見停止按鈕但是無(wú)法顯示運(yùn)行按鈕,相反的配置完成的算子是可以顯示運(yùn)行按鈕但是不能展示停止按鈕的。

上面是我們的狀態(tài)抽象類的一部分代碼截圖。 里面看到有一個(gè)抽象方法是 validateNodeUI, 用來(lái)執(zhí)行驗(yàn)證操作。 不同狀態(tài)的子類有著不同的邏輯。 比如下面這個(gè)處于 Running 狀態(tài)的子類。

這個(gè) running 狀態(tài)的子類覆蓋實(shí)現(xiàn)了父類的 validateNodeUI 方法,running 狀態(tài)的算子只能看到停止按鈕。 然后我們?cè)倏纯唇K止?fàn)顟B(tài)的算子和運(yùn)行成功狀態(tài)的算子。


終于狀態(tài)的算子是可以重新運(yùn)行的但是看不到停止按鈕, 而運(yùn)行成功的算子因?yàn)橐呀?jīng)到了算子的最終狀態(tài), 所以它既不能運(yùn)行,也不能停止。 這樣我們就有了我們的狀態(tài)類。 接下來(lái)我們看怎么使用這些狀態(tài)類。 我們需要在所有算子的父類 (Node) 里寫一個(gè)查詢當(dāng)前狀態(tài)的方法。意思是通過(guò) UI 來(lái)查看當(dāng)前算子的運(yùn)行狀態(tài)是哪一種并返回。然后在自己驗(yàn)證控件的方法中,使用相應(yīng)的狀態(tài)類。如下:

PS: 也許會(huì)有小伙伴問(wèn)上面寫了那么多 if else 來(lái)創(chuàng)建各種不同的狀態(tài)類, 為什么不用工廠模式來(lái)做? 那是因?yàn)檎麄€(gè)項(xiàng)目中只有這一個(gè)地方使用了狀態(tài)類, 也就沒有必要專門封裝一個(gè)工廠類了。 大家要小心過(guò)度設(shè)計(jì)哦~~
這樣我們的每一種狀態(tài)下的 UI 控件的驗(yàn)證就都寫好了。 case 中使用的時(shí)候入下:

當(dāng)然狀態(tài)模式中不只有驗(yàn)證 UI 控件這一個(gè)功能。 由于不同的狀態(tài)下?lián)碛兄煌男袨?,假如由?case 編寫者的失誤, 非要在終止?fàn)顟B(tài)下的算子上點(diǎn)擊終止按鈕, 那肯定會(huì)在查找控件超時(shí)后拋出一個(gè) element not found 的 error 出來(lái)。 這樣有兩個(gè)不好的地方:
element not found 的報(bào)錯(cuò)信息并不友好,尤其是有些控件的查找方式用 xpath 查找的,用非文案的方式查找的。 讓會(huì)再看 report 的時(shí)候并不能很容易看出來(lái)錯(cuò)誤出在了哪里。需要到代碼里去看或者 debug。
一般查找控件的 API 都是自旋等待并設(shè)定超時(shí)時(shí)間的,比如我再項(xiàng)目中設(shè)置的隱式等待時(shí)間是 10s. 要等 10s 后才拋出這個(gè)異常也是滿耗時(shí)的。我們希望立刻就拋出這個(gè)錯(cuò)誤。
所以不同狀態(tài)的子類中可以去實(shí)現(xiàn)不同的行為, 如下:

可以看到停止?fàn)顟B(tài)的子類的 stop 方法會(huì)直接拋出一個(gè)異常。 只要一個(gè)對(duì)象的行為取決于它的狀態(tài),并且它必須在運(yùn)行時(shí)刻根據(jù)狀態(tài)改變它的行為,就可以使用狀態(tài)模式
接下來(lái)分析一下狀態(tài)模式的優(yōu)點(diǎn):
在產(chǎn)品復(fù)雜業(yè)務(wù)邏輯和狀態(tài)流轉(zhuǎn)下, 可以有效的以一種結(jié)構(gòu)化的方式把我們的代碼組織起來(lái)。 如果我們不使用狀態(tài)模式,會(huì)導(dǎo)致在 case 或者 page 類中出現(xiàn)大量的 if else。導(dǎo)致后期的維護(hù)成本和可讀性都很差。
裝飾器模式
裝飾器,適配器和代理我覺得可以不用分得那么清, 都是為了使現(xiàn)有的類的行為滿足我們新的需求,而做的一層封裝。 最經(jīng)典的例子就是 java io 中的高級(jí)流,低級(jí)流了。 感興趣的同學(xué)可以去看看。 那么在 UI 自動(dòng)化中會(huì)有什么情況會(huì)用到呢? 最常用的就是重試的功能。 UI 自動(dòng)化是出了名的不穩(wěn)定的, 有很多公司都會(huì)啟用失敗重跑的功能。 記得我再外包那些年的時(shí)候, 經(jīng)歷過(guò)的國(guó)外公司幾乎都會(huì)在 UI 自動(dòng)化中實(shí)現(xiàn)失敗重跑的功能。邏輯很暴力,當(dāng) case 運(yùn)行失敗的時(shí)候就重跑整個(gè) case。 這種暴力的做法優(yōu)缺點(diǎn)很分明。
優(yōu)點(diǎn):
實(shí)現(xiàn)簡(jiǎn)單,一些測(cè)試框架比如 testng 已經(jīng)支持這種功能 缺點(diǎn):
失敗的 case 也會(huì)進(jìn)入 report 中,要對(duì) report 單獨(dú)處理
暴力的不管三七二十一,只要失敗就重跑的策略會(huì)在很多時(shí)候大大的增加了測(cè)試的執(zhí)行時(shí)間。 比如本來(lái)就是 bug 引起的失敗還是會(huì)去重新運(yùn)行的話,最終還是會(huì)失敗的,白白的浪費(fèi)了執(zhí)行的時(shí)間和資源。尤其是在像我們這種有很多長(zhǎng)時(shí)間的異步任務(wù)的產(chǎn)品,這種策略更加無(wú)法忍受。
鑒于上面說(shuō)的缺點(diǎn), 我們希望可以有重試的功能, 但是還比較希望能夠控制重試的執(zhí)行粒度。比如運(yùn)行時(shí)間短的,成本較低的,容易出錯(cuò)的,UI 操作復(fù)雜的是可以重試的, 但是那些運(yùn)行時(shí)間長(zhǎng)的,不容易出錯(cuò)的非 UI 任務(wù)我們是不重跑的。 比如對(duì)于我們的產(chǎn)品來(lái)說(shuō),在 UI 上設(shè)置算子的配置,組件算子的 dag 圖,這些都是 UI 操作,運(yùn)行時(shí)間較短,但是大量的 UI 操作是比較容易出錯(cuò)的。 但是這些算子一旦運(yùn)行起來(lái),就都是后臺(tái)的操作,UI 上沒有任何變化,這時(shí)候 case 就是在那里自旋等待,輪詢算子狀態(tài)而已,一般來(lái)說(shuō),只要算子運(yùn)行失敗了,那基本就是真失敗了,就算再跑一次也大概率還是會(huì)失敗的,即便不是 bug,那不管是因?yàn)榄h(huán)境問(wèn)題還是集群?jiǎn)栴},都不是一個(gè)失敗重試能解決的。 所以我們要重試的就是組裝 Dag 的操作。 還記得上一次說(shuō)建造者模式中的 DAGBuilder 么? 是的,我們現(xiàn)在就是要對(duì)它進(jìn)行失敗重試,dagBuilder 只負(fù)責(zé)構(gòu)建 UI 上的復(fù)雜操作,并不負(fù)責(zé)執(zhí)行和等待后臺(tái)的任務(wù)結(jié)束,正是把構(gòu)建和執(zhí)行拆分了開來(lái),正合適進(jìn)行失敗重跑的場(chǎng)景 (這里也體現(xiàn)出設(shè)計(jì)原則中的一個(gè)類只負(fù)責(zé)一件事的好處)。 那么問(wèn)題來(lái)了, 原本的 DagBuilder 就只是一個(gè)在 UI 上構(gòu)建 DAG 圖的操作,并沒有失敗重跑的操作。 而我們也并不希望把失敗重跑的功能加到 DAGBuilder 里面, 一來(lái)是因?yàn)槲覀円裱O(shè)計(jì)原則,只讓一個(gè)類負(fù)責(zé)盡量少的事情。 二來(lái)是有些情況下,我們也希望沒有失敗重跑的功能,直接將異常拋出來(lái)。由調(diào)用方處理。 所以我們使用裝飾器模式, 封裝一個(gè)裝飾器類。 如下:

裝飾器類可以實(shí)現(xiàn)被裝飾的 DagBuilder 的接口,保持接口兼容和使用方式一致
裝飾器類在創(chuàng)建時(shí)傳遞被裝飾的對(duì)象,然后在方法中調(diào)用被裝飾的 DagBuilder 的方法。并添加自己的新功能, 也就是失敗重跑。
上面截圖中,就是在 dagBuilder 拋出異常后,捕獲異常,然后重置頁(yè)面初始狀態(tài) (刷新頁(yè)面) 重新調(diào)用 DagBuilder 的方法。 使用的時(shí)候入下:

PS: 這里發(fā)現(xiàn)我們只對(duì) dagBuilder 做了失敗重試, 大家會(huì)發(fā)現(xiàn)上面的登錄,頁(yè)面切換,創(chuàng)建 project 等操作并沒有重試的功能。 因?yàn)檫@些操作簡(jiǎn)單,并且足夠穩(wěn)定, 一旦失敗,除開 bug 的原因就是環(huán)境發(fā)生了問(wèn)題或者是 UI 發(fā)生了變化而腳本沒有及時(shí)更新。不論哪種情況都不是重試能解決的, 當(dāng)然也有一種情況是環(huán)境的服務(wù)出現(xiàn)了一些性能問(wèn)題, 比如我們?cè)?jīng)遇見過(guò)集群 IO 負(fù)載過(guò)高,導(dǎo)致一個(gè)接口請(qǐng)求就數(shù)秒甚至 10 幾秒。 所以有時(shí)這些穩(wěn)定簡(jiǎn)單的操作會(huì)超時(shí),這時(shí)候重試是有可能會(huì)讓這些操作跑過(guò)去,但是我們是不會(huì)這么做的。 因?yàn)榄h(huán)境本身就出現(xiàn)了問(wèn)題,這里我們是希望 case 就這么直接失敗的,減少不必要的運(yùn)行時(shí)間。所謂失敗了也要快速的失敗,快速的反饋。
PS2:使用 python 的同學(xué)實(shí)現(xiàn)失敗重試就簡(jiǎn)單多了, python 自帶裝飾器的語(yǔ)法糖。
原型模式
原型模式是一個(gè)很簡(jiǎn)單的模式,它適用于我們要復(fù)制一個(gè)對(duì)象的時(shí)候。 那在 UI 自動(dòng)化中,有什么場(chǎng)景需要我們復(fù)制一個(gè)對(duì)象呢。 以我們產(chǎn)品為例,在執(zhí)行測(cè)試的時(shí)候,一個(gè) DAG 中會(huì)出現(xiàn)兩個(gè)相同的算子, 比如一般會(huì)有兩個(gè)特征抽取算子,一個(gè)連接訓(xùn)練數(shù)據(jù),一個(gè)連接測(cè)試數(shù)據(jù)。 但他們兩個(gè)的配置是相同的 (在機(jī)器學(xué)習(xí)中,如果這倆哥們不一樣,就出問(wèn)題了)。 那么問(wèn)題來(lái)了, 我們看要設(shè)置一個(gè)特征抽取算子都需要哪些參數(shù)。

這樣就很煩了,我要手動(dòng)創(chuàng)建兩個(gè) FENode 的對(duì)象,把完全相同的參數(shù) set 進(jìn)去。也許有小伙伴會(huì)說(shuō)你可以就用一個(gè) FENode 作為參數(shù),重復(fù)利用么。 這也是不行的, 雖然他們的配置相同,但是有一樣是不同的。 那就是在 UI 上搜尋控件的方式。 由于這是兩個(gè)完全一樣的算子,他們擁有相同的文案,相同的控件。唯一能區(qū)分他們的方式就是在 DOM 樹中他們的下標(biāo) [index]。 所以在每個(gè) Node 里都會(huì)有一個(gè)額外的屬性叫 index,表明他們?cè)?UI 上是第幾個(gè)同類算子。 如下:

所以如果我們重復(fù)使用一個(gè) FENode,你會(huì)發(fā)現(xiàn)你操作的還是同一個(gè) FE。 所以這時(shí)候我們希望能有一個(gè) clone 方法, 能夠幫我們創(chuàng)造出一個(gè)新的對(duì)象的同時(shí),還擁有原始對(duì)象中一樣的屬性。 這在 java 中比較容易實(shí)現(xiàn)。 在 java 中 object 有 clone 方法,而所有對(duì)象都是集成 object 的。 所以我們只需要實(shí)現(xiàn)一個(gè)名字叫 Cloneable 的空接口,標(biāo)記本類是可以 clone 的,就可以直接調(diào)用 object 的 clone 來(lái)完成復(fù)制對(duì)象的目的了。 如下:


看上面我們直接調(diào)用了 object 的 clone 來(lái)復(fù)制對(duì)象, 然后讓 index 屬性自增 1。這樣就滿足了我們的需要。
原型模式在 UI 自動(dòng)化中常見的場(chǎng)景都是類似這種,我們要在 UI 上做很多相似的 UI 操作, 這些操作需要傳遞很多配置。 這些配置大多數(shù)是相同的,但是有一小部分是不同的。而我們又不能直接通過(guò)不停的改變一個(gè)對(duì)象的屬性來(lái)完成這項(xiàng)任務(wù) (因?yàn)橹筮€要使用這些對(duì)象做其他操作)。 所以需要原型模式出馬。比如我們要在項(xiàng)目中導(dǎo)入很多數(shù)據(jù)。 這些數(shù)據(jù)的導(dǎo)入方式是差不多的,比如格式,數(shù)據(jù)源等等, 可能只有數(shù)據(jù)的路徑和名字不一樣。 當(dāng)然我們也可以只使用一個(gè)對(duì)象,引入一個(gè)數(shù)據(jù)后,立馬改變這個(gè)對(duì)象的數(shù)據(jù)路徑和名字,去引入下一個(gè)對(duì)象。 這樣做也是可以的,但是這樣做的壞處是你之后就不能使用這個(gè)對(duì)象操作之前的那些數(shù)據(jù)了。 比如我們引入數(shù)據(jù)后需要等待數(shù)據(jù)引入結(jié)束, 但是你的當(dāng)前對(duì)象的名字和都變成最后一次操作的配置了。 你已經(jīng)失去了跟蹤之前的數(shù)據(jù)導(dǎo)入的能力了。 所以這時(shí)候原型模式就很有用了, 迅速為你 clone 出一個(gè)符合你需求的對(duì)象使用。
PS:上面講的使用 object 的 clone 的方式都是淺拷貝, 什么是淺拷貝呢? 比如我們對(duì)象中的屬性如果有引用類型,例如 list,map 或者另一個(gè)對(duì)象。 這時(shí)候是不會(huì)復(fù)制一個(gè)新的,而是直接把這些引用類型屬性的引用地址復(fù)制過(guò)來(lái)。也就是說(shuō),雖然外層對(duì)象已經(jīng)是新的了,但是里面的引用屬性使用的還是一個(gè)對(duì)象。 而如果是深拷貝的話,它是會(huì)把引入類型也 clone 一份出來(lái)。 當(dāng)然如果要實(shí)現(xiàn)深拷貝,那就需要我們自己編寫邏輯了。 但是大多數(shù)情況下淺拷貝是可以滿足我們的需求的。 例如上面的關(guān)于特征抽取算子的例子,不一樣地方只是一個(gè) int 類型的 index。 所以這時(shí)候淺拷貝完全夠用。