kanna

kanna / fixture

kanna-fixture

v0.0.10 Go 1.26 pkg.go.dev

CI codecov Go Reference License: MIT Release English

kanna-fixture は、パッケージのすべての構造体に、全フィールドが埋まった値を返すコンストラクタを生成します。そのため、テストには関心のあることだけを書けば済みます。フィールドの値には slug-1、email-1@example.com のように番号が付くのでフィクスチャ同士は衝突せず、validate タグで宣言したルールを満たすように埋められます。fake タグを付けたフィールドには、代わりに gofakeit のテンプレートが使われます。

kanna コードジェネレータファミリーの 1 つです。Go 1.26 以上が必要です。

インストール

go get -tool github.com/go-kanna/fixture/cmd/kanna-fixture

上のコマンドは gofakeit も依存に追加します。生成コードは gofakeit を必要とするフィールドのためにこれを呼び出し、ジェネレータはそれらのテンプレートのチェックにこれを使います。どのフィールドも gofakeit を必要としないパッケージのフィクスチャは、gofakeit を import しません。

使い方

package model

//go:generate go tool kanna-fixture

type Role string

const (
	RoleAdmin  Role = "admin"
	RoleMember Role = "member"
)

type User struct {
	ID        int64
	Name      string `fake:"{firstname} {lastname}"`
	Email     string `validate:"required,email"`
	Age       int    `validate:"gte=18,lte=130"`
	Role      Role
	Nickname  *string
	CreatedAt time.Time
	DeletedAt time.Time `fake:"skip"`
}

type Article struct {
	ID     uuid.UUID
	Slug   string
	Title  string
	Author User
}

go generate ./... を実行すると、モデルの隣に独立したパッケージとして fixture/fixture_gen.go が書き出されます:

// Code generated by kanna-fixture. DO NOT EDIT.

package fixture

var seqArticle atomic.Int64

// Article returns a new model.Article with every field it can fill set, then
// applies setters in order.
func Article(setters ...func(*model.Article)) model.Article {
	n := seqArticle.Add(1)
	x := model.Article{
		ID:     uuid.MustParse(fmt.Sprintf("00000000-0000-4000-8000-%012x", n)),
		Slug:   fmt.Sprintf("slug-%d", n),
		Title:  fmt.Sprintf("title-%d", n),
		Author: User(),
	}
	for _, set := range setters {
		set(&x)
	}
	return x
}

var seqUser atomic.Int64

// User returns a new model.User with every field it can fill set, then
// applies setters in order.
func User(setters ...func(*model.User)) model.User {
	n := seqUser.Add(1)
	f := gofakeit.NewFaker(rand.NewPCG(uint64(n), 0x5f2c8e0a9d4b7136), false)
	x := model.User{
		ID:        n,
		Name:      mustGenerate(f, "{firstname} {lastname}"),
		Email:     fmt.Sprintf("email-%d@example.com", n),
		Age:       int(18 + (n-1)%113),
		Role:      model.RoleAdmin,
		CreatedAt: time.Date(2000, time.January, 1, 0, 0, int(n), 0, time.UTC),
	}
	for _, set := range setters {
		set(&x)
	}
	return x
}

テストでは、必要なものを取得し、関心のあるところだけを変更します:

a := fixture.Article()
b := fixture.Article(func(a *model.Article) { a.Title = "Generating fixtures" })
// a.Slug == "slug-1" and b.Slug == "slug-2": two articles never collide.
// a.Author.Email == "email-1@example.com", b.Author.Email == "email-2@example.com".

セッターはただの関数なので、チームで繰り返し使うものには名前を付けられます:

member := func(u *model.User) { u.Role = model.RoleMember }
u := fixture.User(member, func(u *model.User) { u.Age = 70 })

フィクスチャはモデルを import するので、他のパッケージのテストとモデルの外部テスト(package model_test)からは使えますが、モデルの内部テストからは使えません。import の循環になるからです。

