[Ruby] Rspecで始めるユニットテスト(単体テスト)

概要

ユニットテスト(単体テスト)をあまり書いたことがない人が、RubyのテスティングフレームワークであるRSpecを用いてユニットテストの第一歩を踏み出すお話。

本記事では、RSpecを使って極単純なクラスの各種メソッドについてのユニットテストを作成する。

前提

以下環境で動作確認

要素 バージョン
debian 8.6
ruby 2.2.2
rspec 3.7.0

ユニットテストとは

(wikipedia)

ユニットテストとは、ソースコードの個々のユニット、すなわち、1つ以上のコンピュータプログラムモジュールが使用に適しているかどうかを決定するために、関連する制御データ、使用手順、操作手順とともにテストする手法である[1]。ユニットとはアプリケーションのテスト可能な最小の部品単位である、と直観的にとらえることができる

端的にいうと、クラス及び各種メソッドが、仕様通りに動作しているかを機械的に検証する仕組み。
ユニットテストを書くとどんな旨味があるのかは以下の通り

  • コード修正時の影響範囲を検証できるので、デグレーションなどを防げる
  • テストコードを書きやすくするためには、元のユニットもシンプルにわかりやすく書き上げる必要があるので、コード品質が高まる
  • テストコードを書く時はユニットの利用者になるので、利用者的に使いやすいコードを書けるようになる
  • クラス/メソッドの使い方とその結果がテストコードに記述されるので、ドキュメントにもなる

などなど。
「テスト」というからには「正しく動いていることを確認する」ことに目的を起きがちだが、実はテストを書くことで元のコードを高品質に書けるようになるという大きなメリットがあるようだ。

RSpecとは

Rubyでユニットテストを作成する手段はいくつもあり、minitest/unitはRubyに標準で含まれている(2.0系)ので、すぐに利用できる。

本記事では、その中でも人気のRSpecを用いてユニットテストを作成する。
RSpecの主な特徴は以下の通り

  • Rubyで最もポピュラーなテスティングフレームワーク
  • メタ視点でのテストが書けるため、テストコードが自然言語っぽくなる(※但し英語を用いた場合に限る)
  • 初期はRailsでも標準のテスティングフレームワークだった(学習コストを考慮して現在ではminitestになってるみたい)
  • ユニットテストに限らず、結合テストやブラウザテストなどにも使える
  • ビヘイビア駆動開発を想定したやや独特な記法を用いるので学習コストが微妙に高い

なんともしっくりこない特徴がチラホラあるが、本記事ではあまり影響しないので気にせず使う。

RSpecのインストール

RSpecはgemでインストールできる。今回はグローバルインストールするので以下のようにインストールする。

$ gem install rspec

グローバルコマンドで使えるようになる

vagrant$ rspec -v
RSpec 3.7
  - rspec-core 3.7.0
  - rspec-expectations 3.7.0
  - rspec-mocks 3.7.0
  - rspec-support 3.7.0

Rspecの初期化

作業ディレクトリにて、以下コマンドを実行するとRspecに関する初期ディレクトリ、ファイルを生成してくれる

$ rspec --init

specディレクトリが作成され、中にspec_helper.rbが作成される。このファイルはRspecの振る舞いを定義した設定ファイルで、デフォルトで色々記述されているが、本記事では特に編集しないのでそのまま利用する。

vagrant$ tree -a
.
├── .rspec
└── spec
    └── spec_helper.rb

1 directory, 2 files

テスト対象クラスの作成

今回は初めてのユニットテストなので、極シンプルな以下のクラスを実装する。以下Calcクラスは、オブジェクト生成後、各種メソッドを用いて、足し算/引き算/かけ算/割り算を行うことができる。

class Calc

  def add(a, b)
    a + b
  end

  def subtract(a, b)
    a - b
  end

  def multiply(a, b)
    a * b
  end

  def divide(a, b)
    a / b
  end

end

これを、calc.rbというファイル名で、srcディレクトリ直下に配置する。
また、これから作成する、Calcクラスをテストするテストスクリプト(calc_spec.rb)を、specディレクトリ直下に配置する。

ディレクトリ構造は以下の通り。

