kanna

kanna / fixture

kanna-fixture

v0.0.10 Go 1.26 pkg.go.dev

CI codecov Go Reference License: MIT Release 日本語

kanna-fixture writes a constructor for every struct of a package, with every field already filled, so a test states only what it cares about. Fields are numbered — slug-1, email-1@example.com — so no two fixtures collide, and filled to pass the rules their validate tags declare; a field tagged fake takes a gofakeit template instead.

Part of the kanna family of code generators. Requires Go 1.26.

Install

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

The line also requires gofakeit, which the generated code calls for the fields that ask for it and the generator uses to check their templates. Fixtures for a package whose fields ask for nothing do not import it.

Use

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 ./... writes fixture/fixture_gen.go, a package of its own beside the model:

// 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 test asks for what it needs and changes what it cares about:

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".

Setters are plain functions, so a team can name the ones it reuses:

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

The fixtures import the model, so tests in other packages and the model's external tests (package model_test) can use them; the model's internal tests cannot, as that would be an import cycle.

Values

Every call takes the next number n of its type, starting at 1, and each field takes the first rule that matches:

Field Value
tagged fake see The fake tag
a struct that gets a fixture in this run that fixture: Author: User(). One that would recur stays zero
a named type with constants declared beside it the first constant that is not the zero value: model.RoleAdmin
a value type of kanna-value its constructor's, of what a field named after it would hold, or zero
time.Time n seconds past 2000-01-01T00:00:00Z
uuid.UUID, from the standard library or github.com/google/uuid a UUID ending in n: 00000000-0000-4000-8000-000000000001
any other named type of a basic kind the value of its kind, as below, converted: model.UserID(1)
an integer or a float n, wrapping around in a type too narrow for it
a string named …Email, …URL, or …UUID that format: email-1@example.com, https://example.com/url-1, a UUID
a string the field's name and n: slug-1, first-name-1
a bool false
a pointer, slice, map, interface, channel, func, array, or other struct the zero value

Then the rules of its validate tag adjust the value. Unexported fields are left alone, since the fixtures cannot set them from their own package, and so are fields of a type they cannot name from there: an unexported type, or a struct type written out with an unexported field.

What the number feeds is unique per type, and names its field: title-3 in a failure is the Title of the third Article made. What it does not feed — constants, the first of a oneof, bools — is the same whichever tests ran first, so a test behaves the same alone, in the whole run, and in parallel. The values built from n do move with the number of fixtures made before; a test that asserts on one sets it.

The numbers count within one process. Test binaries that share a database each make their own email-1@example.com, so give each package a schema or a transaction of its own, or set the unique fields.

A pointer stays nil and a slice stays nil, because absent is a value worth keeping: a fixture with a Nickname would erase the difference between a user who has one and a user who does not. required fills them.

Rules

A field's validate tag is read so that its value passes it. The tag is the one go-playground/validator and kanna-validate both read; kanna-fixture depends on neither, and fills the fields the same whichever of them checks the struct, if any does. A fixture is valid as made, so a test of one rule changes one field:

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.
Rule Value
required not the zero value: true, a number other than 0, a pointer to a value, a slice of one element
min, max, len on strings and slices a length within them: password-001 for min=12, k elements for min=k
gt, gte, lt, lte, min, max on integers n, counted from the lower bound, or from 1, and wrapping within the upper: gt=0 → 1, 2, 3, …
gt, gte, lt, lte, min, max on floats the lowest value they allow, or the highest without a lower bound: gt=0 → 1
eq that value
ne another value: the next choice of a oneof, or where it rules out part of a range, the lowest value left
oneof=a b c the first: "admin"
eqfield=Field the other field's value
email, url, uuid that format, numbered within min and max: em-0001@example.com for len=19
pattern=regexp, which only kanna-validate has a string gofakeit generates from the expression
dive the rules after it, for each element made

The rules are read to be satisfied, not checked: a rule kanna-fixture does not know is left to whatever validates the struct, and the value it gets may not pass it. A pattern is the one rule a number cannot meet, and gofakeit cannot generate every expression; when what it makes does not match, or does not fit within min, max, or len, generation warns and asks for a fake tag. So it does for a format that cannot fit within them: a UUID is 36 characters long, and an address or a URL needs room for its number beside @example.com or https://example.com/.

