WebOS Goodies

WebOS の未来を模索する、ゲームプログラマあがりの Web 開発者のブログ。

WebOS Goodies へようこそ! WebOS はインターネットの未来形。あらゆる Web サイトが繋がり、共有し、協力して創り上げる、ひとつの巨大な情報システムです。そこでは、あらゆる情報がネットワーク上に蓄積され、我々はいつでも、どこからでも、多彩なデバイスを使ってそれらにアクセスできます。 WebOS Goodies は、さまざまな情報提供やツール開発を通して、そんな世界の実現に少しでも貢献するべく活動していきます。
Subscribe       

Ruby スクリプトのユニットテスト・チュートリアル

少し前に公開した Ruby 用 JSON クラスに数多くのバグを仕込んでしまい(たいへんご迷惑をおかけしました m(_ _)m)、テストの重要性を改めて痛感している今日この頃です。今後も開発を続けるにあたって、現在の行き当たりばったりなテスト方法ではとてもやっていけないと危機感を持ちまして、きちんとしたユニットテストの方法を調べてみました。

で、実際に試してみたところ、これがとても(゜∀゜)イイ!バグを早期に発見できるのはもちろんですが、実は開発効率という面でも大きなメリットがあることがわかりました。小規模な部品の状態でもきちんと動作確認できるので、パーツごとに各個撃破でプログラムを組み上げていく、完全なボトムアップ型アプローチが可能になります。これにより、最も効率の良い順番でコーディングを進めることができるわけです。従来は早い段階で動作テストをするために無理をして全体を仮組みしていましたが、それに比べて工数面でも精神面でもかなりの負担軽減になります。この手のメリットは言葉ではいろいろ聞いていましたが、実際に体験するとまったく別次元です。どんなことでも、やってみるのが重要ですね。

そんなわけで、本日は Ruby におけるユニットテストの方法をチュートリアル的にまとめてみました。標準の Test::Unit によるテストはもちろん、 FlexMock によるモックオブジェクトの作成、 rcov によるカバレッジテストなんてものにも挑戦しています。今回も詳細は省いて基本的な手順にフォーカスしていますので、これからユニットテストをやってみようという方は、ぜひ参考にしてください。目指せテストファースト開発!(笑)

Test::Unit によるユニットテスト

Ruby スクリプトのテストを自動化する際の基本的な枠組みが Test::Unit です。これは Ruby に標準で添付されているクラスライブラリですので、すぐに使い始めることができます。今回はチュートリアルということで、以下のような簡単なクラスをテストしてみましょう。

class NumericInspector
 
  def is_negative(v)
    v <= 0
  end
 
  def is_integer(v)
    v.is_a?(Integer)
  end
 
end

この NumericInspector クラスは数値オブジェクトの調査を行うもので、引数が負の値なら真を返す is_negative と、引数が整数なら真を返す is_integer の 2 つのメソッドがあります。 is_negative には明らかな間違いがありますが、このバグは後のテストで検出することにしましょう。

Test::Unit によるユニットテストを実行するためには、テスト内容を記述した Ruby スクリプトを作成しなければなりません。 NumericInspector クラスに対するテストスクリプトは以下のような感じになります。

require 'test/unit'
require'numericinspector'
 
class TC_NumericInspector < Test::Unit::TestCase
 
  def setup
    @obj = NumericInspector.new
  end
 
  def test_is_negative
    assert(!@obj.is_negative(1), "1 が負の数として判定されました。")
    assert(@obj.is_negative(-1.5), "-1.5 が負の数として判定されませんでした。")
    assert(!@obj.is_negative(0), "0 が負の数として判定されました。")
  end
 
  def test_is_integer
    assert(@obj.is_integer(1), "1 が整数として判定されませんでした。")
    assert(!@obj.is_integer(1.5), "1.5 が整数として判定されました。")
  end
 
end

テスト用に TC_NumericInspector クラスを定義しています。クラスの名前はなんでもかまいませんが、必ず Test::Unit::TestCase を継承するようにしてください。

