mokkit

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 11 Imported by: 0

README

go-mokkit

A Go port of Mokkit. Tests read like the scenario they describe, written in a vocabulary you author, checked by the compiler — no DSL, no feature files, no runtime binding layer.

func TestGetClient_WhenCached_ReturnsDeserializedClient(t *testing.T) {
	f := newFixture(t)

	client := f.Arrange().CacheHasClient(WithName("Acme Corporation"))

	result := f.Act().GetClient(client.ID)

	f.Inspect().
		RetrievedClientMatching(result, client).
		CacheQueried(client.ID)
}

Every name in that test is one you wrote. CacheHasClient, GetClient and CacheQueried are ordinary Go methods on your own types, so they are autocompleted, renamed, and type-checked like any other code — and a failure reports the test's own line:

client_cache_service_test.go:14: inspect: CacheQueried: the cache was never asked for "client-1"

Requires Go 1.27, which is the first release where methods may declare type parameters. That is load-bearing here, not incidental: it is what lets artifacts be reached by a token instead of a string, and what puts Resolve on the host rather than in a free function.


Install

go get github.com/GrafGenerator/go-mokkit

The three phases

A test has an Arrange block, an Act, and an Inspect. They differ in one way that matters:

phase on failure
Arrange, Act hardt.Fatalf, so the rest of the chain never runs. A broken setup makes every later step meaningless.
Inspect softt.Errorf, and carry on, so one run reports every failing observation rather than only the first.

Chains are eager. There is no terminal call and nothing is deferred: by the time a verb returns, its step has already run. That is why an Act verb can simply return its result, and why a chain can be broken into several statements whenever that reads better.


Authoring a vocabulary

Declare your own phase types by embedding *mokkit.Chain, and hang verbs on them. gofumpt wants the three declarations grouped:

type (
	Arrange struct{ *mokkit.Chain }
	Act     struct{ *mokkit.Chain }
	Inspect struct{ *mokkit.Chain }
)

A verb marks itself a helper, adds a named step, and returns the chain so it composes:

func (a Arrange) CacheIsReachable() Arrange {
	a.Helper()
	a.Add("CacheIsReachable", func(ctx context.Context, h mokkit.Host) error {
		h.Resolve[*cacheProbe]().reachable = true

		return nil
	})

	return a
}

a.Helper() is the first line of every verb. Without it a failure reports the verb's body instead of the test's line, which makes a suite materially harder to work in.

An Act verb returns its artifact directly, because the chain is eager:

func (a Act) GetClient(id string) *clients.Client {
	a.Helper()

	var out *clients.Client
	a.Add("GetClient", func(ctx context.Context, h mokkit.Host) error {
		var err error
		out, err = h.Resolve[*cache.ClientCacheService]().GetClient(ctx, id)

		return err
	})

	return out
}
Where verbs live

One rule, and it is worth enforcing in review: a scenario file holds tests and nothing else. Verbs live in files named for their phase.

fixture_test.go     composition, tokens, the fixture. No verbs.
arrange_test.go     Arrange verbs
act_test.go         Act verbs
inspect_test.go     Inspect verbs, and the plain-function Steps And and All take
<feature>_test.go   tests

The reason is that a verb in a scenario file is invisible: it reads as part of the story on first encounter, so the next person writes a second one beside it rather than reaching for the one that already exists, and the vocabulary stops compounding. Keeping verbs out of scenario files is what makes "is there already a verb for this?" a question with an answer.

A suite large enough to want it can split a phase by feature — arrange_cache_test.go, arrange_billing_test.go — which is the Go-normal shape and keeps the rule intact.

Verbs should be atomic

A verb sets up one condition and says so in its name. Resist the verb that arranges a whole working world: it hides which of the things it did the test actually depends on, and every test that needs a variation grows another parameter until nobody can tell what a given call sets up.

// Each condition is named, so the branch under test is visible in the test.
f.Arrange().
	ACategoryThatAllowsActivation[Card]().
	AnEmissionRequiringCVC[Card]("4321").
	APlasticCardReadyToActivate[Card]()

