Python(mutmut)でミュテーションテスト(mutation testing)してみる
ミュテーションテスト(mutation testing)とは
テストの品質を測る手法の一つです。
ユニットテストを作成したとして、それが意味のあるテストか確認することは(一般論として)難しいです。
一つの方法として、カバレッジもありますが、例えば、
- テスト対象のコードを実行
- 結果を捨てる
- 常に成功するアサーション(assert True)を実行
のような無意味なテストを作成しても、カバレッジ100%になりえます。
そこで登場するのが、ミュテーションテスト(mutation testing)です。ミュテーションテストでは、 テスト対象のコードを変異(ミュテーション) させ、
- いずれかのテストが失敗したら、そのミュテーションに対してはチェックできている
- 全てのテストが成功したら、テストが十分ではない
- (=本来とは違うコードなのにどのテストでも検知できなかった)
とみなします。これにより、言うなればテストに対するテストをを行うことができます。
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
どんなミュテーションがあるのか
例えば、
- 二項演算子(+とか%とか)
- キーワード(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を同じディレクトリに置いて
# venvの仮想環境ディレクトリを作る
python -m venv mutmut2
# venvのディレクトリがある状態でmutmut実行
mutmut run --paths-to-mutate=./
(一部省略)
2. Checking mutants
⠼ 562/204923 🎉 15 ⏰ 0 🤔 0 🙁 547 🔇 0
他のライブラリ
他にもあります。良さげなやつがあれば教えてください
Author And Source
この問題について(Python(mutmut)でミュテーションテスト(mutation testing)してみる), 我々は、より多くの情報をここで見つけました https://zenn.dev/notrogue/articles/09dc9e0575a3ad著者帰属:元の著者の情報は、元のURLに含まれています。著作権は原作者に属する。
Collection and Share based on the CC protocol