実際のテスト内容は名前が "test_" で始まるメソッドとして定義します。これらのメソッドは、テストを実行した際にすべて自動的に呼び出されます。メソッドの内部で呼び出している assertTestCase クラス(正確には Test::Unit::Assertions モジュール)のメソッドで、第一引数が偽であればテスト失敗としてメッセージを表示します、。その他にも多数の判定メソッドがありますので、詳細はリファレンスマニュアルをご覧ください。

残りの setup メソッドはそれぞれの "test_" メソッドの直前に毎回呼び出されるメソッドで、全テスト共通の初期化処理などを記述するのに便利です。類似のメソッドとして teardown もあり、こちらは各 "test_" メソッドの終了直後に呼ばれます。

さて、テストスクリプトができたところで、さっそくテストしてみましょう。単純にテストスクリプトを実行すれば OK です。

ruby test_numericinspector.rb

実際にやってみると・・・

$ ruby test_numericinspector.rb
Loaded suite test_numericinspector
Started
.F
Finished in 0.026 seconds.

  1) Failure:
test_is_negative(TC_NumericInspector) [test_numericinspector.rb:13]:
0 が負の数として判定されました。.
<false> is not true.

2 tests, 5 assertions, 1 failures, 0 errors

ありゃ、失敗です。 NumericInspector#is_negative(0) が真を返してしまったようです。ソースを見てみると、たしかにメソッドの中身が v <= 0 になっていました。ここは v < 0 でなければいけませんよね。そのように修正して再度テストを実行すると・・・

$ ruby test_numericinspector.rb
Loaded suite test_numericinspector
Started
..
Finished in 0.003 seconds.

2 tests, 5 assertions, 0 failures, 0 errors

今度はばっちり成功です。

以上が Test::Unit を利用したユニットテストの基本的な流れです。もし、 NumericInspector に新しい機能を追加したときは、対応する "test_" メソッドを TC_NumericInspector クラスに追加すれば良いでしょう。さらに詳しい使い方に関しては、リファレンスマニュアルにひと通り掲載されているのでご参照ください。

FlexMock でモックオブジェクトを作成

他のクラスに依存していない単純なクラスなら、前述の Test::Unit だけでじゅうぶんです。しかし、内部で他のクラスを呼び出すような複雑なクラス、とくにデータベースやネットワークなどの外部環境にアクセスするようなクラスの場合はどうでしょう。通常、外部環境は場合によって返してくるデータが異なりますから、テスト結果を判定するのはひと苦労です。ましてや書き込みを行うような機能などは、テストのために実際にデータを変更するわけにもいかないので、明らかに無理がありますよね。

一般的に、このような状況には「モックオブジェクト」を作るすることで対処します。「モックオブジェクト」はテスト対象のコードが内部で使用するオブジェクトと同じインターフェースを持ちますが、実際の処理は実行せず、引数の検証とテストコードを動かすために必要な最低限の出力のみを行います。 Ruby で簡単にモックオブジェクトを作れるライブラリはいくつかあるようですが、今回は FlexMock というものを使ってみました。

http://onestepback.org/software/flexmock/

それでは実際に使ってみましょう。 FlexMock は標準ライブラリには含まれていないので、まずはインストールからです。 RubyGems が使えるなら、以下のコマンドで簡単にインストールできます。

gem install --remote flexmock

もし RubyGems をまだ入れていないという場合は、この際なのでインストールしてしまいましょう。こちらの記事で簡単にご紹介していますので、参考にしてください。

テストするコードとして、今回は以下のものを使うことにしましょう。各行に "名前=値" という環境変数定義のような記述をしたファイルを読み込み、その内容をハッシュとして返すという単純な関数です。

require 'net/http'
 
def env_read(path)
  file = File.new(path, "r")
  begin
    result = {}
    file.readlines().each do |line|
      if /^\s*(\w+)\s*\=\s*(\S*)/ === line
        result[$1] = $2
      end
    end
    result
  ensure
    file.close
  end
end

モックオブジェクトの記述を単純にするため、こちらのコードはいささか冗長な記述になっています。本末転倒な感じですが、サンプルということでご勘弁ください。