A test for the refusal path then differs from the success path by exactly one verb, which is what makes it obvious what is being tested. Verbs that depend on an earlier one should say so when it is missing — "no category arranged: an emission belongs to one" beats a foreign-key violation.

Vocabulary from another package

Go methods must live in their type's package, so verbs on Arrange share one package — split across files, one per feature. Vocabulary from elsewhere is written as a plain function returning a mokkit.Step, and enters through And, which keeps the chain unbroken:

func HasClient(id string) mokkit.Step {
	return mokkit.NewStep("cache.HasClient", func(ctx context.Context, h mokkit.Host) error {
		...
	})
}

f.Arrange().
	CacheIsReachable().
	And(cachevocab.HasClient("client-1")).
	RateIs(Vip, 0.15)

And, All and WithContext are promoted from the embedded *Chain returning *mokkit.Chain, so a call to any of them would end your fluent chain. Re-declare the ones you want fluent — one line each, written once per suite:

func (a Arrange) And(steps ...mokkit.Step) Arrange { a.Helper(); a.Chain.And(steps...); return a }
func (i Inspect) All(steps ...mokkit.Step) Inspect { i.Helper(); i.Chain.All(steps...); return i }

All three have the same shape — call for effect, return the receiver.

Do not report around the chain

Chain deliberately does not embed TB. A verb that calls t.Fatalf directly — or hands the chain to require — reports around the phase machinery: inside an All branch it would Goexit the wrong goroutine, lose the phase: verb: prefix, and let a fail-fast chain carry on regardless.

Return an error from the step. When you do want an assertion library, hand it c.TB(), never the chain: assert suits Inspect's soft failure, require suits Arrange's hard one.


Artifacts

A verb often produces something a later verb or assertion needs. There are two ways to hold it, and a suite mixes them freely.

The return form — the default for a one-off

The producing verb hands the artifact back, and the test binds it at the point it is created:

client := f.Arrange().AClient(WithName("Acme"))

result := f.Act().GetClient(client.ID)

Nothing is declared above, nothing is a pointer, and go-to-definition on client lands on the verb that made it. A producing verb written this way is terminal — its return type ends the chain — which is exactly why the second form exists.

Tokens — for named roles, and to keep the chain whole

A token is a type that names a role and declares what that role stands for:

type (
	Buyer  struct{ mokkit.Artifact[Client] }
	Seller struct{ mokkit.Artifact[Client] }
	Cart   struct{ mokkit.Artifact[Order] }
)

One line each, declared once for the suite. The artifact's type is inferred from the token, so every call site spells only the token:

f.Arrange().
	ClientExists[Buyer](Vip).
	ClientExists[Seller](Regular).
	OrderFor[Cart](f.Of[Buyer](), 100)

discount := f.Act().DiscountFor[Cart]()

f.Inspect().
	DiscountIs(discount, 15).
	All(
		clientQueried[Buyer](f),
		clientNotQueried[Seller](f),
	)

f.New[Buyer]() is the write side and hands a producing verb its sink; f.Of[Buyer]() is the read side and returns a value, usable in any phase. Nothing is declared above the test, and the chain never breaks.

When the artifact has identity — a recording double whose state the Act mutates and the Inspect observes — read it with f.Ref[Buyer](), which hands back the pointer. A copy would be a different thing, and the failure would be silent: assertions reading stale state. Ref fails as loudly as Of when nothing was arranged, so the guarantee is the same; only the aliasing differs. Prefer Of, so a read-only phase cannot write through what it is observing.

What the compiler checks for you: a misspelt token is undefined: Byer; passing a token that names an Order to a verb declared [K mokkit.Token[Client]] is a type error; and reading a role that no verb produced fails loudly, at the test's line, naming what was arranged:

discount_test.go:23: mokkit: nothing arranged for main_test.Ghost (have: main_test.Buyer, main_test.Seller)

The verb side declares the pairing once:

func (a Arrange) ClientExists[K mokkit.Token[Client]](status string) Arrange {
	a.Helper()
	a.Add("ClientExists["+mokkit.NameOf[K]()+"]", func(ctx context.Context, h mokkit.Host) error {
		c := Client{ID: "client-" + mokkit.NameOf[K](), Status: status}
		*a.New[K]() = c
		h.Resolve[*fakeClients]().add(c)

		return nil
	})

	return a
}

