徒然Ruby(24)minitest、mock、stub - おもこん

おもこん

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

徒然Ruby(24)minitest、mock、stub

今回もminitestの話です。 mockとstubに焦点をあて説明します。

単語帳プログラム

今回は単語帳プログラム「wordbook」を、テストしながら作ることにします。 このプログラムは、テストの例示に使うためのものなので、最小限の機能に絞りました。

端末からの入力に従って、単語帳を編集し、ファイル「に保存/から読み出し」できるというものです。 端末からの入力は「コマンド 英語 日本語訳」という形を原則にしています。

  • a: append(追加)。英語と日本語訳をデータに追加
  • d: delete(削除)。英語(と日本語訳)をデータから削除
  • c: change(変更)。指定された英語の日本語訳を変更
  • p: print(表示)。指定された英語のデータを表示
  • q: quit(終了)。プログラムを終了

コマンドはこの5つだけです。

wb > a add 追加 #=> 英単語「add」と日本語訳「追加」をデータに追加
wb > p add #=> 英単語「add」を表示
add 追加
wb > c add 加える #=> 「add」の訳を「加える」に変更
wb > p add
add 加える
wb > a subtract 減じる
wb > p . #=> 正規表現が可能。任意の文字にマッチ(マッチは単語の一部で良い)
add 加える
subtract 減じる
wb > d add #=> 「add」とその日本語訳を削除
wb > p .
subtract 減じる
wb > q #=> 終了

プログラム・ファイルは4つに分かれます。

  • wordbook.rb: 端末から起動するファイル。コマンドラインの解析をする
  • lib_wordbook.rb: WordBookクラスの定義。実質的なメインプログラム
  • input.rb: Inputクラスの定義。端末から一行入力する
  • db.rb: DBクラスの定義。データのファイルへの保存/読み出しをする

開発は、トップダウンで行うことにします。 トップダウンとは、メインになるプログラムから開発し、メインから呼び出される個々のパーツ・プログラムを後に回す方法です。 逆の手順はボトムアップです。 ボトムアップの利点はひとつひとつ動くパーツから組み立てるので、着実に積み上げることができることです。 ただ、メイン部分で問題が発生すると、また下位のパーツを作り直さなければならなくなるという不利な点があります。

コマンドラインとのインターフェース

