Ruby
TDD
uml
plantuml
rdra

Redux で気軽にできる TDD

TDDBC大阪4.0 のテーマは 気軽にできるTDD です。

気軽とは、態度がもったいぶらず、打ち解けやすいさま。きさく。また、こだわりなくすぐ物事をするさま。

TDDRed Green Refactor のサイクルを繰り返します。その初回のサイクルはこちらです。

  1. まず、テストから書いて Red (Assert First)
  2. すぐ、テストを成功させて Green (Fake It)
  3. シュッとさせる Refactor (Refactoring)

初回のサイクルこそ気軽にやりたい、そして、リファクタリングでも楽したいです。
Redux のテクニックを使うと気軽にできそうに思えたのでサンプルコードを書きました。
当日は Ruby のTAで参加したのでテストツールには RSpec を使っています。

Exercise

今回の課題は飲み物の自動販売機です。

ステップ0

お金の投入と払い戻し

  • 10円玉、50円玉、100円玉、500円玉、1000円札を1つずつ投入できる。
  • 投入は複数回できる。
  • 投入金額の総計を取得できる。
  • 払い戻し操作を行うと、投入金額の総計を釣り銭として出力する。

ステップ1

扱えないお金

  • 想定外のものが投入された場合は、投入金額に加算せず、それをそのまま釣り銭としてユーザに出力する。
    • 想定は硬貨:1円玉、5円玉。お札:千円札以外のお札

ステップ2

ジュースの管理

  • 値段と名前の属性からなるジュースを1種類格納できる。
    • 初期状態で、コーラ(値段:120円、名前”コーラ”)を5本格納している。
  • 格納されているジュースの情報(値段と名前と在庫)を取得できる。

ステップ3

購入

  • 投入金額、在庫の点で、コーラが購入できるかどうかを取得できる。
  • ジュース値段以上の投入金額が投入されている条件下で購入操作を行うと、ジュースの在庫を減らし、売り上げ金額を増やす。
  • 投入金額が足りない場合もしくは在庫がない場合、購入操作を行っても何もしない。
  • 現在の売上金額を取得できる。
  • 払い戻し操作では現在の投入金額からジュース購入金額を引いた釣り銭を出力する。

ステップ4

機能拡張

  • ジュースを3種類管理できるようにする。
    • 在庫にレッドブル(値段:200円、名前”レッドブル”)5本を追加する。
    • 在庫に水(値段:100円、名前”水”)5本を追加する。
  • 投入金額、在庫の点で購入可能なドリンクのリストを取得できる。

ステップ5

釣り銭と売り上げ管理

  • ジュース値段以上の投入金額が投入されている条件下で購入操作を行うと、釣り銭(投入金額とジュース値段の差分)を出力する。
    • ジュースと投入金額が同じ場合、つまり、釣り銭0円の場合も、釣り銭0円と出力する。
    • 釣り銭の硬貨の種類は考慮しなくてよい。

Example

Rubyはオブジェクト指向プログラミングをサポートするための機能でクラスを使うことができます。
最初にどう作っていくか話すときにクラス図があるとよいかもしれません。
今回は自動販売機を "お金で飲み物を購入することができる装置" と決めました。

class

PlantUMLで図を書くとテストを書くときに文字が使えて便利です。

Money ..o MoneyStore
Drink ..o DrinkStore

MoneyStore ..* VendingMachine
DrinkStore ..* VendingMachine

class Money {
  name
  value
  +valid?()
}

class MoneyStore {
  moneys
  amount
  +insert()
  +eject()
}

class Drink {
  name
  price
}

class DrinkStore {
  drinks
  +insert()
  +eject()
}

class VendingMachine {
  deposit_amount
  change_moneys
  stock_amount
  sale_drinks
  stock_drinks
  choose_drinks
  +insert_money()
  +eject_money()
  +insert_drink()
  +purchase_drink()
}

Money

お金には名前があって、その名前で自動販売機が扱う価値をみなすと決めました。

money

まず 10円玉10 という Assert ですぐ 10 の仮実装をするだけなので、ここは気軽にTDDができますね。
RSpec は shared_examples を使うとテストを DRY に書くことができます。

shared_examples "valid money" do |name, value|
  subject { Money.new(name) }

  example "value of #{name} is #{value}" do
    expect(subject.value).to eq value
  end

  example "#{name} is valid" do
    expect(subject.valid?).to be_truthy
  end
end

