🧠

GoでFunctional Options Patternを使うとモックで引数の比較ができない問題に対応したい

2021/04/21に公開

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)

このようにオプションの比較をしたいところであるが、このように書いたテストは上記の理由により必ず失敗する。

問題点の解決

以下のいずれかで対応していることが多い。他にいい方法があれば教えてください。🥺

  1. gomock.Any()で引数の比較をそもそも諦める
  2. DoAndReturn()でなんとか頑張る
  3. インターフェースで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として扱うのが適当そうです。

Discussion