値

呼び出しのたびに、その型の次の番号 n を 1 から順に取り、各フィールドには次の表で最初に一致するルールが適用されます:

フィールド 値
fake タグ付き fake タグを参照
この実行でフィクスチャが生成される構造体 そのフィクスチャ: Author: User()。再帰することになるものはゼロ値のまま
同じパッケージにその型の定数が宣言されている名前付き型 ゼロ値でない最初の定数: model.RoleAdmin
kanna-value の値オブジェクト 型と同じ名前のフィールドに入る値をコンストラクタに渡した結果、またはゼロ値
time.Time 2000-01-01T00:00:00Z の n 秒後
標準ライブラリまたは github.com/google/uuid の uuid.UUID 末尾が n の UUID: 00000000-0000-4000-8000-000000000001
基底型が基本型であるその他の名前付き型 基底型について下の行で決まる値を変換したもの: model.UserID(1)
整数または浮動小数点数 n。n が収まらない狭い型では折り返す
名前が …Email、…URL、…UUID の文字列 その形式: email-1@example.com、https://example.com/url-1、UUID
文字列 フィールド名と n: slug-1、first-name-1
bool false
ポインタ、スライス、マップ、インターフェース、チャネル、関数、配列、その他の構造体 ゼロ値

その後、validate タグのルールに従って値が調整されます。非公開フィールドには何も設定しません。フィクスチャは自身のパッケージからそれらを設定できないからです。そのパッケージから型を記述できないフィールド、つまり非公開の型のフィールドや、非公開フィールドを持つ無名構造体型のフィールドも同様です。

番号が使われる値は型ごとに一意で、どのフィールドのものかも分かります。テストの失敗に title-3 とあれば、3 番目に作られた Article の Title です。番号が使われない値(定数、oneof の先頭の値、bool)は、どのテストが先に実行されても同じなので、テストは単独でも、全体の実行の中でも、並列でも同じように動作します。一方、n から作られる値は、それ以前に作られたフィクスチャの数によって変わります。そのような値をアサートするテストでは、その値を自分で設定してください。

番号は 1 つのプロセスの中で数えられます。データベースを共有するテストバイナリはそれぞれが自分の email-1@example.com を作るので、パッケージごとに専用のスキーマかトランザクションを用意するか、一意であるべきフィールドを自分で設定してください。

ポインタとスライスは nil のままです。値がないことも、残しておく価値のある値だからです。Nickname を持つフィクスチャは、ニックネームがあるユーザーとないユーザーの違いを消してしまいます。required があれば値が埋められます。

ルール

フィールドの validate タグは、値がそれを満たすように読み取られます。このタグは go-playground/validator と kanna-validate の両方が読むものです。kanna-fixture はどちらにも依存せず、どちらが構造体をチェックしても、どちらもチェックしなくても、同じようにフィールドを埋めます。フィクスチャは作られた時点で有効なので、1 つのルールのテストでは 1 つのフィールドを変更するだけで済みます:

type SignUp struct {
	Email    string `json:"email"    validate:"required,email"`
	Password string `json:"password" validate:"required,min=12"`
	Confirm  string `json:"confirm"  validate:"eqfield=Password"`
	Role     string `json:"role"     validate:"required,oneof=admin member guest"`
	Age      int    `json:"age"      validate:"gte=18,lte=130"`
}
req := fixture.SignUp(func(r *model.SignUp) { r.Email = "nope" })
// Validating req reports Email, and nothing else.
ルール 値
required ゼロ値以外: true、0 以外の数値、値へのポインタ、要素が 1 つのスライス
文字列とスライスの min、max、len それらの範囲内の長さ: min=12 なら password-001、min=k なら要素 k 個
整数の gt、gte、lt、lte、min、max 下限から(下限がなければ 1 から)数えた n。上限の範囲内で折り返す: gt=0 → 1, 2, 3, …
浮動小数点数の gt、gte、lt、lte、min、max 許される最小の値。下限がなければ最大の値: gt=0 → 1
eq その値
ne 別の値: oneof の次の選択肢。範囲の一部を除外する場合は、残った中で最小の値
oneof=a b c 先頭の値: "admin"
eqfield=Field 指定したフィールドの値
email、url、uuid その形式の値。min と max の範囲内で番号を付ける: len=19 なら em-0001@example.com
pattern=regexp(kanna-validate にのみある) gofakeit が正規表現から生成する文字列
dive dive より後のルールを、作られた各要素に適用

