おもこん

おもこんは「思いつくままにコンピュターの話し」の省略形です

徒然Ruby(23)minitest、ripper

アプリ作成の記事でminitestを使いました。 今回はminitestについて、また一般にテストについて、私の考えを書こうと思います。

テストとは

一般にテストはプログラムのバグを除くために広範に使われている手法です。 開発に従事している方は既に良くご存知のことと思います。 私は「仕事として」つまり給料をもらって開発をしたことがありません。 おそらくプロの開発の方は、私よりもっとテストの経験も知識もお持ちだと思います。 そういう方は、この記事を読む必要はないかもしれません。

また、テストには「テスト駆動開発」とか「振る舞い駆動開発」などというのもあって、ひとつの開発手法にもなっています。

  • テスト駆動開発=>Test Driven Development=>TDD
  • 振る舞い駆動開発=>Behavior Driven Development=>BDD

そのような手法では、まずテストを書いてからアプリを書く、といわれます。 ですが、私はどちらが先でも良いのではないかと思っています。

テストを書くときは、あまりプログラムの細かい部分に立ち入るよりも、その振る舞い(behavior)が期待通りかをテストするようにします。 そうすることで、そのプログラムの内部構造を変更、改良したのちも同じテストプログラムでテストすることができます。 つまり、テストは内部構造まで立ち入らず、振る舞いのみにフォーカスすべきです。

また、テストではその規模も問題になります。

  • ユニットテスト: クラスあるいはメソッドなどのプログラムを構成する単位に対して行うテスト。 比較的簡単に作成、テストができる
  • 統合テスト: ユニットテストの対象よりも大きなプログラムの部分、あるいは全体について行うテスト。 プログラムの構成によってその実際は変わるが、例えばユニットテスト済みの単体同士の連携などをテストする。 このような大きなテストでは入出力、ネットワーク、データベースなどを扱うために、特別な方法が必要になることが多い。

この記事ではユニットテストを扱います。

自作テストプログラム

minitestはテスト用のフレームワークですが、それを使わずに自分でテストプログラムを作ることもできます。

例えば、あるメソッドが数字を表す文字列を引数に取り、その数値を返す、というものだとしましょう。 メソッド名をto_numberとしておきましょう。 今回は先にto_numberを作ってから、そのテストプログラムを作ることにします。

def to_number(s)
  s.to_i
end

引数の文字列をto_iメソッドで整数にして返す、というメソッドです。 おそらく、皆さんはすぐにこのプログラムの欠点を見つけたことでしょう。 問題ありありのプログラムですが、テストの説明には適しています。

テストプログラムを考える上でのポイントは「メソッドの振る舞い」をテストするということです。 この場合、「引数が与えられたら、その引数の表す数値を返す」というのが振る舞いですが、もう少し詳しく考えないといけません。 引数と返し値の組み合わせを「引数=>返し値」で表すことにします。

  • 数字を表す文字列=>対応する数(ただし、整数と浮動小数点数の2種類の数がある。ここでは複素数、有理想は想定しない)
  • 数字ではない文字列=>??
  • 文字列以外のオブジェクト=>??

??と書いたところは仕様が決められていなかったので、そう書きました。 テストを考えただけで、仕様の不十分さも分かりました。 これもテストを導入するメリットの1つです。 つまり、振る舞いに対するテストを書くためには、振る舞いをより完全に定義しなければならなくなります。

今回は??のところはnilを返して、エラー処理は呼び出し側に任せることにしましょう。

テストはこんな感じです。

def to_number(s)
  s.to_i
end

# テストプログラム
def test_to_number
  a = to_number("0")
  print "0 にならずに #{a} が返された\n" unless a == 0
  a = to_number("-0.1")
  print "-0.1にならずに #{a} が返された\n" unless a == -0.1
  a = to_number("1.23e10")
  print "1.23e10にならずに #{a} が返された\n" unless a == 1.23e10
  a = to_number("abc")
  print "nilにならずに #{a} が返された\n" unless a == nil
  a = to_number(100)
  print "nilにならずに #{a} が返された\n" unless a == nil
end

test_to_number
#=> -0.1にならずに 0 が返された
#=> 1.23e10にならずに 1 が返された
#=> nilにならずに 0 が返された
#=> nilにならずに 100 が返された

