mokkit

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Sep 6, 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 — the token accessors and Host.Resolve are generic methods.


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. An Act verb returns its result directly, and 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 and runs its step through mokkit.Do, which hands the phase back so the verb is one return:

func (a Arrange) CacheIsReachable() Arrange {
	a.Helper()

	return mokkit.Do(a, func(h mokkit.Host) {
		h.Resolve[*cacheProbe]().reachable = true
	})
}

The body is a func(mokkit.Host) when it cannot fail, a func(mokkit.Host) error when it can, a mokkit.StepFunc when it wants the context as an argument, or a mokkit.Step from another package. The step is named after the verb, so a failure reads arrange: CacheIsReachable: ...; a Step keeps its own name.

a.Helper() is the first line of every verb. Without it a failure reports the verb's body instead of the test's line.

An Act verb returns its artifact directly, through Get. An error fails the chain:

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

	return a.Get(func(h mokkit.Host) (*clients.Client, error) {
		return h.Resolve[*cache.ClientCacheService]().GetClient(h.Context(), id)
	})
}

A test about a refusal wants the error as its artifact. Try hands back an Outcome instead of failing on it:

func (a Act) TryGetClient(id string) mokkit.Outcome[*clients.Client] {
	a.Helper()

	return a.Try(func(h mokkit.Host) (*clients.Client, error) {
		return h.Resolve[*cache.ClientCacheService]().GetClient(h.Context(), id)
	})
}

outcome := f.Act().TryGetClient("ghost")

f.Inspect().Refused(outcome, "no such client")

Attempt is Try for an operation whose only outcome is whether it failed: it hands back the error.

Each of these has an As form that takes the step's name — DoAs, GetAs, TryAs, AttemptAs — and a For form for a verb generic over a role, which appends the role to the name: DoFor[K], GetFor[K], TryFor[K], AttemptFor[K].

Where verbs live

A scenario file holds tests and nothing else. Vocabulary lives beside it, in as many files as its size warrants:

fixture_test.go     composition and the fixture. No verbs.
vocabulary_test.go  the verbs, in Arrange, Act and Inspect sections
<feature>_test.go   tests

A suite of a handful of tests may keep the fixture and the vocabulary in one suite_test.go. A vocabulary past a few hundred lines splits by phase — arrange_test.go, act_test.go, inspect_test.go — and a phase splits by feature when it grows again: arrange_cache_test.go, arrange_billing_test.go.

Verbs should be atomic

A verb sets up one condition and says so in its name. Do not write a verb that arranges a whole working world.

// 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 a refusal path then differs from the success path by exactly one verb. A verb that depends on an earlier one reports the missing prerequisite: "no category arranged: an emission belongs to one".

Vocabulary from another package

Vocabulary from another package is written as a plain function returning a mokkit.Step, and enters through And with 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 use — 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.

Report through the chain

A step reports by returning an error — never by calling the test's Fatalf or Errorf from inside the step. When you 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. Like Of, it fails when nothing was arranged. Prefer Of everywhere else: a value cannot be written through by accident.

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, and DoFor[K] puts the role in the step label:

func (a Arrange) ClientExists[K mokkit.Token[Client]](status string) Arrange {
	a.Helper()

	return mokkit.DoFor[K](a, func(h mokkit.Host) {
		c := Client{ID: "client-" + mokkit.NameOf[K](), Status: status}
		*a.New[K]() = c
		h.Resolve[*fakeClients]().add(c)
	})
}

The role is what the failure message reports under:

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. A Fixture is what a test body talks to: the three phases, typed as the suite's own vocabulary, with New, Of and Ref promoted onto it.

When the subject is cheap to build, compose and enter per test:

type fixture = mokkit.Fixture[Arrange, Act, Inspect]

func newFixture(t *testing.T) *fixture {
	t.Helper()

	b := bag.New()
	bag.Fresh[fakeUsers](b)
	bag.Alias[UserRepository, *fakeUsers](b)
	bag.Scoped(b, func(r mokkit.Resolver) *DiscountService {
		return &DiscountService{Users: mokkit.Resolve[UserRepository](r)}
	})

	return mokkit.Enter[Arrange, Act, Inspect](t, b)
}

