its: mocker にさらに(破壊的)変更を加えようとしている
お久しぶりです。youta-t です。テストマッチャフレームワーク "its" をつくってます。
実は前回の記事とリリースからこっち、ずっと「モック関連の機能ってこれでいいんだっけ?」という疑問が脳内にありました。
its/mocker
の与える機能が良いものだとおもえなくなってしまったのです。
its/mocker
v0.3.0 は何をするものか
そもそも its/mocker
は type ... func(...)...
な型(関数型)や type ... interface { ... }
な型(インタフェース型)に対するモック実装を与えるコードジェネレーターです。
デフォルトでは gen_mock
という(サブ)パッケージにモック実装を書き出します。
関数については、
var behaviour gen_mock.SomeFuncBehaviour = gen_mock.NewSomeFuncCall(its.Equal(want_x), its.Equal(want_y)).
ThenReturn(a, b)
のように使えるモックビルダーを与えます。
上例は type SomeFunc function (X, Y) (A, B)
に対するモックを与える例です。
New${関数名}Call
が引数リスト中の各引数に対するマッチャを受け取って「正しく呼び出されているか」を検証できるようにします。その次の .ThenReturn
メソッドが戻り値をモックします。
こうすると go_mock.${関数名}Behaviour
[1] という型の「関数の振る舞い」を表現した値が得られます。ここから実際に呼び出すことができる関数を得るには
behaviour.Mock(t)
のように *testing.T
を与えてやる必要があります。
インタフェース型のモックも、各メソッドについては関数とよく似たイディオムが使えるようになっています。
さらに、メソッドを束ねてインタフェースにする必要があるので、そのためのコンテナが与えられています。
適当なインタフェース型 type Interface interface { Method (X, Y) (A, B) }
を例にとると
var mock Interface = gen_mock.NewMockedInterface(
t,
gen_mock.InterfaceImpl{
Method: gen_mock.NewInterface_MethodCall(
its.Equal(want_x), its.Equal(want_y),
).
ThenReturn(a, b) .
Mock(t),
},
)
のようにします。
its/mocker
v0.3.0 の何がしっくりこないか
モックをつかったテーブルテストを書くことを考えてみましょう。
for name, tt := range map[string]struct{
// ...
Behaviour: gen_mocker.InterfaceBehaviour,
// ...
} {
"testcase": {
Behaviour: gen_mocker.InterfaceBehaviour{
// ...
Method: gen_mocker.NewInterface_MethodCall(...).
ThenReturn(...),
// ...
},
},
// ...
} {
// ...
t.Run(name, func(t *testing.T){
mocked := gen_mocker.NewMockedInterface(t, gen_mock.InterfaceImpl{
Method: tt.Behaviour.Method.Mock(t),
})
})
// ...
}
というような表現になっていました。
これをながめていると、気がつくことがいくつかあります。
出てくる名前が多すぎる
gen/mocker
が生成したパッケージ gen_mocker
から公開されている名前として、次のものが参照されています。
-
gen_mocker.InterfaceBehaviour
: インタフェース型Interface
の各メソッドについての振る舞い(Behaviour)を属性に持つ型。- ここでは初めて触れましたが、これも
its/mocker
が生成しています。
- ここでは初めて触れましたが、これも
-
gen_mocker.NewInterface_MethodCall
:Interface.Method
というメソッドの振る舞いを与える関数。 -
gen_mock.InterfaceImpl
: インタフェースInterface
の各メソッドの実装であるfunc
を属性にもつ型。 -
gen_mocker.NewMockedInterface
:InterfaceImpl
に基づいてInterface
型の値を構築する関数。
こうみたとき、 InterfaceBehaviour
と InterfaceImpl
の機能に重複があることがわかります。
InterfaceBehaviour
の各属性(Interface_MethodBehaviour
など)からモックされた関数を得る方法は決まっている(...Behaviour.Mock
) 上に、必要な追加のパラメータは *testing.T
だけです。その *testing.T
は NewMocked${インタフェース名}
も受け取っていることまで考慮にいれれば、 NewMocked${インタフェース名}
は ${インタフェース名}Behaviour
を ${インタフェース名}Impl
に変換するタスクを隠蔽できそうです。
命名規約がとっちらかっている
- 名前は、go の標準的な原則にしたがって
CamelCase
でつけていますが、例外があります。インタフェース名とメソッド名は_
で接続されています。 -
New
から始まる名前が多すぎるので、入力補完の絞り込みも 3 文字分は情報がないことになってしまっています。-
NewMocked${インタフェース名}
はなお悪いと言えるでしょう。
-
-
...Impl
と...Behaviour
の意味の違いは、名前だけでは不明瞭です。 -
New${関数名}Call
は、特に何かを呼び出しているわけではありません。呼び出され方を決めているだけです。
...と、ちょっと見通しが悪そうです。
_
については、もっと一般的な規則を与えたほうが良いと思われます。モック対象の型も大抵は CamelCase
で命名されているわけですから、コードジェネレータが名前を組み立てる際には _
区切りにすることで、名前の見た目にも区別がつきやすくなるでしょう。
「New
始まり」に関する問題は、いきなりモックしたい対象の名前で始められればすっきりしそうです。ユーザも、自分のやりたいことをエディタに押し付けられるようになるでしょう。
...Impl
と ...Behaviour
の問題は、前述の内容を踏まえれば ...Impl
側を隠蔽できそうです。...Impl
を隠蔽するということは、 func
の形でモック関数を持ち回るのはもうやめて、...Behaviour
をもっぱら取り扱うようになるのだ、ということを意味します。それから、ごく些細なことですが、Behaviour という綴りは、アメリカ綴り(Behavior)にすると 1 文字短くなりますね。
New${関数名}Call
は、単に名前を変えるべきだと思われます。
こうできるだろう、 v0.4.0 ではこうしたい
あらためてモックをつかったテーブルテストを書くことを考えてみましょう。
for name, tt := range map[string]struct {
// ...
Spec: gen_mocker.Interface_Spec,
// ...
} {
"testcase": {
// ...
Spec: gen_mocker.Interface_Spec{
// ...
Method: gen_mocker.Interface_Method_Expects(...).
ThenReturn(...),
// ...
},
// ...
},
// ...
} {
// ...
t.Run(name, func(t *testing.T){
mocked := gen_mocker.Build_Interface(t, tt.Spec)
})
// ...
}
このように書ける(ようにできる)はずです。
t.Run
内部での「Mock
して詰め直し」が減っていて、多少マシになるだろうと思います。
リネーム
次のように名前が置き換わっています。
-
New${関数名}Call
->${関数名}_Expects
-
New${インタフェース名}_${関数名}Call
->${インタフェース名}_${関数名}_Expects
-
${インタフェース名}Behaviour
->${インタフェース名}_Spec
- 関数のほうの
Behavior
とは、やはり意味が異なると思われたので、別のサフィックスを選びました。
- 関数のほうの
-
NewMocked${インタフェース名}
->${インタフェース名}_Build
いずれも、 its/mocker
が名前を組み立てた際のデリミタとして _
をつかう、という規約で統一しています。
廃止
...Impl
型は廃止されます。すでに述べた通り、不要です。
${関数名}Behaviour
および ${インタフェース名}_${関数名}Behaviour
型は廃止されます。これについては後述します。
他の機能との関係
さて、its/mocker
は宙に浮いた存在ではありません。もちろん、他の機能とも関係があります。
普通の関数(非モック関数)の取り扱い
これまでは ...Impl
型があったので、モックではないただの関数を直接インタフェースの実装として与えることができました。
これからは ...Impl
型がなくなるので、そうはいきません。
そこで、こうします。
- 「
Mock
すると関数が得られる型」をFuncBehavior[T]
というインタフェースとみなすことにします。its/mocker
が生成したモック関数ビルダも、個別の関数についてのBehaviour
を与えるのではなくて、FuncBehavior[func(...)...]
を返すようにします。 - ただの
func
をFuncBehavior[T]
にラップする関数Effect
を新設します。
この関数名 Effect
はもちろん、モック関数ビルダの .ThenEffect
を踏まえたものです。
パッケージの整理
FuncBehavior[T]
型や Effect
関数を公開するために、あたらしく its/mocker/mockkit
というパッケージを作成します。
さて、この mockkit
ですが、モックに関係した機能を すべて 収録したパッケージにします。
つまり、従来 its/mocker/scenario
にあったシナリオテスト関連の機能も、今後は mockkit
の一部だ、ということにします。
itskit
と似た雰囲気の名前になるので、その点でも一貫性がとれるんじゃないかな? と思っています。
シナリオテストとの関係
シナリオテストは従来、
func TestWithScenario(t *testing.T) {
sc := scenario.Begin(t)
defer sc.End()
fn := scenario.Next(sc, func(...) { ... } )
testee(fn)
}
のようにやってきました。
ところで、 its/mocker
が通常 FuncBehavior[T]
を取り回すようになって、関数そのものは隠蔽するようになるなら、シナリオテストもそうすべきです。
そこで、こうします。さらに、前述のとおり mockkit
に引っ越しているので...
func TestWithScenario(t *testing.T) {
sc := mockkit.BeginScenario(t)
defer sc.End()
behavior := mockkit.Next(sc, mockkit.Effect(func(...) { ... }))
testee(fn)
}
mockkit.Next
(旧 scenario.Next
) が直接 func
を引数に取らなくなったので、 mockkit.Effect
でラップしたものを渡しています。Next
の戻り値は func
ではなくて FuncBehavior
になります。
もし Next
に渡したい関数がすでにモックされたものなら...
behvavior := mockkit.Next(
sc,
gen_mock.Func_Expects(...).
ThenReturn(...),
)
これでOKです。
破壊的変更
以上の変更は、its/mocker
の生成コードについての破壊的変更です。またか! とお思いの方もあるでしょう。すみません。
しかし、こうすることで従来よりも使いやすくなるだろうと考えています。
FuncBehavior
についてだけ考えればよくなり、純粋な仕様記述がやりやすくなるでしょう。
Behavior を func
に落とし込むボイラープレートコードは(僅かではありましたが)なくなります。
また、諸々の名前もより実態に近くなり、IDE の補完も活用しやすくなるはずです。
この変更がおわったら、 v0.4.0 として公開する予定です。しばらくおまちください。
また、ご意見・ご要望をお持ちの方がいらっしゃいましたら、是非お聞かせ願いたくおもいます。
-
イギリス風の綴りです。アメリカ風の綴り behavior のほうが馴染み深い、という方も多いかもしれません。 ↩︎
Discussion