mokkit.NameOf[K]() puts the role in the failure message, which is worth the two extra tokens:

discount_test.go:23: arrange: OrderFor[Cart]: the client it was given is unset

Which to use. Reach for the return form when a test has one artifact and no reason to name it. Reach for tokens when a test has several actors, when the artifact is read in more than one phase, or when the chain has to stay one sentence. Tokens are static by nature — if you need to pick a role at run time, that is what the return form is for.


Composition

A Setup is composed once and is expensive; a Stage is a scope over it, entered per test and closed when the test ends.

var composition *mokkit.Setup

func TestMain(m *testing.M) {
	mocks := mokkitgomock.New()
	mokkitgomock.Add[clients.DistributedCache](mocks, clients.NewMockDistributedCache)

	app := bag.New()
	bag.Scoped(app, func(r mokkit.Resolver) *cache.ClientCacheService {
		return cache.New(mokkit.Resolve[clients.DistributedCache](r))
	})

	setup, err := mokkit.NewSetup(context.Background(), mocks, app)
	if err != nil {
		panic("composing the cache suite: " + err.Error())
	}
	composition = setup

	m.Run()
}

Compose in TestMain, not initgochecknoinits is common in strict lint configs.

The per-test fixture embeds the stage's tokens, which is what puts New and Of on the fixture:

type fixture struct {
	*mokkit.Tokens
	stage *mokkit.Stage
}

func newFixture(t *testing.T) *fixture {
	t.Helper()
	stage := composition.EnterStage(t)

	return &fixture{Tokens: stage.Tokens(), stage: stage}
}

func (f *fixture) Arrange() Arrange { return Arrange{f.stage.Arrange()} }
func (f *fixture) Act() Act         { return Act{f.stage.Act()} }
func (f *fixture) Inspect() Inspect { return Inspect{f.stage.Inspect()} }
container/bag — hand-wired

The primary container, not a fallback: in Go, hand-wiring is the idiom.

b := bag.New()

bag.Instance[Clock](b, fixedClock)                  // shared by every stage

bag.Scoped(b, func(mokkit.Resolver) *fakeUsers {    // built once per stage
	return newFakeUsers()
})
bag.Alias[UserRepository, *fakeUsers](b)            // one instance, two keys

Alias is the shape a double wants: the vocabulary arranges and observes it through its concrete type, while the subject receives it through the interface. A Scoped value implementing io.Closer is closed when the stage ends; an Instance is yours to close, and an alias closes nothing, because it never owned what it handed back.

Factories receive a resolver spanning the whole composition, so a real service is built over doubles another container registered. That is the entire mock-to-DI bridge, with no ambient state.

The adapters

The core module has no dependencies. Everything that touches a third-party library is its own nested module under container/, so a suite pays only for what it uses:

module adapts the seam it demonstrates
container/bag nothing — hand-wiring scoped lifetimes, aliases, cycle detection (in core)
container/mokkitgomock go.uber.org/mock dual-key mocks, per-stage controller, Satisfied()
container/mokkitmockery mockery / testify mock the same shape over testify expectations
container/mokkitminimock gojuno/minimock the same shape; reports at cleanup
container/mokkitdo samber/do v2 a DI-built subject over the stage's doubles, with shutdown hooks
container/mokkitdig uber-go/dig Bridge/Expose: the mock-to-DI seam both ways

report/allure (stdlib-only, lives in core) writes Allure 2 results from the Observer seam, so a report reads in the vocabulary the suite was written in.

The gomock adapter in detail — the others follow its shape:

container/mokkitgomock — go.uber.org/mock
mocks := mokkitgomock.New()
mokkitgomock.Add[UserRepository](mocks, NewMockUserRepository)

Registers the generated mock under two keys: the interface, so the subject resolves it, and the mock's own type, so vocabulary reaches EXPECT(). One gomock.Controller per stage, bound to that stage's test, so expectations are asserted when the test finishes.