When the composition is expensive — mocks, a database, a broker — build it once in TestMain and enter it per test:

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()
}
type fixture = mokkit.Fixture[Arrange, Act, Inspect]

func newFixture(t *testing.T) *fixture {
	t.Helper()

	return composition.Enter[Arrange, Act, Inspect](t)
}

Compose in TestMain, not init. EnterContext on either form runs the stage's steps under an explicit context. A suite that wants more on its fixture embeds *mokkit.Fixture[...] in its own struct.

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.Fresh[fakeRates](b)                             // *fakeRates, new(fakeRates) once per stage
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 — the mock-to-DI bridge.

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 Do added in v0.3.0

func Do[V Vocabulary, F Body](v V, fn F) V

Do runs fn as a step of v's chain and hands v back, so a verb is one return:

func (a Arrange) SequenceYields(ids ...int64) Arrange {
    a.Helper()

    return mokkit.Do(a, func(h mokkit.Host) {
        h.Resolve[*fakeSequence]().values = ids
    })
}

The step is named after the verb that called Do; a Step keeps its own name.

func DoAs added in v0.3.0

func DoAs[V Vocabulary, F Body](v V, name string, fn F) V

DoAs is Do with the step's name given, for a verb whose own name is not the one to report under.

func DoFor added in v0.3.0

func DoFor[K any, V Vocabulary, F Body](v V, fn F) V

DoFor is Do for a verb generic over a role: the role K is appended to the step's name in brackets, as UserExists[Buyer].

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 registered; inside a Step, the executor recovers the panic and reports it as a failure attributed to the step, so vocabulary calls Resolve without error handling. Inside a verb, prefer Host.Resolve.

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; the type is inferred at every call site.

type Body added in v0.3.0

type Body interface {
	func(Host) | func(Host) error | func(context.Context, Host) error | Step
}

A Body is the work a verb hands to Do. It receives the stage's Host and reports failure by returning an error; a body that cannot fail returns nothing. A Step is a body too, so a verb can run vocabulary from another package and keep its own type.

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, with the chain unbroken:

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) Attempt added in v0.3.0

func (c *Chain) Attempt(fn func(Host) error) error

Attempt runs fn as a step and returns the error it reported without failing the chain on it: Try for an operation whose only outcome is whether it failed. A panic inside fn still fails the chain.

The step is named after the verb that called Attempt.

func (*Chain) AttemptAs added in v0.3.0

func (c *Chain) AttemptAs(name string, fn func(Host) error) error

AttemptAs is Attempt with the step's name given.

func (*Chain) AttemptFor added in v0.3.0

func (c *Chain) AttemptFor[K any](fn func(Host) error) error

AttemptFor is Attempt for a verb generic over a role, named as DoFor names its step.

func (*Chain) Context

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

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

func (*Chain) Get added in v0.3.0

func (c *Chain) Get[T any](fn func(Host) (T, error)) T

Get runs fn as a step and returns what it produced. An error fails the chain according to its FailMode; the value is returned only when there was none.

func (a Act) Discount(userID string) Result {
    a.Helper()

    return a.Get(func(h mokkit.Host) (Result, error) {
        return h.Resolve[*Service]().Calculate(h.Context(), userID)
    })
}

The step is named after the verb that called Get.

func (*Chain) GetAs added in v0.3.0

func (c *Chain) GetAs[T any](name string, fn func(Host) (T, error)) T

GetAs is Get with the step's name given.

func (*Chain) GetFor added in v0.3.0

func (c *Chain) GetFor[K, T any](fn func(Host) (T, error)) T

GetFor is Get for a verb generic over a role, named as DoFor names its step.

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.

func (*Chain) Try added in v0.3.0

func (c *Chain) Try[T any](fn func(Host) (T, error)) Outcome[T]

Try runs fn as a step and returns its outcome without failing the chain on the error, so a test for a refusal inspects the error the way it inspects a value. A panic inside fn still fails the chain.