コマンドラインとのインターフェースはwordbook.rbに書きます。 このプログラムは、起動時の引数の処理をします。

  • 引数なし=>データファイル名はデフォルト名(db.csv
  • 引数が-hまたは--help=>使い方メッセージを標準エラー出力に出力
  • 引数がひとつ=>引数をデータファイル名とする
  • その他=>使い方表示

プログラムは次のようになります。

#!/bin/sh
exec ruby -x "$0" "$@"
#!ruby

require_relative 'lib_wordbook.rb'

def usage
  $stderr.print "Usage: wordbook [file]\n"
  exit
end

if ARGV.size > 1 || ARGV[0] =~ /--help|-h/
  usage
end
if ARGV.size == 1
  wb = WordBook.new(ARGV[0])
else
  wb = WordBook.new
end
wb.run

$stderrは標準エラー出力のオブジェクトを表す変数でprintメソッドを持っています。 このメソッドは関数形式のprintメソッドと同じで、出力先が違うだけです。 --heloと-hは使い方を表示して終了します。 exitはプログラムを終了するメソッドです。

正しい引数で起動された場合は、WordBookクラスのインスタンスを生成し、そのオブジェクトのrunメソッドを呼び出します。 runメソッドが実質的なメインプログラムになります。

wordbook.rb のテスト

wordbook.rbはコマンドライン引数の解析をするので、テストもコマンドラインから起動して行いたいところです。 そこで、Kernelモジュールのバックティック(`)メソッドを利用して、rubyを実行し、その標準出力を入手してテストに用いることにします。 バックティック・メソッドは「徒然Ruby(11)」を参照してください。 テストプログラムのファイル名は「test_main_wordbook.rb」とします。

require 'minitest/autorun'
require 'fileutils'

# The test will be done under 'temp_test_main_wordbook' directory
class TestMainWorkbook < Minitest::Test
  include FileUtils
  def setup
    @tempd = 'temp_test_main_wordbook' 
    mkdir_p @tempd
    cp 'wordbook.rb', "#{@tempd}/wordbook.rb"
    # Put a stub of "lib_wordbook.rb" under the tepmorary directory.
    # It just prints the argument.
    File.write("#{@tempd}/lib_wordbook.rb", <<~'EOS')
    class WordBook
      def initialize(file="db.csv")
        @file = file
      end
      def run
        print @file, "\n"
      end
    end
    EOS
    cd @tempd
  end
  def teardown
    cd '..'
    remove_entry_secure @tempd
  end
  def test_main_wordbook
    assert_equal("Usage: wordbook [file]\n", `ruby wordbook.rb --help 2>&1`)
    assert_equal("Usage: wordbook [file]\n", `ruby wordbook.rb -h 2>&1`)
    assert_equal("Usage: wordbook [file]\n", `ruby wordbook.rb a.csv b.csv 2>&1`)
    assert_equal("db.csv\n", `ruby wordbook.rb`)
    assert_equal("abc.csv\n", `ruby wordbook.rb abc.csv`)
  end
end

コマンドラインからruby wordbook.rbと入力すると、wordbook.rbはlib_wordbook.rbを読み込み、WordBookクラスのインスタンスを作ろうとします。 まだ、lib_wordbook.rbは書いていませんし、またそれが書けていたとしてもテストには向きません。 ここでは、テスト用のlib_workbook.rbを使ってテストしたいので、新たにテンポラリ・ディレクトリ(一時ディレクトリ)を作り、その中でテストをすることにします。 テンポラリ・ディレクトリ名は「temp_test_main_wordbook」とします。 setupメソッドで上に述べた「下準備」をします。

  • temp_test_main_wordbookを作成する
  • wordbook.rbをそのディレクトリにコピーする
  • 仮のlib_wordbook.rbをそのディレクトリに書き込む。 その内容は、ヒアドキュメントで書いてあるとおりで、runメソッドは単に引数を標準出力に書き出す
  • テンポラリ・ディレクトリにカレントディレクトリを移動する

setupと対になるのがteardownで、これはテスト終了後の後始末をします。 teardownでは次のことを行います。

テストをするメソッドは「test_main_wordbook」です。

  • `ruby wordbook.rb --help 2>&1`でバックティック内のコマンドを実行する。 2>&1標準エラー出力の出力先を標準出力に変更する(bashのリファランス参照)。 バックティックはコマンドの標準出力を捕らえ、メソッドの返り値にする
  • assert_equalで、その返り値が文字列"Usage: wordbook [file]\n"に等しいかをテストする。 2、3番目のテストも同様
  • 4番めは引数なしで起動した場合。 そのときwordbook.rbはrunメソッドを引数"db.csv"をつけて起動する。 テスト用のlib_wordbook.rb内のrunメソッドはその引数を標準出力に書き出すので、db.csvがバックティックメソッドで返されるはずである。 それをassert_equalでチェックする。 5番目も同様

テストしてみます。

$ ruby test_main_wordbook.rb 
Run options: --seed 23981

# Running:

.

Finished in 0.391359s, 2.5552 runs/s, 12.7760 assertions/s.
1 runs, 5 assertions, 0 failures, 0 errors, 0 skips
$

トップレベルのファイルはどうしてもコマンドラインの解析があるので、テスト用のライブラリファイル(このようなものをスタブという)を作り、テスト用のテンポラリディレクトリでテストする形になります。 これは私流のやりかたですが、もし他にもっと良い方法をご存知の方がいれば、コメントで教えていただけるとありがたいです。

スタブ(stub)とモック(mock)

Minitestのstubは「Objectクラスに追加したメソッド」です。 すべてのクラスはObjectの子孫ですから、どのオブジェクトの上でもstubメソッドを呼ぶことができます。 また、「クラスも一種のオブジェクト」ということから、クラスの上でもstubメソッドを呼ぶことができます。

stubはオブジェクトの既存のメソッドの返り値を変更することができます。

オブジェクト.stub(メソッド名, 返り値){ ・・・・・}

このような形で使います。 正確には、引数にさらに付け加えられる情報があるのですが、詳細はMinitestのドキュメントをご覧ください。

stubはどんなオブジェクトに対しても使えるので、とくに入力関係のオブジェクトに使うと効果的です。 例えばFileクラスのクラスメソッドreadに対して、

File.stub(:read, "abcd\n") {・・・・・}

とすると、{}の中、すなわちブロックの中ではFile.read(ファイル名)はいつも"abcd\n"を返します。 stubによるメソッドの変更はブロックの中だけで有効です。

モックは「みせかけのもの」という意味です。 本当のオブジェクトではなく、テストのためにそれらしい振る舞いをするオブジェクトのことをいいます。 minitestのモック・オブジェクトでは、みせかけのインスタンスメソッドとその引数、返り値を定義することができます。

  • モックオブジェクトを生成し、みせかけのインスタンスメソッドを定義
  • そのインスタンスメソッドを実行
  • 期待通りの形で(引数の種類や数、呼ばれた回数)でメソッドが呼ばれたかをチェック

という手順でテストをします。

require 'minitest/autorun'
class TestFoo < Minitest::Test
  def test_foo
    @mock = Minitest::Mock.new
    @mock.expect(:read, "Hello world!")
    assert_equal("Hello world!", @mock.read)
    @mock.verify
  end
end
  • 4行目: モック・オブジェクトを生成しインスタンス変数@mockに代入
  • 5行目: モック・オブジェクトに「みせかけのメソッド」を定義。 定義はモックのexpectメソッドで行う。 1番目の引数=>みせかけのメソッド名、2番目の引数=>そのメソッドの返り値。 この例ではみせかけのメソッドに引数は設定されていないが、もし引数を設定する場合は、3番めの引数に配列で渡す
  • 6行目: @mock.readで、「みせかけのメソッド」readを呼び出す。 定義どおりに、引数なしで呼び出し。 また、その返り値は"Hello world!"になるので、assert_equalのアサーションも通過するはず
  • 7行目: モックのverifyメソッドは、定義されたメソッドが呼ばれたかをチェック。 上記のテストでは6行目で呼び出しているので、テストはパスする。 なお、expectメソッドを複数回行うことができ、そのときは「みせかけのメソッド」の呼び出しも同じ回数だけ行う。 それらの回数が等しいかどうかもverifyはチェックする。 verifyはexpectに対して呼び出しが少ないときにフェイルにするだけで、逆にexpectに対して呼び出しが多いときは呼び出し時にフェイルになる。 また、expectメソッドを複数回使うと、返り値をその回数だけ定義できる。 これらの返り値は呼び出しごとに次々に変わっていく

実際のテストでは、モックオブジェクトを本来のオブジェクトに差し替えてテストをします。 差し替えをどのように行うかは対象となるプログラムによりますが、結構難しくなる場合もあります。 対象プログラムの中身に立ち入らないのがテストの原則ですが、オブジェクトの差し替えはどうしても原則どおりには行かないことが多いと思います。 そのときは、中身に関する事柄をできるだけ少なくします。

スタブとモックを組み合わせて使うこともよくあります。 それは、スタブの2番めの引数(書き換えられたメソッドの返り値)にモックを置くことです。 そのことによって、モックをテスト対象のオブジェクトに送り込むのです。 これは、newメソッドをスタブで書き換え、newで返すオブジェクトをモックに取り替えてしまう、という方法で用いられます。

スタブのより柔軟で高度な使い方としては、2番めの引数(返り値)のところに、callメソッドを持つオブジェクトを置く方法があります。 このときスタブはcallメソッドを実行し、その値を返り値にします。 ここにはProcオブジェクトを入れるのがピッタリですが、モックを入れることも考えられます。 つまり、モックに「みせかけのメソッド」としてcallを定義するのです。 モックは複数回expectを使い、callメソッドの返り値をその回数分セットすることができます。 ということは、スタブで書き換えたメソッドに複数回分の異なる返り値をセットすることが可能になるのです。

require 'minitest/autorun'
# sample class
class A
  def initialize
    @b = B.new
  end
  def show_b
    @b.show
  end
end
class B
  def show
    "class B のオブジェクトです\n"
  end
end
class TestStubAndMock < Minitest::Test
  def test_stub_and_mock
    @a = A.new
    assert_equal("class B のオブジェクトです\n", @a.show_b)
    @mock = Minitest::Mock.new
    B.stub(:new, @mock) do
      @a = A.new
    end
    @mock.expect(:show, "ぼくはモックだよ!\n")
    @mock.expect(:show, "わたしはモックよ!\n")
    assert_equal("ぼくはモックだよ!\n", @a.show_b)
    assert_equal("わたしはモックよ!\n", @a.show_b)
    @mock.verify
  end
end

この例では、クラスAのインスタンス生成時にクラスBのインスタンスを作って@bに代入します。 クラスAのshow_bメソッドでは、@b.showによってクラスBのshowメソッドが呼ばれ"class B のオブジェクトです\n"が返されます。 ちょっと入り組んでいますが、良いでしょうか。

テストプログラムtest_stub_and_mockの最初の2行は今述べたことを実行して、@.show_bによって上述の文字列が返されたことを確認しています。 これは正しく動作し、テストはパスします。

メソッドの3行目から6行目では、モックオブジェクト@mockを生成し、stubメソッドによって、B.newの返り値を@mockにします。 本来B.newはクラスBのオブジェクトを返すのですが、モックを返すようになっているのです。 これによって、クラスAのオブジェクト@a上ではクラスBの振る舞いがモックの振る舞いに置き換わってしまいます。

次の2行はモックのshowメソッドが返す値を設定しています。 @a.show_bの中で、@b.showを実行しますが、@bにはクラスBのオブジェクトではなく、モックが入っているので返り値がモック設定のものになります。 そこで、2つのassert_equalが成功し、最後のverifyも予定通り2回呼ばれていたので成功します。 テストを実行するとすべてパスします。

この方法がクラスAで想定しているのは、initializeメソッドでB.newが呼ばれるだろうということだけです。 それがクラスAのリファクタリングで変更される可能性はごく小さいはずなので、テストはリファクタリング後も使える可能性が高いといえます。

それでは、次のセクションで単語帳プログラムの実例を見てみましょう。

lib_wordbook.rbとそのテスト

lib_wordbook.rbではWordBookクラスを定義します。 このクラスは、InputクラスとDBクラスのインスタンスを生成します(それぞれ@inputと@db)。 WordBookクラスのrunメソッドは、これらのインスタンスを使い、次のような動作をします。

  • ループの中で、@input.inputを呼び出す。 そのメソッドは標準入力(キーボード)からの入力を「コマンド、英語、日本語」の配列に変換して返す
  • コマンドに応じて、@dbのappend、delete、change、list、closeの各メソッドを呼び出す
  • コマンドがqならば、ループを抜け出すとともにrunメソッドを抜け出す

プログラムは次のようになります。

require_relative 'input.rb'
require_relative 'db.rb'

class WordBook
  def initialize(*file)
    @input = Input.new
    if file[0]
      @db = DB.new(file[0])
    else
      @db = DB.new
    end
  end

  def run
    while true
      a = @input.input #=> an array like [command, English, Japanese]
      return unless a
      case a[0]
      when 'a'
        @db.append(a[1], a[2])
      when 'd'
        @db.delete(a[1])
      when 'c'
        @db.change(a[1], a[2])
      when 'p'
        d = @db.list(a[1]).to_a
        d.each do |e,j|
          print "#{e} - #{j}\n"
        end
      when 'q'
        @db.close # save data
        break
      end
    end
  end
end

クラスから生成されるインスタンスの初期化はinitializeメソッドで行います。 このメソッドの引数が*fileとなっているのは、可変長引数を表します。 呼び出し側が、ファイルを引数にする場合と、引数なしの場合があるので、可変長にしました。 引数は配列の形でパラメータfileに代入されます。 実際には引数はあったとしてもひとつで、それはfile[0]に代入されています。 その引数があれば、それを引数にしてDBクラスのインスタンスを生成します。 引数が無ければ(f[0]==nil)、引数なしでDBクラスのインスタンスを生成します。 また、Inputクラスのインスタンスも作ります。

runメソッドはwhile trueの無限ループ内で、入力に応じた@dbのメソッドを呼ぶだけです。 pコマンドの時だけ、@dbから得たデータを標準出力に出力するのが、唯一自分自身の仕事になっています。

さて、このファイルをテストする段階で、まだinput.rbとdb.rbはできていません。 require_relativeでエラーにならないように、空のファイルを置いているだけです。 それらのファイルが定義するInputクラスとDBクラスはテストプログラムの中で定義されます。 また、それらのメソッドはモックの「みせかけのメソッド」になります。 以下はtest_lib_wordbook.rbのプログラムリストです。

require 'minitest/autorun'
require_relative 'lib_wordbook.rb'

# dummy class
class Input
end
class DB
  def initialize(*file)
  end
end

class TestLibWordbook < Minitest::Test
  def test_run
    @mock_input = Minitest::Mock.new
    @mock_db = Minitest::Mock.new
    Input.stub(:new, @mock_input) do
      DB.stub(:new, @mock_db) do
        @wordbook = WordBook.new
      end
    end

    args = []
    args << [['a', 'append', '付け足す'], :append, nil, ['append', '付け足す']]
    args << [['d', 'append'], :delete, nil, ['append']]
    args << [['c', 'append', '付け加える'], :change, nil, ['append', '付け加える']]
    args << [['p', 'app...'], :list, [['append', '付け加える']], ['app...']]

    args.each do |a|
      @mock_input.expect(:input, a[0])
      @mock_db.expect(a[1], a[2], a[3])
      @mock_input.expect(:input, ['q'])
      @mock_db.expect(:close, nil)
      if a[0][0] == 'p'
        assert_output("append - 付け加える\n") {@wordbook.run}
      else
        @wordbook.run
      end
      @mock_input.verify
      @mock_db.verify
    end
  end
end

テストプログラムについて説明します。

InputとDBクラスを定義しておきます。 これらはテスト用のダミーです。 なお、DBクラスのnewメソッド呼び出しには引数がある場合と無い場合があるので、initializeメソッドの引数にはアスタリスクを付けて可変長にします。

WordBookクラスのinitializeメソッドでInput、DBクラスのインスタンスが@inputと@dbに代入されます。 テストではそれらにモックを入れるために、stubメソッドで両クラスのnewメソッドの返り値をモックに変えてWordBook.newを実行します。 これで、runメソッドで使う@inputと@dbがモックオブジェクトを表すようになります。

test_runメソッドがテスト本体です。 まず、argの配列を作ります。 4行あるのが、それぞれ、a、d、c、pのコマンドを入力するときの諸データを配列にしたもので、それが<<メソッドでargに追加されていきます。 最初のデータがeachメソッドのループでどのように使われるかを見ていきましょう。

  • a[0]=['a', 'append', '付け足す'] 'なので、まず@wordbook.input.expect(:input, a[0])で、@inputのモックがinputメソッドに対し'['a', 'append', '付け足す']'を返すように定義をします。 これにより、@input.inputが呼ばれた時に['a', 'append', '付け足す']`が返されます。
  • a[1] = :appenda[2] = nila[3] = ['append', '付け足す']なので、@wordbook.db.expect(a[1], a[2], a[3])のところでは、@dbのモックがappendメソッドに対し、返り値nilで引数が'append', '付け足す'となるように定義をします。 返り値はrunメソッド内では使われていないので、nil以外のものでも構いません。
  • @wordbook.input.expect(:input, ['q'])で次の@input.inputメソッドの返り値を'q'にします。 これはrunメソッドの2回めのループでの呼び出しです。
  • @wordbook.db.expect(:close, nil)で、@db.closeが引数なしで呼び出されるよう定義します。
  • a[0][0]'a'でしたから、else節が実行され、@wordbook.runすなわちrunメソッドが実行されます。 このなかで@input.input、@db.append、@input.input、@db.closeがこの順で呼ばれるはずです。
  • @wordbook.input.verifyで@inputに代入されたモックが、設定されたメソッドを呼んだかをチェックします。
  • @wordbook.db.verifyで@dbに代入されたモックが、設定されたメソッドを呼んだかチェックします。

以上が1セットでこれを内容を変化させて全部で4セット行います。 実行すると、

$ ruby test_lib_wordbook.rb 
Run options: --seed 3358

# Running:

.

Finished in 0.005851s, 170.9253 runs/s, 170.9253 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

無事にテストが通過しました。 内容が複雑でしたが、大丈夫でしょうか。

モックが下位プログラムの代わりをしてくれた、ということが大事な点です。

さて、このテストプログラムではWordBook.newでInputクラスとDBクラスのインスタンス生成が行われていると仮定しました。 これが将来のリファクタリングで変更される可能性は小さいですが、もし変更されればテストプログラムの変更もしなければなりません。 それは、テストプログラムがWordBookクラスの内容に(わずかですが)立ち入っているために起こることです。 すなわち、テストは「対象の振る舞いにフォーカスする」「内部構造に立ち入らない」という原則に触れていることになります。

これを原則に忠実なテストに置き換えるには、モックを諦めなければならないと思います。 なぜならモックはインスタンスの置き換えだからです。

代案としては「クラスのスタブを作る」方法があります。 ここでいうスタブとは、代用品のことで、本物のInputクラス、DBクラスではなく、テスト用に作るものです。 スタブにはテストに必要なすべてのメソッドを持たせ、テストに適するような出力をさせます。 このプログラムは分かりやすく、単純化されます。 モックよりもずっと簡単なので、勧められる方法です。

require 'minitest/autorun'
require_relative 'lib_wordbook.rb'

# dummy class
class Input
  def initialize
    @count = -1
  end
  def input
    @count += 1
    [['a', 'append', '付け足す'], ['d', 'append'], ['c', 'append', '付け加える'], ['p', 'app...'], ['q']][@count]
  end
end
class DB
  def initialize(*file)
  end
  def append(e,j)
    print "append(#{e}, #{j})\n"
  end
  def delete(e)
    print "delete(#{e})\n"
  end
  def change(e,j)
    print "change(#{e}, #{j})\n"
  end
  def list(e)
    print "list(#{e})\n"
  end
  def close
    print "close\n"
  end
end

class TestLibWordbook < Minitest::Test
  def setup
    @wordbook = WordBook.new
  end
  def test_run
    expected_output = "append(append, 付け足す)\ndelete(append)\nchange(append, 付け加える)\nlist(app...)\nclose\n"
    assert_output(expected_output) {@wordbook.run}
  end
end

inputメソッドは、カウンタを使って呼ばれるたびに異なる値を返します。 DBの各メソッドは呼ばれるたびに、メソッド名と引数を標準出力に書き出します。 テスト本体ではrunメソッドの出力結果(上記のDBお各メソッドの出力のトータル)と期待される文字列を比較するだけです。 このテストの良いところは

  • プログラムが分かりやすい
  • テスト対象の内部構造に関わらない

ということです。

このセクションでは、モックを使ったプログラムを書きましたが、それはモックの説明をしたかったからです。 実際にはモックを使わないプログラムの方が適切なテストプログラムだと私は思います。 テストには決まった方法がありません。 いろいろな方法が可能なので、その中で最も良いものをチョイスしてください。

input.rb

入力を担当するInputクラスの書かれたファイルinput.rbは次のようになります。

require 'readline'

class Input
  def input
    while true
      buf = Readline.readline("wb > ", false)
      if buf =~ /^[ac] +[a-zA-Z]+ +\S+$|^d +[a-zA-Z]+$|^p +\S+$|^q$/
        return buf.split(' ')
      else
        $stderr.print "(a|c) 英単語 日本語訳\nd 英単語\np 正規表現\nq\n"
      end
    end
  end
end

readlineライブラリをrequireし、一行入力を可能にします。

このように非常に簡単ですが、Readline.readlineの入力部分はテストする際にはスタブに置き換えて人為的に入力を作り出します。 なお、ここではモックを使うのが難しいのです。 というのは、モックはオブジェクトなのでReadlineに代入したいのですが、Readlineが定数なので再代入できないのです。 それで、モックを直接使うことはできません。 それでは、スタブを使ったテストプログラムを見ていきましょう。

require 'minitest/autorun'
require_relative 'input.rb'

class TestInput < Minitest::Test
  def test_input
    @in = Input.new
    Readline.stub(:readline, "a append 付け足す") { assert_equal(['a', 'append', '付け足す'], @in.input) }
    Readline.stub(:readline, "d append") { assert_equal(['d', 'append'], @in.input) }
    Readline.stub(:readline, "c append 付け足す") { assert_equal(['c', 'append', '付け足す'], @in.input) }
    Readline.stub(:readline, "p a..end") { assert_equal(['p', 'a..end'], @in.input) }
    Readline.stub(:readline, "q") { assert_equal(['q'], @in.input) }
    m = Minitest::Mock.new
    m.expect(:call, "abcd", ["wb > ", false])
    m.expect(:call, "q", ["wb > ", false])
    Readline.stub(:readline, m) {assert_output(nil, "(a|c) 英単語 日本語訳\nd 英単語\np 正規表現\nq\n"){ @result = @in.input }}
    assert_equal(['q'], @result)
  end
end

input.rbをrequire_relativeで取り込んでおきます。 test_inputメソッドがテストプログラムです。

  • まず、Inputクラスのインスタンスを@inに代入しておく
  • Readline.readlineメソッドをスタブで置き換え、"a append 付け足す"を返すようにする
  • ブロック({}の中)で@in.inputでinputメソッドを呼び出す。 Readline.readlineの返した文字列は有効な入力なので、それを配列にして['a', 'append', '付け足す']を返すはずである。 それをassert_equalでテストする。
  • 以下同様にd、c、p、qの各コマンドをテストする
  • 次に有効でない入力があったときのテストをするが、このとき「常に無効な入力=>無限ループ」になってしまうので、最初の入力は無効で2回めの入力を有効にしたい。 スタブだけではそれを実現できないのでモックと組み合わせる

ここで、前の方に出てきたモックとスタブの組み合わせが使われています。 複雑なので、もう一度説明しましょう。

スタブの引数は、メソッド名、返り値になっています。 返り値には、Procオブジェクトなどを入れることができます。 返り値にはProcオブジェクトが返されるのではなく、Procオブジェクトのcallメソッドを実行した値が返されます。 また、このオブジェクトはProcオブジェクトでなくてもcallメソッドを持っていれば、同様にcallメソッドの実行結果を返してくれます。 そこで、モックのexpectメソッドでみせかけのメソッドcallを定義します。 すると、stubはモックのcallメソッドを呼び、expectで設定した返り値が返されます。 モックは複数回expectメソッドを使って、順に異なる返り値を設定できます。

  • モックオブジェクトを生成して変数mに代入する
  • mの最初の返り値を"abcd"(無効な入力)、2回めの返り値を"q"(有効な入力)とする
  • @in.inputを実行すると入力形式の案内が標準エラー出力に出るので、assert_outputでテストする
  • @in.inputの返り値は@resultに代入しておき、次の行でassert_equalでテストする

ここではモックを使って2回の呼び出しに対して異なる返り値を作成しました。 同じことはProcオブジェクトを使ってもできますし、むしろモックよりも複雑なことをできます。 モックで機能が足りないと思ったらProcオブジェクトを考えてみてください。

テストの実行結果は掲載しませんが、きちんとパスします。

スタブを使うのは複雑になりがちです。 それに対して、前のセクションでクラスのスタブを使ったように、Readlineのスタブを作る方法もあります。 これは、Readlineモジュールのreadlineメソッドをテスト用に再定義してしまう方法です。 こんなおそろしいことをして良いのかと思うかもしれませんが、Rubyでは珍しいことではありません。

require 'minitest/autorun'
require 'readline'
require 'stringio'
require_relative 'input.rb'

module Readline
  def self.readline(pronpt="> ", history=false)
    unless @stringio
      @stringio = StringIO.new("a append 付け足す\nd append\nc append 付け足す\np a..end\nq\nabcd\nq\n")
    end
    @stringio.readline.chomp
  end
end

class TestInput < Minitest::Test
  def test_input
    @in = Input.new
    assert_equal(['a', 'append', '付け足す'], @in.input)
    assert_equal(['d', 'append'], @in.input)
    assert_equal(['c', 'append', '付け足す'], @in.input)
    assert_equal(['p', 'a..end'], @in.input)
    assert_equal(['q'], @in.input)
    assert_output(nil, "(a|c) 英単語 日本語訳\nd 英単語\np 正規表現\nq\n"){ @result = @in.input }
    assert_equal(['q'], @result)
  end
end

Readlineモジュールの書き換えのためにrequire 'readline'が必要です。 readlineはReadlineの特異メソッドなので、def self.readlineとして再定義します。

文字列をファイルのように見立てるStringIOというクラスがあります。 このクラスにはreadlineメソッドがあり、文字列から1行ずつ返してくれます。 これがちょうどReadline.readlineの代わりに良いので、再定義の中で使います。 StringIOを使うにはrequire 'stringio'が必要です(ただ、このプログラムではminitestがrequireしているので、書かなくてもrequireされますが)。 はじめて呼ばれるときは@stringioが未定義なので、StringIOのインスタンスを代入します。 StringIO.newの引数が入力の元となる文字列です。 2度目の呼び出しではunlessのところを飛び越します。 @stringio.readlineによって、文字列から1行ずつ(つまり\nで区切られた文字列がひとつずつ)返されます。 Readline.readlineでは行末の改行が切られているので、chompメソッドで改行を落としておきます。

テスト本体ではassert_equalなどで順にInput#inputメソッド(Inputクラスのインスタンスメソッドinputをこのように書くことがあります。 これはドキュメントの中だけで、プログラム中で書くのではありません)をテストするだけです。 このプログラムではstubメソッドを使わずにReadline.readlineを書き換えています。 どちらが良いかは一概に言えませんが、今回のテストプログラムでは後者の方が分かりやすくすっきりとしています。

今回、非常に簡単なプログラムに対して難しいテストプログラムを書きましたが、これは正しい方法なのでしょうか? 私だったら、直接動かしてチェック(人手でチェック)します。 このような簡単で短いプログラムでは、その方が手っ取り早いからです。 今回はテストプログラムを書いたのは、あくまでスタブの説明のためです。

ただ、一般にはテストプログラムは必要で有効なことが多いです。

CSVクラス

CSVクラスはcsv(comma separated values)、コンマ区切りデータ形式を扱うクラスです。 IOクラスのように使え、かつコンマ区切りデータを扱えます。 コンマ区切りデータとはその名の通り、行の中でコンマで区切られたデータです。

pen,ペン
bread,パン

このように、各行には同じ数のコンマ区切りのデータがあります。 上記の例はRubyのデータ構造では次のようになります。

[["pen","ペン"], ["bread","パン"]]
  • CSVファイルの読み出しにはCSV.readを使う。 上記の例のようにCSVファイルの内容が2次元配列として返される。 1行目をヘッダ(タイトル行)とすることもできる。 その場合、引数にハッシュ{header: true}を入れる。 タイトルが無ければ{header: false}を用いる
  • 書き込みにはCSV.openと<<演算子を使う

次のプログラムは、CSVを使った読み書きの典型的な例です。

# 読み込み
array = CSV.read(CSVファイル名, headers: false)
# 書き出し
CSV.open(CSVファイル名) do |csv|
  array.each {|a| csv << a}
end

DBクラスでは単語帳のデータを2次元配列で表し、作業の開始、終了時点でCSVファイルに読み込み、書き出しをします。

db.rbとそのテスト

db.rbの内部ではデータを2次元配列インスタンス変数@dbに格納し、各メソッドで@dbにデータの付加、削除、変更、照会などをします。 プログラムは短く簡単です。

require "csv"

class DB
  def initialize(file='db.csv')
    @file = file
    if File.exist?(@file)
      @db = CSV.read(@file, headers: false)
    else
      @db = []
    end
  end
  def append(e,j)
    @db << [e,j]
  end
  def delete(e)
    i = @db.find_index{|d| e == d[0]}
    @db.delete_at(i) if i # i is nil if the search above didn't find e in @db.
  end
  def change(e,j)
    i = @db.find_index{|d| e == d[0]}
    if i
      @db[i] = [e,j]
    else
      @db <<[e,j]
    end
  end
  def list(e)
    pat = Regexp.compile(e)
    @db.select{|d| pat =~ d[0]}
  end
  def close
    CSV.open(@file, "wb") do |csv|
      @db.each {|x| csv << x}
    end
  end
end
  • initializeでCSVデータを読み込み、closeで書き出しをする
  • append、delete、change、listは2次元配列への追加、削除、変更、照会をする。
  • listメソッドでは引数を正規表現オブジェクトに変えてから(Regexp.compileメソッド)、それに一致するデータの配列を返す

このプログラムのテストは、2つに分かれます。

  • CSVファイルの入出力は、CSVクラスの仕事なので、それをテストの対象から除いた部分のテストをする
  • CSVファイルの入出力が上手くコントロールされているかの部分のテストをする

本来のテストは1番めだけで良いと思いますが、ここでは2番めもテストします。

require 'minitest/autorun'
require_relative 'db.rb'

class TestDB < Minitest::Test
  def test_db
    File.stub(:exist?, true) do
      CSV.stub(:read, [["pen","ペン"],["pencil","鉛筆"]]) do
        @db = DB.new
      end
    end
    assert_equal([["pen","ペン"]], @db.list("^pen$"))
    assert_equal([["pen","ペン"],["pencil","鉛筆"]], @db.list("pen"))
    @db.append("circle","")
    assert_equal([["circle",""]], @db.list("cir"))
    @db.change("circle","円周")
    assert_equal([["circle","円周"]], @db.list("cir"))
    @db.delete("pen")
    assert_equal([["pencil","鉛筆"], ["circle","円周"]], @db.list("."))
  end
  def test_csv
    File.write("test.csv",<<~CSV)
    pen,ペン
    pencil,鉛筆
    CSV
    @db = DB.new("test.csv")
    @db.append("circle","")
    @db.change("circle","円周")
    @db.delete("pen")
    @db.close
    assert_equal("pencil,鉛筆\ncircle,円周\n",File.read("test.csv"))
    File.delete("test.csv")
  end
end
  • test_dbメソッドでは、stubメソッドをネストして使い、ファイル入力の結果が[["pen","ペン"],["pencil","鉛筆"]]になるとしている
  • test_dbメソッドではlistメソッドで内容照会をし、assert_equalでテストする方法をとっている 他のappend、delete、changeについては、その実行後にlistメソッドを使い、正しく実行されているかをテストしている
  • test_csvメソッドではテスト用のCSVファイル「test.csv」を作り、append、delete、change、closeの後に「test.csv」がその作業を反映しているかどうかをテストする

実際にテストを実行してみると、すべてパスします。

wordtest.rbの実行

テストはすべて通ったので、wordtest.rbを実行してみました。 いくつか英単語と日本語訳を入力して、作成されたCSVファイルを見てみると、正しく反映されていました。 小さいプログラムですが、動くと嬉しいものです。 プログラムの今後の発展方向としては

  • 例文、備考などのフィールドを追加する
  • 単語テストのコマンドを作る(英=>日と日=>英の両方向のテスト)
  • CSVでなく、データベースを使う

などが考えられます。 ただ単語帳ソフトが本当に役立つプログラムなのかは疑問が残ります。 どうでしょうか? この問に対する答えは英語教育の専門家でなければ出せないでしょう。 一般に、プログラムが有用かどうかは開発者には分からないことが多いです。 その分野の専門家とソフト開発者の協力はとても大切なことです。

今回は実用には程遠い単語帳プログラムではありますが、開発とテストの実例として見てきました。 実際の開発はもっと規模が大きいですが、同様の手順、すなわちユニットごとに作成とテストを繰り返すことになります。 そのときには、minitestを有効に活用して開発を進めてください。

最後にminitestについて述べます。

minitestは高速です。 大きな開発で使うとそれがよく分かります。 なぜかというと複数のテストをマルチメソッドで並行して行うからです。 逆にこのことはテスト相互が独立していないとコンフリクトを起こす可能性があることを示唆しています。 プログラムの上から下へテストするのではなく、各メソッドは同時並行で非同期に進みます。

minitestはウェブ開発フレームワークRuby on Railsにおける標準のテストシステムになっています。 Railsでは、railsに合うようにminitestの機能を拡張しています。 詳しくはRails Guideを参照してください。 日本語訳もあります。

大きなプログラムのテストでは、Rakeを使ってテストを自動化することができます。 これについては、「はじめてのRake」に説明があります。

今回のテストをするためのRakefile

require "rake/testtask"

FileList['test*.rb'].each do |file|
  Rake::TestTask.new do |t|
    t.test_files = [file]
    t.verbose = false
  end
end

です。 コマンドラインから

$ rake test

とすると、すべてのテストが実行されます。 rakeに引数testが必要なことに注意してください(通常は引数なしでrakeを起動することが多いので)。