はじめに

この記事は書籍「プロを目指す人のためのRuby入門」に掲載できなかったトピックを著者自らが紹介するアドベントカレンダーの21日目です。
本文に出てくる章番号や項番号は書籍の中で使われている番号です。

今回は第9章の例題で使用した「正規表現チェッカープログラム」のテストを自動化する方法を紹介します。

必要な前提知識

「プロを目指す人のためのRuby入門」の第9章まで読み終わっていること。

正規表現チェッカープログラムのテストを自動化する

正規表現チェッカープログラムのおさらい

第9章では次のような仕様の正規表現チェッカープログラムを作成しました。

  • ターミナル上で動作する対話型のCUI(character user interface)プログラムとする
  • 起動すると"Text?: "の文言が表示され、正規表現の確認で使うテキストの入力を求められる
  • テキストを入力すると、"Pattern?: "の文言が表示され、正規表現パターンの入力を求められる
  • 正規表現として無効な文字列だった場合は、"Invalid pattern: "の文言に続いて具体的なエラー内容が表示され、再度パターンの入力を求められる
  • 正規表現の入力が終わると、"Matched: "の文言に続いて、すべてのマッチした文字列がカンマ区切りで表示される
  • ひとつもマッチしなかった場合は"Nothing matched."の文言が表示される
  • マッチング結果を表示したらプログラムを終了する

以下はいくつかの文字列がマッチした場合の実行例(パターンの再入力あり)です。

