blog

ソフトウェア開発|Python変異テスト入門

未知のバグを修正するための変異テスト...

Oct 12, 2025 · 6 min. read
シェア

突然変異テストによる未知のバグの修正。

もしかしたら、プロジェクトリポジトリにテストカバレッジ100%というバッジを貼っているかもしれません。どうしてわかるのですか?

開発者はユニットテストについてよく知っています。テストは書かなければなりません。時には期待通りに動かないこともあります。テストが成功することもあれば、コードを変更せずに失敗することもあります。ユニットテストを通して発見された小さな問題は貴重ですが、たいていは開発者のマシンに黙って現れ、バージョン管理にコミットされる前に修正されます。しかし、本当に心配な問題のほとんどは目に見えません。最悪なのは、完全に目に見えないことです。バグがユーザーの手に渡るまで、バグが発見されないことはありません。

見えないエラーを見えるようにするテストがあります。

変異テストは、アルゴリズムによってソースコードを変更し、各テストで「変異体」が生き残るかどうかをチェックします。単体テストで生き残った変異型はすべて問題です。それは、コードへの変更が標準のテストスイートで検出されなかったことを意味します。

Python 突然変異をテストするためのフレームワークの1つに mutmutあります。

例えば、時計の時針と分針の間の角度を度単位で計算するコードを書く必要があるとします:

  1. def hours_hand(hour, minutes):
  2.     base = (hour % 12 ) * (360 // 12)
  3.     correction = int((minutes / 60) * (360 // 12))
  4.     return base + correction
  5. def minutes_hand(hour, minutes):
  6.     return minutes * (360 // 60)
  7. def between(hour, minutes):
  8.     return abs(hours_hand(hour, minutes) - minutes_hand(hour, minutes))

まず、簡単なユニットテストを書きます:

  1. import angle
  2. def test_twelve():
  3.     assert angle.between(12, 00) == 0

これで十分ですか?このコードにはif文がないので、カバレッジを見ると

  1. $ coverage run `which pytest`
  2. ============================= test session starts ==============================
  3. platform linux -- Python 3.8.3, pytest-5.4.3, py-1.8.2, pluggy-0.13.1
  4. rootdir: /home/moshez/src/mut-mut-test
  5. collected 1 item                                                              
  6. tests/test_angle.py .                                                    [100%]
  7. ============================== 1 passed in 0.01s ===============================

完璧です!テストは100%のカバレッジでパスし、あなたは本当にテストのエキスパートになりました。しかし、突然変異テストを使うと、カバレッジはどうなるでしょうか?

  1. $ mutmut run --paths-to-mutate angle.py
  2. <snip>
  3. Legend for output:
  4. ? Killed mutants. The goal is for everything to end up in this bucket.
  5. Timeout. Test suite took 10 times as long as the baseline so were killed.
  6. ? Suspicious. Tests took a long time, but not long enough to be fatal.
  7. ? Survived. This means your tests needs to be expanded.
  8. ? Skipped. Skipped.
  9. <snip>
  10. ⠋ 0? 0 ? 16 ? 0

なんと、21人の変異体のうち生き残ったのは16人。突然変異検査に合格したのは5人だけでしたが、どういうことですか?

突然変異のテストごとに、 mutmut は潜在的なエラーをシミュレートするためにソースコードの一部を修正します。この境界条件に対する単体テストがない場合、突然変異は "生きる "ことになります。

より良いユニットテストを書く時間です。 results 使った変更をチェックするのは簡単です:

  1. $ mutmut results
  2. <snip>
  3. Survived ? (16)
  4. ---- angle.py (16) ----
  5. 4-7, 9-
  6. $ mutmut apply 4
  7. $ git diff
  8. diff --git a/angle.py b/angle.py
  9. index b5dca41..644
  10. --- a/angle.py
  11. +++ b/angle.py
  12. @@ -1,6 +1,6 @@
  13. def hours_hand(hour, minutes):
  14. hour = hour % 12
  15. - base = hour * (360 // 12)
  16. + base = hour / (360 // 12)
  17.     correction = int((minutes / 60) * (360 // 12))
  18.     return base + correction

これは mutmut がソースコードを解析し、演算子を異なるものに変更する突然変異を起こした典型的な例です。この場合は乗算から除算です。一般に、ユニットテストは演算子が変更されたときのエラーを検出する必要があります。そうでないと、効果的に動作をテストすることができません。このロジックに従って mutmut はテストをダブルチェックするためにソースコードを走査します。

def minutes_hand(hour, minutes): 使えば、失敗したミュータントを適用することができます。hour 引数が正しく使用されていることをほとんどチェックしていなかったことが判明しました。修正してください:

  1. $ git diff
  2. diff --git a/tests/test_angle.py b/tests/test_angle.py
  3. index f51d43a..1a2e4df
  4. --- a/tests/test_angle.py
  5. +++ b/tests/test_angle.py
  6. @@ -2,3 +2,6 @@ import angle
  7. def test_twelve():
  8.     assert angle.between(12, 00) == 0
  9. +def test_three():
  10. + assert angle.between(3, 00) == 90

以前は12時方向のテストだけでしたが、今は3時方向のテストを加えるだけで十分ですか?

  1. $ mutmut run --paths-to-mutate angle.py
  2. <snip>
  3. ⠋ 0? 0 ? 14 ? 0

この新しいテストでは、以前よりもうまく2匹のミュータントを殺すことができましたが、もちろんまだ先は長いです。残りの14のテストケースについては、パターンがはっきりしていると思うので、ひとつひとつ取り組むつもりはありません。

変異テストは、カバレッジと同様、テストスイートの網羅性を確認するためのツールです。これを使うことで、テストケースを改善する必要があります。生き残っている変異体は、人間がコードを改ざんするときに犯す可能性のあるミスであり、プログラムに潜む隠れたバグでもあります。テストを続け、バグ探しを楽しんでください。

Read next