ダメ人間を支える技術

プログラミングとか。インターンとか。

RSpecの基本

こんばんは。
学習は、15日目です。前回の続き、自動テストについてです。
テスト駆動開発(TDD)と振舞駆動開発(BDD) - ダメ人間が生きている
今日は、RSpecによるテストを書いていきます。

testinitで書いたテストをRSpecで書いてみる

前回testinitで作成した、引数が"shigotop"か否かを判定するメソッドis_shigotopに対するテストを、今回はRSpecで書いてみます。

require "./spec_helper.rb"
require "./nameinspecter.rb"

describe NameInspecter do
  before do
    obj = NameInspecter.new
  end

  it "shigotoはfalse" do
    @obj.is_shigotop('shigoto').should be_false
  end

  it "shigotopはtrue" do
    @obj.is_shigotop('shigotop').should be_true
  end

  it "shigotopoはfalse" do
    @obj.is_shigotop('shigotopo').should be_false
  end
end

文法とかも、前回ブログ参照。

[chanco:spec]rspec name_spec.rb 
Run options: include {:focus=>true}

All examples were filtered out; ignoring {:focus=>true}
FFF

Failures:

  1) NameInspecter shigotopoはfalse
     Failure/Error: @obj.is_shigotop('shigotopo').should be_false
     NoMethodError:
       undefined method `is_shigotop' for nil:NilClass
     # ./name_spec.rb:18:in `block (2 levels) in <top (required)>'

  2) NameInspecter shigotopはtrue
     Failure/Error: @obj.is_shigotop('shigotop').should be_true
     NoMethodError:
       undefined method `is_shigotop' for nil:NilClass
     # ./name_spec.rb:14:in `block (2 levels) in <top (required)>'

  3) NameInspecter shigotoはfalse
     Failure/Error: @obj.is_shigotop('shigoto').should be_false
     NoMethodError:
       undefined method `is_shigotop' for nil:NilClass
     # ./name_spec.rb:10:in `block (2 levels) in <top (required)>'

Finished in 0.00091 seconds
3 examples, 3 failures

Failed examples:

rspec ./name_spec.rb:17 # NameInspecter shigotopoはfalse
rspec ./name_spec.rb:13 # NameInspecter shigotopはtrue
rspec ./name_spec.rb:9 # NameInspecter shigotoはfalse

Randomized with seed 43035

エラーです。
nilに対するis_shigotopというメソッドは定義されてません」とのこと。
テストコード中でis_shigotopに引数として与えている文字列が、nilと認識されてるのでしょうか。
とおもいきや、before内で、objとしているところに@が抜けていました。
@も含めたクラス内変数、というかんじですね。

[chanco:spec]rspec name_spec.rb 
Run options: include {:focus=>true}

All examples were filtered out; ignoring {:focus=>true}
...

Finished in 0.00483 seconds
3 examples, 0 failures

Randomized with seed 65395

無事、通りました。
本来は、通らないべきですが、ここは割愛。

Rubyの課題のテストをRSpecで書いてみる

次は、Rubyのときにやったプログラムの、テストを書きます。
Rubyのインストールと基本 - ダメ人間が生きている

と、ここで問題が。
上記課題で書いたプログラム、OrangeTreeクラスとDragonクラスの課題以外、すべて処理の羅列です。
オブジェクト指向皆無のプログラムです。
なので、RSpecでテストできません。
できるのかもしれませんが、やりません。

というわけで、OrangeTreeクラスをテストしてみる。

まず、「一年経過」。
oneYearPassesメソッド実行後、インスタンス変数@yearがインクリメントされてるか。

it "一年経過" do
  @tree.oneYearPasses
  @tree.instance_variable_get(:@year).should == 1
end

インスタンス変数は、instance_varible_getメソッドで取得できます。
特に問題ありません。

次に、「七年経過で木が死ぬ」。
七年経過したら、標準出力に「木は死にました」と表示し、exitするかテストします。

標準出力のテスト

標準出力をテストするには、どうすればいいのでしょうか。
RSpecで標準出力をテストしたい - Qiita
ここでやっていることが、ぼくには理解できません。
コピペしたら案の定エラー出たので、文法を理解します。

上記サイトで、以下の処理が意味不明です。

 capture(:stdout) { @target.hello }.should == 'hello\n'

焦らず、順番に、解読していきます。

シンボルと文字列の違い

まず、:stdoutの:とは??シンボルと呼ぶらしいですが。
Ruby のシンボル | すぐに忘れる脳みそのためのメモ

シンボルとは、Ruby が内部でメソッド名などの識別に使っている数値で、任意の文字列に対して異なった値が割り当てられます。

文字列とおなじ感覚で使えますが、文字列との違いとしては、例えばいったん:xが定義されると、どこから参照しても、:xは同一のオブジェクトを指します。たぶん。
つまり、:stdoutは、標準出力を参照するシンボルとしてRuby内部で定義されているっぽいです。

