ミューテーションテストという計測手法をご存知でしょうか。

今回はミューテーションテストの紹介と、Rails プロジェクトへのミューテーションテストの導入方法を説明します。


ミューテーション テストとは

ミューテーションテストとは、テストスイートがどれだけの確度でコードをテストできているかを計測する手法になります。 おおよそ次のようなステップで行われます。


  1. テスト対象コードを解析
  2. テスト対象コードの一部からミュータント(典型的なプログラミングのミス)を生成
  3. テスト対象コードの一部をミュータントを入れ替えながらテストスイートを実行
  4. テストスイートが異常を検知したかを確認

ミュータントは、true/false を入れ替えたり演算子を入れ替えたり、よくあるプログラミングのミス集から生成されます。
コードの一部をミュータントに入れ替えた時にテストが失敗すれば入れ替え元のコードはテストされている事になり、逆にテストが成功してしまった場合はテストが十分に働いていないという事を示します。

ミュータントを検知する事を「 kill する」といい、kill 出来たミュータントが多ければテストスイートに投入しているデータやテストケースが十分であるという根拠になります。

テストスイートの計測手法として他にも C0 や C1 などのカバレッジを計測する手法がよく知られていますが、 それらとは少し違った結果になるかと思うので既にカバレッジを計測しているプロジェクトで計測してみるのも面白いかと思います。


導入方法

Rails プロジェクトでミューテーションテストを行うには mutant-rspec という gem を使います。 https://github.com/mbj/mutant

導入方法は通常の gem と同じです。

Gemfile に gem 'mutant-rspec' と記載して bundle install します。
または、直接 gem install mutant-rspec コマンドでインストールします。


実行方法

実行は

bundle exec mutant -r ./config/environment --use rspec <対象クラス名>

です。

試しに動かしてみます。 検証に使用した環境は Rails 4.2.3、Ruby2.1 です。

サンプルとして下記のようなモデルと spec を作成しました。

class User < ActiveRecord::Base
  def full_name
    return nil unless first_name && last_name
    "#{first_name} #{last_name}"
  end
end
require 'rails_helper'

RSpec.describe User, type: :model do
  let(:params) { { first_name: 'Taro', last_name: 'Yamada' } }

  describe '#full_name' do
    subject { User.new(params).full_name }

    context 'input first_name, last_name' do
      it 'return full_name' do
        is_expected.to eq 'Taro Yamada'
      end
    end

    context 'not input first_name' do
      let(:params) { { last_name: 'Yamada' } }

      it 'return nil' do
        is_expected.to eq nil
      end
    end
  end
end

シンプルなコードですが、一応この時点で C0 は 100 % です。 次に先ほどのコマンドで mutant を実行してみます。

bundle exec mutant -r ./config/environment --use rspec User
...略
evil:User#full_name:/myapp/app/models/user.rb:7:5c600
@@ -1,7 +1,7 @@
 def full_name
-  unless (first_name && last_name)
+  unless first_name
     return nil
   end
   "#{first_name} #{last_name}"
 end
-----------------------
evil:User#full_name:/myapp/app/models/user.rb:7:aea1c
@@ -1,7 +1,7 @@
 def full_name
-  unless (first_name && last_name)
+  unless (first_name && self)
     return nil
   end
   "#{first_name} #{last_name}"
 end
-----------------------
Mutant configuration:
Matcher:         #
Integration:     Mutant::Integration::Rspec
Expect Coverage: 100.00%
Jobs:            4
Includes:        []
Requires:        ["./config/environment"]
Subjects:        1
Mutations:       31
Kills:           29
Alive:           2
Runtime:         3.88s
Killtime:        11.93s
Overhead:        -67.50%
Coverage:        93.55%
Expected:        100.00%

コマンドを打ち込むと次々にテストが実行されていきます。出力は長いので省略しますが、最終出力は上記のようになります。
31 個のミュータントが生まれ、29 が Kill されたので、カバレッジは 93.55 % となっています。
Kill できなかった mutant も上の方に出力されます。

今回は lastname のテストデータの投入が不足している為に、 lastname を無視したり入れ替えたりした mutant が kill できなかったのでカバレッジが下がりました。
きちんと last_name をテストするように rspec を追加した所、無事カバレッジを 100 % にする事が出来ました。

context 'not input last_name' do
  let(:params) { { first_name: 'Taro' } }

  it 'return nil' do
    is_expected.to eq nil
  end
end
Mutant configuration:
Matcher:         #
Integration:     Mutant::Integration::Rspec
Expect Coverage: 100.00%
Jobs:            4
Includes:        []
Requires:        ["./config/environment"]
Subjects:        1
Mutations:       31
Kills:           31
Alive:           0
Runtime:         4.12s
Killtime:        12.33s
Overhead:        -66.60%
Coverage:        100.00%
Expected:        100.00%

注意点

最後にミューテーションテストにおける注意になります。

等価ミュータント

ミュータントを生成する時、「等価ミュータント」と呼ばれるテスト対象コードと同じ結果になってしまうミュータントを生成してしまう事があります。

例えば下記のようなテスト対象コードに対して

v = 4
if v / 4 == 0
  puts 'true'
end

下記のような等価ミュータントが生まれます。

-  if ((v % 4) == 0)
+  if ((v % (-4)) == 0)
     "true"
   end

こういったミュータントは kill する事ができないので、人が判断して無視するしかないようです。

日本語の Rspec コード

mutant を実行した時に下記のような出力になり、テストは実行されても結果が出力されない場合があります。
自分の検証した所だと、rspec の descrive や context、it に日本語が含まれている場合にこのようなエラーになってしまうようです。

--- Neutral failure ---
Original code was inserted unmutated. And the test did NOT PASS.
Your tests do not pass initially or you found a bug in mutant / unparser.
Subject AST:
(def :full_name
  (args)
  (begin
    (if
      (and
        (send nil :first_name)
        (send nil :last_name)) nil
      (return
        (nil)))
    (dstr
      (begin
        (send nil :first_name))
      (str " ")
      (begin
        (send nil :last_name)))))
Unparsed Source:
def full_name
  unless (first_name && last_name)
    return nil
  end
  "#{first_name} #{last_name}"
end
Test Result:
- 2 @ runtime: 0.530121051
  - rspec:1:./spec/models/user_spec.rb:10/User#full_name input first_name, last_name return full_name
  - rspec:2:./spec/models/user_spec.rb:18/User#full_name not input first_name return nilあ
Test Output:
marshal data too short