Python(mutmut)でミュテーションテスト(mutation testing)してみる


ミュテーションテスト(mutation testing)とは

テストの品質を測る手法の一つです。
ユニットテストを作成したとして、それが意味のあるテストか確認することは(一般論として)難しいです。

一つの方法として、カバレッジもありますが、例えば、

  1. テスト対象のコードを実行
  2. 結果を捨てる
  3. 常に成功するアサーション(assert True)を実行

のような無意味なテストを作成しても、カバレッジ100%になりえます。

そこで登場するのが、ミュテーションテスト(mutation testing)です。ミュテーションテストでは、 テスト対象のコードを変異(ミュテーション) させ、

  • いずれかのテストが失敗したら、そのミュテーションに対してはチェックできている
  • 全てのテストが成功したら、テストが十分ではない
    • (=本来とは違うコードなのにどのテストでも検知できなかった)

とみなします。これにより、言うなればテストに対するテストをを行うことができます。

mutmutとは

https://mutmut.readthedocs.io/en/latest/
https://github.com/boxed/mutmut

Pythonのミュテーションテストのツールです。テスト自体は別のツール(e.g. pytest)で実行しつつ、mutmutはテスト対象のコードを変更します(※)。

コード眺めた感じでは、parsoというライブラリでPythonコードをパース・変更しているようです

準備

インストール

pip install mutmut

サンプルコード

 tree
.
├── src
│   └── fizz_buzz.py
└── tests
    └── test_fizz_buzz.py

1 directory, 2 files

テスト対象のコードはみんなだいすきFizz Buzzで、

  • 3の倍数かつ5の倍数の時はfizz buzz
  • 3の倍数はfizz
  • 5の倍数はbuzz

という文字列を返します。

def fizz_buzz(n):
    if n % 15 == 0:
        return 'fizz buzz'

    if n % 3 == 0:
        return 'fizz'

    if n % 5 == 0:
        return 'buzz'

ユニットテストはこちらで、

  • 3の倍数の例として、3の時にfizz
  • 5の倍数の例として、5の時にはbuzz

を返すことは確認しますが、15の倍数の場合は、実質的にテストしていないです。

from fizz_buzz import fizz_buzz

def test_fizz():
    assert fizz_buzz(3) == 'fizz'

def test_buzz():
    assert fizz_buzz(5) == 'buzz'

def test_fizz_buzz():
    fizz_buzz(15)
    assert True

大分恣意的な例ですが、このテストでもpytest-covでのカバレッジは100%になります。

 PYTHONPATH="$PYTHONPATH:src" pytest --cov
=========================================================================================== test session starts ============================================================================================
platform darwin -- Python 3.9.9, pytest-7.1.1, pluggy-1.0.0
rootdir: /Users/m-kimura/projects/mutmut/fizz_buzz
plugins: cov-3.0.0
collected 3 items

tests/test_fizz_buzz.py ...                                                                                                                                                                          [100%]

---------- coverage: platform darwin, python 3.9.9-final-0 -----------
Name                      Stmts   Miss  Cover
---------------------------------------------
src/fizz_buzz.py              7      0   100%
tests/test_fizz_buzz.py       8      0   100%
---------------------------------------------
TOTAL                        15      0   100%

ミュテーションテストやってみる

ミュテーションの実行

PYTHONPATH="$PYTHONPATH:src" mutmut run --paths-to-mutate=src/

- Mutation testing starting -
These are the steps:
1. A full test suite run will be made to make sure we
   can run the tests successfully and we know how long
   it takes (to detect infinite loops for example)
2. Mutants will be generated and checked

Results are stored in .mutmut-cache.
Print found mutants with `mutmut results`.

Legend for output:
🎉 Killed mutants.   The goal is for everything to end up in this bucket.
⏰ Timeout.          Test suite took 10 times as long as the baseline so were killed.
🤔 Suspicious.       Tests took a long time, but not long enough to be fatal.
🙁 Survived.         This means your tests need to be expanded.
🔇 Skipped.          Skipped.

mutmut cache is out of date, clearing it...
1. Running tests without mutations
⠇ Running...Done
2. Checking mutants
⠹ 15/15  🎉 11 ⏰ 0  🤔 0  🙁 4  🔇 0

この結果は以下のように読みます。

  • 15のミュテーションが行われた
  • 11のミュテーション(🎉)はテストで検知(=テストが失敗)された
  • 4のミュテーション(🙁)はテストで検知できなかった(=テストが成功したまま)

つまり、🎉が多く 🙁 が少ないほど良いと思います。

具体的にミュテーションを見てみる

まずは失敗したミュテーションのidを確認します。1,2,4,5です(乱数やバージョン依存で番号変わるかも)。