Stub with AnyTimes() in Arrange and assert interactions in Inspect — either through a captured value, or with Times(n) plus a closing mokkitgomock.Satisfied(), which puts the failure on the test's line while the controller's own cleanup still names the missing call.


Groups

All runs its branches concurrently and reports every failure, so one run tells you everything that is wrong. Group makes a branch out of several steps, which run in order and stop at the first failure:

f.Inspect().All(
	mokkit.Group("db", dbRowExists(id), dbIndexUpdated(id)),
	apiClientMatches(id),
	eventPublished("clients.created", id),
)

Branches share nothing, and report by returning an error.


Integration and end-to-end

The Setup/Stage split is what makes an expensive composition reusable. Register the shared resource with bag.Instance and the per-test unit of work with bag.Scoped; if the scope implements io.Closer, bag closes it when the stage ends, so a test that does not commit leaves nothing behind.

bag.Instance(b, pool)                               // built once
bag.Scoped(b, func(r mokkit.Resolver) *unitOfWork { // opened per stage
	return begin(mokkit.Resolve[*Pool](r))          // Close() rolls back
})

Two rules that are easy to get wrong:

  • An isolation scope must be resolved eagerly. bag.Scoped builds on first resolve, so a test that never touches the unit of work never builds it — and therefore never cleans up. Resolve it when the stage is entered: mokkit.Resolve[*unitOfWork](stage).
  • Code that opens its own transaction cannot be wrapped. Rolling back a transaction the test supplied undoes everything that went through that handle, but a real handler commits its own. Such a suite needs a cleanup step alongside the rollback.

integration_pattern_test.go demonstrates the whole shape.


Status

Pre-v1 and unreleased; the API is still moving. DESIGN.md records why it looks the way it does.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func NameOf

func NameOf[K any]() string

NameOf reports a token's bare name, so a verb can build a readable step label or seed an identifier with the role it is acting for.

func Resolve

func Resolve[T any](r Resolver) T

Resolve returns the service registered as T.

It panics when nothing is. Inside a Step that is intentional — the executor recovers the panic and reports it as a failure attributed to the step — so vocabulary can call Resolve without error handling. Host.Resolve is the same thing spelled as a method, which is what a verb normally wants.

func TryResolve

func TryResolve[T any](r Resolver) (T, bool)

TryResolve returns the service registered as T, reporting false when nothing is registered under that type or the registration cannot be used as T.

Types

type Artifact

type Artifact[T any] struct{}

Artifact is the phantom a token embeds to declare what it names:

type Buyer struct{ mokkit.Artifact[User] }

One line declares both the role and the type of the thing that role stands for, and the type is recovered by inference at every call site — so a token is spelled once and carries its meaning everywhere it is used.

type Chain

type Chain struct {
	// Tokens is embedded so a verb reaches its artifacts the same way a test
	// does — a.New[Buyer]() to produce, a.Of[Buyer]() to read.
	*Tokens
	// contains filtered or unexported fields
}

A Chain is one phase of a test — arrange, act or inspect — that executes each step as it is added. There is no terminal call: by the time a verb returns, its step has run.

Vocabulary is authored by embedding a *Chain in your own type and hanging verbs on it:

type Arrange struct{ *mokkit.Chain }

func (a Arrange) UserExists[K mokkit.Token[User]](s Status) Arrange {
    a.Helper()
    a.Add("UserExists["+mokkit.NameOf[K]()+"]", func(ctx context.Context, h mokkit.Host) error {
        *a.New[K]() = newUser(s)
        h.Resolve[*MockUsers]().EXPECT()...
        return nil
    })
    return a
}

func (*Chain) Add

func (c *Chain) Add(name string, fn StepFunc) *Chain

Add runs one named step and reports failure according to the chain's FailMode. The name is what identifies the step in a failure message, so it should be the verb the reader wrote — including the role it acted for, which NameOf supplies.

func (*Chain) All

func (c *Chain) All(steps ...Step) *Chain

All runs steps concurrently and continues once every one has finished, so the group is unordered within itself but ordered against its neighbors in the chain.

