gomockの課題点と改善への取り組み

中村圭助氏:最近、コードを書いていて、mockがしんどいというところがあります。僕は、mockを使う時は、けっこうgomockを使います。(スライドを示して)ここにも書いているんですけど、gomockを使う場合、EXPECT().Return()の型補完が利かないというデメリットがあります。これはなぜかというと、gomock側で自動生成されるコードの受け取る型がanyで定義されているからです。

もう1つ、interfaceのmockの実装がけっこうしんどくなってきます。関数の中で呼び出しているinterfaceのメソッドが増えると、mockが不足したり過剰に実装してしまうことがあります。

gomockを使っている人はこれがわかると思うんですけど、mockが不足していた場合とか、過剰に実装してしまっている場合は、エラーで返されるので、また実装を見直す工程が必要になります。これは開発体験がけっこう悪いかなと思っています。

先ほどもちょっと述べたんですけど、EXPECT().Return()の課題点です。これ、go.devパッケージ(pkg.go.dev)を見ると、Callの部分でinterfaceですね、anyで返すようになっています。

これは、interfaceのスライスになっているので、IDE等の補完が利かない。任意の値を設定できるため、テストの実行時に、単体テストのランタイムでしか型の正しさを検証できないようになっています。

先ほどのEXPECT().Return()の部分で、元の定義した型と異なる型を返すようにReturnする値を実装した場合は、ちょっと拡大すると、(スライドを示して)このように本来はerrorで返すところをtrueで返すように実装しています。

こういう実装をするとanyで渡せちゃうんですけど、実際の実行時にこういうふうに「ここのReturnのargumentの部分がおかしい」「errorなのにboolを返しているよ」と返されます。

そもそも、なぜgomockのEXPECT().Return()の実装がこのような設計になっているのかを考えてみると、mockのフレームワークは、柔軟性がけっこう求められる側面が強いです。

any型であるため、柔軟に値を設定できます。例えば、この中に関数を埋め込んで必要なデータ型を返すという実装ができますが、特定のシーケンスに含まれる場合に柔軟に実装できるようにするために、(スライドを示して)こういう実装になっています。

ただ、先ほど言ったように、IDEの補完が利かないとか、特定のシーケンスを単体テストで実現するというのは、そもそも単体テスト対象の関数が複雑になっていて、any型を渡す場合、内部で型アサーションが起こっているので、潜在的に制御結合の可能性があります。

先ほどboolとエラーの部分で説明したとおり、実際に異なるデータ型を渡している場合、ランタイムでエラーになります。

gomockの柔軟性を落とさない、かつ、IDEの恩恵を受けられるツールがあればよさそうだなっていうのをモチベーションに、機能拡張を今行っています。

柔軟性を落とさないというところで言うと、anyの部分はanyのままで扱い、自動生成されるコードに対しては変更を加えないことをもとに機能拡張しています。

また、その部分で、それは残しつつもIDEの恩恵を受けられるようにするために、EXPECT().Return()に渡す値が、定義したinterfaceと一致する型をスケルトンコードとして生成するということをやっていきます。

ここで一致する型を定義することによって、IDEがその型を読み込むことができるため、中のフィールドにどういうものをセットすればいいのかとか、そういうところでIDEの恩恵をけっこう受けられるかなと思っています。

スケルトンコードのイメージですけど、gomockは、(スライドを示して)このようなかたちです。受け取るEXPECT()のところは、model.Userを受け取って、Returnは、errorのなにも入っていない空文字列を返すかたちで生成できればと思っています。

gomockのmockの部分まで含んだスケルトンコードの自動生成までに行われているところですが、(スライドを示して)これは途中までの実装です。

まず、自動生成されたmockのファイルから依存元の情報を取得する。これはコメントアウトされているので、コメントの部分を解析するだけでできます。import文からinterfaceとmockの依存関係の紐付けを行います。

その中で、関数の中で呼び出されている関数のCallExprと、生成されたmockのファイルを紐付けをして、その後ASTを設定、作成するという工程に入ります。そのASTをテストファイルとして吐き出すことで、gomockのIDEの補完が利くテストコードの自動生成を行おうと思っています。

パッケージ依存関係の解析と自動生成プロセス

ここからパッケージの依存関係とか、Goのファイルの依存関係についてです。自動生成されたmockのファイルから依存元の情報を取得するために、どういうふうにするんだというところですが、(スライドの)下のようなコードで、けっこう楽に取得できます。