最初のテストだけ通って、残りの4つは通りませんでした。 テストを通ることを「成功(サクセス)」通らないことを「失敗(フェイル)」といいます。 またテストを通ることを「パスする」ともいいます。

to_numberは引数について場合分けして、それぞれに対して適切な値を返さなければなりません。

  • 整数を表す文字列=>その整数を返す
  • 浮動小数点数を表す文字列=>その浮動小数点数を返す
  • それ以外の文字列=>nilを返す
  • 文字列でないオブジェクト=>nilを返す

メソッドto_numberを書き直してみましょう。

def to_number(s)
  return nil unless s.is_a?(String)
  case s
  when /\A-?\d+\Z/
    s.to_i
  when /\A-?\d+\.\d+([Ee]\d+)?\Z/
    s.to_f
  else
    nil
  end    
end

正規表現を使って、文字列が数字にマッチする場合に数値に変換しています。 結構複雑なことをしなければならないのですね。 これでテストは通るようになります。

Ripper

ちょっと脇道にそれますが、このように文字列が数値を表すかどうかをチェックするのは一種の字句解析に当たります。 rubyはプログラムを読む時に「字句解析」をして、その文字列をキーワード、識別子、区切り記号、数値などに分解したのちに構文解析、実行に移ります。 そこで、rubyの字句解析を使って数値を取り出すこともひとつの方法として考えられます。 ripperという標準添付ライブラリはRuby プログラムを解析するためのライブラリです。 メソッドRipper.lexは字句解析をし、字句(トークン)の配列を返します。

require 'ripper'
p Ripper.lex("123 -1.2e5")
#=> [[[1, 0], :on_int, "123", END], [[1, 3], :on_sp, " ", END], [[1, 4], :on_op, "-", BEG], [[1, 5], :on_float, "1.2e5", END]]

配列の中身の詳細はRubyのドキュメントを参照してください。 注目すべきは、各配列の2番目のシンボルです。

これを利用すると、正規表現を使わずに「整数」「浮動小数点数」「その他」に文字列を分類できます。 to_numberをRipper.lexを使って書き直してみましょう。

require 'ripper'

# メソッドの再々定義
def to_number(s)
  return nil unless s.is_a?(String)
  a = Ripper.lex(s)
  unless a.size == 1 || (a.size == 2 && a[0][1] == :on_op && a[0][2] == "-")
    return nil
  end
  case a[-1][1]
  when :on_int
    s.to_i
  when :on_float
    s.to_f
  else
    nil
  end    
end

Ripperを使うことはあまりないでしょうが、Ruby同様の字句解析をしたいときはRipperが便利です。 本題とはあまり関係ないことを書きましたが、Ripperも面白いと思っていたので、この機会に紹介しました。

minitest

to_numberメソッドをテストするminitestのプログラムを作ってみましょう。 次のプログラムはto_numberに対して8つのテストをします。 to_numberメソッドはこのプログラムの前に挿入しておくこととします。 (require_relativeメソッドで取り込んでも良いです)。

require 'minitest/autorun'
class TestToNumber < Minitest::Test
  def setup
  end
  def teardown
  end

  def test_to_number
    [["0",0],["12",12],["-5",-5],["12.34",12.34],["2.27421e5",2.27421e5],["-23.56",-23.56],["abc",nil], [123,nil]].each do |s,v|
      if v == nil
        assert_nil to_number(s)
      else
        assert_equal v, to_number(s)
      end
    end
  end
end