shared_examples "invalid money" do |name|
  subject { Money.new(name) }

  example "value of #{name} is 0" do
    expect(subject.value).to eq 0
  end

  example "#{name} is invalid" do
    expect(subject.valid?).to be_falsey
  end
end

describe Money do
  context "valid money" do
    describe "10 yen coin" do
      include_examples "valid money", "10円玉", 10
    end

    describe "50 yen coin" do
      include_examples "valid money", "50円玉", 50
    end

    describe "100 yen coin" do
      include_examples "valid money", "100円玉", 100
    end

    describe "500 yen coin" do
      include_examples "valid money", "500円玉", 500
    end

    describe "1000 yen bill" do
      include_examples "valid money", "1000円札", 1000
    end
  end

  context "invalid money" do
    describe "1 yen coin" do
      include_examples "invalid money", "1円玉"
    end

    describe "5 yen coin" do
      include_examples "invalid money", "5円玉"
    end

    describe "2000 yen bill" do
      include_examples "invalid money", "2000円札"
    end

    describe "5000 yen bill" do
      include_examples "invalid money", "5000円札"
    end

    describe "10000 yen bill" do
      include_examples "invalid money", "10000円札"
    end
  end
end

to_valuecase が気になるなら {"10円玉" => 10, "50円玉" => 50, ...} にリファクタリングされていくのかもしれません。

class Money
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def value
    @value ||= to_value
  end

  def valid?
    value > 0
  end

  private
  def to_value
    case name
    when "10円玉"
      10
    when "50円玉"
      50
    when "100円玉"
      100
    when "500円玉"
      500
    when "1000円札"
      1000
    else
      0
    end
  end
end

MoneyStore

お金を入れたり出したりできる装置は 入れたお金そのお金の総額 の状態を記憶すると決めました。

money_store

これは行動で状態が変化する装置です。これを気軽にTDDをしたいです。そこで ReduxReducer を参考にします。
Reducer変更前の状態行動 をパラメータにセットすると 変更後の状態 をリターンするメカニズムです。
独立した機構なので TDD の Assert FirstFake It で実装していくのに相性がよさそうです。

Reducerstateactionlet を準備して reducersubject に設定すると is_expected で Assert を書くことができます。
Action には typepayload が設定されます。reducer をコールする dispatch のパラメータに Action をセットする方式にしているので RSpec では receive(:dispatch)Action の Assert を書くことができます。

describe MoneyStore do
  let(:money_10_yen_coin){ Money.new("10円玉") }
  let(:money_50_yen_coin){ Money.new("50円玉") }
  let(:money_100_yen_coin){ Money.new("100円玉") }
  let(:money_store){ MoneyStore.new }

  describe "reducer" do
    describe "insert" do
      context "first" do
        let(:state){ {moneys: [], amount: 0} }
        let(:action){ {type: :insert, payload: money_10_yen_coin} }
        subject { money_store.send(:reducer, state, action) }
        it { is_expected.to match a_hash_including(moneys: [money_10_yen_coin]) }
        it { is_expected.to match a_hash_including(amount: 10) }
      end

      context "append" do
        let(:state){ {moneys: [money_10_yen_coin], amount: 10} }
        let(:action){ {type: :insert, payload: money_50_yen_coin} }
        subject { money_store.send(:reducer, state, action) }
        it { is_expected.to match a_hash_including(moneys: [money_50_yen_coin, money_10_yen_coin]) }
        it { is_expected.to match a_hash_including(amount: 60) }
      end
    end

    describe "eject" do
      let(:state){ {moneys: [money_100_yen_coin, money_10_yen_coin], amount: 110} }
      let(:action){ {type: :eject, payload: state[:amount]} }
      subject { money_store.send(:reducer, state, action) }
      it { is_expected.to match a_hash_including(moneys: [], amount: 0) }
    end
  end

  describe "dispatch" do
    describe "insert" do
      example do
        expect(money_store).to receive(:dispatch).with({:type=>:insert, :payload=>money_10_yen_coin})
        money_store.insert(money_10_yen_coin)
      end
    end

    describe "eject" do
      example do
        expect(money_store).to receive(:dispatch).with({:type=>:eject, :payload=>"10円玉"})
        money_store.eject(money_10_yen_coin.name)
      end
    end
  end
end

このサンプルでは reducer の内部で state の値を書き換えていますが、Redux では state の値を書き換えません。JavaScript の Object.assign() を使われることが多いです。
Ruby で同様の機能は reduce() で出来ますね。 [].reduce({}, :merge) と書きます。

