📣

its: mocker にさらに(破壊的)変更を加えようとしている

2024/03/17に公開

お久しぶりです。youta-t です。テストマッチャフレームワーク "its" をつくってます。

https://github.com/youta-t/its

実は前回の記事とリリースからこっち、ずっと「モック関連の機能ってこれでいいんだっけ?」という疑問が脳内にありました。
its/mocker の与える機能が良いものだとおもえなくなってしまったのです。

そもそも its/mocker v0.3.0 は何をするものか

its/mockertype ... 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 型の値を構築する関数。

こうみたとき、 InterfaceBehaviourInterfaceImpl の機能に重複があることがわかります。
InterfaceBehaviour の各属性(Interface_MethodBehaviourなど)からモックされた関数を得る方法は決まっている(...Behaviour.Mock) 上に、必要な追加のパラメータは *testing.T だけです。その *testing.TNewMocked${インタフェース名} も受け取っていることまで考慮にいれれば、 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(...)...] を返すようにします。
  • ただの funcFuncBehavior[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 として公開する予定です。しばらくおまちください。
また、ご意見・ご要望をお持ちの方がいらっしゃいましたら、是非お聞かせ願いたくおもいます。

脚注
  1. イギリス風の綴りです。アメリカ風の綴り behavior のほうが馴染み深い、という方も多いかもしれません。 ↩︎

Discussion