メソッドにブロックを渡す

{ @target.hello }は、ブロックです。たぶん。
わからないのは、captureメソッド(引数) {ブロック}、というつながりです。
ここでブロックは、なんなんでしょうか。
Minituku - a Ruby e-learning system

ブロック付きメソッド呼出しをおこなった時に、メソッドの後ろに付けるブロックはRubyの特徴的な機能のひとつです。
yieldを使うことによって、ブロック付きメソッド呼出しの時にブロックの中のプログラムの処理をおこないます。
定義したメソッドを呼び出した時にyieldがメソッドの中にないと、ブロックの中の処理をおこないません。yieldがないとブロックの中にある処理は無視します。メソッドの中にyieldがある時は、ブロックの中へ処理を移します。ブロックの中の処理をすべておこなってから、またメソッドの中に戻り、yieldの下から処理をおこないます。

つまり、上記の処理の流れは以下になります。

  1. 標準出力を引数としてcaptureメソッドに渡す
  2. captureメソッドの処理を、yieldの直前まで実行する
  3. @target.helloを実行する
  4. captureメソッドのyield以降の処理を実行する


次に、captureメソッドの中身を見ていきます。

def capture(stream)
    begin
        stream = stream.to_s
        eval "$#{stream} = StringIO.new"
        yield
        result = eval("$#{stream}").string
    ensure
        eval "$#{stream} = #{stream.upcase}"
    end
    result
end
例外処理

begin、ensureは例外処理です。
制御構造
begin以降の実行中に例外が発生した場合でも、begin式終了前にensure節を評価します。begin式全体の評価値は、本体/rescue節/else節のうち 最後に評価された文の値であり、ensure節の値は無視されます。
これだけではよくわからないので、さらにその中身を見ていきます。

コード内での展開

第17章 動的評価

evalを使うと実行時にその場で文字列をコンパイルし、評価することができる。 返り値はそのプログラムの最後の式の値だ。

また、$は、グローバル変数、#{}はリテラルの式展開です。つまり、eval "$#{stream} = StringIO.new"は、eval "$(:stdout.to_s) = StringIO.new"というかんじになります。グローバル変数である:stdoutに対して、StringIOインスタンスを代入します。


以上より、captureメソッドのの中身も含めた処理の流れは、以下になります。

  1. 標準出力のハンドラをcaptureメソッドに渡す
  2. 標準出力のハンドラに、文字列処理のインスタンスを代入する
  3. @target.helloを実行し、標準出力に"hello"と出力する
  4. 標準出力のハンドルから"hello"を取得し、その文字列を返す

やっと解読できました。。

exitのテスト

bear.mini : RSpec で exit したかどうかをチェックする方法
ここにそって、テストを書きます。lambda{}は、Proc.new{}と同じ(厳密には違う)で、ブロックをオブジェクトにします。詳しくは、こちら

というわけでテストを書いてみる

長くなりましたが、ここまでのテストを書いてみます。「一年たったら一つ歳をとる」「七歳で木が死んでメッセージ表示」「七歳で木が死んでexit」のテストです。

require "./spec_helper.rb"
require "../lib/orange_tree.rb"
require "stringio"

def capture(stream)
  begin
    stream = stream.to_s
    eval "$#{stream} = StringIO.new"
    yield
    result = eval("$#{stream}").string
  ensure
    eval "$#{stream} = #{stream.upcase}"
  end
  result
end

describe OrangeTree do
  before :each do
    @tree = OrangeTree.new
  end

  it "一年たったら一つ歳をとる" do
    @tree.oneYearPasses
    @tree.instance_variable_get(:@year).should == 1
  end

  it "七歳で木が死んでメッセージ表示" do
    6.times do
      @tree.oneYearPasses
    end
    capture(:stdout){@tree.oneYearPasses}.should be_include '木は死にました' 
  end

  it "七歳で木が死んでexit" do
    6.times do
      @tree.oneYearPasses
    end
    lambda {@tree.oneYearPasses}.should raise_error(SystemExit)
  end
end

実行結果は、以下のとおりです。

[chanco:spec]rspec orange_tree_spec.rb 
Run options: include {:focus=>true}

All examples were filtered out; ignoring {:focus=>true}
.F木は死にました
.

Failures:

  1) OrangeTree 七年で死んでメッセージ表示
     Failure/Error: capture(:stdout){@tree.oneYearPasses}.should == '木は死にました'
     SystemExit:
       exit
     # /Users/kabata/Documents/intern/rspec_exercise/lib/orange_tree.rb:16:in `exit'
     # /Users/kabata/Documents/intern/rspec_exercise/lib/orange_tree.rb:16:in `oneYearPasses'
     # ./orange_tree_spec.rb:31:in `block (3 levels) in <top (required)>'
     # ./orange_tree_spec.rb:9:in `capture'
     # ./orange_tree_spec.rb:31:in `block (2 levels) in <top (required)>'

