この記事は Zenn でも公開されています。
https://zenn.dev/sanpo_shiho/articles/c06f6b156029a5
TL;DR
モックしている関数が Functional Options Pattern を採用している場合、引数の比較に苦労する。
そのため、gomock を使用している際は
DoAndReturn
を使用し頑張ってオプションを比較する- Functional Options Pattern の採用を諦め、こちらの記事で紹介がある手法を使用する
といった対応が考えられる。
[追記] この記事では「受け取る側でオプションを func 型として受け取っているパターン」のみを Functional Options Pattern と読んでいます、正確にはこの扱いは誤りでした(→ 詳細は記事の最後に
Functional Options Pattern とは
Go では optional な引数を取ることができないので Functional Options Pattern という方法がとられる場合が多い。
go-patterns を見るのがわかりやすい
https://github.com/tmrts/go-patterns/blob/master/idiom/functional-options.md
(以下は全て上記の go-patterns の引用)
オプションは以下のようにそれぞれ「関数を返す関数」で定義されている
package file
type Options struct {
UID int
GID int
Flags int
Contents string
Permissions os.FileMode
}
type Option func(*Options)
func UID(userID int) Option {
return func(args *Options) {
args.UID = userID
}
}
func GID(groupID int) Option {
return func(args *Options) {
args.GID = groupID
}
}
func Contents(c string) Option {
return func(args *Options) {
args.Contents = c
}
}
func Permissions(perms os.FileMode) Option {
return func(args *Options) {
args.Permissions = perms
}
}
オプションを受け取る関数では実行時に受け取った全てのオプションの関数を実行し、適応する
package file
func New(filepath string, setters ...Option) error {
// Default Options
args := &Options{
UID: os.Getuid(),
GID: os.Getgid(),
Contents: "",
Permissions: 0666,
Flags: os.O_CREATE | os.O_EXCL | os.O_WRONLY,
}
for _, setter := range setters {
setter(args)
}
f, err := os.OpenFile(filepath, args.Flags, args.Permissions)
if err != nil {
return err
} else {
defer f.Close()
}
if _, err := f.WriteString(args.Contents); err != nil {
return err
}
return f.Chown(args.UID, args.GID)
}
以下のようにオプションをつけて関数を使用する
emptyFile, err := file.New("/tmp/empty.txt")
if err != nil {
panic(err)
}
fillerFile, err := file.New("/tmp/file.txt", file.UID(1000), file.Contents("Lorem Ipsum Dolor Amet"))
if err != nil {
panic(err)
}
Functional Options Pattern の問題点
Go では関数は比較することができない。
すなわち上記の例でいうと、
reflect.DeepEqual(file.UID(1000), file.UID(1000))
これは常に false になる。
これによって生じる問題点として、「Functional Options Pattern を採用している関数をモック化した時に引数の確認が容易ではなくなる」という点がある。
この記事では簡単のためにモックの作成にgomockを使用することを前提とする。
gomock だと
mockfile.EXPECT().New("/tmp/empty.txt", file.UID(1000), file.Contents("Lorem Ipsum Dolor Amet")).Return(nil)
このようにオプションの比較をしたいところであるが、このように書いたテストは上記の理由により必ず失敗する。
問題点の解決
以下のいずれかで対応していることが多い。他にいい方法があれば教えてください。🥺
- gomock.Any()で引数の比較をそもそも諦める
- DoAndReturn()でなんとか頑張る
- インターフェースで Apply 関数を持つ構造体をオプションとして定義し、使用する
個人的には 3 派であり、以下の記事で紹介されている。
https://ww24.jp/2019/07/go-option-pattern
1.gomock.Any()で引数の比較をそもそも諦める
個人的にこれを一番よく見かける。 gomock.Any()とは、その引数の確認をしないということを意味する。
先程の例でいくと、
mockfile.EXPECT().New("/tmp/empty.txt", gomock.Any()).Return(nil)
となり、これだとオプションがどの値かが確認されない。
これで十分な場合もあるかもしれないが、以下のようにオプションが動的に変更される場合、テストではどのオプションで呼ばれているかを確認したいところではないだろうか。
func Hoge(contents string) {
fillerFile, err := file.New("/tmp/file.txt", file.UID(1000), file.Contents(contents))
}
2.DoAndReturn()でなんとか頑張る
gomock では DoAndReturn()という関数が使用できる。詳しくは ↓
https://pkg.go.dev/github.com/golang/mock/gomock#Call.DoAndReturn
これを用いると以下のように頑張って引数の比較が実現できる。
mockfile.EXPECT().New("/tmp/empty.txt", gomock.Any()).DoAndReturn(
func(s string, setters ...Option) error {
args := &Options{
UID: os.Getuid(),
GID: os.Getgid(),
Contents: "",
Permissions: 0666,
Flags: os.O_CREATE | os.O_EXCL | os.O_WRONLY,
}
for _, setter := range setters {
setter(args)
}
// 比較する
if args.Contents != "want contents" {
// 比較が失敗した時の処理を書く
}
return nil
}
)
Functional Options Pattern を採用するならこれがいいかもしれない
3. Functional Options Pattern をそもそも使用しない
個人的には Functional Options Pattern を使わず、以下の記事で紹介されている方法を採用している。
https://ww24.jp/2019/07/go-option-pattern
[追記] 「Uber Go Style Guide」でも紹介されていました
https://github.com/uber-go/guide/blob/master/style.md#functional-options
上記記事内の例を借りると、
package main
import (
"fmt"
"reflect"
)
type Option interface {
Apply(*conf)
}
type conf struct{ a int }
type AOption int
func (o AOption) Apply(c *conf) {
c.a = int(o)
}
func OptionA(v int) AOption {
return AOption(v)
}
オプションを interface を使用して「Apply()関数をもつ構造体」というふうに定義している。賢い。
関数では以下のようにオプションを解釈する。
func Hoge(str string, opts …Option) error { c := &conf{a: 99999} for _, opt := range opts { opt.Apply(c) }
// cの値から振る舞いを変える
fmt.Println(str)
fmt.Println(c.a)
return nil
}
https://play.golang.org/p/-Ghl62Zrbmv
この手法だとオプションの実体は構造体であるため、比較可能である。
reflect.DeepEqual(OptionA(12), OptionA(12)) // → true
終わりに
シンプルにオプションを構造体として扱いたい場合は 3 が良さそうに見えるが、3 は Functional Options Pattern よりコードの量が増える。(しょうがないけど…)
そのため、Functional Options Pattern を使用したい場合(もしくは今更 3 に変更できない場合)は 2 を採用するのが良さそう。他にいい方法があれば教えてください。
[追記] そもそも Functional Options Pattern とは何を指しているのかという話
https://twitter.com/sanpo_shiho/status/1385178852754739201?s=20
Functional Options Pattern は何を指しているのでしょうか? この記事ではオプションの受け取り側でオプションを func 型として受け取っているパターンを Functional Options Pattern と読んでいました。
Functional Options Pattern の話が提唱されている記事は以下になります
https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis
そしてこれは以下の記事を元にしたアイデアであるとしています。
At this point I want to make it clear that that the idea of functional options comes from a blog post titled. Self referential functions and design by Rob Pike, published in January this year. I encourage everyone here to read it.
https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html?m=1
この二つの記事を見るとオプションの受け取り側でオプションを func 型として受け取っているパターンのことを Functional Options Pattern として指しているようです。
実際に「Golang Functional Options」🔍 で検索すると出てくるような記事では Functional Options を上記のように紹介していることが多く見えます。(そして僕自身もこのパターンのみを Functional Options Pattern と認識していました)
しかし、Uber が出している「Uber Go Style Guide」ではこの記事でいうところの 3 番で紹介している方法を Functional Options として扱っています。
https://github.com/uber-go/guide/blob/master/style.md#functional-options
また、First-class Function と一つのメソッドのみをもつ interface は等価であるという考え方があるそうです(Twitterで教えていただきました)
At GopherCon last year Tomás Senart spoke about the duality of a first class function and an interface with one method. You can see this duality play out in our example; an interface with one method and a function are equivalent. https://dave.cheney.net/2016/11/13/do-not-fear-first-class-functions
そのため正確には 3 番で紹介している方法も含めて Functional Options Pattern として扱うのが適当そうです。