The step is named after the verb that called Try.

func (*Chain) TryAs added in v0.3.0

func (c *Chain) TryAs[T any](name string, fn func(Host) (T, error)) Outcome[T]

TryAs is Try with the step's name given.

func (*Chain) TryFor added in v0.3.0

func (c *Chain) TryFor[K, T any](fn func(Host) (T, error)) Outcome[T]

TryFor is Try for a verb generic over a role, named as DoFor names its step.

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 Fixture added in v0.3.0

type Fixture[A, C, I Phase] struct {
	*Tokens

	// Stage is the runtime the fixture's chains run against.
	Stage *Stage
}

A Fixture is what a test body talks to: the three phases, typed as the suite's own vocabulary, and the artifacts its verbs produce. Embedding *Tokens puts f.New, f.Of and f.Ref on the fixture itself.

type fixture = mokkit.Fixture[Arrange, Act, Inspect]

func newFixture(t *testing.T) *fixture {
    t.Helper()

    b := bag.New()
    bag.Fresh[fakeUsers](b)
    bag.Scoped(b, func(r mokkit.Resolver) *Service { ... })

    return mokkit.Enter[Arrange, Act, Inspect](t, b)
}

func Enter added in v0.3.0

func Enter[A, C, I Phase](t TB, builders ...ContainerBuilder) *Fixture[A, C, I]

Enter builds the containers, opens a stage for t and returns the fixture over it. It is for a composition built per test; a composition shared by a package is built once with NewSetup and entered with Setup.Enter. A container that cannot be built fails t.

func EnterContext added in v0.3.0

func EnterContext[A, C, I Phase](ctx context.Context, t TB, builders ...ContainerBuilder) *Fixture[A, C, I]

EnterContext is Enter with an explicit context, which the containers are built with and the stage's steps run with.

func (*Fixture[A, C, I]) Act added in v0.3.0

func (f *Fixture[A, C, I]) Act() C

Act starts a chain for the operation under test, typed as the suite's Act.

func (*Fixture[A, C, I]) Arrange added in v0.3.0

func (f *Fixture[A, C, I]) Arrange() A

Arrange starts a setup chain, typed as the suite's Arrange.

func (*Fixture[A, C, I]) Inspect added in v0.3.0

func (f *Fixture[A, C, I]) Inspect() I

Inspect starts an observation chain, typed as the suite's Inspect.

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.

type Outcome added in v0.3.0

type Outcome[T any] struct {
	Value T
	Err   error
}

An Outcome is what Try hands back: the value fn produced and the error it returned. Exactly one of them is meaningful.

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 Phase added in v0.3.0

type Phase interface{ ~struct{ *Chain } }

A Phase is a vocabulary type declared as a struct whose only field is an embedded *Chain:

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

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) Enter added in v0.3.0

func (s *Setup) Enter[A, C, I Phase](t TB) *Fixture[A, C, I]

Enter opens a stage for t and returns the fixture over it.

func (*Setup) EnterContext added in v0.3.0

func (s *Setup) EnterContext[A, C, I Phase](ctx context.Context, t TB) *Fixture[A, C, I]

EnterContext is Enter with an explicit context, which the stage's steps run with.

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. Embed it in a fixture to have f.New[Buyer]() and f.Of[Buyer]() promoted onto it.

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.

Unlike testing.TB it can be implemented — by a fake reporter, or by a runner that is not the standard one — and it is wide enough for the common assertion libraries, so it 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. New hands a producing verb its sink and Of reads the value back, both spelled only with the token, in any phase.

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 is safe to call mid-chain, as an argument to a later verb in the same expression.

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.

Use Ref for an artifact with identity — a recording double or probe whose state the Act mutates and a later phase observes. Everywhere else use Of, which hands back a value.

type Vocabulary added in v0.3.0

type Vocabulary interface {
	// contains filtered or unexported methods
}

A Vocabulary is any type that embeds *Chain: a phase such as Arrange, or a scope that carries a value alongside the chain.

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