Under FailFast the first failure reported ends the test, so later failures in the same group are not shown; under FailSoft every failure is reported. Branches run on their own goroutines and must report by returning an error, never by calling the test's own Fatal.

func (*Chain) And

func (c *Chain) And(steps ...Step) *Chain

And runs vocabulary authored as plain functions, including from packages that cannot add methods to this chain's type. This is what keeps a chain unbroken across a foreign verb, and it is named to read as a continuation of the sentence rather than as an imperative aside:

f.Arrange().
    UserExists[Buyer](Vip).
    And(cachevocab.HasUser[Buyer](f)).
    RateIs(Vip, 0.15)

A vocabulary type re-declares this to keep its own return type, and may name it whatever reads best there — And, Also, Then.

func (*Chain) Context

func (c *Chain) Context() context.Context

Context reports the context steps in this chain are run with.

func (*Chain) TB

func (c *Chain) TB() TB

TB reports the test this chain belongs to. Hand it, not the chain, to an assertion library: assert suits Inspect's soft failure, require suits Arrange's hard one, and going through TB keeps a verb from reporting around the chain by accident.

func (*Chain) WithContext

func (c *Chain) WithContext(ctx context.Context) *Chain

WithContext runs subsequent steps with ctx. It mutates the chain and returns it, exactly as And and All do, so a vocabulary type's forwarder is written the same way as theirs:

func (a Arrange) WithContext(ctx context.Context) Arrange {
    a.Helper(); a.Chain.WithContext(ctx); return a
}

It does not affect steps already run.

type Container

type Container interface {
	BeginScope(ctx context.Context, sc StageContext) (Scope, error)
}

A Container is a built, immutable composition. It hands out one Scope per entered stage.

type ContainerBuilder

type ContainerBuilder interface {
	Build(ctx context.Context) (Container, error)
}

A ContainerBuilder composes one container. Builders are run once, by NewSetup, before any stage is entered.

The C# original ran builders through four phases so a DI builder could see a mock builder's registrations and bridge them. Hand-wiring removes the need: a factory takes the stage Resolver and pulls collaborators itself. If a runtime-DI adapter ever needs peer visibility, it arrives as an optional interface NewSetup type-asserts for, which is additive.

type Executor

type Executor interface {
	// Run executes one step. The Step arrives whole — name included — so an
	// executor that traces or reports has something to say about it.
	Run(ctx context.Context, s Step) error
	Close() error
}

An Executor runs steps against a stage.

The chain API is identical whichever Executor is in use, so a channel-backed executor — one worker goroutine per stage, allowing steps to be posted from other goroutines and giving tracing a single choke point — can replace the inline one without touching Chain or any vocabulary.

type FailMode

type FailMode int

FailMode decides what a failing step does to the test.

const (
	// FailFast reports through t.Fatal, which unwinds the test goroutine — so
	// the rest of the chain never runs. Used by Arrange and Act, where a broken
	// setup makes every later step meaningless.
	FailFast FailMode = iota

	// FailSoft reports through t.Error and carries on, so a run reports every
	// failing observation rather than only the first. Used by Inspect.
	FailSoft
)

type Host

type Host struct {
	// contains filtered or unexported fields
}

Host is what a Step receives: the stage's resolver plus its context. It is a struct rather than an interface so that Resolve can be a generic method on it.

func NewHost

func NewHost(ctx context.Context, r Resolver) Host

NewHost pairs a resolver with the context steps run under.

func (Host) Context

func (h Host) Context() context.Context

Context reports the context this step is running under.

func (Host) Resolve

func (h Host) Resolve[T any]() T

Resolve returns the service registered as T, panicking when nothing is. Inside a Step that is intentional: the executor recovers the panic and reports it against the step, so vocabulary resolves without error handling.

func (Host) Resolver

func (h Host) Resolver() Resolver

Resolver reports the underlying resolver, for the places that still want the interface — passing it to a container, or to the free Resolve.

func (Host) TryResolve

func (h Host) TryResolve[T any]() (T, bool)

TryResolve returns the service registered as T, reporting false when nothing is registered under that type.

func (Host) TryResolveType

func (h Host) TryResolveType(t reflect.Type) (any, bool)