ルールは満たすために読み取るもので、チェックするためのものではありません。kanna-fixture が知らないルールは構造体をバリデーションする側に任され、入った値がそのルールを満たさないこともあります。パターンは番号では満たせない唯一のルールで、gofakeit はすべての正規表現から値を生成できるわけではありません。生成した値が正規表現にマッチしない場合や、min、max、len の範囲に収まらない場合は、生成時に警告を出し、fake タグを求めます。形式がこれらの範囲に収まらない場合も同じです。UUID の長さは 36 文字で、アドレスや URL には @example.com や https://example.com/ のほかに番号を入れる余地が必要です。

fake タグ

fake タグは、gofakeit.Struct が読むタグと同じ gofakeit 自身の構文を取ります。現実的な値や、ルールでは表せない形式が欲しいフィールドに使います:

タグ 値
fake:"{firstname} {lastname}" テンプレートから生成した値: Neha Wang
fake:"{number:18,65}" 範囲内の数値。その範囲が収まる数値フィールドに使う
fake:"???-####" ? は英字、# は数字: zSW-1523
fake:"123-4567" その値のまま(固定)
fake:"skip" または fake:"-" ゼロ値

文字列のフィールドには、どのテンプレートも使えます。数値のフィールドには、そのフィールドに収まる範囲で、数値のテンプレート({number}、{intrange}、{uintrange}、{float32range}、{float64range}、{price})を使います。time.Time には {date}、UUID には {uuid} を使います。数値と bool には、fake:"42" のように値をそのまま書くこともできます。名前付き型は、基底型が受け取るものを変換します。ポインタは、通常なら nil のままですが、値を指すように設定されます。フィールドの validate タグのルールは適用されません。テンプレートこそが指定された値だからです。

タグ付きのフィールドは、フィクスチャの番号と型をシードにした faker で作られます。そのため、3 番目に作られた User は、他の型のフィクスチャがどう動いても、どの実行でも同じ名前になります。その値は一意ではないので、一意なカラムにはタグを付けないでください。

gofakeit は理解できないテンプレートをそのまま値として返すので、kanna-fixture は生成時に、モジュールが依存している gofakeit に照らしてすべてのテンプレートをチェックします:

$ go generate ./...
./model.go:9:2: error: unknown function "numbr" in fake tag "{numbr:1,10}" (field Age)
  did you mean "number"?
./model.go:10:2: error: fake tag "{email}" cannot fill int (field Count)
./model.go:11:2: error: fake tag "{number:1,300}": 300 does not fit int8 (field Level)
./model.go:14:2: warning: values generated from the pattern do not match it; give the field a fake tag (field Code)

構造体

パッケージの公開構造体にはすべてフィクスチャが生成されます。明示的に有効にする必要はないので、モデルに追加した構造体には、次の go generate でフィクスチャが生成されます。

ディレクティブ 効果
//kanna:fixture skip この構造体のフィクスチャを生成しない

-exclude は同じことをコマンドラインから行います。sqlc が生成するモデルのように、ソースを自分で書かないパッケージのためのものです。パッケージが宣言していない名前を指定すると警告が出ます。