さて、上記のコードでは外部ファイルを読み込んでいるため、テストのためにはテスト用データを格納したファイルを用意して、それがきちんと Hash に変換されているかを検証しなくてはなりません。これを回避してテストコード内で自由にテストデータを生成できるようにするには、 File クラスをモックオブジェクトに差し替えるのが良さそうです。それを実装したテストコードは以下のようになります。

#! /usr/bin/ruby
 
require 'rubygems'
require 'test/unit'
require 'flexmock'
require 'env_read'
 
class TC_EnvRead < Test::Unit::TestCase
  include FlexMock::TestCase
 
  def test_env_read
    source = []
    result = { "name1" => "value1", "name2" => "value2" }
    result.each do |key, value|
      source << key + "=" + value + "\n"
    end
 
    flexstub(File) do |fileclass|
      fileclass.should_receive(:new).with('test.env', 'r').and_return do
        flexmock('file') do |fileobj|
          fileobj.should_receive(:readlines).with_no_args.and_return(source)
          fileobj.should_receive(:close).with_no_args.once
        end
      end
    end
 
    assert_equal(result, env_read('test.env'))
  end
 
end

こちらのテストもずいぶんいい加減ですが、ご勘弁を。FlexMock を使ったテストの場合も、基本は Test::Unit による枠組みを使用しますので、だいたいの処理の流れは既にご理解いただけると思います。以降、 FlexMock に関連する部分に的を絞って、軽くご紹介していきましょう。

まずは最初に "flexmock" を require しています。当然ながら、これをやらないと FlexMock の機能は利用できません。次に、テストケースのクラスに FlexMock::TestCase モジュールを include しています。これを行うことで、モックオブジェクトを作成するためのメソッドなどを簡単に呼び出せます。もちろん名前空間をいちいち指定すればこれは必要ありませんが、あまりメリットはないでしょう。ここまでは FlexMock を利用する際の一般的な手順なので、しきたりとして覚えてしまってください。

さあ、ここからが肝です。今回のコードでは、 test_env_read メソッド内でモックオブジェクト作成のすべての処理を行っています。まず、使用しているメソッドの概要をまとめてご紹介してしまいましょう。

flexstub
第一引数に指定した既存のオブジェクトをモックオブジェクト化するメソッドです。主にクラスやグローバルオブジェクトの挙動を変更したい場合に利用します。このメソッドを読んだだけでは既存オブジェクトの挙動はほとんど変化しませんが、後に should_receive などを利用してメソッドを再定義することができます。このように、既存のオブジェクトを基にしたモックオブジェクトを「スタブ」と呼びます。
flexmock
空のモックオブジェクトを作成します。主に new メソッドなどでクラスインスタンスを作成する際に利用します。 flexstub で作成したオブジェクトと同様に、 should_receive でメソッドを定義できます。引数は任意の文字列で、エラーメッセージなどに使われます。
should_receive
モックオブジェクトのメソッドを(再)定義します。引数としてメソッド名のシンボルを渡してください。このメソッドの後ろに withand_return などの呼び出しを続けることで、引数や戻り値などの追加情報 (Expectation) を指定することができます。同じメソッドに対して複数 should_receive を呼び出して、引数によって場合分けすることも可能です。
with
メソッドに指定される引数を指定します。マッチングは === で行われますので、クラスや正規表現などを指定することも可能です。
with_no_args
メソッドに引数がないことを宣言します。
and_return
引数に指定したオブジェクトをメソッドの戻り値とします。ブロックを指定した場合は、メソッドが呼ばれた際にそのブロックを評価して、その結果をメソッドの戻り値とします。
once
そのメソッドが 1 回のみ呼ばれなければならないことを宣言します。