TryResolveType satisfies Resolver, so a Host can be handed anywhere one is wanted.

type Observer

type Observer interface {
	// StageEntered reports a test beginning to run against the composition.
	StageEntered(test, stageID string)

	// StepRan reports one step, after it finished.
	StepRan(event StepEvent)

	// StageClosed reports the stage releasing its scopes. failed is the test's
	// verdict at that moment — soft failures included, since cleanup runs after
	// the test body.
	StageClosed(test, stageID string, failed bool)
}

An Observer hears what a composition's stages do: a stage entered per test, every step that ran, and the stage closing with the test's verdict. It is the seam a reporter plugs into — the steps arrive already carrying the vocabulary names the suite was written in, so a report reads as the scenario did.

Implementations must be safe for concurrent use: All runs its branches on their own goroutines, and suites may run tests in parallel. Events for one stage may therefore interleave with another's; StageID is what groups them.

An Observer must not fail the test. It has no TB on purpose.

type PanicError

type PanicError struct {
	Value any
	Stack []byte
}

PanicError reports a panic raised inside a step, carrying the stack from the point of the panic rather than from where it was recovered.

func (*PanicError) Error

func (e *PanicError) Error() string

func (*PanicError) Unwrap

func (e *PanicError) Unwrap() error

Unwrap lets errors.As reach a panicked error value, so a caller can test for *ResolveError after Resolve panicked inside a step.

type PathResolver

type PathResolver interface {
	Resolver

	// TryResolveTypePath resolves t with path naming the types already under
	// construction on this goroutine, outermost first.
	TryResolveTypePath(t reflect.Type, path []reflect.Type) (any, bool)
}

PathResolver is the optional half of the contract: a resolver that can carry the chain of types currently under construction. A container detects a dependency cycle by checking that chain, and the chain has to survive the hop through the stage — otherwise a cycle spanning two containers is invisible to both and deadlocks on a lock one of them already holds.

Implement it on a Scope whose factories can resolve their own collaborators. Stage implements it, and threads the path through every scope that does.

type ResolveError

type ResolveError struct {
	Type reflect.Type

	// Present reports that something was registered under Type but could not be
	// used as it — a container handed back the wrong thing, which is a
	// different bug from having registered nothing.
	Present bool
}

ResolveError reports a service that no container had registered, or one registered under a type it cannot be used as.

func (*ResolveError) Error

func (e *ResolveError) Error() string

type Resolver

type Resolver interface {
	// TryResolveType looks up a service by type. It reports false when nothing
	// is registered under t. Implementations must be safe for concurrent use.
	TryResolveType(t reflect.Type) (any, bool)
}

Resolver hands back a service registered in a container, keyed by its type. Stage, Scope and a container's own factory resolver all satisfy it, so Resolve and TryResolve work against any of them.

type Scope

type Scope interface {
	Resolver
	Close() error
}

A Scope holds the per-stage instances of a container's services. Scopes are resolved from concurrently, so implementations must be safe for concurrent use, and are closed when the stage is.

type Setup

type Setup struct {
	// contains filtered or unexported fields
}

A Setup is a composition of containers, built once. Building is the expensive part; entering a stage from it is cheap and happens per test.

func NewSetup

func NewSetup(ctx context.Context, builders ...ContainerBuilder) (*Setup, error)

NewSetup builds every container. Do this once per system-under-test, in whatever "run once" hook the test runner offers — TestMain, not init.

func (*Setup) EnterStage

func (s *Setup) EnterStage(t TB) *Stage

EnterStage opens a fresh, isolated stage for one test and registers its cleanup with t. Scoped services are created per stage and released when the test ends, so nothing leaks between tests.

func (*Setup) EnterStageContext

func (s *Setup) EnterStageContext(ctx context.Context, t TB) *Stage

EnterStageContext is EnterStage with an explicit context, which steps in this stage's chains are run with.

func (*Setup) Observe

func (s *Setup) Observe(observers ...Observer) *Setup

Observe registers observers for every stage later entered from this setup. Call it once, where the composition is built:

