Skip to content

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.

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

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.

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.

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 };

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

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.

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()));

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 build is 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.

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 else

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.

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.

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 and Of supplies 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.