ここから、interfaceの依存の部分について話していきます。interfaceに依存しているパッケージやGoのファイルは、依存元のパッケージに依存しています。

gomockを使うと、ここのパッケージとかファイルをもとにmockパッケージとしてコードが生成されます。

単体テスト時にこのmockパッケージを参照して、レコードを実装します。そして単体テスト時に、このinterfaceに依存している関数であったりメソッドを呼び出します。

今回機能拡張しているgo-test-generatorのmockの自動生成をどういうふうにやっているかというと、まずmockパッケージを見にいって、mockの情報、依存元の情報であったりmockのファイルの置き場を取得しています。

次に、interfaceに依存しているパッケージ、Goファイルのパッケージ、関数、interfaceなど、すべて取得しています。

その次に、依存元のパッケージ、関数、interfaceをすべて取得していって、その後、依存関係の紐付けとか、mockのASTを取得する、作成することをします。

ここの依存関係の紐付けが、ロジックとしてけっこう複雑なので、今現状は、そこを実装しているかたちです。

ASTが作成されたら、単体テストのファイルとして出力するだけなので、ASTを出力します。

各メソッドとか関数のASTを解析して、呼び出されるCallExprを取得するところですが、だいたいCallExprは、ast.ExprStmtとか、AssignStmtから取得できるので、(スライドを示して)こういうふうにASTが表現されます。ここのCallExprを取っていくかたちになります。

CallExprから関数とかメソッドの定義元を取得するんですけど、ソースコード全体をASTにパースする必要があったり、パッケージ単位でGoのコードを解析したりしないといけないので、go/astだけだとパッケージをまたいだパースはできません。

このgo/astだけで実装すると、実装がけっこう複雑になりますし、独自実装になります。それを楽にするツールが開発されていて、golang.org/x/tools/go/packagesというものを使っています。これを使うと、パッケージごとのソースコード、ASTへのパースとか型情報の取得を楽に行ってくれます。

今の開発のつらみですけど、先ほど最初に挙げた必要なテスト数を列挙するツールは、go/astのみの実装になっているので、golang.orgが提供しているpackagesのツールと互換がない部分がけっこうあるため、つらいです。

例えばで言うと、go/astのみでパースしたfuncのdeclarationと、go/packagesでパースしたfuncのdeclarationを比較する時に、そのまま「==」じゃできないとか、トークンのポジションでわざわざ比較しないといけないというつらみがあります。

「CallExprからレシーバの型情報を取得できる」についてです。実際のコードですけど、interfaceでの型アサーションができるので、これがinterface経由で実装されているのか、呼び出しなのかというのが判定可能になります。

「mockのコードを自動生成する」です。コードの自動生成に必要なものは、mock対象の構造体とか、mockのinterfaceのメソッドに対応する関数の引数、戻り値の型です。最終的に出力したいものは、mockの構造体を初期化する関数です。

(スライドを示して)これは、実装している部分のイメージですが、テストの構造体にmockの初期化関数の型を定義すると、こういうふうな実装になります。先ほど言ったとおり、宣言的に書くようになっています。

現在開発中ですけど、「テストコードのASTを構築する」ところは、(スライドを示して)こういうふうになっています。テストコードの部分の関数の呼び出しとか、スケルトンコードの部分の実装ですけど、ここのbuildSkeltonTestCodeという中で、先ほどのmockの生成も行おうとしています。

実際に自動生成したコードをファイルに吐き出すというところで言うと、(スライドを示して)こういうふうに、os.Statでファイルの存在も確認して、format.Nodeで書き込むことができます。

ここからまとめです。テストカバレッジを満たすために必要なテストケースを自動生成するツールを作りました。必要なテストケースは、基本的には分岐を考えればよいです。ただ、Exportedな関数から呼ばれているUnexportedな関数の分岐はまだ考慮できていないので、今後このあたりも作っていかないといけないと思っています。

gomockで補完がけっこう利かない場所を自動生成して楽にコーディングできるようなツールを作ろうとしています。

先ほども言ったんですけど、パッケージ間依存をgo/astのみで解決するのは、けっこう大変です。packagesという便利ツールがあるので、これを使いましょう。

これも同じですね。Unexportedな関数のmockにも対応できるようにするかたちになっています。

僕の発表は以上で終わります。ご清聴ありがとうございました。