TDDBC大阪4.0 のテーマは 気軽にできるTDD です。
気軽とは、態度がもったいぶらず、打ち解けやすいさま。きさく。また、こだわりなくすぐ物事をするさま。
TDD は Red Green Refactor のサイクルを繰り返します。その初回のサイクルはこちらです。
- まず、テストから書いて Red (Assert First)
- すぐ、テストを成功させて Green (Fake It)
- シュッとさせる 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はオブジェクト指向プログラミングをサポートするための機能でクラスを使うことができます。
最初にどう作っていくか話すときにクラス図があるとよいかもしれません。
今回は自動販売機を "お金で飲み物を購入することができる装置" と決めました。
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
お金には名前があって、その名前で自動販売機が扱う価値をみなすと決めました。
まず 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_value
の case
が気になるなら {"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
お金を入れたり出したりできる装置は 入れたお金
と そのお金の総額
の状態を記憶すると決めました。
これは行動で状態が変化する装置です。これを気軽にTDDをしたいです。そこで Redux の Reducer を参考にします。
Reducer は 変更前の状態
と 行動
をパラメータにセットすると 変更後の状態
をリターンするメカニズムです。
独立した機構なので TDD の Assert First と Fake It で実装していくのに相性がよさそうです。
Reducer は state
と action
の let
を準備して reducer
を subject
に設定すると is_expected
で Assert を書くことができます。
Action には type
と payload
が設定されます。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
飲み物は名前と値段が属性にあるだけと決めました。
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
飲み物を入れたり出したりできる装置は 入れた飲み物
の状態を記憶すると決めました。
MoneyStore とほとんど同じなので割愛します。
VendingMachine
ステップ0 と ステップ1 のサンプルです。
有効なお金を入れると預り金に入り、無効なお金を入れるとお釣りの装置に入ると決めました。
また、お金の返却すると預り金の全てをお釣りの装置に入れると決めました。
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(ラドラ)の方式を参考に書いたサンプルです。独自に書いた図なので正しくはこちらを参照してください。
コンテキストモデル
要求モデル
システム外部環境
システム境界
システム
システム地図
外部からシステムを見たり、内部からシステムを育てたりの繰り返しをして、気軽が気楽になるといいですね。
気楽とは、気兼ねや心配がなく、物事にこだわらず、のんきなさま