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

April 21, 2021

この記事は 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)

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

問題点の解決

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

  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 として扱うのが適当そうです。

このエントリーをはてなブックマークに追加