mutmut results
To apply a mutant on disk:
    mutmut apply <id>

To show a mutant:
    mutmut show <id>


Survived 🙁 (4)

---- src/fizz_buzz.py (4) ----

1-2, 4-5

検知に失敗したミュテーションを具体的に見てみます(mutmut show allで一度に確認も可)。

1番。fizz buzzのチェックの演算子が余剰から除法に

mutmut show 1                                                   
--- src/fizz_buzz.py
+++ src/fizz_buzz.py
@@ -1,5 +1,5 @@
 def fizz_buzz(n):
-    if n % 15 == 0:
+    if n / 15 == 0:
         return 'fizz buzz'

     if n % 3 == 0:

2番。fizz buzzのチェックが15の倍数から16の倍数に

--- src/fizz_buzz.py
+++ src/fizz_buzz.py
@@ -1,5 +1,5 @@
 def fizz_buzz(n):
-    if n % 15 == 0:
+    if n % 16 == 0:
         return 'fizz buzz'

     if n % 3 == 0:

4番。15の倍数ではなく1あまりに。

--- src/fizz_buzz.py
+++ src/fizz_buzz.py
@@ -1,5 +1,5 @@
 def fizz_buzz(n):
-    if n % 15 == 0:
+    if n % 15 == 1:
         return 'fizz buzz'

     if n % 3 == 0:

5番。15の倍数の時の戻り値がfizz buzz以外に。

--- src/fizz_buzz.py
+++ src/fizz_buzz.py
@@ -1,6 +1,6 @@
 def fizz_buzz(n):
     if n % 15 == 0:
-        return 'fizz buzz'
+        return 'XXfizz buzzXX'

     if n % 3 == 0:
         return 'fizz'

テスト対象のコード(fizz_buzz.py)には15の倍数のチェック(fizz buzz)がありますが、ユニットテストでは確認していないため、それに関係した箇所のミュテーションを検知できていないわけです。

テスト修正する

15の倍数の時のテストを追加します。

def test_fizz_buzz():
   assert fizz_buzz(15) == 'fizz buzz'

今度は全てのミュテーションを検知できます。

PYTHONPATH="$PYTHONPATH:src" mutmut run --paths-to-mutate=src/
- Mutation testing starting -

These are the steps:
1. A full test suite run will be made to make sure we
   can run the tests successfully and we know how long
   it takes (to detect infinite loops for example)
2. Mutants will be generated and checked

Results are stored in .mutmut-cache.
Print found mutants with `mutmut results`.

Legend for output:
🎉 Killed mutants.   The goal is for everything to end up in this bucket.
⏰ Timeout.          Test suite took 10 times as long as the baseline so were killed.
🤔 Suspicious.       Tests took a long time, but not long enough to be fatal.
🙁 Survived.         This means your tests need to be expanded.
🔇 Skipped.          Skipped.

1. Using cached time for baseline tests, to run baseline again delete the cache file

2. Checking mutants
⠸ 15/15  🎉 15  ⏰ 0  🤔 0  🙁 0  🔇 0

どんなミュテーションがあるのか

May The Source Be With You

例えば、

  • 二項演算子(+とか%とか)
  • キーワード(isをis notに、TrueをFalse、breakをcontinueになど)
  • 文字列を変える('XX'を適当に追加)
  • andとorを入れ替える

などのミュテーションがあります。

ディレクトリ構成

上述の例では、

  • src/下にテスト・ミュテーション対象のコード
  • tests/下にユニットテストのコード
  • PYTHONPATHと--paths-to-mutateにsrc/を指定

としています。大分不器用な感じがするので、いい感じのディレクトリ構成、もしくは指定の方法があれば教えて欲しいです…

その他

  • mutmut runの結果は、.mutmut-cacheというファイルにキャッシュされます
    • コードの変更は無視され、テストの追加は追加したケースだけ実行されるようです
    • ファイルを消すか、idを指定して実行(e.g. mutmut run 2)するとキャッシュ無視できます
  • デフォルトではpaths-to-mutateに指定したディレクトリの全ての.pyファイルをミュテーション対象にします。venvやサードパーティのライブラリもミュテーション対象としないように注意した方が良さそうです
    • venvを同じディレクトリに置いてmutmut runした時の例を下に示します。20万ミュテーションされてます。めっちゃ時間かかります
    • pre_mutationというフックで対応できそう
# venvの仮想環境ディレクトリを作る
python -m venv mutmut2
# venvのディレクトリがある状態でmutmut実行
mutmut run --paths-to-mutate=./
(一部省略)
2. Checking mutants
⠼ 562/204923  🎉 15  ⏰ 0  🤔 0  🙁 547  🔇 0

他のライブラリ

他にもあります。良さげなやつがあれば教えてください