//kanna:value が付いた構造体にもフィクスチャは生成されず、何も報告されません。これは kanna-value の値オブジェクトで、唯一のフィールドは非公開であり、値はコンストラクタを通して作られるからです。このような型のフィールドは、型と同じ名前のフィールドに入る値をそのコンストラクタに渡して作られます。Email なら orZero(model.NewEmail(fmt.Sprintf("email-%d@example.com", n))) です。型のルールが拒否した値は、panic にはせず、値が渡されなかったときと同じくゼロ値になります。フィクスチャはセッターの実行前に作られるので、panic するとセッターでは回避できないからです。そのようなフィールドは、セッターの中で MustX を使って設定してください。

次の構造体は警告を出してスキップします。ジェネリックな構造体(フィクスチャが型引数を選べないため)と、sync.Mutex を持つ構造体のようにコピーしてはならない構造体(フィクスチャは値を返すため)です。protobuf のメッセージもその 1 つで、コピーすれば go vet が指摘します。ディレクティブの綴り間違いにも警告が出ます。警告がなければ、その構造体にはフィクスチャが生成されたまま、何も報告されないからです。//kanna:fixture の引数が skip 以外の場合や、引数がない場合はエラーになります:

./model.go:3:1: warning: "//kanna:fixtur skip" is not recognized as a directive
  did you mean "//kanna:fixture skip"?
./model.go:8:1: error: //kanna:fixture takes skip, got "skp"
  did you mean "//kanna:fixture skip"?

対象の構造体が 1 つも残らない場合は、生成時に警告を出し、以前に書き出した fixture_gen.go を削除します。

フラグ

kanna-fixture [flags] [package]
フラグ 意味
-destination <dir> fixture_gen.go を dir に書き出す(デフォルトは fixture)
-package <name> 出力先に Go ファイルがまだないとき、パッケージ名を name にする(デフォルトはディレクトリ名)
-exclude <names> スキップする構造体の名前(カンマ区切り)
-check 書き出す代わりに、出力が最新であることを検証する。不要な出力が残っていないことも含む
-v 書き出した、削除した、変更しなかったファイルを一覧表示する
-version バージョンを表示する

パッケージのデフォルトはカレントディレクトリのパッケージなので、モデルに書いた //go:generate go tool kanna-fixture には引数が不要で、モデルのファイルの隣に fixture/ を書き出します。モジュールのルートに書いた //go:generate go tool kanna-fixture ./model は、ルートに fixture/ を書き出します。パスは go generate が実行されるディレクトリ、つまり //go:generate の行があるディレクトリからの相対パスです。

出力先はモデルとは別のパッケージで、そのフィクスチャがモデルを import します。出力先には手書きのファイルを置くこともできます。生成ファイルはそれらのファイルと同じパッケージに入り、-package で別の名前を指定するとエラーになります。手書きのファイルがすでに宣言している名前のフィクスチャがあると、生成を止めます。手書きのファイルは、初回の実行でフィクスチャが書き出される前から、フィクスチャを呼び出していても構いません。kanna-fixture は、これから書き出す内容を組み込んだ状態で手書きのファイルをもう一度型チェックし、コンパイルが通らない間は何も書き出しません。そのため、フィクスチャがないために隠れていた間違いはこのとき報告され、まだ呼び出されているフィクスチャは削除されません。他のパッケージのテストは読み込まれないので、そのテストが呼び出しているフィクスチャがなくなった場合は go vet で分かります。成功した実行は、警告以外何も出力しません。fixture_gen.go という名前でも、生成コードのヘッダがないファイルには決して触れません。

サンプル

example は、小さなブログのモデルとサインアップのリクエストのフィクスチャを生成し(番号付きの値、定数、テンプレート、パターン)、サインアップのフィクスチャが作られた時点で有効であることを kanna-validate でチェックして、いくつかを表示します。CI はこれを再生成し、出力が変わる場合は失敗します。

開発

make test       # unit tests
make lint       # golangci-lint
make example    # regenerate the example, then build and run it

ライセンス

MIT