(define -ayalog '())

括弧に魅せられて道を外した名前のないプログラマ

Mutation Testing with Ruby

最近ちょっと興味をもったテスト手法(技法?)があって、ちょこっとだけ調べたので書いてみます。

まえがき

テストを書いていて何を指標にテスト書いたらいいか分からない、ということがありませんか?
うん?コードカバレッジ 100% を目指してテストを書く?ええ、それは確かにひとつの指標としてはありかもしれせん。本当に動作するのか/しないのか、そのコードが生きているのか/死んでいるのか、それを確かめるひとつの手段にはなりえると思います。あるひとつの指標としてコードカバレッジ 100% を目指すことは決して間違いではないと思います。
ですが現実問題として "テストが意味を成していない*1" けど "コードカバレッジを満たしてしまう" ケースが同様に存在することも多いと思います*2。コードカバレッジ 100% がイコール「良いテスト」であるとは限りません。そのような場合にどう僕らは対処すべきか、そのひとつの答えが Mutation Testing にあると僕は思っています。

Mutation Testing

Mutation Testing とは簡潔に言えば「テストのテスト」です。 Mutation Testing は小さくプログラムを改変し、それを Mutation Testing の文脈において Mutant と呼びます。 Mutant とオリジナルのプログラムの振る舞いが違うことを確認できるものを集め*3、それらを kill できた Mutant と呼ぶことが出来ます。全ての Mutant のうちどれだけの Mutant を kill 出来たかをパーセンテージで示し、それによってテストコードの良し悪しを測ることが出来るという話。

Mutant をどのように生成するのかというと Mutation Operators というよくある典型的なプログラミングミス集的なものを定義しておき、それを元にプログラムの一部を自動的に変更するのですね。例えば以下のような例。

def foo(args=true)
  args || fail
end

このとき foo メソッドの || を && などに変更したり、デフォルト引数の true に 0 や nil に変更したりします。あるあるなプログラミングミスですよね。こういうように一部だけを変更して Mutant を生成します。ちなみにこの例だと、もし foo を実行したときに true になることというテストしか書いてない場合、 || fail の部分を取り除いても振る舞いが変わらないのでテストを相変わらずパスしてしまい Mutant を kill できてないと見做すことが出来ます。

まぁ説明だけしても理解し難いので、実際にコードで示してみましょう。

Mutation Testing on Ruby

Ruby で Mutation Testing をするには mutant という gem を使います。

mbj/mutant · GitHub

簡単な例を書きましょう。

module MyCalendar
  class Year
    def initialize(src=2015)
      @src = src
    end

    def olympic_year?
      return false if @src < 1896
      (@src % 4).zero?
    end

    def leap_year?
      return false unless (@src % 4).zero?
      return true unless (@src % 100).zero?
      (@src % 400).zero?
    end
  end
end

シンプルな「年」のクラスです。オリンピックの年か判定するメソッド閏年かを判定するメソッドがあるだけです。これに対してテストを書いてみましょう。

require('rspec')
require_relative('../lib/my_calendar')

RSpec.describe MyCalendar::Year do
  describe '#olympic_year?' do
    subject { year.olympic_year? }

    describe '1911 is not olympic year' do
      let(:year) { MyCalendar::Year.new(1911) }
      it { expect(subject).to eq false }
    end
  end

  describe '#leap_year?' do
    subject { year.leap_year? }

    describe '2000 is leap year' do
      let(:year) { MyCalendar::Year.new(2000) }
      it { expect(subject).to eq true }
    end
  end
end

このテストはどう考えてもテストケースが不十分ですが、コードカバレッジ目線で見てみるとどうでしょう。試しに SimpleCov を使ってカバレッジを計測してみます。
f:id:ayato0211:20150202083829p:plain

コードカバレッジ 100% です。
ではこの後、どういうテストを足せばこのテストはより良くなるのでしょう*4。単純に見ると false/true になる両方のケースを書いてないのは一目瞭然です。それから境界値などの判定もないですね。

まぁ言ってても仕方ないので mutant を使ってみます。まずは Gemfile に以下の記述を書き足します。

gem 'mutant'
gem 'mutant-rspec'

今回は RSpec を使用しているので mutant-rspec も一緒に記述しています。
そして実行。

bundle exec mutant -I lib -r my_calendar --use rspec MyCalendar::Year

結果は次のように出力されます。
f:id:ayato0211:20150202085824p:plain
生成された Mutant の数は 137 、うち 79 を kill することに成功したけど生き残った Mutant の数は 58 なので得ることが出来た Mutation-Coverage は 57.66% 。当然ですが、だいぶ低いスコアですね。
結果の上の方に diff のようなものが見えますが、これが Mutant を生成したときに変更した一部分ということになります。つまりどういうことかというと、この diff の変更を行ったとしてもテストはコケないけど良いの?ということですね。

この Mutation-Coverage を上げるのはこれを読んでる人への宿題にしたい感じがあるのですが、一点これまでに書いてなかったことがあるので注意書きしておきます。

evil:MyCalendar::Year#olympic_year?:/home/ayato/programming/ruby/mutant_test/lib/my_calendar.rb:7:ea54f
@@ -1,7 +1,7 @@
 def olympic_year?
   if (@src < 1896)
     return false
   end
-  (@src % 4).zero?
+  (@src % (-4)).zero?
 end

この Mutant は決して kill することができません(いや、分からないけど少なくとも僕には思い浮かばない)。「 4 で割り切れたら、夏季オリンピック開催年」という判定の仕方をしているわけなんですが、 -4 で割っても同様に割り切れてしまうのでこれはどうしようもないです。これは現状の Mutation Testing の既知の問題としてあり、実用するのが難しいとされている原因のひとつで Equivalent Mutant 問題と言われています。つまり、プログラムを改変したとしても出力が変わり得ないケースということですね。ここの出力が変わり得ないケースというのをどのように判断するか、判断することが機械的にもっとしっかり出来ればよりメジャーなテストを支える技術として地位を築けるのだろうなと期待しているところです。

ちなみに僕はこのテストコードの Mutation-Coverage を最終的に 94.48% まで上げることが出来ました。いっそプログラムを書き換えたほうがすっきりする可能性もいくらかありますが、とりあえずここでの説明はここまででいいでしょう。

まとめ

Mutation Testing 素晴らしいよ、という話を書きました。しかし、この手法は幾つかの問題を抱えています。ひとつは既に記述したように Equivalent Mutant 問題、他には実行に時間が多少かかる面などがあります。それを瑣末だと捉えることも出来ますし、使うシーンを限定すれば多大なる恩恵を受けることが出来ると思います。勿論 CI で実行するなども効果があるように思います。必ずしも 100% に出来ない気がするので、この辺はある程度指標的に使うと良いと思います。

またこれは自動的にテストをテストしてくれるので、テストについて造詣が必ずしも深い必要はありません(勿論深い方がいいですが)。なので、テストに対して理解が浅くても手軽に利用して、自分のテストを改善していくことが可能です。

この話を先日の勉強会の際にしたのですが、 Clojure などの Lisp 族ではやりにくいかもねという話になりました。理由としてはマクロなんですが、マクロを展開しきらないと + が + なのかとかが分からないので Mutation Operators が役に立たないんじゃないみたいな話になりました。 僕は Lisp 系だったら構文木そのままイジれるから楽なんじゃないかって思ってたんですが、マクロを考慮してなかったのでなるほどなと思いました。

ソフトウェアテスト界隈だとここから自動的にテストを生成するなどといった研究がされていたりするようです。

ちなみに日本語で情報を探す場合、「ミューテーション解析」「ミューテーション法」「ミューテーションテスト」などで探すとちょこちょこ Hit すると思います。

*1:あるいは、効果が薄い

*2:ていうか、実際ある…

*3:Killing

*4:僕のテストの書き方が良くない、英語が間違ってるなどの指摘は気付いたらコメントにでも書いてもらえると助かりますが、今回の主題はそこではないことを改めて書いておきます