The fake tag

The fake tag takes gofakeit's own syntax, the tag gofakeit.Struct reads, for a field that wants a realistic value or a format the rules do not describe:

Tag Value
fake:"{firstname} {lastname}" the template, generated: Neha Wang
fake:"{number:18,65}" a number in the range, for a numeric field it fits
fake:"???-####" ? a letter, # a digit: zSW-1523
fake:"123-4567" that value, fixed
fake:"skip" or fake:"-" the zero value

A string field takes any template. A numeric field takes the numeric ones — {number}, {intrange}, {uintrange}, {float32range}, {float64range}, and {price} — with a range that fits it; a time.Time takes {date}, and a UUID {uuid}. A number or a bool also takes a value as it stands, such as fake:"42", and a named type converts what its kind takes. A pointer is set to point to the value, though it would otherwise stay nil. The rules of the field's validate tag are not applied: the template is the value asked for.

A tagged field is made by a faker seeded with the fixture's number and its type, so the third User made has the same name on every run, whatever the fixtures of other types do. Its values are not unique: leave a unique column untagged.

gofakeit returns a template it does not understand as the value itself, so kanna-fixture checks every template while generating, against the gofakeit the module requires:

$ 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)

Structs

Every exported struct in the package gets a fixture. There is nothing to opt into, so a struct added to the model has one by the next go generate.

Directive Effect
//kanna:fixture skip do not generate a fixture for this struct

-exclude does the same from the command line, for a package whose source you do not write, such as the models sqlc generates. A name it lists that the package does not declare gets a warning.

A struct marked //kanna:value gets no fixture either, and says nothing: it is a value type of kanna-value, whose one field is unexported and whose values are made through its constructor. A field of such a type is made with that constructor, of what a field named after the type would hold: orZero(model.NewEmail(fmt.Sprintf("email-%d@example.com", n))) for an Email. A value the type's rule rejects leaves the zero value, as one never given, rather than a panic, since a fixture is made before its setters run and a setter could not get past one; set such a field with MustX in a setter.

Skipped with a warning: a generic struct, for which a fixture has no type arguments to pick, and a struct that must not be copied, such as one holding a sync.Mutex, since a fixture returns a value. A protobuf message is one of these, as go vet would say of copying it. A misspelled directive also gets a warning, which would otherwise leave the struct with a fixture and say nothing, and //kanna:fixture with an argument other than skip, or none, an error:

./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"?

When no struct is left, generation warns and removes the fixture_gen.go it wrote before.

Flags

kanna-fixture [flags] [package]
Flag Meaning
-destination <dir> write fixture_gen.go into dir (default fixture)
-package <name> call the package name when the destination has no Go files yet (default: the directory's name)
-exclude <names> comma-separated names of structs to skip
-check verify the output is up to date instead of writing it, including that no output is left over
-v list the file written, removed, or left unchanged
-version print the version

The package defaults to the one in the current directory, so //go:generate go tool kanna-fixture in the model needs no argument and writes fixture/ beside its files; //go:generate go tool kanna-fixture ./model at the root of the module writes fixture/ there. Paths are relative to the directory go generate runs in, the one holding the //go:generate line.

The destination is a package other than the model, which its fixtures import. It may hold hand-written files too: their package is the one the generated file joins, and -package naming another is an error; a fixture whose name they already declare stops generation. They may call the fixtures before the first run has written them: kanna-fixture type-checks them once more with what it is about to write in place, and writes nothing while they do not compile with it, so a mistake a missing fixture hid is reported then, and a fixture they still call is not removed. Tests in other packages are not loaded, so go vet tells of a fixture they call that is gone. A run that succeeds prints nothing but its warnings. A file named fixture_gen.go without the generated-code header is never touched.

Example

example generates fixtures for a small blog model and a sign-up request — numbered values, constants, templates, and a pattern — checks with kanna-validate that the sign-up fixture is valid as made, and prints a few. CI regenerates it and fails if the output would change.

Development

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

License

MIT