これで test_env_read がなにをやっているかはだいたい予想がつくのではないでしょうか。念のために簡単にまとめておくと、以下のようになっています。

  1. 最初に、テストデータ(結果として変えるはずの Hash と、そこから逆算したソースファイルのテキスト)を生成しています。
  2. flexstub メソッドを使って、 File オブジェクトをスタブ化しています。 flexstub にブロックを与えると、スタブオブジェクトを引数に渡してそのブロックを評価します。
  3. should_receive メソッドを使って、 File::new を再定義しています。引数として ('test.env', 'r') を指定していますので、これ以外の引数で呼ばれた場合はテスト失敗となります。
  4. File::new の処理中で、 flexmock オブジェクトを使って新規のモックオブジェクトを作成し、それに readlinesclose のメソッドを追加しています。 readlines の戻り値として (1) で生成したテキストを指定しているので、 env_read 内で readlines が呼ばれた際にこのテキストが返るわけです。また、 close には once を呼び出しているので、テスト中で必ず一回 close が呼ばれなければテスト失敗となります。
  5. 最後に、テスト対象の env_read メソッドを呼び出し、その結果が (1) で生成した Hash と等しいかどうかを assert_equal で検証しています。

今回使用した Expectation はごく基本的なものだけですので、工夫すればさらに詳細な指定も可能です。利用可能な Expectation はリファレンスマニュアルにリストアップされていますので、そちらをご参照ください。また、今回はテストメソッドの中ですべてのモックオブジェクトを作成していますが、これは必須ではありません。例えば、すべてのテストで共通に利用するモックオブジェクトがあるなら、 setup メソッドで作成するのが適当だと思います。

rcov によるカバレッジテスト

カバレッジテストという言葉をご存知でしょうか。今回ご紹介したようなテストを実行した際に、プログラムのどれだけの部分が実行されたかという網羅率(カバレッジ)を測定するテストのことです。いわば、テストのテストとも言うべきものですね(笑)。 Ruby でも簡単なカバレッジテストを行うツールがいくつか公開されています。今回は、その中から rcov というツールを試してみましたので、その使い方なども簡単にご紹介しておきます。

http://eigenclass.org/hiki/rcov

まずはインストール方法です。 rcov は高速化のために C のライブラリを含んでいますので、インストールには C コンパイラが必要です。ただし、 Windows 環境では例外的にコンパイル済みのバイナリが提供されています。いずれにせよ、インストールには RubyGems を利用するのが簡単です。

 gem install --remote rcov

実行すると以下のような選択肢が表示されるます。 Windows で One-Click Installer を使っている場合は (mswin32) を、それ以外の場合は (ruby) を選択してください。 Cygwin の場合も (ruby) で問題なくインストールできました。

Select which gem to install for your platform (i386-cygwin)
 1. rcov 0.8.0.2 (mswin32)
 2. rcov 0.8.0.2 (ruby)
 3. rcov 0.8.0.1 (mswin32)
 4. rcov 0.8.0.1 (ruby)
 5. Skip this gem
 6. Cancel installation

まだ私も使い始めたばかりなので細かい機能は把握しきれていないのですが、とりあえず以下のように測定したいスクリプトを指定して実行するだけで、 HTML 形式のレポートが生成されます。

rcov <スクリプトファイル名>

例えば、 FlexMock のサンプルコードを rcov にかけると、以下のようなレポートが生成されます。

rcov のレポート画面 1

さらにテスト対象コードの詳細を表示させたのがこちらです。まあ、ほとんど分岐もないので当然のように 100% で、あまり参考になりませんが・・・(^^ゞ

rcov のレポート画面 2

もし実行されていない行があれば、赤く表示されるのですぐにわかります。もちろん、これが 100% になれば完璧というわけではありませんし、ソースの記述方法によっては実行されているはずの行が赤く表示されたりもするのですが、だいたいの目安にはなります。テスト漏れがないように、ときどきチェックすると良いでしょう。

以上、駆け足になってしまいましたが、本日は Ruby でユニットテストを行う方法を簡単にご紹介しました。とくに FlexMock はかなり使えますね。下位モジュールなどが完成していなくても、取り合えず大枠の動作を確認できるので、開発がかなり楽になります。さらに rcov でカバレッジをきちんとチェックしながらテストを作成すれば、ソフトウェアの品質もだいぶ向上しそうです。ま、ツールはあくまでツール、活用しなければ意味がないわけなので、今後は意識的にこれらを使っていこうと思っています。バグが減らなくてお困りの方、ぜひ試してみてください!

関連記事

この記事にコメントする

Recommendations
Books
「Closure Library」の入門書です。
詳しくはこちらの記事をどうぞ!
Categories
Recent Articles