setup, err := mokkit.NewSetup(ctx, mocks, app)
...
setup.Observe(allure.New(resultsDir))

It returns the setup, so it chains. Observing after stages have been entered affects only stages entered afterwards.

type Stage

type Stage struct {
	// contains filtered or unexported fields
}

A Stage is the runtime one test runs against: the services it resolves, the tokens its artifacts live under, and the executor its steps run on.

func (*Stage) Act

func (s *Stage) Act() *Chain

Act starts a chain for the operation under test. It fails fast.

func (*Stage) Arrange

func (s *Stage) Arrange() *Chain

Arrange starts a setup chain. It fails fast: a broken setup makes every later step meaningless.

func (*Stage) Chain

func (s *Stage) Chain(phase string, mode FailMode) *Chain

Chain starts a phase that fails according to mode. Arrange, Act and Inspect are the named phases; this is for anything else.

func (*Stage) Close

func (s *Stage) Close() error

Close releases every scope, newest first, and empties the resolution cache. EnterStage registers it with t.Cleanup, so tests do not normally call it. Closing twice is a no-op.

func (*Stage) Context

func (s *Stage) Context() context.Context

Context reports the context this stage's steps run with.

func (*Stage) Host

func (s *Stage) Host() Host

Host reports what a step receives, for the occasions that want one outside a chain.

func (*Stage) ID

func (s *Stage) ID() string

ID reports the stage's unique identifier.

func (*Stage) Inspect

func (s *Stage) Inspect() *Chain

Inspect starts an observation chain. It fails soft, so one run reports every failing observation rather than only the first.

func (*Stage) TB

func (s *Stage) TB() TB

TB reports the test this stage belongs to, so a fixture accessor can mark itself a helper and keep failures pointing at the test's own line.

func (*Stage) Tokens

func (s *Stage) Tokens() *Tokens

Tokens reports the per-test artifact registry backing New and Of. A fixture embeds it, which is what puts f.New[Buyer]() and f.Of[Buyer]() on the fixture itself.

func (*Stage) TryResolveType

func (s *Stage) TryResolveType(typ reflect.Type) (any, bool)

TryResolveType finds the first container that has typ registered, caching the result so every step and every branch of an All group sees one instance.

func (*Stage) TryResolveTypePath

func (s *Stage) TryResolveTypePath(typ reflect.Type, path []reflect.Type) (any, bool)

TryResolveTypePath is TryResolveType carrying the types already under construction, so a container can report a cycle that crosses into another container instead of deadlocking inside it.

The cache lock is not held while a scope resolves, because a container's factory may resolve its own collaborators back through this method; holding it would deadlock. A scope that builds lazily is therefore responsible for handing back the same instance under concurrent resolution — see container/bag.

type StageContext

type StageContext struct {
	// T is the test the stage belongs to. A container may use it to bind
	// per-test machinery — a gomock Controller, for instance, which asserts its
	// expectations on cleanup.
	T TB

	// StageID is unique per entered stage.
	StageID string

	// Resolver is the stage itself, spanning every container in the
	// composition. A container whose factories build real services uses it to
	// pull collaborators another container registered — which is all the
	// mock-to-DI bridge amounts to here.
	//
	// It must only be used lazily, from a factory at resolve time: while
	// BeginScope runs, the sibling scopes it would reach are still opening.
	Resolver Resolver
}

StageContext describes the stage a scope is being opened for.

type Step

type Step struct {
	Name string
	Run  StepFunc
}

A Step is a StepFunc with the name it reports under when it fails. It is the contract for vocabulary authored as plain functions — including in packages that cannot add methods to a chain's type — which Chain.And then runs:

func HasClient(c *Client) mokkit.Step {
    return mokkit.NewStep("cache.HasClient", func(ctx context.Context, h mokkit.Host) error {
        ...
    })
}

The name is given rather than recovered from the runtime because the compiler may inline the verb that built the closure, which would attribute the step to whatever function called it.

func Group

func Group(name string, steps ...Step) Step

Group sequences steps into one, so a branch of All can be several steps rather than one. Within the group they run in order and stop at the first failure, which reports as "group: step"; between branches of an All nothing is shared, so every other branch still finishes.

