s4na's blog

s4naのテックブログ

ファーストペンギン

「プロになるためのRuby入門」を読みながら「TDDで改札機プログラムを実装」してみた


test-unit実践

今回は昨日学んだテスト駆動開発(以下、TDD)について、test-unitを使いつつ実装してみます。 内容は「プロを目指す人のためのRuby入門 言語仕様からテスト駆動開発・デバッグ技法まで (Software Design plusシリーズ)」に書いてある改札機のプログラムです。

単にコピペしても勉強にならないと思ったので、なるべく本を読まないようにして作ってみました。(とはいえわからないところ含め、変数名などはだいぶ似てしまっている)

まずは要件を文章化

# 改札機プログラム

## 作りたいもの

改札機のプログラム
- チケットをお金で買うことができる
- 改札機から入ることができる
- 料金が正しければ、降りた駅で改札機から出れる
- 料金が間違っていれば、改札機から出れない

## 要件

- 山手線
  - 駅
    - 池袋
    - 新宿
    - 渋谷
  - 値段
    - 120yen: 池袋 〜 新宿
    - 120yen: 新宿 〜 渋谷
    - 200yen: 池袋 〜 渋谷

## 動作する仕様

``
ikebukuro = Gate.new(:ikebukuro)
sibuya = Gate.new(:sibuya)

ticket = Ticket.new(120)
ikebukuro.enter(ticket)
sibuya.exit(ticket) #=> false

ticket = Ticket.new(200)
ikebukuro.enter(ticket)
sibuya.exit(ticket) #=> true
``

要件を満たすシナリオの作成

シナリオ1

  • 120円の切符を購入
  • 池袋で入場、新宿で出場
  • 出場できる

シナリオ2

  • 120円切符を購入
  • 池袋で入場、渋谷で出場
  • 出場できない

シナリオ3

  • 200円の切符を購入
  • 池袋で入場、渋谷で出場
  • 出場できる

シナリオ4

  • 120円の切符を購入
  • 新宿で入場、渋谷で出場
  • 出場できる

ディレクトリ準備

ruby_ticket_machine
- lib
  - gate.rb
- test
  - gate_test.rb

クラスオブジェクトの作成

Redにする(テストから書き始める)

クラスオブジェクト作成の確認から

# frozen_string_literal: true

require "test/unit"
require "./lib/gate"

class GateTest < Test::Unit::TestCase
  def test_gate
    assert Gate.new
  end
end

Greenにする

# frozen_string_literal: true

class Gate
end

Refactor

今回は特に必要がなさそうなので終了

シナリオ1の実装

テストメソッドに処理の順番を仮置きする

  def test_gate
    # 前準備
    # 実行
    # 検証
    # 後片付け(状況により必要)
  end

実装したいコードの確認

ikebukuro = Gate.new(:ikebukuro)
sinjyuku = Gate.new(:sinjyuku)

ticket = Ticket.new(120)
ikebukuro.enter(ticket)
sinjyuku.exit(ticket) #=> true

シナリオ1のタスク細分化

  1. Gate.new(:ikebukuro)できる
  2. Gate.new(:sinjyuku)できる
  3. Ticket.new(120)できる
  4. ikebukuro.enter(ticket)できる
  5. ticket.stamp(駅名)で乗車駅を保存
  6. ikebukuro.enter(ticket)した後、sinjyuku.exit(ticket) #=> falseになる
  7. ticket.stamped_atで乗車駅を参照できるように
  8. Gatecalc_fireで生産できるかテスト(あとでプライベート化するかもしれないがその前にテスト)

Task1. Gate.new(:ikebukuro)できる, 2. Gate.new(:sinjyuku)できる

過去のコードで対応できているので省略

Task3. Ticket.new(120)できる

Red

# ./test/ticket_test.rb
# frozen_string_literal: true

require "test/unit"
require "./lib/ticket"

class TicketTest < Test::Unit::TestCase
  def test_new
    assert Ticket.new(120)
  end
end

Green

# ./lib/ticket.rb
# frozen_string_literal: true

class Ticket
  def initialize(fare)
    @fare = fare
  end
end

Refactor

なし

Task4. ikebukuro.enter(ticket)できる

Red

# ./test/gate_test.rb
# 省略

class GateTest < Test::Unit::TestCase
  # 中略

  def test_enter
    ikebukuro = Gate.new(:ikebukuro)
    ticket = Ticket.new(120)
    ikebukuro.enter(ticket)
  end
# 省略

Green

# ./lib/gate.rb
# frozen_string_literal: true

class Gate
  # 中略
  def enter(ticket)
    true
  end
end

Refactor

なし

Task5. ticket.stamp(駅名)で乗車駅を保存

Red

# ./test/ticket_test.rb
# 中略

class TicketTest < Test::Unit::TestCase
  # 中略
  def test_stamp
    ticket = Ticket.new(120)
    assert ticket.stamp(:ikebukuro)
  end

Green

# ./lib/ticket.rb
# 中略

class Ticket
  # 中略
  def stamp(station)
    @boarding_station = station
  end
end

Refactor

なし

Task6. ikebukuro.enter(ticket)した後、sinjyuku.exit(ticket) #=> falseになる

