j-kbt 備忘録

日々のお勉強や思ったことを残しておくもの

Effective Testing With RSpec 3 を読んだのでまとめる

Part1 Geting Started

Getting Started With RSpec

この章では基本的知識が身につく. 列挙されていたのは以下. - RSpecのインストールと簡単な例 - describe と it の記述方法 - expect を用いた 結果確認 - テスト失敗の記述方法 - 繰り返しの 前処理を避ける方法

テストを書くことによるメリットとして以下がある. - 設計の手引となること. 頭の中のアイディアを実際に動くメンテナンス容易なコードにできる - セーフティネットとなること. 本番環境に出る前にバグを検出できる. - ドキュメントとなること. システムの振る舞いを記述しているため, 保守の助けとなる.

RSpecをインストールする

前提として, Homebrewがインストールされているものとする. まず, rubyの環境を整えるために, rbenvをインストールする. rbenvを使うときには, ruby-buildも必要となるため, 同時にインストールする.

$ brew update
Updating Homebrew...
$ brew install rbenv ruby-build
... 略

インストールが終わったら, rubyとrbenvのバージョンを確認しておく.

$ ruby --version
ruby 2.6.3p62 (2019-04-16 revision 67580) [x86_64-darwin18]
$ rbenv -v
rbenv 1.1.2

今回の私の環境だと, 最新の安定版より少し前のバージョンであることがわかったため, rbenvを使って最新の安定版をインストールする. オブジェクト指向スクリプト言語 Ruby
rbenv install --list で最新のバージョンが有ることを確認して, インストールする.

$ rbenv install --list
1.8.5-p52
1.8.5-p113
1.8.5-p114
...
2.7.0-preview3
2.7.0-rc1
2.7.0-rc2
2.7.0
...

$ rbenv install 2.7.0

使用するrubyを新しいバージョンにし, その後, バージョンを確認する. 新しくなっていたらOK.

$ rbenv global 2.7.0
$ ruby --version
ruby 2.7.0p0 (2019-12-25 revision 647ee6f091) [x86_64-darwin19]

ここまで整えて, ようやくRSpecのインストール. RSpec公式 を参照すると, 現在3.9が最新版らしいので, これをインストールする.

$ gem install rspec -v 3.9.0
...
6 gems installed
$ rbenv rehash
$ rspec --version
RSpec 3.9
  - rspec-core 3.9.1
  - rspec-expectations 3.9.0
  - rspec-mocks 3.9.0
  - rspec-support 3.9.1

RSpecの基本的な記述

ここまで来たら一旦準備は完了. RSpecのコードを書いていけばよい. 実態のコードは本の GitHub を見てもらえば良くてここでは, 勉強したことを要約して記述する.

RSpec.describe 'テスト対象' do
  it '振る舞い' do
    #何らかの定義 (宣言とかのコード)
    example = Example.new('引数1', '引数2')
    #期待する振る舞いを見るためのコード
    expect(example.first).to eq('引数1')
  end
end

ここで, RSpec.describeブロックはexample groupを作る. example groupは何をテストするかを定義し, 関連する spec を保持する.
ネストの中にある it で始まるブロックは, テスト対象がどのような振る舞いをする (期待されているか) を記述するもの(example: 他のテストフレームワークではテストケースと呼ばれるもの).
expect の記述で期待される振る舞いを記載する.

specを書く目的 - コードがどのように動くかを記述するもの - 想定されたとおりにコードが動くか

ドキュメントをきちんと整備することについても非常に重要だと思うが, 正直単体レベルの仕様書なんて書いていられないのは間違い無いと思う. メンテナンスコストもかかるし. だったらきちんとテストを読みやすく書いておいたら振る舞いがはっきりするよねという発想は自然なアイディアかなと思う.
RSpecの思想的にはより読みやすくメンテナンス性の高いコードを提供するといった感じに読み取れた.

RSpecの初期化コードを共通化する

例えば特定のクラスのテストをする場合, そのインスタンスをテストのたびに作成し, 初期化することになる. これをそれぞれのテストコードに書いていった場合, 途中で変更が入った場合変更が難しくなる (できなくはないが非常に面倒くさい) そレに対策するために, RSpecではいくつかの方法がサポートされている.

  • hooks を利用する
  • Helper methods を利用する
  • let を利用する

例えば, 以下みたいなテストコードがあったとする.

RSpec.describe 'テスト対象' do
  it '振る舞い1' do
    #インスタンスの作成
    example = Example.new('引数1', '引数2')
    #期待する振る舞いを見るためのコード
    expect(example.first).to eq('引数1')
  end

    it '振る舞い2' do
      #インスタンスの作成
      example = Example.new('引数1', '引数2')
      #期待する振る舞いを見るためのコード
      expect(example.second).to eq('引数2')
    end
end

この例では, Example のインスタンスを作成する宣言が重複しており, この部分の定義が変わるたびに全てのインスタンス宣言を書き換える必要がある. これは保守性が悪いため, それぞれの対処法を確認していこう.

Hook を利用する

example group の先頭に before hook を追加する. これにより全ての example 実行前に before hook の内容を実行する.

RSpec.describe 'テスト対象' do
  before {@example = Example.new('引数1', '引数2')}
  it '振る舞い1' do
    expect(@example.first).to eq('引数1')
~ 略

before hook を用いると各 example 実行前に必ず初期化処理などが走るため, テストをうまく整理することができるが, 数点不利な点もある (以下の列挙は引用)

  1. スペルミス, Typo のときにrubyはその状態で実行してしまうので何故失敗したのかが分かりづらい
  2. リファクタリングする時にすべての example の該当部分を @なんたら みたいな形に置き換えなくてはいけない
  3. hook の中に重い処理やコストの高い処理を入れてしまうと, それを使用しない example の実行前にも走ってしまうため, 工夫が必要

どれもしょうがない気もする. 一番つらいのは3つ目かなと感じた. これは example group を分けるなどの工夫が必要なのかなと感じる.

Helper methods を利用する

先程まで使用していた, example groupsの例では不十分であるため, 新たに以下の例を使用する.

RSpec.describe 'テスト対象' do
  it '振る舞い1' do
    #インスタンスの作成
    target = Target.new('引数1', [])
    first = target.first
    #期待する振る舞いを見るためのコード
    expect(first).to eq('引数1')
  end

  it '振る舞い2' do
    #インスタンスの作成
    target = Target.new('引数1', [])
    target.second << 'a'
    second = target.second
    
    #期待する振る舞いを見るためのコード
    expect(second).to eq('引数2')
  end
end

このコードの example group の先頭にメソッドを追加して以下のようなコードとする.

RSpec.describe 'テスト対象' do
  def target
    Target.new('引数1', [])
  end
  it '振る舞い1' do
    #インスタンスの作成
    first = target.first
    #期待する振る舞いを見るためのコード
    expect(first).to eq('引数1')
  end

  it '振る舞い2' do
    #インスタンスの作成
    target.second << 'a'
    second = target.second
    
    #期待する振る舞いを見るためのコード
    expect(second).to eq('引数2')
  end
end

これは, 先頭でメソッドを定義することにより, 実行のたびに初期化が実行されるものとなる.
一見うまくいくように見えるが, RSpec を実行してみると失敗する事がわかる. 'target' と記載されている部分で, new で新しいインスタンスが作成されるため, 下記1行目の target と2行目の target は別のインスタンスを指しているため, 2行目の second には 'nil' が入っていることになる.

  target.second << 'a'
  second = target.second

毎回同じ結果を返すメソッドであるならば, memorization と言われる方法を用いて同じ結果を返したほうが効率がよい. こちらも, 使い方の注意などがあり (メソッドが失敗したときの動作をどうするかなど) 深い議論については Justin Weiss の記事 が参考になる. この議論をもとに, 実装すると以下のようになる.

RSpec.describe 'テスト対象' do
  def target
    @target ||= Target.new('引数1', [])
  end
  it '振る舞い1' do
    #インスタンスの作成
    first = target.first
    #期待する振る舞いを見るためのコード
    expect(first).to eq('引数1')
  end

  it '振る舞い2' do
    #インスタンスの作成
    target.second << 'a'
    second = target.second
    
    #期待する振る舞いを見るためのコード
    expect(second).to eq('引数2')
  end
end

この '||=' 演算子は左辺が未定義もしくは偽であれば右辺の内容を代入するものである. 要するに初回呼び出しのときにだけ右辺が評価されて, それ以外のときは初回の結果を返すものとなる.

let を利用する

let を使った例は簡潔である.

RSpec.describe 'テスト対象' do
  let(:target) {Target.new('引数1', [])}
  it '振る舞い1' do
    #インスタンスの作成
    first = target.first
    #期待する振る舞いを見るためのコード
    expect(first).to eq('引数1')
  end

  it '振る舞い2' do
    #インスタンスの作成
    target.second << 'a'
    second = target.second
    
    #期待する振る舞いを見るためのコード
    expect(second).to eq('引数2')
  end
end

let は helper method の例と同様に memorize されている. 作者的にはこの方法が簡潔に記述でき, メンテナンス性も向上するそうである. これがなぜかは分かり次第記載していきたいと思う.