Building your test vocabulary
This is the page that matters most. Everything else in Mokkit exists to support one practice:
You author a vocabulary of verbs in your domain’s language, and every test is a short composition of them.
In C# the verbs are plain extension methods on ITestArrange and ITestInspect; in Go they are methods
on your own types embedding *mokkit.Chain. Either way: once you have a handful, a test stops looking
like plumbing and starts reading like the scenario it describes.
A test is a composition
Section titled “A test is a composition”Here’s a test written entirely in a client-management vocabulary:
await Arrange .NewClient(out var clientId, WithName("Acme Corporation"), WithEmail("acme@e2e.test"));
var result = await Act .UpdateClient(clientId, WithName("Renamed Corporation"));
await Inspect .WriteResult(result).Updated() .ApiClient(clientId, c => c.Name.ShouldBe("Renamed Corporation")) .EventPublished("clients.updated", clientId);f.Arrange(). NewClient[Client](WithName("Acme Corporation"), WithEmail("acme@e2e.test"))
result := f.Act().UpdateClient(f.Of[Client]().ID, WithName("Renamed Corporation"))
f.Inspect(). Updated(result). ApiClientNamed(f.Of[Client]().ID, "Renamed Corporation"). EventPublished("clients.updated", f.Of[Client]().ID)NewClient, WithName, UpdateClient, WriteResult, Updated, ApiClient, EventPublished aren’t Mokkit
APIs — they’re your methods. Mokkit provides Arrange / Act / Inspect and the machinery underneath;
you provide the words.
Three kinds of verb
Section titled “Three kinds of verb”Arrange verbs — set up, and produce
Section titled “Arrange verbs — set up, and produce”An arrange verb sets the world up and usually produces an artifact later steps refer to — through a capture in C#, under a token in Go.
public static ITestArrange NewClient( this ITestArrange arrange, out Trapture<Guid> id, params ClientFieldFn[] fields){ var capture = Trapture.Start(out id); return arrange.Then(async host => { await host.ExecuteAsync<HttpClient>(async http => { var result = await ClientApi.CreateAsync(http, Build(fields)); result.Status.ShouldBe(HttpStatusCode.Created); // precondition guard capture.Set(result.ClientId!.Value); }); });}The out capture is the trick that lets verbs pass data to each other while everything stays deferred:
NewClient returns immediately with an empty clientId; when the chain is awaited, the step runs and
fills it. See Capture vs Trapture.
func (a Arrange) NewClient[K mokkit.Token[Client]](fields ...ClientField) Arrange { a.Helper()
return mokkit.DoFor[K](a, func(h mokkit.Host) error { result, err := createClient(h.Context(), h.Resolve[*http.Client](), build(fields...)) if err != nil { return err }
*a.New[K]() = result
return nil })}The token K is how verbs pass data to each other: NewClient files the client under the role, and any
later verb or assertion reads it back with Of. The chain stays whole, and DoFor[K] puts the role in
the step label: arrange: NewClient[Buyer]. See Tokens.
Small parameter helpers (WithName, WithEmail, …) let a caller compose exactly the setup they need —
funcs over a request in either language:
public static ClientFieldFn WithName(string name) => r => r with { Name = name };public static ClientFieldFn WithEmail(string email) => r => r with { Email = email };func WithName(name string) ClientField { return func(r *ClientRequest) { r.Name = name } }func WithEmail(email string) ClientField { return func(r *ClientRequest) { r.Email = email } }Act verbs — do the thing, and maybe return a result
Section titled “Act verbs — do the thing, and maybe return a result”An act verb performs the operation under test and hands its artifact back.
// Return flavor — `var result = await Act.UpdateClient(...)`.public static ITestAct<ClientWriteResult> UpdateClient( this ITestAct act, Guid clientId, params ClientFieldFn[] fields) => act.Returning(host => host.ExecuteAsync<HttpClient, ClientWriteResult>( http => ClientApi.UpdateAsync(http, clientId, Build(fields))));
// Void flavor — fire the operation; its effects surface downstream in Inspect.public static ITestAct ProduceStatusChanged(this ITestAct act, Guid clientId, StatusChangedMessage message) => act.Then(host => host.ExecuteAsync<IProducer<string, string>>( producer => producer.ProduceAsync("clients.status-changed", Serialize(clientId, message))));// Return — Get runs the step and hands the result back; an error fails the act.func (a Act) UpdateClient(id string, fields ...ClientField) WriteResult { a.Helper()
return a.Get(func(h mokkit.Host) (WriteResult, error) { return updateClient(h.Context(), h.Resolve[*http.Client](), id, build(fields...)) })}
// Outcome — Try hands back the value and the error together, for a test about a refusal.func (a Act) TryUpdateClient(id string, fields ...ClientField) mokkit.Outcome[WriteResult] { a.Helper()
return a.Try(func(h mokkit.Host) (WriteResult, error) { return updateClient(h.Context(), h.Resolve[*http.Client](), id, build(fields...)) })}
// Void — fire the operation; its effects surface downstream in Inspect.func (a Act) ProduceStatusChanged(id string, msg StatusChanged) Act { a.Helper()
return mokkit.Do(a, func(h mokkit.Host) error { return h.Resolve[Producer]().Produce(h.Context(), "clients.status-changed", serialize(id, msg)) })}Attempt is Try for an operation that returns only an error.
Act verbs are what let a test grow from a single triple into a scenario — a sequence of Arrange / Act / Inspect blocks that walks a whole lifecycle.
Inspect verbs — observe
Section titled “Inspect verbs — observe”An inspect verb resolves what it needs from the stage and asserts. It only reads:
public static ITestInspect ApiClient( this ITestInspect inspect, Guid clientId, Action<ClientResponse> assert) => inspect.Then(async host => await host.ExecuteAsync<HttpClient>(async http => { var response = await http.GetAsync($"/api/v1/clients/{clientId}"); response.StatusCode.ShouldBe(HttpStatusCode.OK); assert((await response.Content.ReadFromJsonAsync<ClientResponse>())!); }));
public static ITestInspect EventPublished(this ITestInspect inspect, string topic, Guid clientId) => inspect.Then(async host => await host.ExecuteAsync<KafkaProbe>(async probe => (await probe.SawMessageKeyed(topic, clientId.ToString())).ShouldBeTrue()));func (i Inspect) EventPublished(topic, clientID string) Inspect { i.Helper()
return mokkit.Do(i, func(h mokkit.Host) error { if !h.Resolve[*KafkaProbe]().SawMessageKeyed(topic, clientID) { return fmt.Errorf("no %s message keyed %s", topic, clientID) }
return nil })}A verb’s first line is i.Helper(), so a failure reports the test’s line rather than the verb’s body.
A step reports by returning an error, never by failing the test directly. The step is named after the
verb; DoAs takes a name, and Do also accepts a mokkit.Step published by another package.
Why this beats a DSL
Section titled “Why this beats a DSL”Because your vocabulary is code, you get everything a Gherkin step binding gives up (see Why Mokkit?):
- Autocomplete. Type
Inspect.and your project’s assertions are right there. - Go-to-definition & debugging. Step into
EventPublished— no binding layer in between. - Rename & find-usages. Refactor a verb and every test that uses it updates; the ones that don’t fit the new signature stop compiling.
- Typed parameters.
Guid clientId, not a string parsed out of a sentence. - Provably-correct tests.
dotnet build/go buildis a real check that the vocabulary is wired up — a nonsensical test can’t even compile, let alone reach a runner.
The vocabulary is an asset that compounds. The first test costs a few verbs; the tenth reuses them and reads in seconds.
Where verbs live
Section titled “Where verbs live”Keep vocabulary next to what it describes, and out of the files where the tests live. In C# that is
colocated Arrange<Feature>.cs / Inspect<Feature>.cs files per feature; in Go:
fixture_test.go composition and the fixture. No verbs.vocabulary_test.go the verbs, in Arrange, Act and Inspect sections<feature>_test.go tests — and nothing elseA 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.
Keep verbs atomic — one named condition each — so a refusal-path test differs from the success path by exactly one verb. The project structure page shows both layouts; the guides build real vocabulary for mocked services, databases, message queues and full end-to-end flows.
Levelling up your verbs
Section titled “Levelling up your verbs”As scenarios get richer, a few Mokkit features become vocabulary-authoring techniques rather than test-body noise:
- Value & context scopes — group assertions over one value inside a verb.
Ensure(C#) — derive, guard-as-non-empty, and capture a value in one step, so ids flow cleanly between verbs. In Go, tokens carry ids between verbs andOfsupplies the guard.[MokkitCapture](C#) — let the source generator write the boilerplate body of a “build this object” arrange verb. The Go port has no generator; struct literals with option funcs fill the role.
Each is covered in its own guide.