func NewStep

func NewStep(name string, fn StepFunc) Step

NewStep pairs a step's work with the name it reports under.

type StepEvent

type StepEvent struct {
	Test     string
	StageID  string
	Phase    string
	Step     string
	Started  time.Time
	Duration time.Duration
	Err      error
}

A StepEvent describes one step that ran: which test asked for it, the phase and verb the reader wrote, how long it took, and how it ended. Err is nil for a step that passed, the step's own error for one that failed, and a *PanicError for one that crashed — a reporter that wants to distinguish "failed" from "broken" branches on that.

type StepFunc

type StepFunc func(ctx context.Context, h Host) error

A StepFunc is the work one unit of a test does: set up a collaborator, run the operation under test, observe an outcome. It receives the stage's Host and reports failure by returning an error.

type TB

type TB interface {
	Helper()
	Name() string
	Cleanup(func())
	Errorf(format string, args ...any)
	Fatalf(format string, args ...any)
	FailNow()
	Failed() bool
}

TB is the part of *testing.T that mokkit reports through. *testing.T and *testing.B satisfy it.

It is deliberately narrower than testing.TB, which is sealed and so cannot be implemented — by mokkit's own tests, by a fake reporter, or by a runner that is not the standard one. It is also wide enough for the common assertion libraries, so a vocabulary type can be handed straight to assert.Equal or require.NoError.

type Token

type Token[T any] interface {
	// contains filtered or unexported methods
}

Token constrains a role to one artifact type. A verb generic over Token[User] will not accept a token that names an Order, so the pairing is checked by the compiler rather than discovered at run time.

type Tokens

type Tokens struct {
	// contains filtered or unexported fields
}

Tokens holds the artifacts a test's verbs produce, keyed by the token that names each one. It is the answer to "declare the artifact where you use it": New hands a producing verb its sink and Of reads the value back, both spelled only with the token, in any phase, without a variable declared above.

Tokens belongs to a single test. Stage creates one per stage, so nothing leaks between tests.

func NewTokens

func NewTokens(t TB) *Tokens

NewTokens returns an empty registry that reports lookup failures through t.

func (*Tokens) Declared

func (r *Tokens) Declared[K Token[A], A any]() bool

Declared reports whether the role K has been produced, without producing it and without failing.

func (*Tokens) New

func (r *Tokens) New[K Token[A], A any]() *A

New returns the sink for the role K, creating it on first use. It is the write side: a producing verb fills the pointer it returns.

func (a Arrange) UserExists[K mokkit.Token[User]](s Status) Arrange {
    a.Add("UserExists["+mokkit.NameOf[K]()+"]", func(...) error {
        *a.New[K]() = newUser(s)
        ...
    })
    return a
}

func (*Tokens) Of

func (r *Tokens) Of[K Token[A], A any]() A

Of returns the value filed under the role K, failing the test when no verb has produced it. It returns a value rather than a pointer, which keeps *T out of read-only positions and is safe inside a single chain expression: the Go spec orders method calls left to right, where a bare variable operand is unordered against the call that fills it.

func (*Tokens) Ref

func (r *Tokens) Ref[K Token[A], A any]() *A

Ref returns the artifact filed under the role K as a pointer, failing the test when no verb has produced it. It is Of for an artifact that has identity: a double whose state a verb mutates and a later phase observes.

Prefer Of. A value cannot be written through by accident, which is what keeps a read-only phase read-only. Reach for Ref when a copy would be a different thing from the original — a recording double, a probe, anything whose whole point is that the artifact the Act mutated is the artifact the Inspect reads.

Directories

Path Synopsis
container
bag
Package bag is mokkit's hand-wired container.
Package bag is mokkit's hand-wired container.
mokkitgomock module
report
allure
Package allure is a mokkit.Observer that writes Allure 2 result files, so any Allure consumer — TestOps, the allure CLI — renders a mokkit suite with each test reading as its scenario.
Package allure is a mokkit.Observer that writes Allure 2 result files, so any Allure consumer — TestOps, the allure CLI — renders a mokkit suite with each test reading as its scenario.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL