深入淺出之動態測試方法


標簽(空格分隔): 深入淺出之動態測試方法


人工動態方法

人工動態方法,可以真正檢測代碼的業務邏輯功能,其關注點是“什么樣的輸入,執行了什么代碼,產生了什么樣的輸出”,主要用於發現算法錯誤和部分算法錯誤,是最主要的代碼級測試手段。

從人工動態方法的定義中,你可以很清楚地看出:代碼級測試的人工動態測試方法,其實就是單元測試所采用的方法。所以,下面的分享,我會從單元測試方法的角度展開。

如果有一些代碼基礎,那么你在學習單元測試框架或者工具時,會感覺單元測試很簡單啊,一點都不難:

  • 無非就是用驅動代碼去調用被測函數,並根據代碼的功能邏輯選擇必要的輸入數據的組合,然后驗證執行被測函數后得到的結果是否符合預期。

但是,一旦要在實際項目中開展單元測試時,你會發現有很多實際的問題需要解決。

了解單元測試的難點:

  • 單元測試用例“輸入參數”的復雜性;
    單元測試用例“預期輸出”的復雜性;
    關聯依賴的代碼不可用。

單元測試用例“輸入參數”的復雜性

提到“輸入參數”的復雜性,你應該已經記起了,我在前面的分享中提到過:如果你認為單元測試的輸入參數只有被測函數的輸入參數的話,那你就把事情想得過於簡單了。

其實,這也是源於我們在學習單元測試框架時,單元測試用例的輸入數據一般都是被測函數的輸入參數,所以我們的第一印象會覺得單元測試其實很簡單。

但是到了實際項目時,你會發現單元測試太復雜了,因為測試用例設計時需要考慮的“輸入參數”已經完全超乎想象了。

結合一些代碼示例和你詳細聊聊這些輸入參數吧。

第一,被測試函數的輸入參數

  • 這是最典型,也是最好理解的單元測試輸入數據類型。假如你的被測函數是下面這段代碼中的形式,那么函數輸入參數 a 和 b 的不同取值以及取值的組合就構成了單元測試的輸入數據。

int someFunc(int a, int b)
{
  …
}

第二,被測試函數內部需要讀取的全局靜態變量

  • 如果被測函數內部使用了該函數作用域以外的變量,那么這個變量也是被測函數的輸入參數。
    下面這段代碼中,被測函數 Func_SUT 的內部實現中使用了全局變量 someGlobalVariable,並且會根據 someGlobalVariable 的取值去執行 FuncA() 和 FuncB() 這不同的代碼分支。

在做單元測試時,為了能夠覆蓋這兩個分支,你就必須構造 someGlobalVariable 的不同取值,那么自然而然,這個 someGlobalVariable 就成為了被測函數的輸入參數。
所以,在這段代碼中,單元測試的輸入參數不僅包括 Func_SUT 函數的輸入參數 a,還包括全局變量 someGlobalVariable。


bool someGlobalVariable = true;
void Func_SUT(int a)
{
  ...
  if(someGlobalVariable == true)
  {
    FuncA();
  }
  else
  {
    FuncB();
  }
  ...
}

第三,被測試函數內部需要讀取的類成員變量

如果你能理解“被測函數內部需要讀取的全局靜態變量”是單元測試的輸入參數,那么“被測試函數內部需要讀取的類成員變量”也是單元測試的輸入參數就不難理解了。因為,類成員變量對被測試函數來講,也可以看做是全局變量。

我們一起看一段代碼。這段代碼中,變量 someClassVariable 是類 someClass 的成員變量,類的成員函數 Func_SUT 是被測函數。Func_SUT 函數,根據 someClassVariable 的取值不同,會執行兩個不同的代碼分支。同樣地,單元測試想要覆蓋這兩個分支,就必須提供 someClassVariable 的不同取值,所以 someClassVariable 對於被測函數 Func_SUT 來說也是輸入參數。


class someClass{
  ...
  bool someClassVariable = true;
  ...
  void Func_SUT(int a)
  {
    ...
    if(someClassVariable == true)
    {
      FuncA();
    }
    else
    {
      FuncB();
    }
    ...
  }
  ...
}

第四,函數內部調用子函數獲得的數據

“函數內部調用子函數獲得的數據”也是單元測試的輸入數據,從字面上可能不太好理解,那我就通過一段代碼,和你詳細說說這是怎么回事吧。


