突變測試的測試模式 - Mutation Testing in Patterns

Documentation Status

突變測試是一個用來衡量現有軟體測試程式質量的一種技巧。突變測試牽涉到小量變更程式,例如將 True 置換為 False,然後重新運行測試程式。當測試程式失敗則代表*突變* 被 殺死。這能夠告訴我們測試程式的質量為何。這個指南的目標是使用實際的案例來描述不同的軟體以及測試模式。

某些部份的描述與該語言特性有關,敬請參閱相關的章節來取得有關安裝以及運行必要工具、範例的資訊。

突變測試工具 - Mutation testing tools

這是一份突變測試工具的列表,列表中的工具都有活躍使用以及社群維護。

LLVM-based 的語言,如 C、C++、Rust、與 Objective-C 可以參考 mull ,一個看起來不錯專案,但是可能還沒 ready for production。

確保你的工具可用 - Make sure your tools work

突變測試依賴於動態更改程式模組以及讀取從記憶體中變異 instance。根據不同的程式語言會有不同的方式來指涉同一個模組。在 Python 中下面的程式碼是等價的:

import sandwich.ham.ham
obj = sandwich.ham.ham.SomeClass()

from sandwich.ham import ham
obj = ham.SomeClass()

備註

“等價”在這邊的意思是訪問了相同的模組 API。

如果有許多不同的 importing styles 被使用的話,當我們對最左邊的 ham 模組進行突變,我們的工具可能沒有辦法解析出相同的模組。請參考範例: 因為 import 問題而無法殺死突變.

另一個可能的問題是程式會動態的讀取模組,或是在執行時期改變模組搜尋的路徑。根據突變測試工具的不同,這些操作可能會產生一些相關的問題。範例請參考:突變在動態載入模組時沒有被殺死

確保你的測試可行 - Make sure your tests work

突變測試的可靠度是基於當引入突變時,你的測試套件 (test suite) 會失敗 (fail)。這代表任何的失敗都會殺死突變!突變測試工具沒有辦法得知你的測試套件是因為突變引起一個斷言還是因為其他原因而失敗。

確保你的測試套件 (test suite) 是穩健的,並且不會因為外部因素而隨機的失敗!請參考範例 Mutant killed due to flaky test

分治法 - Divide and Conquer

最為基本的突變測試演算法如下:

for operator in mutation-operators:
    for site in operator.sites(code):
        operator.mutate(site)
        run_tests()
  • mutation-operators 是指那些在程式中有微小變動的部份。

  • operator.sites 代表在你的程式中突變運算子可以被使用的地方。

我們可以得知,突變測試是一個代價非常高的操作。舉例而言,pykickstart 專案約有 5523 個可能的突變與 347 個測試,這會讓測試平均時間來到 100 秒左右。一個完整的突變測試需要花 6 天的時間才能夠運行完成。

不過,在實務上並不是所有的測試都有必要。這代表突變運算子只需要測試整個測試套件中的子集就可以。如此可以透過對每個獨立的檔案或是模組排程測試那些相關的部份來降低執行的時間。最佳情況是所有的原始檔名都有相對應的測試檔案名稱。

舉例而言:

for f in `find ./src -type f -name "*.py" | sort`; do
    TEST_NAME="tests/$f"
    runTests $f $TEST_NAME
done

runTests 只對於原始檔在有相對應的測試程式時才會運行。這個方法讓**pykickstart** 的突變測試運行時間減少到只需要6個小時!。

備註