Text?: 123-456-789
Pattern?: [1-6+
Invalid pattern: premature end of char-class: /[1-6+/
Pattern?: [1-6]+
Matched: 123, 456

こちらはマッチする文字列がなかった場合の実行例です。

Text?: abc-def-ghi
Pattern?: [1-6]+
Nothing matched.

書籍内では実装例として、次のようなRubyプログラムを作成しています。

lib/regexp_checker.rb
print 'Text?: '
text = gets.chomp

begin
  print 'Pattern?: '
  pattern = gets.chomp
  regexp = Regexp.new(pattern)
rescue RegexpError => e
  puts "Invalid pattern: #{e.message}"
  retry
end

matches = text.scan(regexp)
if matches.size > 0
  puts "Matched: #{matches.join(', ')}"
else
  puts "Nothing matched."
end

「プロを目指す人のためのRuby入門」は原則としてテスト駆動開発(TDD)で例題プログラムを実装するようにしていますが、この例題だけはTDDを使わずに実装していました。

この正規表現チェッカープログラムもテストを書こうと思えば書くことができます。少し難しいですが、以下でその方法を紹介します。

メインプログラムをメソッド化する

まず、メインプログラム(lib/regexp_checker.rb)をメソッド化します。さらに、コマンドラインから実行された場合のみ、そのメソッドを実行するようにします。

lib/regexp_checker.rb
# トップレベルではなく、メソッド内に先ほどのプログラムを記述する
def regexp_checker_main
  print 'Text?: '
  text = gets.chomp

  # (以下省略)
end

# コマンドラインからこのファイルを直接実行した場合だけ、メソッドを実行する
if __FILE__ == $0
  regexp_checker_main
end

if __FILE__ == $0はコマンドラインから自分が直接実行されたかどうかを判定する条件分岐です。__FILE__には現在のソースファイルのファイルパスが、$0にはrubyコマンド実行時に指定されたファイルのファイルパスがそれぞれ入っています。この値がどちらも同じであれば、「自分が直接実行された」と見なすことができるわけです。

ピンと来ない場合は次のようにして__FILE__$0の中身を確認してみてください。

lib/regexp_checker.rb
def regexp_checker_main
  # 省略
end

# __FILE__と$0の中身を確認する
puts "__FILE__: #{__FILE__}"
puts "      $0: #{$0}"

if __FILE__ == $0
  regexp_checker_main
end
# regexp_checker.rbを直接実行した場合
$ ruby lib/regexp_checker.rb
__FILE__: ./lib/regexp_checker.rb
      $0: ./lib/regexp_checker.rb

# テストコードからregexp_checker_mainメソッドを呼びだした場合
$ ruby test/regexp_checker_test.rb
__FILE__: /(ファイルが存在するパス)/lib/regexp_checker.rb
      $0: ./test/regexp_checker_test.rb

マッチする結果がある場合のテストコードを書く

では、テストコードに移ります。今回はtestディレクトリにregexp_checker_test.rbというファイルを作ります。以下がそのテストコードです。

test/regexp_checker_test.rb
require './lib/regexp_checker'
require 'minitest/autorun'

class RegexpCheckerTest < Minitest::Test
  def test_main_when_matched
    # ユーザーの入力値
    inputs = [
      "123-456-789\n",
      "[1-6+\n",
      "[1-6]+\n"
    ]
    # 期待する標準出力の内容
    expected = [
      'Text?: ',
      'Pattern?: ',
      "Invalid pattern: premature end of char-class: /[1-6+/\n",
      'Pattern?: ',
      "Matched: 123, 456\n"
    ].join

    # スタブを使って標準入力の入力値を偽装する
    stub :gets, -> { inputs.shift } do
      # regexp_checker_mainを実行して、期待通りの出力が得られるか検証する
      assert_output(expected) do
        regexp_checker_main
      end
    end
  end
end

これまでのテストコードとは異なり、急に複雑になりましたね。このテストコードが書きにくい理由は標準入力と標準出力をそれぞれプログラムのインプットとアウトプットとしているためです。

スタブを使って標準入力を偽装する

まず、標準入力についてはスタブ(stub)と呼ばれるMinitestの機能を使ってgetsメソッドの戻り値を偽装しています。stub :getsで始まる行がスタブを設定しているコードです。-> { input.shift }はラムダと呼ばれるオブジェクトです。ラムダについては第10章で詳しく説明しますが、ここでは「一種のブロックのようなもの」と考えてください。

次に、regexp_checker_mainメソッドの内部でgetsメソッドが呼ばれるたびにラムダ内のコードが実行されます。配列inputsの内容はshiftメソッドによって前から順番に取り出され、getsメソッドの戻り値になります(つまりユーザーの入力が偽装されます)。

このようにして、ユーザーが実際に標準入力から文字列を入力したかのように、regexp_checker_mainメソッドをだましています。

assert_outputメソッドで標準出力の内容を検証する

標準出力の内容はMinitestで用意されているassert_outputメソッドを使って検証します。assert_outputメソッドは渡されたブロックが実行し、そこで出力された標準出力の内容がassert_outputメソッドの第1引数(ここでは変数のexpected)と一致すれば、テストがパスします。

気を付けなければいけないのは改行文字の扱いです。メインプログラム内ではprintメソッドとputsメソッドの2種類を使っていますが、改行文字が入るのはputsメソッドだけです。なので、putsメソッドで出力した文字列だけが標準出力上でも改行されます。また、標準入力から入力した文字列("123-456-789"など)は標準出力への出力値とは見なされません。こうしたことから、変数expectedに設定した文字列は実際のターミナル上で目に見える文字列とは少し異なっています。

配列 + joinでexpectedの文字列を作成した理由

なお、上のコードではexpectedの文字列を作成するために、一度配列に入れた文字列をjoinメソッドで連結しています。expectedの中身は次のようなひと続きの文字列になります。

expected = [
  'Text?: ',
  'Pattern?: ',
  "Invalid pattern: premature end of char-class: /[1-6+/\n",
  'Pattern?: ',
  "Matched: 123, 456\n"
].join

expected
#=> "Text?: Pattern?: Invalid pattern: premature end of char-class: /[1-6+/\nPattern?: Matched: 123, 456\n"

配列とjoinを使わずに、最初から次のようにひと続きの文字列を代入しても結果はもちろん同じです。

expected = "Text?: Pattern?: Invalid pattern: premature end of char-class: /[1-6+/\nPattern?: Matched: 123, 456\n"

ですが、ひと続きの文字列だと処理の区切りが分かりづらいと考えたため、処理の区切りが見た目にわかりやすくなるよう、あえて配列に入れて最後にjoinするようにしています。

何もマッチしなかった場合のテストコードを書く

同じ要領で、何もマッチしなかった場合のテストを書くこともできます。以下はそのテストコードです。

class RegexpCheckerTest < Minitest::Test
  # 省略

  def test_main_when_not_matched
    inputs = [
      "abc-def-ghi\n",
      "[1-6]+\n"
    ]
    expected = [
      'Text?: ',
      'Pattern?: ',
      "Nothing matched.\n"
    ].join

    stub :gets, -> { inputs.shift } do
      assert_output(expected) do
        regexp_checker_main
      end
    end
  end
end

Ruby初心者の方は今回紹介したテストコードを最初から完全に理解したり、自分で書いたりするのは難しいと思います。なので、今の時点では「こんなやり方もできるんだ」という雰囲気だけなんとなくつかんでもらえれば、それで構いません。

次回予告

次回はHash#to_procMethod#to_procについて説明します。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.