はじめに
本記事では、Python におけるミューテーションテストを実行するライブラリ mutmut の使い方を紹介します。
ミューテーションテストに関する日本語の情報はまだ多くなく、書籍やブログの紹介内容が古いバージョンに基づいていることもあり、動かないケースがありました。そこで、私自身の環境構築と実行経験をもとに、設定方法や実行手順を整理しました。
サンプルのソースコードや Dockerfile は、以下の GitHub リポジトリにまとめています。よろしければご活用ください。
また、ミューテーションテストの基本的な解説は以下の記事で行っておりますので合わせてご覧になってください。
動作環境
- OS:Windows11
- Python:3.12.9
-
pytest-cov:6.0.0
- pytest:8.3.4
- coverage:7.6.12
- mutmut:3.2.3
- DockerDesktop:v4.37.1
補足
本記事では一例として上記のバージョンを記載していますが、実際にはpyproject.toml
で厳密に固定していない部分もあるため、環境によって多少前後する可能性があります。必要に応じてcoverage
やpytest
のバージョンを明示的に指定すると、ビルド環境の差異によるトラブルを減らせます。
ミューテーションテストの概要については以下の記事で解説しておりますので合わせてご覧になってください。
mutmut は内部的にプロセスのフォーク(fork()
)を多用するため、Windows ネイティブ環境では設定がやや複雑になります。
公式ドキュメント的には Linux/Unix 系OS 上での動作が推奨されており、Windows で実行すると動かない・不安定となる場合があります。
したがって本記事では、Docker を用いて Linux 環境を構築し、そこで mutmut
を実行しています。。
Docker Desktop の導入方法については、公式ドキュメントを参考にしてください。
加えて、Windows 11 上で Docker をスムーズに動かすためには WSL (Windows Subsystem for Linux) のインストールが必要です。Microsoft の公式ドキュメントを参照お願いします。
※ Docker の公式サイト右上にある “Ask AI” ボタンから起動するチャットボットに質問すると、ドキュメントの内容も踏まえた回答が得られるので便利です。
mutmutとは
以下は、mutmut GitHub README を元にした概要です。
mutmut は Python コードに対してミューテーションテストを行うためのツールです。
ミューテーションテストとは、ソースコードに意図的な小さな変更(ミュータント)を加えてテストを実行し、テストがその変更を検知できるかを評価してテストの充実度(抜け漏れの有無)を深く検証する手段として知られています。
mutmut には以下の特徴があります:
-
簡単なミュータント適用
コマンドひとつで生成されたミュータントをディスクに反映させ、変更結果をすぐ確認可能 -
インクリメンタルな実行
前回の実行結果や手動による判定をキャッシュしており、途中で中断しても再開が容易 -
効率的なテスト選択
最小限のテストだけを実行するよう自動的に判定する機能があり、実行時間を短縮 -
インタラクティブなTUI
ターミナル上でミュータント一覧や再テストを直感的に操作できる -
高速かつ並列実行
大規模コードベースにも対応できるパフォーマンス設計
公式リポジトリは以下からご覧いただけます。
フォルダ構成
以下は、本記事のサンプルプロジェクトのディレクトリ構成です。
mutation/
├─src/
│ └─mutation/
│ ├─__init__.py
│ └─script.py
├─tests/
│ ├─__init__.py
│ └─test_script.py
├─docker-compose.yaml
├─Dockerfile
├─pyproject.toml
└─README.md
テスト対象のコード
テスト対象のコードとなります
def calculate_membership_fee(age: int, is_student: bool) -> float:
"""
会員料金を年齢と学生判定に基づいて計算する関数です。
- 18歳未満: 基本料金は 5.0
- 18歳以上 65歳未満: 基本料金は 20.0
- 65歳以上: 基本料金は 15.0
- 学生の場合は基本料金に 20% の割引を適用
"""
if age < 18:
fee = 5.0
elif age < 65:
fee = 20.0
else:
fee = 15.0
if is_student:
fee *= 0.8 # 学生割引を適応.
return fee
テストコード
上記の関数を検証するためのテストコードです。
from mutation.script import calculate_membership_fee
def test_minor_non_student():
# 18歳未満かつ非学生の場合、正確な料金をチェック
assert calculate_membership_fee(16, False) == 5.0
def test_minor_student():
# 18歳未満かつ学生の場合、割引後の料金をチェック
assert calculate_membership_fee(16, True) == 4.0 # 5.0 * 0.8
def test_adult_non_student():
# 18歳以上 65歳未満かつ非学生の場合
assert calculate_membership_fee(30, False) == 20.0
def test_adult_student():
# 18歳以上 65歳未満かつ学生の場合
fee = calculate_membership_fee(30, True)
# ここでは、基本料金より割引後の料金が下がっていることだけを確認しており、具体的な数値はチェックしていない
assert fee < 20.0
def test_senior_non_student():
# 65歳以上かつ非学生の場合
assert calculate_membership_fee(70, False) == 15.0
def test_senior_student():
# 65歳以上かつ学生の場合
fee = calculate_membership_fee(70, True)
# 基本料金が割引適用後に減っているかだけのチェック(具体的な割引率は検証していない)
assert fee < 15.0
pyproject.toml
Poetry を使って依存関係を管理するための設定ファイルです。
[tool.poetry]
name = "mutation"
version = "0.1.0"
description = "Mutation test sample code."
authors = ["Your Name <your.email@example.com>"]
readme = "README.md"
packages = [{include = "mutation", from = "src"}]
[tool.poetry.dependencies]
python = "^3.12"
mutmut = "3.2.3"
pytest-cov = "6.0.0"
[tool.pytest.ini_options]
pythonpath = "src"
testpaths = ["tests",]
[tool.mutmut]
paths_to_mutate = ["src/"]
tests_dir = ["tests/"]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
-
[tool.mutmut]
セクションでpaths_to_mutate
とtests_dir
を指定すると、mutmut
がどのフォルダを対象にすべきか明示できます。 - pytest の設定は
[tool.pytest.ini_options]
で行い、テスト対象をtests/
ディレクトリに限定しています。
Docker関連
Dockerfile
以下の Dockerfile は、コンテナ内で Python と Poetry をインストールし、pyproject.toml
を使って依存関係を管理する構成です。
FROM python:3.12.9-bookworm
# 必要なパッケージのインストール
RUN apt-get update && \
apt-get -y install locales && \
localedef -f UTF-8 -i ja_JP ja_JP.UTF-8 && \
rm -rf /var/lib/apt/lists/*
# 環境変数の設定
ENV LANG=ja_JP.UTF-8 \
LANGUAGE=ja_JP:ja \
LC_ALL=ja_JP.UTF-8 \
TZ=Asia/Tokyo
RUN pip install --upgrade pip
# Poetryのパスの設定
ENV PATH /root/.local/bin:$PATH
# Poetryをインストール. バージョンは1.8.5を指定.
RUN curl -sSL https://install.python-poetry.org | python - --version 1.8.5
# Poetryが仮想環境を生成しないようにする
RUN poetry config virtualenvs.create false
# `pyproject.toml` と `poetry.lock` をコピー
COPY pyproject.toml poetry.lock* ./
# 依存関係をインストール
RUN poetry install --no-root
# PYTHONPATH 環境変数にプロジェクトの src ディレクトリを追加する
ENV PYTHONPATH="${PYTHONPATH}:/root/mutation-testing/src"
- 現在(2025年2月時点)での Poetry の最新メジャーバージョンは 2.x系列 ですが、自分の中でメジャーバージョン更新分の情報がまだ十分にまとまっていないため、本記事ではバージョン 1.8.5 を採用しています。
docker-compose.yaml
docker-compose.yaml
を使って、上記 Dockerfile からビルドしたイメージを起動し、ホスト側の src/
と tests/
をコンテナにマウントする設定です。
services:
mutmut:
restart: no
build: .
container_name: 'mutmut-container'
working_dir: '/root/mutation-testing'
tty: true
volumes:
- ./src:/root/mutation-testing/src
- ./tests:/root/mutation-testing/tests
-
volumes:
でホスト側のsrc/
とtests/
ディレクトリをコンテナにマウントすることで、ローカルで編集したソースコードがすぐコンテナに反映されます。 -
working_dir: '/root/mutation-testing'
を指定しておくと、コンテナ起動後のデフォルト作業ディレクトリが/root/mutation-testing
になります。
実行手順
1.Dockerコンテナの起動
まず、プロジェクトのルートディレクトリ(例: mutation/
)に移動します:
cd C:\path\to\mutation
次に、Docker Compose を使ってコンテナをビルド&起動します:
docker compose up -d --build
初回ビルド時は、ベースイメージのダウンロード等が走るため数分かかる場合があります。成功すれば、mutmut-container
が起動した状態になります。
コンテナに入るには、以下のコマンドを実行します:
docker compose exec mutmut bash
コンテナ内のプロンプトが root@...:/root/mutation-testing#
のように表示されればOKです。
2.テストコードのカバレッジ計測
mutmut を実行する前に、まずは単純に pytest-cov を使ってカバレッジを測ってみます:
pytest --cov
実行結果の例:
====================== test session starts ======================
platform linux -- Python 3.12.9, pytest-8.3.4, pluggy-1.5.0
rootdir: /
configfile: pyproject.toml
plugins: cov-6.0.0
collected 6 items
tests/test_script.py ...... [100%]
---------- coverage: platform linux, python 3.12.9-final-0 -----------
Name Stmts Miss Cover
----------------------------------------------
src/mutation/__init__.py 0 0 100%
src/mutation/script.py 9 0 100%
tests/__init__.py 0 0 100%
tests/test_script.py 15 0 100%
----------------------------------------------
TOTAL 24 0 100%
====================== 6 passed in 0.16s ======================
上記のように 100% と表示されると、一見十分なテストに思えます。しかし、ミューテーションテストを実行するとさらに「条件分岐や境界値テストが不十分」などの可能性が見つかります。
3.ミューテーションテストの実行
mutmut run
上記コマンドで、コードに意図的な変更(ミュータント)を加えつつテストが行われます。テストで検出されずに生き残ったミュータント(サバイビングミュータント)がある場合、それはテストが不十分な可能性を示唆します。
実行結果の例:
⠋ Generating mutants
done in 64ms
⠙ Running stats
done
⠹ Running clean tests
done
⠴ Running forced fail test
done
Running mutation testing
⠏ 13/13 🎉 9 🫥 0 ⏰ 0 🤔 0 🙁 4 🔇 0
104.15 mutations/second
ここでは13個のミュータントを生成&テストし、9個は殺せましたが、4個が検出できずに生き残ったことがわかります。
途中で出てくる絵文字は mutmut の TUI でのステータス表示です。主な意味は以下の通りです:
-
🎉
: killed — テストでミュータントを検出して殺せた -
🫥
: skipped/unknown — スキップ、もしくは結果が不明(equivalent mutant
の可能性も) -
⏰
: timeout — テストがタイムアウトした -
🤔
: uncertain — 判定が曖昧で不明なままのケース -
🙁
: survived — テストで検出できず、生き残ったミュータント -
🔇
: no output — テストで何も出力されなかった場合など
ミューテーションテストの結果を詳しく見てみる
mutmut browse
上記コマンドを実行すると TUI が起動し、具体的にどのような差分(diff)でミュータントが変更されたかと、各ミュータントが killed されたか survived したかを確認できます。
Path 🙁 🫥 ⏰ 🤔 🔇 🛑 ? 🎉 name status
src/mutation/__init__.py 0 0 0 0 0 0 0 0 mutation.script.x_calculate_membership_fee__mutmut_1 🙁
src/mutation/script.py 4 0 0 0 0 0 0 9 mutation.script.x_calculate_membership_fee__mutmut_2 🙁
mutation.script.x_calculate_membership_fee__mutmut_5 🙁
mutation.script.x_calculate_membership_fee__mutmut_6 🙁
--- src/mutation/script.py
+++ src/mutation/script.py
@@ -7,7 +7,7 @@
- 65歳以上: 基本料金は 15.0
- 学生の場合は基本料金に 20% の割引を適用
"""
- if age < 18:
+ if age <= 18:
fee = 5.0
elif age < 65:
fee = 20.0
例として if age < 18:
が if age <= 18:
に変わっても、テストコードで「18 歳ちょうど」のケースを検証していなければ殺せません。これはテスト側が境界値テストをしていないことを示唆します。
テストの修正
境界値(18 歳、65 歳)を検証するテストを追加すれば「<
が <=
に変わる」などのミュータントを殺しやすくなります。例として、以下のようにテストコードを修正します。
from mutation.script import calculate_membership_fee
def test_minor_non_student():
# 18歳未満かつ非学生の場合
assert calculate_membership_fee(16, False) == 5.0
def test_minor_student():
# 18歳未満かつ学生の場合
assert calculate_membership_fee(16, True) == 4.0
def test_adult_non_student():
# 18歳以上 65歳未満かつ非学生の場合
assert calculate_membership_fee(30, False) == 20.0
def test_adult_student():
# 18歳以上 65歳未満かつ学生の場合
fee = calculate_membership_fee(30, True)
assert fee < 20.0
def test_senior_non_student():
# 65歳以上かつ非学生の場合
assert calculate_membership_fee(70, False) == 15.0
def test_senior_student():
# 65歳以上かつ学生の場合
fee = calculate_membership_fee(70, True)
assert fee < 15.0
+ def test_edge_age_18():
+ # ちょうど18歳の場合は「成人(adult)」のはず
+ assert calculate_membership_fee(18, False) == 20.0
+ def test_edge_age_65():
+ # ちょうど65歳の場合は「シニア(senior)」のはず
+ assert calculate_membership_fee(65, False) == 15.0
修正したら再度 mutmut run
を実行します。
mutmut run
以下のように、すべてのミュータントが殺された(🙁 0
)場合は修正成功です。
Running mutation testing
⠏ 13/13 🎉 13 🫥 0 ⏰ 0 🤔 0 🙁 0 🔇 0
94.88 mutations/second
まとめ
- テストカバレッジ 100% でも、条件分岐や境界値をしっかり検証していないとミュータントが生き残るケースがあることを確認しました。
- ミューテーションテストは、テストの抜け漏れ(特に境界値) を発見しやすい手法として有効です。
- 本記事のように Docker 環境で実行すると、Windows 上でも比較的スムーズに mutmut を扱えます。
本記事の内容が、ミューテーションテストに興味を持ち始めた方、あるいは既存のテストをより堅牢にしたい方の参考になれば幸いです。
参考:自分がはまった部分
-
Windows ネイティブで
mutmut
を動かそうとしてエラー発生- フォーク問題による制約があり、うまく動かない例が多い。Docker や WSL2 を利用したほうが安定。
-
Docker を使う場合のパスや仮想環境
-
PYTHONPATH
やworking_dir
を適切に設定しないとテストが通らない。pytest がどのフォルダをインポートするか確認しておく。
-
-
import のパスが合わずにスキップ扱い(
🫥
)になる- 例: テストコードが
from src.mutation.script import ~
という形をとりつつ、[tool.mutmut]
でpaths_to_mutate=["src/"]
と指定していないなど、設定の整合性がとれていないケース。
mutmut と coverage がコードを正しく認識できず、結果がすべてスキップ扱いになることがある。
- 例: テストコードが