其他語言或是工具可能會有其組織突變測試的慣例。舉例而言,Ruby 的慣例是將 spec/*_spec.rb 的測試以前面提到的方式表示。Mutant,Ruby 的突變測試工具,會使用這個慣例來找尋需要的測試。Python 的話,使用者則必須要自己決定哪個部份需要被執行!

快速的失敗 - Fail fast

突變測試是基於當你的測試套件 (test suite) 偵測到錯誤的突變時能夠失敗。他不管是因為哪個特定的測試失敗,因為大部分的工具也無法指出失敗的原因是因為突變還是什麼其他原因。這代表他也不管是不是有多個失敗的測試,你可以把這樣的特性當作是一個特點來使用。

當你的測試工具或是框架支援 fail fast 選項的時候記得使用該選項來減少測試執行時間!

重構對於空字串的比較

根據不同語言,比較運算子可能突變不同的數量。大約而言會有 10 種不同的突變。

S 不是個空字串的時候,下面 3 個變體會被當作是 True

  • if S != ""
  • if S > ""
  • if S not in ""

因此存在的測試案例會通過,而突變永遠沒有被殺掉。如 Python 這樣的語言,非空字串都會在布林運算中被視為 True,因此不需要去比較他。這降低了可能突變的數量。

Python 中可以使用 pylint 的 emptystring 套件:

pylint a.py --load-plugins=pylint.extensions.emptystring

更多資訊請參見 pylint #1183 ,相關範例請參考:重構 if str != “” 來殺死突變

警告

某些狀況下空字串是一個合法的值,而重構可能會造成行為改變。小心,小心再小心。

重構與 0 比較的狀況

這與前一個章節有些相似,不過是比較整數數值。Python 可以透過 *comparetozero*找出可能的錯誤。

pylint a.py --load-plugins=pylint.extensions.comparetozero

更多訊息請參考 pylint #1243

Python: 重構 len(X) 跟 0 比較

X 不是空的序列時,下面的表達式相當於與 True 比較,而其結果將會在突變中存活下來。

  • if len(X) != 0
  • if len(X) > 0

此外,如果我們不對 if 的主體做驗證,例如說他會發起 exception,則下列的突變也會存活下來:

  • if len(X) < 0

我們可以重構成:

if X:
    do_something()

是最好的方式。這同時可以降低突變的總數量。可以參考下面使用兩個 lists以及布林運算的更為複雜的範例。

-   if len(self.disabled) == 0 and len(self.enabled) == 0:
+   if not (self.disabled or self.enabled):

以下面的程式碼為例:

# All the port:proto strings go into a comma-separated list.
portstr = ",".join(filteredPorts)
if len(portstr) > 0:
    portstr = " --port=" + portstr
else:
    portstr = ""

與前面的範例相近,我們可以將 len() > 0 重構。對一個空的 listjoining 會產生出一個空字串,因此 else block 是不必要的。因此範例可以改寫為:

# All the port:proto strings go into a comma-separated list.
portstr = ",".join(filteredPorts)
if portstr:
    portstr = " --port=" + portstr

pylint 2.0 中有個新的檢查叫作 len-as-condition,他會在你的程式中有對 len() 的結果與 0 進行比較時提出警告。更多資訊請參考 pylint #1154

範例請參考:重構 if len(list) != 0

Python: 重構 if len(list) == 1

以下的程式碼

if len(ns.password) == 1:
    self.password = ns.password[0]
else:
    self.password = ""

可以重構為:

if ns.password:
    self.password = ns.password[0]
else:
    self.password = ""

警告

當 list 長度大於 1 的時候 (例如:2),這個重構可能會有副作用的產生。這取決於你的程式,這可能,也可能不是其中的一個測試案例。

測試 X != 1

當我們測試不等於的條件時,我們需要至少3個測試案例:

  • 測試小於條件的數值

  • 測試等於條件的數值

  • 測試大於條件的數值

通常我們都會測試相等的條件 (最好狀況) 然後測試大於或小於的其中一個狀況。這讓突變可能不會被殺死。

範例請參考:測試 X != 1

Python: 重構 if X is None

當 X 有 None 的值的時候,下面的突變是相等的且會存活下來:

  • if X is None:
  • if X == None:

此外,靜態分析可能會將這種比較視為一種錯誤。在可行的情況下應該要將``if X is None`` 重構為 if not X

請參考範例: 重構 if X is None.

Python: 重構 if X is not None

這是前一節的相反情況。將 if X is not None: 重構為 if X:。請參考範例:重構 if X is not None

Python: 測試 __eq__ 以及 __ne__

當物件使用自己的比較運算方法比較時,完整的突變測試可以透過比較物件本身、與 None 比較、與兩個相同 attribute 數值的物件比較以及一個一個改變 attribute 來測試。

請參考範例:測試 __eq__ 與 __ne__

以下面的程式碼的錯誤為例:

def __eq__(self, other):
    if not y:
        return False

    return self.device and self.device == y.device

注意到表達式前面冗餘的 self.devie and!當 self.device 內有值的時候 (這邊是字串),表達式與 self.device == other.device 相等。當 self.deviceNone 或是空字串的時候,表達式永遠會回傳 False

如果我們有前面所以的測試的話 (那些突變測試已經確定的) 則我們的測試套件會失敗並且正確的被偵測到:

$ python -m nose -- tests.py
F.....
======================================================================
FAIL: Newly created objects with the same attribute values
----------------------------------------------------------------------
Traceback (most recent call last):
  File "~/example_07/tests.py", line 15, in test_default_objects_are_always_equal
    self.assertEqual(self.sandwich_1, self.sandwich_2)
AssertionError: <sandwich.Sandwich object at 0x7f4603cece80> != <sandwich.Sandwich object at 0x7f4603ceceb8>

----------------------------------------------------------------------
Ran 6 tests in 0.001s

FAILED (failures=1)

備註

在撰寫 Cosmic Ray 時,如果在 baseline 測試執行時出現錯誤,並不會顯示失敗,而會將所有的突變回報為 殺死,這是因為測試套件就失敗了!回報在 CR#111 ,修復在 CR#181

Python: 測試一系列的 if == int

要完整的測試下面的 pattern

if X == int_1:
    pass
elif X == int_2:
    pass
elif X == int_3:
    pass

你需要測試所有的合法數值加上集合外的值。範例請參考:測試有關 if == int 的序列

Python: 測試一系列的 if == string

要完整的測試下面的 pattern

if X == "string_1":
    pass
elif X == "string_2":
    pass
elif X == "string_3":
    pass

你需要測試所有可能的字串數值,以及那些不在可能字串內的值。範例請參考:測試 if == string 序列

Python: 缺失或額外參數

根據你的方法 (method) 定義不同,其用來控制內部行為的參數可以不是必要或是忘記傳入。突變測試可以幫助你檢查這些狀況並且根據對應的情況調整程式碼。

範例請參考:遺失或額外的方法參數

Python: 測試 0 <= X <= 100

當要對數值範圍做測試時我們需要至少 4 個測試:

  • 測試兩個邊界數值

  • 測試邊界外的數值,理想上使用 +1 / -1

  • 測試範圍中的數值並不是完整突變測試需要涵蓋的範圍!

範例請參考:針對 0 <= X <= 100 的測試

Python: 布林表達式

當在處理重要的布林表達式時,突變測試通常可以幫你透撤整個狀況。他讓你需要重新思考整個表達式,通常這會讓你重構程式碼以及殺死突變。範例請參襖:測試與重構布林表達式

重構多重布林表達式

以下方的程式碼為例,表達式 and 左邊都是相同的

if name == "method":
    self._clear_seen()

if name == "method" and value == "cdrom":
    setattr(self.handler.cdrom, "seen", True)
elif name == "method" and value == "harddrive":
    setattr(self.handler.harddrive, "seen", True)
elif name == "method" and value == "nfs":
    setattr(self.handler.nfs, "seen", True)
elif name == "method" and value == "url":
    setattr(self.handler.url, "seen", True)

我們可以簡單的將表達式中 name == "method" 部份移除,移到上層,並將隨後的 if 語句置放於其下:

if name == "method":
    self._clear_seen()

    if value == "cdrom":
        setattr(self.handler.cdrom, "seen", True)
    elif value == "harddrive":
        setattr(self.handler.harddrive, "seen", True)
    elif value == "nfs":
        setattr(self.handler.nfs, "seen", True)
    elif value == "url":
        setattr(self.handler.url, "seen", True)

這樣重構可以讓減少程式碼行數,並且降低突變的次數來減少總體突變測試的時間。這段程式碼可以再以下面的方式進行更基進的重構:

if name == "method":
    self._clear_seen()

    if value in ["cdrom", "harddrive", "nfs", "url"]:
        setattr(getattr(self.handler, value), "seen", True)