Finished in 0.00187 seconds
3 examples, 1 failure

Failed examples:

rspec ./orange_tree_spec.rb:27 # OrangeTree 七年で死んでメッセージ表示

Randomized with seed 42776

エラーです。exitしてしまうので、captureできない、というかんじでしょうか。
ためしに、テスト対象のorange_tree.rbでexitしないように書き換えると、とおりました(当然、代わりにexitの方のテストが引っかかります)。

exitするときって、たいがい何かしらのメッセージをputsして終わると思うんですが、どうやってテストすればいいんでしょうか。
Capturing Standard Out In Unit Tests - Pivotal Labs
こちらを参考に、標準出力を文字列にリダイレクトするよう、テストを修正しました。

--略--
  it "七年で死んでメッセージ表示" do
    6.times do
      @tree.oneYearPasses
    end
    output = capture(:stdout) do
      lambda{@tree.oneYearPasses}.should raise_error(SystemExit)
	end
    output.should be_include '木は死にました'
  end

無事、テストを通りました。

OrangeTreeクラスのSpec

というわけで、全体のSpecを作成しました。長いのでgithubで。
rspec_exercise/orange_tree_spec.rb at master · shigotop/rspec_exercise · GitHub

実行してみました。

[chanco:spec]rspec orange_tree_spec.rb 
Run options: include {:focus=>true}

All examples were filtered out; ignoring {:focus=>true}
FF.....

Failures:

  1) OrangeTree 木になっているオレンジの数を返す
     Failure/Error: @tree.countTheOranges.should == orange
       expected: 3
            got: nil (using ==)
     # ./orange_tree_spec.rb:52:in `block (2 levels) in <top (required)>'

  2) OrangeTree 三年目から毎年年齢と同じ数の実をつける
     Failure/Error: @tree.instance_variable_get(:@orangeCount).should == 0
       expected: 0
            got: -1 (using ==)
     # ./orange_tree_spec.rb:39:in `block (3 levels) in <top (required)>'
     # ./orange_tree_spec.rb:38:in `times'
     # ./orange_tree_spec.rb:38:in `block (2 levels) in <top (required)>'

Finished in 0.00459 seconds
7 examples, 2 failures

Failed examples:

rspec ./orange_tree_spec.rb:47 # OrangeTree 木になっているオレンジの数を返す
rspec ./orange_tree_spec.rb:37 # OrangeTree 三年目から毎年年齢と同じ数の実をつける

Randomized with seed 28518

2つ、バグが見つかりました。
ひとつめは、returnする変数名が間違っていました。ふたつめは、実がなりだす(3年目から)前から、「オレンジの数を前年なった個数分減らす」処理をしていました。
ふたつめの修正の影響で、

rspec ./orange_tree_spec.rb:68 # OrangeTree ある年に取り残したオレンジは次の年に落ちる

となりました。さっきこれがとおっていたので、テストが間違っていたということです。最悪のケースですね。反省します。全体を修正し、無事テストをとおりました。これでデバッグが完了です。

もうひとつ、Dragonクラスもありましたが、疲れたので、とりあえずここまで。githubにpushして確認してもらいます。
(課題クリアに必要なら全課題についてテスト書きますサボりですすいません)

まとめ

合計、5時間くらいです。
わからないところを調べる時間が長くなってしまい、あまり進みませんでした。

RSpecについて

まず、テストを書くのが、難しいです。RSpecをマスターするまで、調べながら、やっていくしかなさそうです。焦らず、まずテストをしっかりとつくり、コードを書いていく、というのがよさそうです。なかなかできませんが。テストを書く練習をめんどくさいと思ってしまう時点で、危ういですね。
プログラム書くの楽しいですが、ちょっと長くなると、とたんに把握できなくなります。そういう意味でも、プログラムを個々の部位に分けて、作っていく、TDD(BDD?)を、はやくマスターしたいです。

今後について

そろそろ頭の容量が追いつかなくなってきましたが、次回は、いよいよ待ちに待ったRailsです。Ruby on Rails チュートリアル:実例を使って Rails を学ぼうを進めていきます。楽しみです。その前に、これまでの全体を復習しておこうと思います。ここまで、十分な理解が得られているのか、不安です。

その他

自分のコードをgithubでみると、インデントがおかしいです。vimが勝手にインデントするので、そのあたりの設定をいじって次回から望みます。それにしても、vimあいかわらず慣れないです。。
それと、他のインターン生のブログにコメントつけてみました。なんでもいいからコメントしあう環境になれば、いいかなあと思って、とりあえず自分から。
本業のほうがしんどくなってきて、心の余裕があまりありませんが、楽しいので、学習がんばって進めます。