void Func_SUT(int a)
  {
  bool toggle = FuncX(a);
  if(toggle == true)
  {
    FuncA();
  }
  else
  {
    FuncB();
  }
}

函數 Func_SUT 是被測函數,它的內部調用了函數 FuncX,函數 FuncX 的返回值是 bool 類型,並且賦值給了內部變量 toggle,之后的代碼會根據變量 toggle 的取值來決定執行哪個代碼分支。

那么,從輸入數據的角度來看,函數 FuncX 的調用為被測函數 Func_SUT 提供了數據,也就是這里的變量 toggle,后續代碼邏輯會根據變量 toggle 的取值執行不同的分支。所以,從這個角度來看,被測函數內部調用子函數獲得的數據也是單元測試的輸入參數。

這里還有一個小細節,被測函數 Func_SUT 的輸入參數 a,在內部實現上只是傳遞給了內部調用的函數 FuncX,而並沒有在其他地方被使用,我們把這類用於傳遞給子函數的輸入參數稱為“間接輸入參數”。

  • 這里需要注意的是,有些情況下“間接輸入參數”反而不是輸入參數。
    就以這段代碼為例,如果我們發現通過變量 a 的取值很難控制 FuncX 的返回值(也就是說,當通過間接輸入參數的取值去控制內部調用函數的取值,以達到控制代碼內部執行路徑比較困難)時,我們會直接對 FuncX(a) 打樁,用樁代碼來控制函數 FuncX 返回的是 true 還是 false。
  • 這樣一來,原本的變量 a 其實就沒有任何作用了。那么,此時變量 a 雖然是被測函數的輸入參數,但卻並不是單元測試的輸入參數。

第五,函數內部調用子函數改寫的數據

理解了前面幾種單元測試的輸入參數類型后,“函數內部調用子函數改寫的數據”也是單元測試中被測函數的輸入參數就好解釋了。

比如,當被測函數內部調用的子函數改寫了全局變量或者類的成員變量,而這個被改寫的全局變量或者類的成員變量又會在被測函數內部被使用,那么“函數內部調用子函數改寫的數據”也就成為了被測函數的輸入參數了。

第六,嵌入式系統中,在中斷調用中改寫的數據

嵌入式系統中,在中斷調用中改寫的數據有時候也會成為被測函數的輸入參數,這和“函數內部調用子函數改寫的數據也是單元測試中的輸入參數”類似,在某些中斷事件發生並執行中斷函數時,中斷函數很可能會改寫某個寄存器的值,但是被測函數的后續代碼還要基於這個寄存器的值進行分支判斷,那么這個被中斷調用改寫的數據也就成了被測函數的輸入參數。

其實在實際工程項目中,除了這六種輸入參數,還有很多輸入參數。在這里,我詳細分析這六種輸入參數的目的,一來是幫你理解到底什么樣的數據是單元測試的輸入數據,二來也是希望你可以從本質上認識單元測試的輸入參數,那么在以后遇到相關問題時,你也可以做到觸類旁通,不會再躊躇無措。

單元測試用例“預期輸出”的復雜性

同樣地,單元測試用例的“預期輸出”,也絕對不僅僅是函數返回值這么簡單。通常來講,“預期輸出”應該包括被測函數執行完成后所改寫的所有數據,主要包括:被測函數的返回值,被測函數的輸出參數,被測函數所改寫的成員變量和全局變量,被測函數中進行的文件更新、數據庫更新、消息隊列更新等。

第一,被測函數的返回值

這是最直觀的預期輸出。比如,加法函數 int add(int a, int a) 的返回值就是預期輸出。

第二,被測函數的輸出參數

要理解“被測函數的輸出參數”是預期輸出,最關鍵的是要理解什么是函數的輸出參數。如果你有 C 語言背景,那么你很容易就可以理解這個概念了。

我們一起來看一段代碼。被測函數 add 包含三個參數,其中 a 和 b 是輸入參數,而 sum 是個指針,指向了一個地址空間。
如果被測函數的代碼對 sum 指向的空間進行了賦值操作,那么在被測函數外,你可以通過訪問 sum 指向的空間來獲得被測函數內所賦的值,相當於你把函數內部的值輸出到了函數外,所以 sum 對於函數 add 來講其實是用於輸出加法結果的,那么顯然這個 sum 就是我們的“預期輸出”。

如果你還沒有理解的話,可以在百度上搜索一下“C 語言的參數傳遞機制”


void add(int a, int b,int *sum)
{
  *sum = a + b;
}
void main()
{
  int a, b,sum;
  a = 10;
  b = 8;
  add(a, b, &sum);
  printf("sum = %d \n", sum);
}

