基於屬性的測試
property-based testing,基於屬性的測試。這個東西的有趣之處在於,和傳統的測試相比,它不需要窮盡一切——或者這麼說,在規格層面上,它覆蓋的面積是「屬性之內全稱」。邏輯上來說,如果定義了一個正確的屬性,那麼它就囊括了該範圍下全部的可能集合。當然,它本身不保證百分百命中,優勢主要來自於對比單點測試。這個不保證從它的操作就能看出來了,在屬性定義了何為正確之後,需要構建一個生成器,去不斷地打隨機看結果是否落在指定的正確範圍內——它的核心思維是取樣,這就決定了必然有覆蓋不到的地方。
這裡先談一下 Daniel Jackson 的 SCH (small scope hypothesis)。這個概念講的是:對於某個錯誤斷言來說,幾乎一定存在最小的反例;對於那些因實現結構而導致的錯誤,我們只需要在小範圍內窮舉就能夠找到它。典型的例子是許可權,不必模擬幾百上千個使用者,許可權互動可能導致的問題在個位數多個許可權來回互動的情況下就會暴露。大部分bug不見得會隨著規模而出現(當然,這是個經驗判斷,也存在那種真的會在特定規模之後才發生的bug),往往在小範圍的情況下就會暴露。
好,既然有了這個,我們為什麼還要用PBT?問題就是出在窮舉上。一旦 scope 寫的稍微大一點,窮舉的空間就會爆炸,而且還得用 Alloy 這種形式化語言去寫。它的優勢是跑完了真的能「證明」該範圍內不存在反例,但代價偏高。而PBT在這裡用隨機取樣替代了有界窮舉,使我們可以不必完全窮舉所有內容就取到SCH的收益,代價當然是犧牲了全稱意義上的窮盡證明,可這種交換也是值得的。
PBT測試套件存在一個很好用的操作,那就是 shrink。前面說到SCH指明瞭錯誤斷言通常存在小反例,但對於取樣來說,發現錯誤後怎麼能說它是當前取樣下的小反例呢?shrink 操作就是為了找到這個小反例。在發現了錯誤之後,套件就會去嘗試不斷的縮小錯誤的輸入值,舉例來說,比如(733921845, -2147483648) 這種對人類來說很難理解(太大、太寬泛)的錯誤,它會不斷去嘗試往零縮,縮完後,會停在一個最小的報錯值那裡——(0,0)正常(-1,0)卻掛了,我們一看就知道錯誤存在於符號而非單純的數值上。要注意這裡的縮小是根據型別來的,陣列會刪元素,字串會減長度。我們可以這樣理解,這就是一個不斷排除最終接近最小的工程手段,而且往往越簡單,對人來說也越好理解。所以,如果PBT發現了什麼錯誤,傳統的用例手段仍然會在這裡作為補充,shrink 之後用一個最小的迴歸測試將其固定下來。
坦白來說,就像 Property-based testing is about to rule the (software) world 說的一樣,PBT確實是相當小眾的,但在AI開發的時代會變得日益重要。核心點就在於生成的速度日益超過了驗證的速度,我們的程式碼正逐漸成為黑盒,我如何保證一個黑盒的質量?現有的測試體系當然可以做到這點,但在效率上始終存在著瓶頸。就拿基於案例的測試來說吧,我(當然,現在測試也是AI寫的)必須去絞盡腦汁的思考一些可能出錯的案例,但侷限性就體現在這裡,如果我想不到怎麼辦?
根本原因在於,傳統的測試要求你去思考「你能夠想到」的東西,但問題恰恰出在「我想不到」的地方上。這幾乎相當於一句廢話(或者悖論?),因為我能想到的東西肯定不再構成問題,既然我想到了,我為什麼不加進去呢?好吧,大部分情況下我做不到窮盡一切,我的經驗和精力限制了我能想到的範圍,甚至這一點就算是AI也做不到:儘管他們有著無窮的記憶,但上下文和注意力也是有限的,他們的思考到的可能集會顯著的受到其自身所收到的輸入範圍之限制。
PBT就是為了解決這個問題的。
寫它會有點反直覺,因為我們正常寫測試用例的時候想到的是「具體」的實現,但寫屬性的時候要求的是想「形狀」——或者用邏輯的說法,我們需要想的是全稱式的定義。集合論的理解會更直觀一些:{1,4,9,16} 和 {n² | n ∈ ℕ} ,前者只涵蓋了四個元素,但是後者涵蓋了所有 n ∈ ℕ 的情況,這就是劃定範圍。
就拿 1+1=2 來說:
// 传统:公理的三个实例
expect(add(1,1)).toBe(2);
expect(add(2,3)).toBe(5);
expect(add(0,7)).toBe(7);
// PBT:公理本身
fc.assert(fc.property(fc.integer(), fc.integer(),
(a,b) => expect(add(a,b)).toBe(add(b,a)))); // 交换律
fc.assert(fc.property(fc.integer(),
(a) => expect(add(a,0)).toBe(a))); // 单位元
fc.assert(fc.property(fc.integer(), fc.integer(), fc.integer(),
(a,b,c) => expect(add(add(a,b),c)).toBe(add(a,add(b,c))))); // 结合律
這裡直接從規則上直接把加法的正確邊界定義了,一旦我寫的加法工具違反了這些規則,它就必然是有問題的。
然後,上面的例子其實是有坑的。這個我一開始也沒有發現,反而是事後進行對抗性審查時才察覺。上述的例子雖然完美的滿足了教科書的加法公理——但這是一個過大的形狀。比如,給入
a|b這樣的位運算,就無法捕獲計算錯誤的場景。而且它相當隱蔽,位運算在當前的測試範圍內完全可以通過,甚至在不進位的情況下輸出的結果和一般的加法沒區別。可一旦涉及到進位,結果就會出錯。4|3轉成二進位制就是100和011,結果是111= 7;而5|3是101和011,結果還是7,但合法的結果應該是8。 因此,應當再補上一個消去律,即a+c ≠ b+c ,其中a≠b,這樣位運算中的 1|1 = 1,0|1 = 1 就會直接讓測試飄紅。當然,即便這樣,也還是有未覆蓋的,異或運算仍然滿足當前的性質,並在全綠的情況下給出非預期結果(1^1 = 0,但預期應該是2)。可這裡就是取捨了,關鍵是怎樣的形狀最貼近業務——我甚至認為常態狀況下位運算根本可以不加,因為常規業務壓根就不會出現位運算。但這個例子的重要性體現在,即便自以為足夠完善的「公理性質」,也會有想不到的盲區。例如此處就是畫的太大,不僅一般加法、位運算、異或運算都包含在其中,有太多業務不該包含的內容。案例測試只是將這個體現在單點上,而PBT這裡則是我們對關係形狀的定義。 by 事後回顧補充
而定義好了後,就要去大量的進行動作,生成隨機的a,b,c喂入被測的實現中,檢查結果是否符合規則。這裡自然引出了一個點——適用範圍。因為PBT要靠大量的覆蓋來逼近全稱,因此,它很不適合放在需要真即時間才能產出結果的東西上面,就比如說DOM渲染,如果每次都要渲染一次,大量打下來時間會膨脹到難以承受。
其次也正是因為這種隨機性,本質來說PBT每次的結果不是確定性的——因為每次打出來的隨機都不一樣,這一次探索了這部分空間,不代表下一次一模一樣。我在探索新的潛在錯誤區域,摸清楚了這一部分,不代表摸清楚了所有部分。但因為每次的隨機存在一個seed,單次復現已有錯誤並不算太難。
需要注意的是,PBT並不是在任何情況下都有效。例如:正確依賴於外部狀態(比如特定的魔法數字,或者語義約束),靠約定來保證正確,那麼這種並非因關係結構定義,而是查表定義的正確性下,施行PBT的收益就不大(或者說弄出來跟基於案例的幾乎沒差別了),畢竟正確從一開始就是寫死的,那還不如直接上案例測試;隨機數生成器大機率無法命中的單點結果,這其實和第一個類似,只有精確的某個值才能正確時,覆蓋式的PBT反而是多餘的,因為生成器打出的內容除了該點之外全都是浪費的,為什麼不直接單例測試?
雖然說PBT可以大範圍覆蓋,但我們也能看出來它對bug的搜尋強度從一開始就被我們自己構建的屬性和生成器決定了。這一點仍然需要人來想,但如果bug剛好出現在覆蓋範圍外,那麼PBT仍然是測不到的。但說實話這種值本來也不該用PBT。PBT的優勢體現在存在大量組合的情況下,因為案例測試始終是單點,而PBT則是範圍式的。因此,從機率上來說,測出想不到的bug的可能性遠比單點高。
實際上從這裡能看出來,PBT這種測試方法實際上會反過來約束實現。對於PBT可測的程式碼,要求需要儘可能的減少外部狀態依賴(就像上述所說的,依賴外部約定的狀態會讓PBT幾乎等同於案例),同時保證職責單一(因為結構需要穩定才能畫它的範圍,過於混雜就會根本畫不出來)。為什麼說它在AI時代非常合適,恰恰是能夠從反過來就約束AI的發揮。
但是,為了剋制AI的為了滿足測試而改程式碼的傾向,仍然要注意以下的內容:
- PBT的性質必須畫在最外層,也即核心函式上,而不能單純的用輔助函式湊數。
- 必須經得住變異測試和對抗性審查,否則給你射箭畫靶編一個能通過的形狀這事兒在PBT這裡仍然適用。而且PBT在變異上存在代價,那就是它每次都要大打隨機,反而會導致變異全跑的速度變慢。
- 順序。我當前主張的先寫測試後寫程式碼的紀律依舊要維持,形狀必須預先固定,然後再去寫程式碼。因為本質上來說,測試是為了固定業務邏輯,只有在充分討論之後,給AI明確了什麼才是「正確的形狀」,出來的程式碼才能是可用的。
- 需要明確什麼時候可以不用PBT。就如我上述所說的,有些東西用案例測試就足夠了,上PBT完全就是沒必要。比如約定好的數值、SQL,這些形狀預先固定的內容若是再由AI強寫成PBT,那就完全是多餘的負收益。
還是回到之前,綜合來講PBT不是個萬金油,而且還有不少坑:比如隨著業務領域的複雜性增加,生成器的複雜度也會增加。並且還有一個隨機打產生的時間問題,執行一次測試的時間會隨著生成複雜性而增加。它的核心適用場景是關係搭配下可能存在組合爆炸的情況,例如多個不同的組合搭配導致案例測試覆蓋起來極其繁瑣——UI操作邏輯測試中的使用者操作序列也屬於此。濫用PBT在收益上可能是負的,大量的隨機生成和操作最終只會拖慢測試時間,甚至搞出來一堆假綠,只會給人心理上的安慰罷了。