精準化測試原理簡介

霍格沃兹测试开发学社發表於2024-03-27

小時候大家應該都玩過一個遊戲,遊戲很簡單,就是找不同,在規定時間內兩幅圖直接的差異點找到就算贏,越快越好,就像下面這樣:

上面這個不同點想找很簡單,那麼下面這樣的呢?

這個,確實有的人會說"我可以!" 。比如在綜藝節目"最強大腦"中,這群"變態"的非人類確實可以

反正我不行,我也不信你們看到文章這裡的人可以~我只有最菜大腦

理論上,我們全面的測試覆蓋,肯定就就可以保證,那麼我們先看下下面的程式碼:

這是一份涉及訂單狀態的各種列舉,每一個狀態的背後都有其業務邏輯,甚至還有交叉,假若按照笛卡爾積或者正交的方式來進行用例設計與覆蓋,有。。。好多好多用例

  • 那麼~你真的有那麼多時間去全覆蓋嗎?
    開發:我改了點程式碼,等會幫忙全面迴歸一遍吧
    測試:好的(*** bi~~ ***)
    什麼?自動化?Are you sure?

測試發展到如今,好像不會點自動化,都不好意思叫測試,簡歷上不寫點自動化都拿不出手,但是自動化真的是測試的銀彈不,做過的應該深有感觸,自動化屬於一個奢侈品:

  • 開發正本
  • 維護成本
  • 如何使用
  • 用例的設計合理性
  • 新功能的滯後性

再者,你確定你真的覆蓋到了被測程式碼?也就是相當於魔方牆上的每個色塊,實際在黑盒測試的過程中很大程度上取決於測試人員的經驗,主觀性很強,這樣就很可能漏測,釋出後出了問題就又要開撕了。。。

可能有的小夥伴會這樣覺得,有人告訴我們答案,也就是告訴我們魔方牆的差異之處。這樣我不就知道關注的測試點了嗎?

沒錯,我們可以讓開發告訴我們本次改了哪些方法,甚至有程式碼許可權的情況下我們有能力可以自己去分析程式碼,妥了,金女士!

那麼問題又來了。針對上面的情況,開發的描述一定是正確全面的嗎?即使開發準確的說明了改動的程式碼,那麼改動所影響到的其他範圍呢?開發本人也不好確認的(不然還要測試幹啥~),開發也有可能偷偷改程式碼不告訴你呢。

這個時候就渴望有這麼一個"最強大腦"

  • 眼過去就可以看出差異點(本次改動的邏輯)
  • 腦海中就有了差異的影響範圍(縮小需要測試的範圍)
  • 再一掃就看出哪些測試覆蓋到了(確認測試覆蓋率)

以求達到一種精準測試的程度

按照上面的描述,大概我們可以分為三個維度:

  • 差異化
  • 呼叫鏈
  • 覆蓋率
    接下來的文章中會一個個詳細來說~

不同的語言,都會有對應不同的語法分析器,語法分析器會把原始碼作為字串讀入、解析,並建立語法樹,這是一個程式完成編譯所必要的前期工作。

我們看下 Java 的編譯過程,重點關注步驟一和步驟二:

這裡我們使用一個簡單的Java物件,解析成AST後看下長什麼樣子

由於層級太多太複雜,這裡選取屬性user做個簡單演示說明。如下:

每一項裡面都包含了最全面的資訊,包括名稱、行號等,具體的可以訪問線上除錯網站https://astexplorer.net/進行除錯檢視

既然所有的程式碼資訊都有了,那麼我們就可以拿著這些資訊進行比對,從而找出程式碼的差異之處;(當然這其中還是要很多降噪處理的,例如註釋、空格、業務無關程式碼get/set等)
大概的流程邏輯如下

3.2.1 位元組碼

因為Java程式碼的執行,是透過javac先將Java檔案編譯成.class結尾的位元組碼,再由JVM去執行;所以在位元組碼檔案中,擁有了足夠的後設資料來解析類中的所有元素:類名稱、父類名、方法、屬性以及 Java 位元組碼(指令);

以如下原始碼為例:

1  public class AccurateTest {
2
3     private int a = 1;
4
5     public String add(int b){
6        return String.valueOf(a + b);
7    }
8 }
9

命令將其編譯為位元組碼檔案,再使用
命令將其反編譯後得到如下資訊:

Classfile /Users/qinzhen/Documents/My/TrainingProject/calctest/src/test/java/AccurateTest.class
  Last modified 2021-7-15; size 386 bytes
  MD5 checksum e67842e9b540c556d288c28b303298fb
  Compiled from "AccurateTest.java"
public class AccurateTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#19         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#20         // AccurateTest.a:I
   #3 = Class              #21            // AccurateTest
   #4 = Class              #22            // java/lang/Object
   #5 = Utf8               a
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               LAccurateTest;
  #14 = Utf8               add
  #15 = Utf8               (I)I
  #16 = Utf8               b
  #17 = Utf8               SourceFile
  #18 = Utf8               AccurateTest.java
  #19 = NameAndType        #7:#8          // "<init>":()V
  #20 = NameAndType        #5:#6          // a:I
  #21 = Utf8               AccurateTest
  #22 = Utf8               java/lang/Object
{
  public AccurateTest();            //建構函式
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_1
         6: putfield      #2                  // Field a:I
         9: return
      LineNumberTable:
        line 1: 0
        line 3: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   LAccurateTest;

  public java.lang.String add(int);        //方法名
    descriptor: (I)Ljava/lang/String;      //方法描述符(入參和返回值型別)                
    flags: ACC_PUBLIC              //方法的訪問標緻
    Code:                    //code開始
      stack=2, locals=2, args_size=2
         0: aload_0
         1: getfield      #2                    // 引用常量池的值 Field a:I
         4: iload_1
         5: iadd
         6: invokestatic  #3                    // Method java/lang/String.valueOf:(I)Ljava/lang/String;
         9: ireturn
      LineNumberTable:              //行號表,將上述操作碼與.java中的行號做對應
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       7     0  this   LAccurateTest;
            0       7     1     b   I      //本地變數
}
SourceFile: "AccurateTest.java"

透過上述資訊我們可以直觀的看到位元組碼中包含了Java執行所需的所有資訊,且JVM對於位元組碼檔案要求嚴格,必須按照固定的組成和順序,而這種特性也就適合利用訪問者模式對位元組碼檔案進行修改;因此也就要介紹我們的呼叫鏈生成的核心技術棧——ASM

3.2.2 ASM

操作;
API介面,每當
,掃描到類註解就會回撥
等;
方法來實現位元組碼的讀取和插入,例如在做呼叫鏈分析時我們就用到了其
方法來對方法體內的呼叫資訊進行過濾和提取

透過上述的資訊進行匹配橋接,我們就可以拿到呼叫鏈中的一系列父子節點,形成我們的方法呼叫鏈

大概的流程邏輯如下:

說到覆蓋率統計,就要介紹當前在這個技術領域中佔據主導地位的開源工具-jacoco
jacoco使用總的來說和裝大象一樣,需要三步

    1. 對被測專案進行位元組碼插樁
    1. 覆蓋率資料的採集與匯出
    1. 覆蓋率資料的統計與報告生成
      下面我們對這三個步驟逐一拆解
      插樁,其實就是安插監控探頭,我們的一行行程式碼就好比一條條馬路,程式碼裡的分支(if-else)就好比馬路上的各種支路岔道,而插樁就相當於在每一條路的路口都裝上了一個探頭

如下就是在位元組碼中插入探針資訊的圖示:

jacoco的插樁模式有兩種:

  • on-the-fly模式(執行時插樁)
  • 透過配置-javaagent在啟動命令中,jacoco介入被測專案部署過程,將探針(探頭)插入class檔案,探針不改變原有方法的行為,只是記錄是否已經執行。
  • 優點:無需提前進行位元組碼插樁,無需考慮classpath 的設定。
  • 缺點:要修改JVM引數,對環境的要求比較高,於一些無法修改啟動命令的場景不適用。
  • offline模式(編譯時插樁)
  • 在測試之前先對檔案進行插樁,生成插過樁的class或jar包,測試插過樁的class和jar包,生成覆蓋率資訊到檔案,最後統一處理,生成報告。
  • 優點:遮蔽工具對虛擬機器環境的依賴;
  • 缺點:需要提前侵入程式碼;無法實時獲取覆蓋率,只能測試完成後停止專案後統一生成報告
    選擇:

方式無須入侵應用啟動指令碼,再加上公司的運維和開發可以配合部署
啟動引數,因此我們最終選擇
模式進行插樁

3.3.2 覆蓋率收集與匯出
看了上面的插樁原理,想必覆蓋率的收集也就很好理解了,依然是以監控探頭為例,當我們測試一行行程式碼時,就相當於開著車跑在一條條道路上,而每進入一行程式碼就像是開車進入了一條道路,那麼進入的時候就會被監控探頭拍攝記錄下來,也就知道你跑過哪條路了。
同理,覆蓋到一行程式碼時,探針就會記錄下資訊,最終也就知道了哪一行程式碼被覆蓋到了