vagrant$ tree
.
├── spec
│   ├── calc_spec.rb
│   └── spec_helper.rb
└── src
    └── calc.rb

2 directories, 4 files

以降では、このClacクラスについてのユニットテストをcalc_spec.rbに記述する。

足し算メソッドをテストする

まずは、前項で作成したCalcクラスの、addメソッドについてテストする。

addメソッドは、引数a,bの和を戻すので理論上は1と1を投げれば1 + 1で2が返ってくるはずだ。これをテストする。

require 'rspec'
require_relative '../src/calc'

RSpec.describe Calc do
  it "足し算できること" do
    expect(Calc.new.add(1, 1)).to eq 2
  end
end

細かいことは学びながら身に付けていけば良いが、describeはテスト対象を、itは期待する振る舞いに関する説明を宣言して、その中にテストコードを記述する。

expect(a).to eq b

は、aがbと一致していることを検証する手法で、本記事ではこれしか使わないのでとりあえずここを抑えておけばよい。

テストの実行はrspecコマンドをそのまま叩くことで実行できる。rspecコマンドは、作業ディレクトリ以下のテストスクリプトを探索し、それを実行して結果を標準出力する。

vagrant$ rspec

Randomized with seed 26931

Calc
  足し算できること

Top 1 slowest examples (0.00037 seconds, 6.6% of total time):
  Calc 足し算できること
    0.00037 seconds ./spec/calc_spec.rb:5

Finished in 0.00556 seconds (files took 0.09292 seconds to load)
1 example, 0 failures

Randomized with seed 26931

初めて見ると、出力が色々出てきて混乱するかもしれないが、

1 example, 0 failures

を見ると、1個のテストを実施して0個失敗した(=全て成功した)ことが確認できる。

ちなみに

Randomized with seed 26931

は、今回のテストの乱数シードを表す。今回は一つのテストしか無いが、基本的にテストは複数のテスト項目に対してまとめて実施する。その場合の実施順序を毎回ランダムにすることで、各ユニットが実行順に依存しないことを証明できる。

足し算クラスのバグを検知する

例えば、誰かがCalcクラスを更新して、足し算メソッドが以下のように変わってしまったとする。

def add(a, b)
  a - b
end

本来は2数の和を戻すメソッドなのに、2数の差が戻ってしまう。これは紛れもなくバグである。

この状態で再度テストを実施すると

vagrant$ rspec

Randomized with seed 58714

Calc
  足し算できること (FAILED - 1)

Failures:

  1) Calc 足し算できること
     Failure/Error: expect(Calc.new.add(1, 1)).to eq 2

       expected: 2
            got: 0

       (compared using ==)
     # ./spec/calc_spec.rb:6:in `block (2 levels) in <top (required)>'

Top 1 slowest examples (0.00991 seconds, 64.0% of total time):
  Calc 足し算できること
    0.00991 seconds ./spec/calc_spec.rb:5