class MoneyStore
  attr_reader :state

  def initialize()
    @state = {
      moneys: [],
      amount: 0
    }
  end

  def insert(money)
    dispatch({type: :insert, payload: money})
  end

  def eject(name)
    dispatch({type: :eject, payload: name})
  end

  private
  def reducer(state, action)
    case action[:type]
    when :insert
      state[:moneys] << action[:payload]
    when :eject
      amount = action[:payload]
      state[:moneys].reject! {|money| amount >= money.value ? amount = amount - money.value : false }
    end
    state[:moneys].sort_by! {|money| - money.value }
    state[:amount] = state[:moneys].map(&:value).reduce(0, :+)
    state
  end

  def dispatch(action)
    @state = reducer(state, action)
  end
end

Drink

飲み物は名前と値段が属性にあるだけと決めました。

drink

describe Drink do
  let(:drink){ Drink.new("コーラ", 120) }

  describe "#to_h" do
    subject { drink.to_h }
    it { is_expected.to a_hash_including(:name=>"コーラ", :price=>120) }
  end
end

飲み物は賞味期限や温度など関心事が増える可能性があるのかなと思いますが、今はその関心事は必要ないです。

class Drink
  attr_reader :name, :price

  def initialize(name, price)
    @name, @price = name, price
  end

  def to_h
    { name: @name, price: @price }
  end
end

DrinkStore

飲み物を入れたり出したりできる装置は 入れた飲み物 の状態を記憶すると決めました。

drink_store

MoneyStore とほとんど同じなので割愛します。

VendingMachine

ステップ0 と ステップ1 のサンプルです。
有効なお金を入れると預り金に入り、無効なお金を入れるとお釣りの装置に入ると決めました。
また、お金の返却すると預り金の全てをお釣りの装置に入れると決めました。

vending_machine

describe VendingMachine do
  let(:vending_machine){ VendingMachine.new }

  describe "insert money" do
    context "valid money" do
      before { vending_machine.insert_money("10円玉") }
      example { expect(vending_machine.state[:deposit_amount]).to eq 10 }
    end

    context "invalid money" do
      before { vending_machine.insert_money("1円玉") }
      example { expect(vending_machine.state[:change_moneys]).to eq ["1円玉"] }
    end
  end

  describe "eject money" do
    before {
      vending_machine.insert_money("10円玉")
      vending_machine.eject_money
    }

    example { expect(vending_machine.state[:deposit_amount]).to eq 0 }
    example { expect(vending_machine.state[:change_moneys]).to eq ["10円玉"] }
  end
end

複雑な機能でも Reducer を利用することで、最初から簡単に仮実装で済ませることができます。

class VendingMachine
  attr_reader :state

  def initialize()
    @deposit_money_store = MoneyStore.new
    @change_money_store = MoneyStore.new
    @state = {
      deposit_amount: 0,
      change_moneys: []
    }
  end

  def insert_money(name)
    money = Money.new(name)
    money_store = money.valid? ? @deposit_money_store : @change_money_store
    money_store.insert(money)

    dispatch({type: :update_money, payload: {
      deposit_amount: @deposit_money_store.state[:amount],
      change_moneys: @change_money_store.state[:moneys]
    }})
  end

  def eject_money
    @deposit_money_store.state[:moneys].each do |money|
      @change_money_store.insert(money)
    end
    @deposit_money_store.eject(state[:deposit_amount])

    dispatch({type: :update_money, payload: {
      deposit_amount: @deposit_money_store.state[:amount],
      change_moneys: @change_money_store.state[:moneys]
    }})
  end

  private
  def reducer(state, action)
    case action[:type]
    when :update_money
      state[:deposit_amount] = action[:payload][:deposit_amount]
      state[:change_moneys] = action[:payload][:change_moneys].map(&:name)
    end
    state
  end

  def dispatch(action)
    @state = reducer(state, action)
  end
end

テクニックで気軽に TDD ができることを説明しました。もっと 気軽にできるTDD をみなさんも見つけてください。
ステップ5までのサンプルコードはこちらです。

Enjoy TDD

RDRA

要件定義が気になったら モデルベース要件定義テクニック の書籍が参考になるかもしれません。
こちらの図はRDRA(ラドラ)の方式を参考に書いたサンプルです。独自に書いた図なので正しくはこちらを参照してください。

コンテキストモデル

要求モデル

システム外部環境

システム境界

システム

システム地図

外部からシステムを見たり、内部からシステムを育てたりの繰り返しをして、気軽が気楽になるといいですね。

気楽とは、気兼ねや心配がなく、物事にこだわらず、のんきなさま