先日、t_wada さんが弊社に公演に来てくださいました。 それに先駆け、以前購入した t_wada さんが訳されたテスト駆動開発を、現在学習中の Go 言語で取り組んでみました。 本記事中の引用は、特に断りがない限りこの本の引用になります。
https://github.com/eyuta/golang-tdd
go version go1.15.6 windows/amd64
今回は、Go の標準の testing パッケージと、こちらサードパーティのassertパッケージを使用しています。
Go の標準の testing パッケージには、Assert が含まれておらず、推奨もされていません。 理由については、以下の記事が詳しいです。 ただ、今回は testing としてのテストではなく、checking としてのテストがメインであることから、手軽にテストケースを記述できる Assert パッケージを使用しています。
参考記事
testing パッケージの使い方は以下を参照しました。
TDD のルール
コードを書く前に、失敗する自動テストコードを必ず書く。 重複を除去する。
TDD のリズム
- まずはテストを 1 つ書く
- すべてのテストを走らせ、新しいテストの失敗を確認する
- 小さな変更を行う
- すべてのテストを走らせ、すべて成功することを確認する
- リファクタリングを行って重複を除去する
- 書くべきテストのリストを作った。
- どうなったら嬉しいかを小さいテストコードで表現した。
- 空実装を使ってコンパイラを通した。
- 大罪を犯しながらテストを通した。
- 動くコードをだんだんと共通化し、ベタ書きの値を変数に置き換えていった。
- TODO リストに項目を追加するに留め、一度に多くのものを相手にすることを避けた。
- $5+10CHF=$10(レートが 2:1 の場合)
- $5*2=$10
- amount を private にする
- Dollar の副作用どうする?
- Money の丸め処理どうする?
type Dollar struct {
amount int
}
func (d *Dollar) times(multiplier int) {
d.amount *= multiplier
}func TestMultiCurrencyMoney(t *testing.T) {
t.Run("$5 * 2 = $10", func(t *testing.T) {
five := Dollar{5}
five.times(2)
assert.Equal(t, 10, five.amount)
})
}
- 設計の問題点(今回は副作用)をテストコードに写し取り、その問題点のせいでテストが失敗するのを確認した。
- 空実装でさっさとコンパイルを通した。
- 正しいと思える実装をすぐに行い、テストを通した。
- $5+10CHF=$10(レートが 2:1 の場合)
- $5*2=$10
- amount を private にする
- Dollar の副作用どうする?
- Money の丸め処理どうする?
type Dollar struct {
amount int
}
func (d *Dollar) times(multiplier int) Dollar {
return Dollar{d.amount * multiplier}
}func TestMultiCurrencyMoney(t *testing.T) {
t.Run("何度でもドルの掛け算が可能である", func(t *testing.T) {
five := Dollar{5}
product := five.times(2)
assert.Equal(t, 10, product.amount)
product = five.times(3)
assert.Equal(t, 15, product.amount)
})
}型のコンバージョンは、Type Assertionを利用した。
- Value Object パターンを満たす条件がわかった。
- その条件を満たすテストを書いた。
- シンプルな実装を行った。
- すぐにリファクタリングを行うのではなく、もう 1 つテストを書いた。
- 2 つのテストを同時に通すリファクタリングを行った。
- $5+10CHF=$10(レートが 2:1 の場合)
- $5*2=$10
- amount を private にする
- Dollar の副作用どうする?
- Money の丸め処理どうする?
- equals()
- hashCode()
- null との等価性比較
- 他のオブジェクトとの等価性比較
type Object interface{}
type Dollar struct {
amount int
}
func (d Dollar) times(multiplier int) Dollar {
return Dollar{d.amount * multiplier}
}
func (d Dollar) equals(object Object) bool {
dollar := object.(Dollar)
return d.amount == dollar.amount
}func TestMultiCurrencyMoney(t *testing.T) {
t.Run("何度でもドルの掛け算が可能である", func(t *testing.T) {
five := Dollar{5}
product := five.times(2)
assert.Equal(t, 10, product.amount)
product = five.times(3)
assert.Equal(t, 15, product.amount)
})
t.Run("同じ金額が等価である", func(t *testing.T) {
assert.True(t, Dollar{5}.equals(Dollar{5}))
assert.False(t, Dollar{5}.equals(Dollar{6}))
})
}
- 作成したばかりの機能を使って、テストを改善した。
- そもそも正しく検証できていないテストが 2 つあったら、もはやお手上げだと気づいた。
- そのようなリスクを受け入れて先に進んだ。
- テスト対象オブジェクトの新しい機能を使い、テストコードとプロダクトコードの間の結合度を下げた。
// 変化なしfunc TestMultiCurrencyMoney(t *testing.T) {
t.Run("ドルの掛け算が可能である", func(t *testing.T) {
five := Dollar{5}
assert.Equal(t, Dollar{10}, five.times(2))
assert.Equal(t, Dollar{15}, five.times(3))
})
t.Run("同じ金額が等価である", func(t *testing.T) {
assert.True(t, Dollar{5}.equals(Dollar{5}))
assert.False(t, Dollar{5}.equals(Dollar{6}))
})
}
- 大きいテストに立ち向かうにはまだ早かったので、次の一歩を進めるために小さなテストをひねり出した。
- 恥知らずにも既存のテストをコピー&ペーストして、テストを作成した。
- さらに恥知らずにも、既存のモデルコードを丸ごとコピー&ペーストして、テストを通した。
- この重複を排除するまでは家に帰らないと心に決めた。
- $5+10CHF=$10(レートが 2:1 の場合)
- $5*2=$10
- amount を private にする
- Dollar の副作用どうする?
- Money の丸め処理どうする?
- equals()
- hashCode()
- null との等価性比較
- 他のオブジェクトとの等価性比較
- 5CHF*2=10CHF
- Dollar と Franc の重複
- equals の一般化
- times の一般化
type Franc struct {
amount int
}
func (f Franc) times(multiplier int) Franc {
return Franc{f.amount * multiplier}
}
func (f Franc) equals(object Object) bool {
franc := object.(Franc)
return f.amount == franc.amount
} t.Run("フランの掛け算が可能である", func(t *testing.T) {
five := Franc{5}
assert.Equal(t, Franc{10}, five.times(2))
assert.Equal(t, Franc{15}, five.times(3))
})
t.Run("同じ金額のフランが等価である", func(t *testing.T) {
assert.True(t, Franc{5}.equals(Franc{5}))
assert.False(t, Franc{5}.equals(Franc{6}))
})- Go には継承の概念が無いため、本章では composition を用いて実装する。
- Dollar, Franc を生成するためのコンストラクタにあたるものを用意した(以下を参考にした)。 Constructors and composite literals
multiCurrencyMoney.goをmoney.go,dollar.go,franc.goに分割したmultiCurrencyMoney_test.goをmoney_test.goに改名し、package 名をmoney_testとしたmoney.goとmoney_test.goの package 名が異なるため、プライベートメソッド(小文字のメソッド)が参照できなくなったので、equals,timesメソッドをパブリックメソッドに変更した- パブリックメソッドにはコメントが必要になるので、簡単なコメントを追加した 参考: Godoc: documenting Go code
このあたりから、言語仕様の違いによりコーディング内容が本と異なってくる
- Dollar クラスから親クラス Money へ段階的にメソッドを移動した。
- 2 つ目のクラス(Franc)も同様にサブクラス化した。
- 2 つの equals メソッドの差異をなくしてから、サブクラス側の実装を削除した。
- $5+10CHF=$10(レートが 2:1 の場合)
- $5*2=$10
- amount を private にする
- Dollar の副作用どうする?
- Money の丸め処理どうする?
- equals()
- hashCode()
- null との等価性比較
- 他のオブジェクトとの等価性比較
- 5CHF*2=10CHF
- Dollar と Franc の重複
- equals の一般化
- times の一般化
- Franc と Dollar を比較する
量が増えてきたので、主な変更点のみ抜粋して表示する 全文: github
package money
// AmountGetter is a wrapper of amount.
type AmountGetter interface {
getAmount() int
}
// Money is a struct that handles money.
type Money struct {
amount int
}
// Equals checks if the amount of the receiver and the argument are the same
func (m Money) Equals(a AmountGetter) bool {
return m.getAmount() == a.getAmount()
}
func (m Money) getAmount() int {
return m.amount
}package money_test
import (
"testing"
"github.com/eyuta/golang-tdd/money"
"github.com/stretchr/testify/assert"
)
func TestMultiCurrencyMoney(t *testing.T) {
t.Run("ドルの掛け算が可能である", func(t *testing.T) {
five := money.NewDollar(5)
assert.Equal(t, money.NewDollar(10), five.Times(2))
assert.Equal(t, money.NewDollar(15), five.Times(3))
})
}
- 頭の中にある悩みをテストとして表現した。完璧ではないものの、まずまずのやり方(getClass)でテストを通した。
- さらなる設計は、本当に必要になるときまで先延ばしにすることにした
- Money に新しく Name フィールドを追加した
- 上記の
getClassの代替。struct の入れ子の場合、レシーバは常に Money になるので、type の比較ができないため
- 上記の
- $5+10CHF=$10(レートが 2:1 の場合)
- $5*2=$10
- amount を private にする
- Dollar の副作用どうする?
- Money の丸め処理どうする?
- equals()
- hashCode()
- null との等価性比較
- 他のオブジェクトとの等価性比較
- 5CHF*2=10CHF
- Dollar と Franc の重複
- equals の一般化
- times の一般化
- Franc と Dollar を比較する
- 通貨の概念
全文: github
package money
// Accessor is a accessor of Money
type Accessor interface {
Amount() int
Name() string
}
// Money is a struct that handles money.
type Money struct {
amount int
name string
}
// Equals checks if the amount of the receiver and the argument are the same
func (m Money) Equals(a Accessor) bool {
return m.Amount() == a.Amount() && m.Name() == a.Name()
}
// Amount returns amount field
func (m Money) Amount() int {
return m.amount
}
// Name returns name field
func (m Money) Name() string {
return m.name
}t.Run("同じ金額のドルとフランが等価ではない", func(t *testing.T) {
assert.False(t, money.NewFranc(5).Equals(money.NewDollar(5)))
})func NewDollar(a int) Dollar {
return Dollar{Money{amount: a, name: "Dollar"}}
}
- 重複を除去できる状態に一歩近づけるために、Dollar と Franc にある 2 つの times メソッドのシグニチャを合わせた。
- Factory Method パターンを導入して、テストコードから 2 つのサブクラスの存在を隠した。
- サブクラスを隠した結果、いくつかのテストが冗長なものになったことに気がついたが、いまはそのままにしておいた。
- Go には抽象クラスの概念が無いため、Times メソッドについては一足先に実装もろとも Money に移行した
- それにより、Dollar, Franc の 2 つの構造体が使われなくなったが、一旦取っておくことにする
- $5+10CHF=$10(レートが 2:1 の場合)
- $5*2=$10
- amount を private にする
- Dollar の副作用どうする?
- Money の丸め処理どうする?
- equals()
- hashCode()
- null との等価性比較
- 他のオブジェクトとの等価性比較
- 5CHF*2=10CHF
- Dollar と Franc の重複
- equals の一般化
- times の一般化
- Franc と Dollar を比較する
- 通貨の概念
- testFrancMultiplication を削除する?
全文: github
package money
// Accessor is a accessor of Money
type Accessor interface {
Amount() int
Name() string
}
// Money is a struct that handles money.
type Money struct {
amount int
name string
}
// NewDollar is constructor of Dollar.
func NewDollar(a int) Money {
return Money{
amount: a,
name: "Dollar",
}
}
// NewFranc is constructor of Dollar.
func NewFranc(a int) Money {
return Money{
amount: a,
name: "Franc",
}
}
// Times multiplies the amount of the receiver by a multiple of the argument
func (m Money) Times(multiplier int) Money {
return Money{
amount: m.amount * multiplier,
name: m.name,
}
}
// Equals checks if the amount of the receiver and the argument are the same
func (m Money) Equals(a Accessor) bool {
return m.amount == a.Amount() && m.name == a.Name()
}
// Amount returns amount field
func (m Money) Amount() int {
return m.amount
}
// Name returns name field
func (m Money) Name() string {
return m.name
}package money
// Dollar is a struct that handles dollar money.
type Dollar struct {
Money
}
- 大きめの設計変更にのめり込みそうになったので、その前に手前にある小さな変更に着手した。
- 差異を呼び出し側(FactoryMethod 側)に移動することによって、2 つのサブクラスのコンストラクタを近づけていった。
- リファクタリングの途中で少し寄り道して、times メソッドの中で FactoryMethod を使うように変更した。
- Franc に行ったリファクタリングを Dollar にも同様に、今度は大きい歩幅で一気に適用した。
- 完全に同じ内容になった 2 つのコンストラクタを親クラスに引き上げた。
currency field は第 7 章で作成した name field を currency に改名しただけになる
- $5+10CHF=$10(レートが 2:1 の場合)
- $5*2=$10
- amount を private にする
- Dollar の副作用どうする?
- Money の丸め処理どうする?
- equals()
- hashCode()
- null との等価性比較
- 他のオブジェクトとの等価性比較
- 5CHF*2=10CHF
- Dollar と Franc の重複
- equals の一般化
- times の一般化
- Franc と Dollar を比較する
- 通貨の概念
- testFrancMultiplication を削除する?
全文: github
package money
// Accessor is a accessor of Money
type Accessor interface {
Amount() int
Currency() string
}
// Money is a struct that handles money.
type Money struct {
amount int
currency string
}
// NewMoney is constructor of Money.
func NewMoney(a int, c string) Money {
return Money{
amount: a,
currency: c,
}
}
// NewDollar is constructor of Dollar.
func NewDollar(a int) Money {
return NewMoney(a, "USD")
}
// NewFranc is constructor of Dollar.
func NewFranc(a int) Money {
return NewMoney(a, "CHF")
}
// Times multiplies the amount of the receiver by a multiple of the argument
func (m Money) Times(multiplier int) Money {
return Money{
amount: m.amount * multiplier,
currency: m.currency,
}
}
// Equals checks if the amount of the receiver and the argument are the same
func (m Money) Equals(a Accessor) bool {
return m.amount == a.Amount() && m.currency == a.Currency()
}
// Amount returns amount field
func (m Money) Amount() int {
return m.amount
}
// Currency returns name field
func (m Money) Currency() string {
return m.currency
}t.Run("通貨テスト", func(t *testing.T) {
assert.Equal(t, "USD", money.NewDollar(1).Currency())
assert.Equal(t, "CHF", money.NewFranc(1).Currency())
})times メソッドについては既に共通化しているため、ログ出力用の String メソッドのみ実装した。
全文: github
func (m Money) String() string {
return fmt.Sprintf("{Amount: %v, Currency: %v}", m.amount, m.currency)
}
- サブクラスの仕事を減らし続け、とうとう消すところまでたどり着いた。
- サブクラス削除前の構造では意味があるものの、削除後は冗長になってしまうテストたちを消した。
- $5+10CHF=$10(レートが 2:1 の場合)
- $5*2=$10
- amount を private にする
- Dollar の副作用どうする?
- Money の丸め処理どうする?
- equals()
- hashCode()
- null との等価性比較
- 他のオブジェクトとの等価性比較
- 5CHF*2=10CHF
- Dollar と Franc の重複
- equals の一般化
- times の一般化
- Franc と Dollar を比較する
- 通貨の概念
- testFrancMultiplication を削除する?
dollar.go, franc.go,ファイルを削除した。
全文: github
