Pythonでミューテーションテストを行うmutmutを動かしてみた

# はじめに
本記事では、Python におけるミューテーションテストを実行するライブラリ **mutmut** の使い方を紹介します。
ミューテーションテストに関する日本語の情報はまだ多くなく、書籍やブログの紹介内容が古いバージョンに基づいていることもあり、動かないケースがありました。そこで、**私自身の環境構築と実行経験**をもとに、設定方法や実行手順を整理しました。

サンプルのソースコードや Dockerfile は、以下の GitHub リポジトリにまとめています。よろしければご活用ください。

https://github.com/ymori1212/mutation

また、ミューテーションテストの基本的な解説は以下の記事で行っておりますので合わせてご覧になってください。

https://qiita.com/ymori1212/items/4077440b6eac882d0f7a

https://qiita.com/ymori1212/items/f806fe7e1bf76dcb89e1

# 動作環境
- **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``` のバージョンを明示的に指定すると、ビルド環境の差異によるトラブルを減らせます。**

:::note info 
mutmut は内部的にプロセスのフォーク（```fork()```）を多用するため、**Windows ネイティブ環境では設定がやや複雑**になります。
公式ドキュメント的には Linux/Unix 系OS 上での動作が推奨されており、**Windows で実行すると動かない・不安定となる場合があります**。
したがって本記事では、**Docker を用いて Linux 環境を構築**し、そこで ```mutmut``` を実行しています。。
:::
Docker Desktop の導入方法については、公式ドキュメントを参考にしてください。
加えて、Windows 11 上で Docker をスムーズに動かすためには **WSL (Windows Subsystem for Linux)** のインストールが必要です。Microsoft の公式ドキュメントを参照お願いします。

- [Docker公式ドキュメント](https://docs.docker.com/)
- [WSL のインストール方法 (Microsoft公式)](https://learn.microsoft.com/en-us/windows/wsl/install)

※ Docker の公式サイト右上にある “**Ask AI**” ボタンから起動するチャットボットに質問すると、ドキュメントの内容も踏まえた回答が得られるので便利です。



# mutmutとは
> **以下は、**[mutmut GitHub README](https://github.com/boxed/mutmut) **を元にした概要です。**

**mutmut** は Python コードに対して**ミューテーションテスト**を行うためのツールです。
ミューテーションテストとは、ソースコードに意図的な小さな変更（ミュータント）を加えてテストを実行し、テストがその変更を検知できるかを評価して**テストの充実度（抜け漏れの有無）を深く検証する**手段として知られています。

mutmut には以下の特徴があります:

- **簡単なミュータント適用**
コマンドひとつで生成されたミュータントをディスクに反映させ、変更結果をすぐ確認可能

- **インクリメンタルな実行**
前回の実行結果や手動による判定をキャッシュしており、途中で中断しても再開が容易

- **効率的なテスト選択**
最小限のテストだけを実行するよう自動的に判定する機能があり、実行時間を短縮
- **インタラクティブなTUI**
ターミナル上でミュータント一覧や再テストを直感的に操作できる
- **高速かつ並列実行**
大規模コードベースにも対応できるパフォーマンス設計

公式リポジトリは以下からご覧いただけます。

- [mutmut GitHub](https://github.com/boxed/mutmut)

# フォルダ構成
以下は、本記事のサンプルプロジェクトのディレクトリ構成です。
```
mutation/
├─src/
│ └─mutation/
│   ├─__init__.py
│   └─script.py
├─tests/
│   ├─__init__.py
│   └─test_script.py
├─docker-compose.yaml
├─Dockerfile
├─pyproject.toml
└─README.md
```
# テスト対象のコード
テスト対象のコードとなります


```python:src/mutation/script.py
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

```

# テストコード
上記の関数を検証するためのテストコードです。
```python:tests/test_script.py
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 を使って依存関係を管理するための設定ファイルです。
```toml:pyproject.toml
[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``` を使って依存関係を管理する構成です。

```dockerfile:Dockerfile
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/``` をコンテナにマウントする設定です。
```yaml:docker-compose.yaml
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/```）に移動します:
```powershell:powershell
cd C:\path\to\mutation
```
次に、Docker Compose を使ってコンテナをビルド＆起動します:
```powershell:powershell
docker compose up -d --build
```
初回ビルド時は、ベースイメージのダウンロード等が走るため数分かかる場合があります。成功すれば、```mutmut-container``` が起動した状態になります。

コンテナに入るには、以下のコマンドを実行します:
```powershell:powershell
docker compose exec mutmut bash
```
コンテナ内のプロンプトが ```root@...:/root/mutation-testing#``` のように表示されればOKです。

## 2.テストコードのカバレッジ計測
mutmut を実行する前に、まずは単純に pytest-cov を使ってカバレッジを測ってみます:

```terminal:bash
pytest --cov
```

実行結果の例:
```terminal
====================== 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.ミューテーションテストの実行
```terminal:bash
mutmut run
```
上記コマンドで、コードに意図的な変更（ミュータント）を加えつつテストが行われます。テストで検出されずに生き残ったミュータント（**サバイビングミュータント**）がある場合、それはテストが不十分な可能性を示唆します。

実行結果の例:
```terminal:bash
⠋ 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 — テストで何も出力されなかった場合など

# ミューテーションテストの結果を詳しく見てみる
```terminal:bash
mutmut browse
```
上記コマンドを実行すると TUI が起動し、具体的にどのような差分（diff）でミュータントが変更されたかと、各ミュータントが **killed** されたか **survived** したかを確認できます。
```diff_python
 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 歳）を検証するテストを追加すれば「```<``` が ```<=``` に変わる」などのミュータントを殺しやすくなります。例として、以下のようにテストコードを修正します。

```diff_python:refactoring_test_script.py
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``` を実行します。
```terminal:bash
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 がコードを正しく認識できず、結果がすべてスキップ扱いになることがある。

# 参考文献
- [Patrick Viafore 著. 「ロバストPython」, 鈴木 駿　監訳、長尾 高弘　訳, O'REILY Japan, 2023, 384p.](https://www.oreilly.co.jp/books/9784814400171/)
- [Michaｌ Jaworski, Tarek Ziade 著,「エキスパートPythonプログラミング 改訂4版」,稲田　直哉 訳, 渋川　よしき 訳, 清水川　貴之 訳, 福田　隼也 訳, KADOKAWA出版, 2023, 584p.](https://www.kadokawa.co.jp/product/302304004673/)
- [notrogue, Zenn, 「Python(mutmut)でミュテーションテスト(mutation testing)してみる」](https://zenn.dev/notrogue/articles/09dc9e0575a3ad)