Finished in 0.01547 seconds (files took 0.09346 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/calc_spec.rb:5 # Calc 足し算できること

Randomized with seed 58714

のようになり、どのテストで失敗したのか、期待した結果と実際に得られた結果がどう違ったのかなどを確認することができ、迅速にコードの修正を行うことができる。

複数のテストをまとめて実行する

同様に、引き算/かけ算/割り算についてのテストも記述する。

RSpec.describe Calc do
  it "足し算できること" do
    expect(Calc.new.add(1, 1)).to eq 2
  end
  it "引き算できること" do
    expect(Calc.new.subtract(2, 1)).to eq 1
  end
  it "かけ算できること" do
    expect(Calc.new.multiply(3, 2)).to eq 6
  end
  it "割り算できること" do
    expect(Calc.new.divide(10, 2)).to eq 5
  end
end

同じようにrspecコマンドでテストを実行すると、全てのテストが実行され、問題ないことが確認できる。

vagrant$ rspec

Randomized with seed 39869

Calc
  かけ算できること
  足し算できること
  引き算できること
  割り算できること

Top 4 slowest examples (0.00071 seconds, 9.1% of total time):
  Calc かけ算できること
    0.00039 seconds ./spec/calc_spec.rb:11
  Calc 足し算できること
    0.00011 seconds ./spec/calc_spec.rb:5
  Calc 割り算できること
    0.00011 seconds ./spec/calc_spec.rb:14
  Calc 引き算できること
    0.0001 seconds ./spec/calc_spec.rb:8

Finished in 0.00781 seconds (files took 0.08813 seconds to load)
4 examples, 0 failures

Randomized with seed 39869

少しだけメソッドを複雑化する

足し算メソッド、引き算メソッドそれぞれをほんの少し改変して、以下のような仕様にする

  • 足し算メソッド: 和が10を超えた場合、nilを戻す
  • 引き算メソッド: 差が0未満の場合、nilを戻す

改変したコードが以下の通り。
※ ここからは簡略化のために、足し算メソッド/引き算メソッドのみにします。

class Calc

  #
  # 2数の和を戻す
  # 但し10を超えた場合nilを戻す
  #
  def add(a, b)
    a + b <= 10 ? a + b : nil
  end

  #
  # 2数の差を戻す
  # 但し0未満の場合nilを戻す
  #
  def subtract(a, b)
    0 <= a - b ? a - b : nil
  end

end

境界値分析を行う

足し算メソッドは、和が10を超えた場合にnilを返す。
となると、分岐の境界になる、和が10、もしくは11になる入力のケースが最もバグが生まれやすいと考えられる。

同様に、引き算メソッドは0が境界になるので、差が0、もしくは-1になる入力のケースが怪しい。

以上のような境界値分析を考慮して、以下のようにテストを追加する。

RSpec.describe Calc do
  describe "足し算メソッド" do
    it "通常ケースで和が戻る" do
      expect(Calc.new.add(5, 3)).to eq 8
    end
    it "和が10の場合は10が戻る" do
      expect(Calc.new.add(8, 2)).to eq 10
    end
    it "和が10を超えた場合はnilが戻る" do
      expect(Calc.new.add(8, 3)).to eq nil
    end
  end
  describe "引き算メソッド" do
    it "通常ケースで差が戻る" do
      expect(Calc.new.subtract(5, 3)).to eq 2
    end
    it "差が0の場合は0が戻る" do
      expect(Calc.new.subtract(3, 3)).to eq 0
    end
    it "差が0未満の場合はnilが戻る" do
      expect(Calc.new.subtract(3, 4)).to eq nil
    end
  end
end

describeは、テスト対象を定義すると述べたが、これは入れ子で表現することもできる。
そのため、Calcに対するテストの中に、「足し算に関するテスト」「引き算に関するテスト」を入れ子でdescribeに定義したことで、何のテストを行っているのかをよりわかりやすくした。

テストを実行した結果は以下の通り

vvagrant$ rspec

Randomized with seed 57848

Calc
  足し算メソッド
    和が10の場合は10が戻る
    和が10を超えた場合はnilが戻る
    通常ケースで和が戻る
  引き算メソッド
    差が0未満の場合はnilが戻る
    通常ケースで差が戻る
    差が0の場合は0が戻る

Top 6 slowest examples (0.00096 seconds, 9.3% of total time):
  Calc 足し算メソッド 和が10の場合は10が戻る
    0.00031 seconds ./spec/calc_spec.rb:9
  Calc 足し算メソッド 和が10を超えた場合はnilが戻る
    0.00015 seconds ./spec/calc_spec.rb:12
  Calc 引き算メソッド 差が0未満の場合はnilが戻る
    0.00014 seconds ./spec/calc_spec.rb:23
  Calc 足し算メソッド 通常ケースで和が戻る
    0.00013 seconds ./spec/calc_spec.rb:6
  Calc 引き算メソッド 差が0の場合は0が戻る
    0.00012 seconds ./spec/calc_spec.rb:20
  Calc 引き算メソッド 通常ケースで差が戻る
    0.00012 seconds ./spec/calc_spec.rb:17

Finished in 0.01034 seconds (files took 0.10127 seconds to load)
6 examples, 0 failures

Randomized with seed 57848

以上のように、全てのテストが通ったことが確認できた

参考

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です