第三,被測函數所改寫的成員變量和全局變量

理解了單元測試用例“輸入參數”的復雜性,“被測函數所改寫的成員變量和全局變量”也是被測函數的“預期輸出”就很好理解了,此時如果你的單元測試用例需要寫斷言來驗證結果,那么這些被改寫的成員變量和全局變量就是 assert 的對象。

第四,被測函數中進行的文件更新、數據庫更新、消息隊列更新等

這應該不難理解。
但在實際的單元測試實踐中,因為測試解耦的需要,所以一般不會真正去做這些操作,而是借助對 Mock 對象的斷言來驗證是否發起了相關的操作。

關聯依賴的代碼不可用

什么是關聯依賴的代碼呢?
假設被測函數中調用了其他的函數,那么這些被調用的其他函數就是被測函數的關聯依賴代碼。

大型的軟件項目通常是並行開發的,所以經常會出現被測函數關聯依賴的代碼未完成或者未測試的情況,也就是出現關聯依賴的代碼不可用的情況。那么,為了不影響被測函數的測試,我們往往會采用樁代碼來模擬不可用的代碼,並通過打樁補齊未定義部分。具體來講,假定函數 A 調用了函數 B,而函數 B 由其他開發團隊編寫,且未實現,那么我們就可以用樁函數來代替函數 B,使函數 A 能夠編譯鏈接,並運行測試。樁函數要具有與原函數完全相同的原形,僅僅是內部實現不同,這樣測試代碼才能正確鏈接到樁函數。一般來講樁函數主要有兩個作用,一個是隔離和補齊,另一個是實現被測函數的邏輯控制。

用於實現隔離和補齊的樁函數實現比較簡單,只需拷貝原函數的聲明,加一個空的實現,可以通過編譯鏈接就可以了。用於實現控制功能的樁函數是最常用的,實現起來也比較復雜,需要根據測試用例的需要,輸出合適的數據作為被測函數的內部輸入。

自動動態方法

我們先來回顧一下,什么是自動動態方法。自動動態方法是,基於代碼自動生成邊界測試用例並執行來捕捉潛在的異常、崩潰和超時的測試方法。

自動動態方法的重點是:如何實現邊界測試用例的自動生成。

解決這個問題最簡單直接的方法是,根據被測函數的輸入參數生成可能的邊界值。

具體來講,任何數據類型都有自己的典型值和邊界值,我們可以預先為它們設定好典型值和邊界值,然后組合就可以生成了。

比如,函數 int func(int a, char *s),就可以按下面的三步來生成測試用例集。

  • 1.定義各種數據類型的典型值和邊界值。 比如,int 類型可以定義一些值,如 int 的最小值、int 的最大值、0、1、-1 等;char* 類型也可以定義一些值,比如“”、“abcde”、“非英文字符串”等。

根據被測函數的原形,生成測試用例代碼模板,比如下面這段偽代碼:


try{
  int a= @a@;
  char *s = @s@;
  int ret = func(a, s);
}
catch{
  throw exception();
}

將參數 @a@和 @s@的各種取值循環組合,分別替換模板中的相應內容,即可生成用例集。

由於該方法不可能自動了解代碼所要實現的功能邏輯,所以不會驗證“預期輸出”,而是通過 try…catch 來觀察是否會引發代碼的異常、崩潰和超時等具有邊界特征的錯誤。

總結

代碼級測試的動態測試方法,可以分為人工動態測試方法和自動動態測試方法。其中人工動態測試方式,是最常用的代碼級測試方法,也是我們在進行單元測試時采用的方法。人工動態方法,也就是單元測試方法,通常看似簡單,但在實際的工程實踐中會遇到很多困難,總結來看這些困難可以概括為三大方面:單元測試用例“輸入參數”的復雜性,表現在“輸入參數”不是簡單的函數輸入參數。本質上講,任何能夠影響代碼執行路徑的參數,都是被測函數的輸入參數。單元測試用例“預期輸出”的復雜性,主要表現在“預期輸出”應該包括被測函數執行完成后所改寫的所有數據。關聯依賴的代碼不可用,需要我們采用樁代碼來模擬不可用的代碼,並通過打樁補齊未定義部分。而自動動態方法,需要重點討論的是:如何實現邊界測試用例的自動生成。解決這個問題最簡單直接的方法是,根據被測函數的輸入參數生成可能的邊界值。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM