GoでFunctional Options Patternを使うとモックで引数の比較ができない問題に対応したい
TL;DR
モックしている関数がFunctional Options Patternを採用している場合、引数の比較に苦労する。
そのため、gomockを使用している際は
-
DoAndReturn
を使用し頑張ってオプションを比較する - Functional Options Patternの採用を諦め、こちらの記事で紹介がある手法を使用する
といった対応が考えられる。
[追記] この記事では「受け取る側でオプションをfunc型として受け取っているパターン」のみをFunctional Options Patternと読んでいます、正確にはこの扱いは誤りでした(→詳細は記事の最後に
Functional Options Patternとは
Goではoptionalな引数を取ることができないのでFunctional Options Patternという方法がとられる場合が多い。
go-patternsを見るのがわかりやすい
(以下は全て上記の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派であり、以下の記事で紹介されている。
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()という関数が使用できる。詳しくは↓
これを用いると以下のように頑張って引数の比較が実現できる。
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を使わず、以下の記事で紹介されている方法を採用している。
[追記] 「Uber Go Style Guide」でも紹介されていました
上記記事内の例を借りると、
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
}
この手法だとオプションの実体は構造体であるため、比較可能である。
reflect.DeepEqual(OptionA(12), OptionA(12)) // → true
終わりに
シンプルにオプションを構造体として扱いたい場合は3が良さそうに見えるが、3はFunctional Options Patternよりコードの量が増える。(しょうがないけど…)
そのため、Functional Options Patternを使用したい場合(もしくは今更3に変更できない場合)は2を採用するのが良さそう。他にいい方法があれば教えてください。
[追記] そもそもFunctional Options Patternとは何を指しているのかという話
Functional Options Patternは何を指しているのでしょうか?
この記事ではオプションの受け取り側でオプションをfunc型として受け取っているパターンをFunctional Options Patternと読んでいました。
Functional Options Patternの話が提唱されている記事は以下になります
そしてこれは以下の記事を元にしたアイデアであるとしています。
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.
この二つの記事を見るとオプションの受け取り側でオプションをfunc型として受け取っているパターンのことをFunctional Options Patternとして指しているようです。
実際に「Golang Functional Options」🔍で検索すると出てくるような記事ではFunctional Optionsを上記のように紹介していることが多く見えます。(そして僕自身もこのパターンのみをFunctional Options Patternと認識していました)
しかし、Uberが出している「Uber Go Style Guide」ではこの記事でいうところの3番で紹介している方法を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として扱うのが適当そうです。
Discussion