至於匯出,覆蓋率的統計資訊會透過暴露的服務埠(預設6300)去獲取,匯出一份以.exec結尾的檔案,檔案中包含了當前的覆蓋率資訊

透過對exec檔案的解析,jacoco便可以獲取所有方法的探針資訊,從而計算覆蓋率,並對程式碼進行染色輸出報告:

針對程式碼的染色如下

  • 紅色:代表未覆蓋
  • 黃色:代表部分覆蓋,
  • 綠色:代表完全覆蓋
    在實際的使用場景中,我們可能還更關注本次修改的程式碼,測試的時候我們會重點測試本輪開發的新增和改動範圍,因此jacoco原生的功能就不能滿足了,jacoco原生統計的是全量的覆蓋率。

對於改動點,我們稱之為增量,所以我們對jacoco的原始碼進行了二次開發,使其支援增量的覆蓋率統計,以滿足日常測試需求;對比上面全量的範圍,可以看到增量的統計範圍就明確了,數量就少了很多:

  • 大概的架構邏輯如下:

開發修改了一個方法或者一個介面,那麼這個介面可能被N個應用去呼叫,一旦這個介面有問題,那麼影響面是相當大的;或者這個介面本身沒問題,但是上下游沒有相容好,呼叫出了問題也是影響產品質量的;所以這個也是我們測試關注的重點。
再者,我們日常的測試有很大一部分比例是介面測試,包括自動化也是,介面自動化用例很多。那麼如果可以透過呼叫鏈路找到本次修改所影響到的最上層的入口介面(
等),那麼透過介面與用例的關聯關係,就可以推薦出本輪修改必須要執行的用例,提高用例的精準程度和更加明確的測試範圍。
還有,如果改動的介面沒有關聯的用例,或者用例執行完以後覆蓋率不達標,那麼也可以對用例進行查漏,新增新的用例進行覆蓋。

  • 優點:方案相對成熟,業界有落地案例,實現難度尚可
  • 缺點:鏈路也是透過插樁監控的,那麼前提就是這條鏈路要走到了才會存在,這樣就有滯後性,新增加的程式碼鏈路還沒有測試過,那這條鏈路自然也就拿不到了

聊到這裡,基本上就把測試人員的靈魂3問給回答完畢了。關於精準化測試,這裡有幾個問題會困擾測試開發人員。這裡給出一些建議,希望可以對讀者有所益處。
1、如果我的程式碼覆蓋率達到100%了,是不是就可以說測試覆蓋完全了,質量有保障了?

答:不是, 覆蓋率低,質量一定沒有保障,但是覆蓋率高,只是保障的一個維度達到了。
這裡我們只是知道了程式碼被覆蓋了,但是程式碼邏輯的正確性呢?精準化是無法判斷的,要靠大家自己去斷言了。
再者,覆蓋到的程式碼都是開發按照自己理解的業務邏輯寫的,如果他漏寫了一些需求邏輯呢?那這部分就不存在覆蓋的情況了。

2、我是不是每次都要保證所有的方法覆蓋率都達到100%?

答:不是,方法的覆蓋率要達到什麼樣的一個值,不好直接下結論。有些程式碼邏輯,好比一些異常的捕獲,這個異常的觸發場景很難,日常測試幾乎走不到,那麼就是覆蓋不了,覆蓋率也就不可能達到100%。

3、根據問題2,既然達不到100%,那麼我是不是設一個閾值,好比80%?90%?,達到這個閾值就可以了?

答:也不是,有些方法,它的程式碼邏輯可能都是核心邏輯,其中的分支都需要覆蓋,缺少了就有漏測出Bug的風險,且理論上都是可以透過測試覆蓋到的,那麼這種方法就需要達到100%的覆蓋率。

4、那要怎麼衡量覆蓋率的指標?

答:一方面可以設定一個最低閾值,哪怕程式碼有些邏輯走不到,也不會大面積並且佔比很高,還是需要一個最低的覆蓋率保障;
再者,需要測試的同學根據自己測試的業務進行情況劃分,具備codereview的能力和習慣,平臺僅作為一個輔助測試的工具;
最後,我們可以記錄下以往測試的覆蓋率,根據不同業務透過測試後的覆蓋率情況統計覆蓋率的趨勢,以歷史的覆蓋率資料為依據來設定閾值或監控告警,如果覆蓋率低於往期正常的值,就進行告警或者卡點

相關文章