Red

# ./test/gate_test.rb
# 中略

class GateTest < Test::Unit::TestCase
  # 中略

  def test_exit
    # 前準備
    ikebukuro = Gate.new(:ikebukuro)
    sinjyuku = Gate.new(:sinjyuku)
    # 実行
    ticket = Ticket.new(120)
    ikebukuro.enter(ticket)
    # 検証
    assert sinjyuku.exit(ticket)
    # 後片付け(状況により必要)
  end
end

Green

# ./lib/gate.rb
# 中略

class Gate
  # 中略
  def exit(ticket)
    true
  end
end

Refactor

なし

Task7. ticket.stamped_atで乗車駅を参照できるように

Red

# ./test/ticket_test.rb
# 中略

class TicketTest < Test::Unit::TestCase
  # 中略

  def test_stamped_at
    ticket = Ticket.new(120)
    ticket.stamp(:ikebukuro)
    assert_equal(:ikebukuro, ticket.stamped_at)
  end
end

Green

# ./lib/ticket.rb
# 中略

class Ticket
  # 中略

  def stamped_at
    @boarding_station
  end
end

Refactor

なし

Task8. Gatecalc_fireで生産できるかテスト(あとでプライベート化するかもしれないがその前にテスト)

Red

あとでプライベート化するかもしれないがその前にGate.calc_fireのテスト

# ./test/gate_test.rb
# 中略

class GateTest < Test::Unit::TestCase
  # 中略

  def test_calc_fire
    # 前準備
    ikebukuro = Gate.new(:ikebukuro)
    sinjyuku = Gate.new(:sinjyuku)
    # 実行
    ticket = Ticket.new(120)
    ikebukuro.enter(ticket)
    # 検証
    assert sinjyuku.calc_fire(:sinjyuku)
  end
end

Green

※追加行は+、削除行は-を先頭に付与しています

# frozen_string_literal: true

class Gate
  STATIONS = [:ikebukuro, :sinjyuku, :sibuya]
  FARES = [120, 200]

  # 中略


  def exit(ticket)
-   true
+   calc_fire(ticket.stamped_at)
  end

  def calc_fire(exit_station)
    distance = (STATIONS.index(@station) - STATIONS.index(exit_station)).abs
    FARES[distance - 1]
  end
end

Refactor

  • Gate.exitが成否ではなく、計算結果を返しているようになっているので修正
# ./lib/gate.rb
# 中略

class Gate
  # 中略

  def exit(ticket)
    pay_of(ticket)
  end

  private
    def pay_of(ticket)
      0 <= (ticket.fare - calc_fire(ticket.stamped_at)) ? true : false
    end
end
# ./lib/ticket.rb
# 中略

class Ticket
  attr_reader :fare

  # 中略
end

シナリオ2

# ./test/gate_test.rb
# 中略

class GateTest < Test::Unit::TestCase
  # 中略

  def test_exit_scenario_2
    # 前準備
    ikebukuro = Gate.new(:ikebukuro)
    sibuya = Gate.new(:sibuya)
    # 実行
    ticket = Ticket.new(120)
    ikebukuro.enter(ticket)
    # 検証
    assert_equal(false, sibuya.exit(ticket))
    # 後片付け(状況により必要)
  end

  def test_exit_scenario_3
    # 前準備
    ikebukuro = Gate.new(:ikebukuro)
    sibuya = Gate.new(:sibuya)
    # 実行
    ticket = Ticket.new(200)
    ikebukuro.enter(ticket)
    # 検証
    assert sibuya.exit(ticket)
    # 後片付け(状況により必要)
  end

  def test_exit_scenario_4
    # 前準備
    sinjyuku = Gate.new(:sinjyuku)
    sibuya = Gate.new(:sibuya)
    # 実行
    ticket = Ticket.new(120)
    sinjyuku.enter(ticket)
    # 検証
    assert sibuya.exit(ticket)
    # 後片付け(状況により必要)
  end
end

疑問点の検討(自問自答)

  • 他のクラスをテストする必要があったらどうすればいい?
      • シナリオ1でgateクラスのtestをしている際、tickeクラスの話が登場してきた
        • しょうがない時もある
        • どういう場合はどっちのクラスに書いたほうがいいなどルールはある?

参考にした本について

プロを目指す人のためのRuby入門 言語仕様からテスト駆動開発・デバッグ技法まで (Software Design plusシリーズ)

めっちゃオススメです!
Ruby初学者の方にオススメなので、本屋さんなどでぜひ手にとってみてはいかがでしょうか?

著者の方について、ブログなどにもたくさん情報が載っているので貼っておきます。
give IT a try - 伊藤淳一 (id:JunichiIto)

あとがき

やり方が悪かったのか、正直「TDDのサイクルが回っている実感」はありませんでした。
もう少しテストの粒度を大きくした方がいいのか(今回は初めてなのであえて小さくしましたが)、それともRefactorできるところがあったのか。

テスト駆動開発について書かれた本ではないので、そういった本に書かれているプログラムを作ってみたらまた違ったのかもしれません。

明日以降はRailsのテストを書く予定なので、そこでまた検討してみたいと思います。