要点を次に記します。

  • minitest/autorunをrequireメソッドで取り込む。 なお、minitestは標準ライブラリなのでインストール不要
  • MiniTest::TesTのサブクラスとして、テスト用のクラスを定義する。 クラス内には、setup、teardown、テスト用のメソッドを記述する
  • setupはテスト前に行う準備を記述する。 良くあるのは、インスタンスの生成など
  • teardownはテスト後の後始末をする。 上記のプログラムではsetupとteardownは何もしていないので、記述しなくても良い
  • テストのメソッドはその名前の先頭にtest_をつける。 ここではto_numberメソッドのテストなのでtest_to_numberとした。 ここでは、2重配列を使い、その8つの要素に対してeachメソッドを用い、テストする。 配列のそれぞれの要素は`["0",0]のように、数字の文字列(=>引数)と数字(返されるオブジェクト)となっている。 最初の例では"0"を引数にしてメソッドを呼び出したとき、0が返されることが期待されている。 期待通りならテストは通過(パス)し、そうでなければ失敗(フェイル)となる。
  • assert_nilは実行結果とnilを比較する。 つまり、期待される結果がnilで実行結果がassert_nilの引数になる。
  • assert_equalはminitestが提供するテスト用メソッドで、2つの引数をとる。 第1引数には期待されるオブジェクト、第2引数には実際にテストで得られたオブジェクトを書く。 両者が一致すればパス、一致しなければフェイルになる。 なお、期待されるオブジェクトがnilのときは、assert_equal nil, (テストで得られたオブジェクト)とすることもできるが、これは古い書き方で現在は推奨されていない。 assert_nilが推奨される書き方になっている。 テスト通過の場合は何も表示されず、失敗のときは期待された(expected)オブジェクトと実際の(actual)オブジェクトが表示される。

実行してみます。

$ ruby example23.rb
Run options: --seed 7828

# Running:

.

Finished in 0.005369s, 186.2654 runs/s, 1490.1234 assertions/s.
1 runs, 8 assertions, 0 failures, 0 errors, 0 skips

テストは成功したので、フェイルの表示が出ません。 ドットはテストするメソッド(この場合はtest_to_number)の個数分だけ表示されます。 一番下の行の「8 assertions」は8つのassert_・・・のテスト(アサーションと呼ぶ)が行われ、失敗0エラー0だったことを示しています。 なお、失敗は「期待した値と実行結果の値が異なること」、エラーは「プログラムのエラー(間違い)」で、違うものを表します。

このテストはトップレベルのメソッドを対象としました。 もしこのメソッドがトップレベルで定義されたインスタンス変数を使っているときは、その定義をsetupで行わなければなりません。 (トップレベルで行う定義をそっくりコピペすれば良い)。 トップレベルのインスタンス変数はmain(トップレベルのオブジェクト)のインスタンス変数になります。 これに対して、minitestのテストはMinitest::Testクラスのサブクラスに定義されたメソッドで行われるので、そこで参照されるインスタンス変数は「そのサブクラスのインスタンスの変数」になります。 したがって、mainのインスタンス変数は参照できないことに注意してください。 これを解決するには、さきほど述べたように、setupメソッドでインスタンス変数を定義しておきます。

標準出力のテスト

次のプログラムはテストするまでもない簡単なものですが、標準出力のテスト例として取り上げます。

@n = 0
def inc
  @n += 1
  p @n
end

inc
inc
inc

メソッドincはインスタンス変数@nを1つ大きくし、標準出力に出力します。 トップレベルでincを3回呼ぶと、

1
2
3

と、呼び出すごとに1つずつ大きくなって表示されます。

このテストは次のようになります。

class TestInc < Minitest::Test
  def setup
    @n=0
  end
  def teardown
  end

  def test_inc
    assert_output("1\n") {inc}
    assert_output("2\n") {inc}
    assert_output("3\n") {inc}
  end
end

大事なことはsetupでインスタンス変数@nの初期化をしなければなりません。 前項で述べたように、トップレベルのインスタンスがmainであるのに対し、test_incメソッドのインスタンスはTestIncクラスのインスタンスです。 したがって、トップレベルのインスタンス変数を参照することはできません。 setupでの@nの初期化は必須で、これがないとincの@n += 1を実行するときに、@nが未定義で参照されるためにnilになり、nilには+メソッドがないのでエラーになります。 これはエラーであってフェイルではありません。

このテストではassert_outputメソッドを使っています。 引数が期待される結果、それをブロックの実行の標準出力と比較して異なるとフェイル・メッセージが表示されます。

自作のテストプログラムで標準出力をテストするには、出力を捕まえる(出力先を変更する)必要があり、大変ですが、minitestを使えばassert_outputを使うだけですみます。

minitestのアサーションは豊富です。 minitestのドキュメントに詳細がありますので、参考にして下さい。