From 32bacca3c6cdaa2212994a22b787c46712a507c5 Mon Sep 17 00:00:00 2001 From: Nikita Ivanov Date: Sat, 5 Sep 2026 00:39:52 +0700 Subject: [PATCH 1/3] add Go documentation across the site: synced language tabs, tokens concept, adapter and reporting guides Co-Authored-By: Claude Fable 5 --- docs/astro.config.mjs | 18 +- docs/src/content/docs/concepts/aai.md | 87 ------ docs/src/content/docs/concepts/aai.mdx | 190 ++++++++++++ docs/src/content/docs/concepts/captures.md | 6 + docs/src/content/docs/concepts/containers.md | 56 +++- docs/src/content/docs/concepts/scenarios.md | 20 +- .../docs/concepts/{stage.md => stage.mdx} | 52 +++- docs/src/content/docs/concepts/tokens.md | 106 +++++++ docs/src/content/docs/concepts/vocabulary.md | 144 --------- docs/src/content/docs/concepts/vocabulary.mdx | 274 ++++++++++++++++++ docs/src/content/docs/guides/bag-container.md | 7 + .../docs/guides/custom-container-adapter.md | 7 + docs/src/content/docs/guides/ensure.md | 5 + .../src/content/docs/guides/inspect-scopes.md | 6 + .../src/content/docs/guides/mock-libraries.md | 8 + .../content/docs/guides/real-di-container.md | 5 + .../content/docs/guides/reporting-allure.md | 90 ++++++ docs/src/content/docs/guides/thenall.md | 6 + docs/src/content/docs/index.mdx | 49 +++- docs/src/content/docs/installation.mdx | 48 ++- .../{introduction.md => introduction.mdx} | 64 +++- docs/src/content/docs/quickstart.md | 123 -------- docs/src/content/docs/quickstart.mdx | 247 ++++++++++++++++ .../src/content/docs/reference/conventions.md | 22 ++ .../docs/reference/project-structure.md | 24 ++ docs/src/content/docs/why-mokkit.md | 4 +- 26 files changed, 1260 insertions(+), 408 deletions(-) delete mode 100644 docs/src/content/docs/concepts/aai.md create mode 100644 docs/src/content/docs/concepts/aai.mdx rename docs/src/content/docs/concepts/{stage.md => stage.mdx} (72%) create mode 100644 docs/src/content/docs/concepts/tokens.md delete mode 100644 docs/src/content/docs/concepts/vocabulary.md create mode 100644 docs/src/content/docs/concepts/vocabulary.mdx create mode 100644 docs/src/content/docs/guides/reporting-allure.md rename docs/src/content/docs/{introduction.md => introduction.mdx} (50%) delete mode 100644 docs/src/content/docs/quickstart.md create mode 100644 docs/src/content/docs/quickstart.mdx diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 9bdddb8..25466dc 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -8,7 +8,7 @@ export default defineConfig({ integrations: [ starlight({ title: 'Mokkit', - description: "Write tests that read like a story in your domain's language — as plain, compilable C#.", + description: "Write tests that read like a story in your domain's language — as plain, compilable C# or Go.", logo: { src: './src/assets/logo.svg', alt: 'Mokkit' }, favicon: '/favicon.svg', head: [ @@ -21,7 +21,8 @@ export default defineConfig({ { tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } }, ], social: [ - { icon: 'github', label: 'GitHub', href: 'https://github.com/GrafGenerator/Mokkit' }, + { icon: 'github', label: 'Mokkit for .NET', href: 'https://github.com/GrafGenerator/Mokkit' }, + { icon: 'github', label: 'Mokkit for Go', href: 'https://github.com/GrafGenerator/go-mokkit' }, ], editLink: { baseUrl: 'https://github.com/GrafGenerator/Mokkit/edit/main/docs/', @@ -43,7 +44,8 @@ export default defineConfig({ { label: 'Building your test vocabulary', slug: 'concepts/vocabulary' }, { label: 'Scenario tests', slug: 'concepts/scenarios' }, { label: 'The Stage & lifecycle', slug: 'concepts/stage' }, - { label: 'Captures: Capture vs Trapture', slug: 'concepts/captures' }, + { label: 'Captures: Capture vs Trapture', slug: 'concepts/captures', badge: { text: 'C#', variant: 'note' } }, + { label: 'Tokens: roles as types', slug: 'concepts/tokens', badge: { text: 'Go', variant: 'tip' } }, { label: 'Containers & the mock→DI bridge', slug: 'concepts/containers' }, ], }, @@ -59,6 +61,7 @@ export default defineConfig({ { label: 'Test a Kafka consumer / producer', slug: 'guides/kafka' }, { label: 'Async / eventually-consistent assertions', slug: 'guides/eventually-consistent' }, { label: 'Deterministic time & ids', slug: 'guides/deterministic-time-ids' }, + { label: 'Report to Allure', slug: 'guides/reporting-allure', badge: { text: 'Go', variant: 'tip' } }, ], }, { @@ -66,9 +69,9 @@ export default defineConfig({ items: [ { label: 'Value & context scopes', slug: 'guides/inspect-scopes' }, { label: 'Parallel inspects with ThenAll', slug: 'guides/thenall' }, - { label: 'Ensure: derive, guard, capture', slug: 'guides/ensure' }, - { label: 'Snapshot assertions with Verify', slug: 'guides/verify-snapshots' }, - { label: 'Source-generated arranges', slug: 'guides/mokkit-capture' }, + { label: 'Ensure: derive, guard, capture', slug: 'guides/ensure', badge: { text: 'C#', variant: 'note' } }, + { label: 'Snapshot assertions with Verify', slug: 'guides/verify-snapshots', badge: { text: 'C#', variant: 'note' } }, + { label: 'Source-generated arranges', slug: 'guides/mokkit-capture', badge: { text: 'C#', variant: 'note' } }, ], }, { @@ -84,7 +87,8 @@ export default defineConfig({ { label: 'How to structure a test project', slug: 'reference/project-structure' }, { label: 'Conventions cheat-sheet', slug: 'reference/conventions' }, // DocFX-generated static site under /api (built separately in CI). - { label: 'API reference', link: '/api/' }, + { label: 'API reference (C#)', link: '/api/' }, + { label: 'API reference (Go)', link: 'https://pkg.go.dev/github.com/GrafGenerator/go-mokkit', attrs: { target: '_blank' } }, ], }, ], diff --git a/docs/src/content/docs/concepts/aai.md b/docs/src/content/docs/concepts/aai.md deleted file mode 100644 index cb81ac4..0000000 --- a/docs/src/content/docs/concepts/aai.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: Arrange / Act / Inspect -description: The three-phase shape every Mokkit test follows, and the discipline that keeps tests honest. ---- - -Every Mokkit test tells the same three-part story. It's the classic Arrange-Act-Assert, with the assert -phase renamed **Inspect** to make its one rule explicit: it only *observes*. - -## Arrange - -`stage.Arrange()` starts a fluent chain. Each `.Then(...)` registers a step; **`await`-ing the chain runs -the steps in order.** A step receives the test host, so it can resolve and configure services: - -```csharp -await stage.Arrange() - .Then(host => host.Execute>(clock => - clock.Setup(x => x.UtcNow).Returns(FixedNow))) - .Then(async host => await host.ExecuteAsync(db => db.Seed(...))); -``` - -Arrange is **deferred**: `.Then(...)` only *records* the step; nothing happens until you `await`. That's what -lets an arrange hand back a **capture** — a placeholder for a value that doesn't exist yet: - -```csharp -var init = Capture.Start(out Capture client); // client is empty for now -return arrange.Then(_ => init.Set(new Client(...))); // filled when the chain runs -``` - -After the `await`, `client.Value` holds the created client. Captures are how the artifacts an arrange -creates flow into later steps. See [Capture vs Trapture](/concepts/captures/). - -## Act - -Act is the one thing under test — and, like Arrange and Inspect, it's a **first-class phase**. `stage.Act()` -starts a fluent chain whose `.Then(...)` steps resolve services from the stage and run them. Because Act is -symmetric with Arrange, an Act operation is reusable **vocabulary** too, and — like Arrange — it may *produce* -an artifact the later Inspect observes. - -An Act comes in three flavors, depending on what (if anything) the operation hands back: - -```csharp -// Void — the operation's effects are observed downstream (e.g. a message is emitted onto a bus). -await stage.Act().Then(host => host.ExecuteAsync(p => p.ProduceAsync(topic, message))); - -// Return — the operation returns its artifact directly (the familiar `var result = await ...`). -var result = await stage.Act().Returning(host => - host.ExecuteAsync(handler => handler.Handle(command))); - -// Capture — the operation threads its result forward through an out capture, exactly like Arrange. -await stage.Act().SaveClient(out var result, command); -``` - -`Execute`/`ExecuteAsync` come in 1-to-4-service arities, so an Act can pull several collaborators at once. -As with Arrange and Inspect, teams give their Acts domain names — `Act.CreateClient(...)`, -`Act.ProduceStatusChanged(...)` — so the test body reads as the story it tells. When a test is a whole -*sequence* of Act steps interleaved with checks, see **[Scenario tests](/concepts/scenarios/)**. - -## Inspect - -`stage.Inspect()` starts another fluent chain, awaited the same way. Inspect steps **read** the world — -query the database, verify a mock, poll an endpoint — and assert on it. They must not mutate state: - -```csharp -await stage.Inspect() - .SaveResult(result).IsSuccess() // a value scope over the result - .DbClientExists(id) // reads the database - .EventPublished("clients.created", id); // verifies a mock -``` - -Inspect also offers two power tools, covered in the guides: - -- **Value scopes** — `ThenValueScope(value)` opens a focused block of assertions over one value. -- **Parallel inspects** — `ThenAll(...)` runs independent observations concurrently while the chain stays - readable. - -## The discipline: produce vs observe - -One rule keeps tests trustworthy and reads across the whole suite: - -> **Arrange and Act *produce* artifacts. Inspect only *observes* them.** - -If setting something up needs a side effect, it belongs in Arrange (or is the Act itself). Inspect never -creates or changes state — so you can always trust that a failing Inspect is reporting on what Act did, not -on something Inspect itself caused. - -The verbs in these three chains — `SaveResult`, `DbClientExists`, `EventPublished` — are the heart of the -matter. They're your project's **[test vocabulary](/concepts/vocabulary/)**. diff --git a/docs/src/content/docs/concepts/aai.mdx b/docs/src/content/docs/concepts/aai.mdx new file mode 100644 index 0000000..4d38edd --- /dev/null +++ b/docs/src/content/docs/concepts/aai.mdx @@ -0,0 +1,190 @@ +--- +title: Arrange / Act / Inspect +description: The three-phase shape every Mokkit test follows, and the discipline that keeps tests honest. +--- + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +Every Mokkit test tells the same three-part story. It's the classic Arrange-Act-Assert, with the assert +phase renamed **Inspect** to make its one rule explicit: it only *observes*. + +## One difference to hold on to: deferred vs eager + +The two languages run their chains differently, and most other differences follow from this one: + +- **C# chains are deferred.** `.Then(...)` records a step; nothing happens until you `await` the chain. + Deferral is what makes captures necessary — an arrange hands back a *placeholder* for a value that + doesn't exist yet. +- **Go chains are eager.** By the time a verb returns, its step has already run. There is no terminal + call, no placeholder to hold, and an Act verb can simply *return* its result. Artifacts travel through + [tokens](/concepts/tokens/) instead of captures. + +Same story, different engine. The rest of this page shows both. + +## Arrange + +Arrange sets up the world: stub a collaborator, seed a database, put a message on a queue. Each step +receives the test host, so it can resolve and configure services. + + + + +`stage.Arrange()` starts a fluent chain. Each `.Then(...)` registers a step; **`await`-ing the chain runs +the steps in order:** + +```csharp +await stage.Arrange() + .Then(host => host.Execute>(clock => + clock.Setup(x => x.UtcNow).Returns(FixedNow))) + .Then(async host => await host.ExecuteAsync(db => db.Seed(...))); +``` + +Deferral is what lets an arrange hand back a **capture** — a placeholder for a value that doesn't exist +yet: + +```csharp +var init = Capture.Start(out Capture client); // client is empty for now +return arrange.Then(_ => init.Set(new Client(...))); // filled when the chain runs +``` + +After the `await`, `client.Value` holds the created client. Captures are how the artifacts an arrange +creates flow into later steps. See [Capture vs Trapture](/concepts/captures/). + + + + +`stage.Arrange()` starts a chain that runs each step **as it is added** — there is nothing to await: + +```go +f.Arrange(). + ClockIsFixed(fixedNow). + DbSeeded(rows) +``` + +An arrange that produces something files it under a **token**, and any later phase reads it back by the +role's name — no placeholder, because by the time the verb returns the artifact already exists: + +```go +f.Arrange(). + NewClient[Buyer](WithName("Acme Corporation")) + +// any later phase: +f.Of[Buyer]() // the client, as a value +``` + +See [Tokens](/concepts/tokens/). Arrange fails **hard** — a broken setup makes every later step +meaningless, so the first failing step ends the test. + + + + +## Act + +Act is the one thing under test — and, like Arrange and Inspect, it's a **first-class phase**. An Act +operation is reusable **vocabulary** too, and it produces the artifact the later Inspect observes. + + + + +An Act comes in three flavors, depending on what (if anything) the operation hands back: + +```csharp +// Void — the operation's effects are observed downstream (e.g. a message is emitted onto a bus). +await stage.Act().Then(host => host.ExecuteAsync(p => p.ProduceAsync(topic, message))); + +// Return — the operation returns its artifact directly (the familiar `var result = await ...`). +var result = await stage.Act().Returning(host => + host.ExecuteAsync(handler => handler.Handle(command))); + +// Capture — the operation threads its result forward through an out capture, exactly like Arrange. +await stage.Act().SaveClient(out var result, command); +``` + +`Execute`/`ExecuteAsync` come in 1-to-4-service arities, so an Act can pull several collaborators at once. + + + + +Eager execution collapses C#'s three flavors to one: the verb is concrete, so it names its own return +type and simply hands the result back: + +```go +func (a Act) SaveClient(cmd SaveCommand) SaveResult { + a.Helper() + + var out SaveResult + a.Add("SaveClient", func(ctx context.Context, h mokkit.Host) error { + var err error + out, err = h.Resolve[*SaveClientHandler]().Handle(ctx, cmd) + + return err + }) + + return out +} + +// in the test: +result := f.Act().SaveClient(cmd) +``` + +Inside a step, `h.Resolve[T]()` pulls any collaborator from the stage. Act fails hard, like Arrange. + + + + +As with Arrange and Inspect, teams give their Acts domain names — `Act.CreateClient(...)`, +`Act.ProduceStatusChanged(...)` — so the test body reads as the story it tells. When a test is a whole +*sequence* of Act steps interleaved with checks, see **[Scenario tests](/concepts/scenarios/)**. + +## Inspect + +Inspect steps **read** the world — query the database, verify a mock, poll an endpoint — and assert on +it. They must not mutate state. + + + +```csharp +await stage.Inspect() + .SaveResult(result).IsSuccess() // a value scope over the result + .DbClientExists(id) // reads the database + .EventPublished("clients.created", id); // verifies a mock +``` + +Inspect also offers two power tools, covered in the guides: + +- **Value scopes** — `ThenValueScope(value)` opens a focused block of assertions over one value. +- **Parallel inspects** — `ThenAll(...)` runs independent observations concurrently while the chain stays + readable. + + + +```go +f.Inspect(). + SaveResult(result).IsSuccess(). // a value scope over the result + DbClientExists(result.ID). // reads the database + All( // independent observations, concurrently + eventPublished(f, "clients.created", result.ID), + auditRowWritten(f, result.ID), + ) +``` + +Inspect fails **soft**: a failing observation reports and the chain keeps going, so one run tells you +everything that is wrong — what C# reaches for `Assert.Multiple` to get. Value scopes are ordinary +vocabulary types carrying the value as a field; `All` runs branches concurrently, and `mokkit.Group` +turns several steps into one branch. + + + + +## The discipline: produce vs observe + +One rule keeps tests trustworthy and reads across the whole suite: + +> **Arrange and Act *produce* artifacts. Inspect only *observes* them.** + +If setting something up needs a side effect, it belongs in Arrange (or is the Act itself). Inspect never +creates or changes state — so you can always trust that a failing Inspect is reporting on what Act did, not +on something Inspect itself caused. + +The verbs in these three chains — `SaveResult`, `DbClientExists`, `EventPublished` — are the heart of the +matter. They're your project's **[test vocabulary](/concepts/vocabulary/)**. diff --git a/docs/src/content/docs/concepts/captures.md b/docs/src/content/docs/concepts/captures.md index f841a0c..a99c744 100644 --- a/docs/src/content/docs/concepts/captures.md +++ b/docs/src/content/docs/concepts/captures.md @@ -3,6 +3,12 @@ title: "Captures: Capture vs Trapture" description: How an artifact created in one phase threads into the next while the whole chain stays deferred. --- +:::note[Coming from Go?] +Captures exist because C# chains are **deferred** — a placeholder must stand in for a value that will +only exist when the chain is awaited. Go Mokkit's chains are eager, so captures have no Go equivalent; +artifacts travel through typed [tokens](/concepts/tokens/) instead. +::: + Arrange and Act are **deferred**: `.Then(...)` only *records* a step; nothing runs until you `await`. That raises a question — if the client isn't created until the chain runs, how does a later step refer to it? The answer is a **capture**: a typed placeholder handed back immediately and filled when the step runs. diff --git a/docs/src/content/docs/concepts/containers.md b/docs/src/content/docs/concepts/containers.md index 2331f51..56beda3 100644 --- a/docs/src/content/docs/concepts/containers.md +++ b/docs/src/content/docs/concepts/containers.md @@ -9,12 +9,16 @@ test arranges is the very same instance the real service under test calls. ## Two roles a container plays -- **Mock containers** hold your test doubles — Moq mocks, NSubstitute substitutes, FakeItEasy fakes. -- **DI containers** compose the *real* application — Microsoft DI, Autofac, Castle Windsor. -- The dependency-free **Bag** is a third option: it just holds a few instances you hand it, no framework at - all (great for a first test or a small SUT). +- **Mock containers** hold your test doubles — Moq mocks, NSubstitute substitutes, FakeItEasy fakes in + C#; gomock, mockery/testify or minimock mocks in Go. +- **DI containers** compose the *real* application — Microsoft DI, Autofac, Castle Windsor in C#; + samber/do or uber-go/dig in Go. +- The dependency-free **Bag** is a third option both sides ship: it holds the instances and factories you + hand it, no framework at all. In Go, where hand-wiring *is* the idiom, Bag is the primary container + rather than a fallback. -You pass the builders for these to `TestStageSetup.Create(...)`, and they're composed together into one Stage. +You pass the builders for these to the setup (`TestStageSetup.Create(...)` / `mokkit.NewSetup(...)`), and +they're composed together into one Stage. ## The mock→DI bridge @@ -62,6 +66,29 @@ The `UsePreBuild(...)` hook is the one moment the four-ph is where the DI builder gets to *see* the mock container's registrations, so it can wire a `ResolveFromStage` for each. After that, the composition is built and every stage entered from it shares the arrangement. +:::note[The bridge in Go] +Go Mokkit needs no build phases for this: every container's factories receive a **resolver spanning the +whole composition**, so a factory just pulls collaborators wherever they live. In the DI adapters the same +idea is spelled per container — `mokkitdo.FromStage[UserRepository](inj)` inside a samber/do provider, or +`mokkitdig.Bridge[UserRepository](di)` to teach dig to take a dependency from the stage: + +```go +mocks := mokkitgomock.New() +mokkitgomock.Add[UserRepository](mocks, NewMockUserRepository) + +di := mokkitdig.New() +di.Provide(func(users UserRepository) *DiscountService { + return &DiscountService{Users: users} +}) +mokkitdig.Expose[*DiscountService](di) +mokkitdig.Bridge[UserRepository](di) // dig asks the stage; the stage answers with the mock + +setup, err := mokkit.NewSetup(ctx, mocks, di) +``` + +The real service and the test share the same mock instance — the identical guarantee, without phases. +::: + The result reads exactly the way you'd want: ```csharp @@ -80,21 +107,22 @@ await Inspect.CacheNotUpdated(); Everything above works with whichever pair you prefer. Each adapter is a small package: -| Role | Packages | -| --- | --- | -| Mock library | `Mokkit.Containers.Moq` · `.NSubstitute` · `.FakeItEasy` | -| DI container | `Mokkit.Containers.Microsoft.Extensions.DependencyInjection` · `.Autofac` · `.CastleWindsor` | -| Dependency-free | `Mokkit.Containers.Bag` | -| Shared contracts | `Mokkit.Containers.Common` (referenced transitively) | +| Role | C# packages | Go modules | +| --- | --- | --- | +| Mock library | `Mokkit.Containers.Moq` · `.NSubstitute` · `.FakeItEasy` | `container/mokkitgomock` · `mokkitmockery` · `mokkitminimock` | +| DI container | `Mokkit.Containers.Microsoft.Extensions.DependencyInjection` · `.Autofac` · `.CastleWindsor` | `container/mokkitdo` · `container/mokkitdig` | +| Dependency-free | `Mokkit.Containers.Bag` | `container/bag` (in the core module) | +| Shared contracts | `Mokkit.Containers.Common` (transitively) | the core `mokkit` package itself | The suites in the [example](https://github.com/GrafGenerator/Mokkit/tree/main/example/Example1) deliberately use *different* stacks — NSubstitute + MS-DI for units, Moq + MS-DI for integration, Bag for E2E — to prove the test body never depends on the choice. :::tip[Rolling your own] -The adapter contract (`IDependencyContainerBuilder` → `IDependencyContainer`) is small. If your stack isn't -covered, you can write an adapter — see [Write a custom container adapter](/guides/custom-container-adapter/), -which walks through `SubstituteContainerBuilder`. +The adapter contract is small in both languages — `IDependencyContainerBuilder` → `IDependencyContainer` +in C#, `ContainerBuilder` → `Container` → `Scope` (three one-method-ish interfaces) in Go. If your stack +isn't covered, you can write an adapter — see +[Write a custom container adapter](/guides/custom-container-adapter/). ::: ## Next diff --git a/docs/src/content/docs/concepts/scenarios.md b/docs/src/content/docs/concepts/scenarios.md index c075f9b..ceaebb3 100644 --- a/docs/src/content/docs/concepts/scenarios.md +++ b/docs/src/content/docs/concepts/scenarios.md @@ -39,7 +39,25 @@ await Inspect ``` Read top to bottom, that test *is* its own specification: build → check → act → check → act → check. No -Gherkin, no step-binding file — just compilable C# that happens to read like the scenario it verifies. +Gherkin, no step-binding file — just compilable code that happens to read like the scenario it verifies. + +:::note[The same story in Go] +The shape carries over untouched — a scenario is a sequence of eager chains, and the id threads through +the story under a [token](/concepts/tokens/) instead of a capture: + +```go +f.Arrange().NewClient[Acme](WithStatus(Active)) +f.Inspect().ApiClientStatus(f.Of[Acme]().ID, Active) + +renamed := f.Act().UpdateClient(f.Of[Acme]().ID, WithName("Acme Holdings")) +f.Inspect().Updated(renamed).ApiClientNamed(f.Of[Acme]().ID, "Acme Holdings") + +f.Act().ProduceStatusChanged(f.Of[Acme]().ID, suspend) +f.Inspect(). + ApiClientEventually(f.Of[Acme]().ID, Suspended). + EventPublished("clients.updated", f.Of[Acme]().ID) +``` +::: ## How it holds together diff --git a/docs/src/content/docs/concepts/stage.md b/docs/src/content/docs/concepts/stage.mdx similarity index 72% rename from docs/src/content/docs/concepts/stage.md rename to docs/src/content/docs/concepts/stage.mdx index c77a878..7193166 100644 --- a/docs/src/content/docs/concepts/stage.md +++ b/docs/src/content/docs/concepts/stage.mdx @@ -3,6 +3,8 @@ title: The Stage & lifecycle description: Where your services live during a test — how a Stage is composed once and entered fresh for every test. --- +import { Tabs, TabItem } from '@astrojs/starlight/components'; + The **Stage** is the runtime a test runs against. It holds the services a test resolves — the real system-under-test plus its (real or mocked) collaborators — and it's what `Arrange`, `Act` and `Inspect` pull from. Everything else in Mokkit sits on top of it. @@ -11,11 +13,13 @@ pull from. Everything else in Mokkit sits on top of it. A Stage comes in two steps: -1. **`TestStageSetup.Create(...builders)`** composes your [containers](/concepts/containers/) — this is the - expensive part, and you do it **once**. -2. **`setup.EnterStage()`** returns a fresh, isolated `TestStage` — you do this **once per test**, and dispose - it afterwards. +1. **Composing** builds your [containers](/concepts/containers/) — this is the expensive part, and you do + it **once**. +2. **Entering a stage** returns a fresh, isolated world — you do this **once per test**; it is released + when the test ends. + + ```csharp // Once — usually in a class/collection fixture. var setup = await TestStageSetup.Create( @@ -28,12 +32,29 @@ var stage = setup.EnterStage(); // ... arrange / act / inspect ... stage.Dispose(); ``` + + +```go +// Once — in TestMain (never init: strict lint configs forbid it). +setup, err := mokkit.NewSetup(context.Background(), mocks, app) + +// Per test. Cleanup is registered with t automatically. +stage := setup.EnterStage(t) +``` + +`EnterStage(t)` registers the stage's release with `t.Cleanup`, so there is nothing to dispose by hand; a +scoped service that implements `io.Closer` is closed when the stage ends. -Each `EnterStage()` opens its own scope, so tests are isolated: scoped services are created per stage and -disposed when the stage is disposed. Nothing leaks between tests. + + + +Each entered stage opens its own scope, so tests are isolated: scoped services are created per stage and +released with it. Nothing leaks between tests. ## What a Stage gives you + + ```csharp stage.Arrange(); // → ITestArrange — start the setup chain stage.Act(); // → ITestAct — start the act chain @@ -44,6 +65,25 @@ stage.ExecuteAsync(svc => ...); // resolve, run, return a resu ``` `Execute`/`ExecuteAsync` come in 1-to-4-service arities, so a step can pull several collaborators at once. + + + +```go +stage.Arrange() // *mokkit.Chain — fails hard +stage.Act() // *mokkit.Chain — fails hard +stage.Inspect() // *mokkit.Chain — fails soft + +mokkit.Resolve[*SignupService](stage) // resolve straight off the stage +stage.Tokens() // the per-test artifact registry +``` + +Inside a step, `h.Resolve[T]()` does the resolving; a small per-suite **fixture** embeds +`stage.Tokens()` so tests read `f.Of[Buyer]()`. Stages can also be observed — every step, with phase, +name, duration and outcome — which is how the [Allure reporter](/guides/reporting-allure/) works. + + + + Your [vocabulary](/concepts/vocabulary/) verbs are thin wrappers over exactly these calls. ## Wiring it to your test framework diff --git a/docs/src/content/docs/concepts/tokens.md b/docs/src/content/docs/concepts/tokens.md new file mode 100644 index 0000000..d6051e0 --- /dev/null +++ b/docs/src/content/docs/concepts/tokens.md @@ -0,0 +1,106 @@ +--- +title: "Tokens: roles as types" +description: How Go Mokkit tests declare, produce and read artifacts — a typed token instead of a capture, checked by the compiler. +--- + +Go Mokkit has no captures — eager chains delete the placeholder they existed to make safe. What remains +is the real question captures answered: **how does an artifact travel between phases** without a `var` +declared above the test, and without a stringly-typed lookup? + +The answer is a **token**: a type that names a role, and declares in the same line what that role stands +for. + +```go +type ( + Buyer struct{ mokkit.Artifact[User] } + Seller struct{ mokkit.Artifact[User] } + 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 role: + +```go +f.Arrange(). + UserExists[Buyer](Vip). + UserExists[Seller](Regular). + OrderFor[Cart](f.Of[Buyer](), 100) + +discount := f.Act().DiscountFor[Cart]() +``` + +## The three accessors + +| accessor | side | returns | when it fails | +| --- | --- | --- | --- | +| `f.New[Buyer]()` | write | `*User` — the sink a producing verb fills | never (create-or-get) | +| `f.Of[Buyer]()` | read | `User` — a value | loudly, if no verb produced the role | +| `f.Ref[Buyer]()` | read | `*User` — the pointer | loudly, if no verb produced the role | + +**Prefer `Of`.** A value cannot be written through by accident, which keeps a read-only phase read-only. +Reach for `Ref` when the artifact has *identity* — a recording double whose state the Act mutates and a +later Inspect observes; a copy there would silently assert on stale state. + +Reading a role nobody produced fails 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) +``` + +## What the compiler checks + +This is where tokens beat both `out var` captures and any string-keyed registry: + +- A **misspelt role** is `undefined: Byer` — a build error. +- A **role of the wrong kind** is a build error too: a verb declared + `func (a Arrange) UserExists[K mokkit.Token[User]](...)` will not accept `Cart`, because `Cart`'s + token declares an `Order`. The role/artifact pairing is enforced by the constraint, not remembered by + the reader. +- The role lands in the **step label** — `arrange: UserExists[Buyer]` — via `mokkit.NameOf[K]()`, so a + failure names the actor it was acting for. + +## Verbs generic over the token + +A producing verb takes the role as a type parameter, fills the sink, and returns the chain — so the +chain never breaks to get an artifact out: + +```go +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 { + u := newUser(mokkit.NameOf[K](), s) + *a.New[K]() = u + h.Resolve[*fakeUsers]().add(u) + + return nil + }) + + return a +} +``` + +## The return form, for one-off artifacts + +When a test has a single artifact and no reason to name it, skip the token entirely. Chains are eager, so +a producing verb can just hand its artifact back, bound with `:=` at the point it is created: + +```go +client := f.Arrange().AClient(WithName("Acme")) + +result := f.Act().GetClient(client.ID) +``` + +Nothing declared above, no pointer, and go-to-definition lands on the verb that made it. The cost: such a +verb is terminal — its return type ends the chain — which is exactly why the token form exists for tests +with more than one actor. A suite mixes both freely. + +Tokens are static by nature: a role is a type, so it cannot be picked at run time. A table-driven loop +over "roles" is what the return form is for — bind the artifact to the loop variable. + +:::note[Coming from C#?] +`Capture`, `Trapture`, `Prop`, `Ensure` and `EnsureValue` have no Go equivalents, and nothing replaced +them one-for-one — eager execution made the placeholder itself unnecessary. The guard `EnsureValue` +provided (reading a value that was never produced) is `Of`'s loud failure. See +[Captures](/concepts/captures/) for the C# side of this story. +::: diff --git a/docs/src/content/docs/concepts/vocabulary.md b/docs/src/content/docs/concepts/vocabulary.md deleted file mode 100644 index aaa4293..0000000 --- a/docs/src/content/docs/concepts/vocabulary.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -title: Building your test vocabulary -description: The idea Mokkit is built around — reusable Arrange/Inspect verbs that become your domain's testing language. ---- - -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.** - -The verbs are plain C# extension methods on `ITestArrange` and `ITestInspect`. Once you have a handful, a -test stops looking like plumbing and starts reading like the scenario it describes. - -## A test is a composition - -Here's a test written entirely in a client-management vocabulary: - -```csharp -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. - -## Three kinds of verb - -### Arrange verbs — set up, and capture - -An arrange verb registers a deferred step and usually hands back a **capture** for the artifact it creates, -so later steps can refer to it: - -```csharp -public static ITestArrange NewClient( - this ITestArrange arrange, out Trapture id, params ClientFieldFn[] fields) -{ - var capture = Trapture.Start(out id); - return arrange.Then(async host => - { - await host.ExecuteAsync(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](/concepts/captures/). - -Small parameter helpers (`WithName`, `WithEmail`, …) let a caller compose exactly the setup they need: - -```csharp -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 - -An act verb performs the operation under test. Like an arrange, it can hand an artifact back — either by -**returning** it (`Returning`) or, for a void act, leaving its effects to be observed later in Inspect: - -```csharp -// Return flavor — `var result = await Act.UpdateClient(...)`. -public static ITestAct UpdateClient( - this ITestAct act, Guid clientId, params ClientFieldFn[] fields) => - act.Returning(host => host.ExecuteAsync( - 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>( - producer => producer.ProduceAsync("clients.status-changed", Serialize(clientId, message)))); -``` - -Act verbs are what let a test grow from a single triple into a [scenario](/concepts/scenarios/) — a sequence -of Arrange / Act / Inspect blocks that walks a whole lifecycle. - -### Inspect verbs — observe - -An inspect verb resolves what it needs from the stage and asserts. It only reads: - -```csharp -public static ITestInspect ApiClient( - this ITestInspect inspect, Guid clientId, Action assert) => - inspect.Then(async host => await host.ExecuteAsync(async http => - { - var response = await http.GetAsync($"/api/v1/clients/{clientId}"); - response.StatusCode.ShouldBe(HttpStatusCode.OK); - assert((await response.Content.ReadFromJsonAsync())!); - })); - -public static ITestInspect EventPublished(this ITestInspect inspect, string topic, Guid clientId) => - inspect.Then(async host => await host.ExecuteAsync(async probe => - (await probe.SawMessageKeyed(topic, clientId.ToString())).ShouldBeTrue())); -``` - -## Why this beats a DSL - -Because your vocabulary is *code*, you get everything a Gherkin step binding gives up (see -[Why Mokkit?](/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` 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. - -## Where verbs live - -Keep vocabulary next to what it describes — colocated `Arrange.cs` / `Inspect.cs` files per -feature or system-under-test. The [project structure](/reference/project-structure/) page shows the layout; -the [guides](/quickstart/) build real vocabulary for mocked services, databases, message queues and full -end-to-end flows. - -## Levelling up your verbs - -As scenarios get richer, a few Mokkit features become vocabulary-authoring techniques rather than test-body -noise: - -- **[Value & context scopes](/concepts/aai/)** — group assertions over one value inside a verb. -- **`Ensure`** — derive, guard-as-non-empty, and capture a value in one step, so ids flow cleanly between - verbs. -- **`[MokkitCapture]`** — let the source generator write the boilerplate body of a "build this object" - arrange verb, so your vocabulary file is just declarations. - -Each is covered in its own guide. diff --git a/docs/src/content/docs/concepts/vocabulary.mdx b/docs/src/content/docs/concepts/vocabulary.mdx new file mode 100644 index 0000000..0f57e63 --- /dev/null +++ b/docs/src/content/docs/concepts/vocabulary.mdx @@ -0,0 +1,274 @@ +--- +title: Building your test vocabulary +description: The idea Mokkit is built around — reusable Arrange/Inspect verbs that become your domain's testing language. +--- + +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.** + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +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 + +Here's a test written entirely in a client-management vocabulary: + + + +```csharp +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); +``` + + +```go +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 + +### 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. + + + +```csharp +public static ITestArrange NewClient( + this ITestArrange arrange, out Trapture id, params ClientFieldFn[] fields) +{ + var capture = Trapture.Start(out id); + return arrange.Then(async host => + { + await host.ExecuteAsync(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](/concepts/captures/). + + + +```go +func (a Arrange) NewClient[K mokkit.Token[Client]](fields ...ClientField) Arrange { + a.Helper() + a.Add("NewClient["+mokkit.NameOf[K]()+"]", func(ctx context.Context, h mokkit.Host) error { + result, err := createClient(ctx, h.Resolve[*http.Client](), build(fields...)) + if err != nil { + return err + } + + *a.New[K]() = result + + return nil + }) + + return a +} +``` + +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 the step label carries the +role. See [Tokens](/concepts/tokens/). + + + + +Small parameter helpers (`WithName`, `WithEmail`, …) let a caller compose exactly the setup they need — +funcs over a request in either language: + + + +```csharp +public static ClientFieldFn WithName(string name) => r => r with { Name = name }; +public static ClientFieldFn WithEmail(string email) => r => r with { Email = email }; +``` + + +```go +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 + +An act verb performs the operation under test and hands its artifact back. + + + +```csharp +// Return flavor — `var result = await Act.UpdateClient(...)`. +public static ITestAct UpdateClient( + this ITestAct act, Guid clientId, params ClientFieldFn[] fields) => + act.Returning(host => host.ExecuteAsync( + 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>( + producer => producer.ProduceAsync("clients.status-changed", Serialize(clientId, message)))); +``` + + + +```go +// Return — eager chains mean the verb simply returns its result. +func (a Act) UpdateClient(id string, fields ...ClientField) WriteResult { + a.Helper() + + var out WriteResult + a.Add("UpdateClient", func(ctx context.Context, h mokkit.Host) error { + var err error + out, err = updateClient(ctx, h.Resolve[*http.Client](), id, build(fields...)) + + return err + }) + + return out +} + +// Void — fire the operation; its effects surface downstream in Inspect. +func (a Act) ProduceStatusChanged(id string, msg StatusChanged) Act { + a.Helper() + a.Add("ProduceStatusChanged", func(ctx context.Context, h mokkit.Host) error { + return h.Resolve[Producer]().Produce(ctx, "clients.status-changed", serialize(id, msg)) + }) + + return a +} +``` + + + +Act verbs are what let a test grow from a single triple into a [scenario](/concepts/scenarios/) — a sequence +of Arrange / Act / Inspect blocks that walks a whole lifecycle. + +### Inspect verbs — observe + +An inspect verb resolves what it needs from the stage and asserts. It only reads: + + + +```csharp +public static ITestInspect ApiClient( + this ITestInspect inspect, Guid clientId, Action assert) => + inspect.Then(async host => await host.ExecuteAsync(async http => + { + var response = await http.GetAsync($"/api/v1/clients/{clientId}"); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + assert((await response.Content.ReadFromJsonAsync())!); + })); + +public static ITestInspect EventPublished(this ITestInspect inspect, string topic, Guid clientId) => + inspect.Then(async host => await host.ExecuteAsync(async probe => + (await probe.SawMessageKeyed(topic, clientId.ToString())).ShouldBeTrue())); +``` + + +```go +func (i Inspect) EventPublished(topic, clientID string) Inspect { + i.Helper() + i.Add("EventPublished", func(ctx context.Context, h mokkit.Host) error { + if !h.Resolve[*KafkaProbe]().SawMessageKeyed(topic, clientID) { + return fmt.Errorf("no %s message keyed %s", topic, clientID) + } + + return nil + }) + + return i +} +``` + +A verb's first line is `i.Helper()` — that is what makes a failure report the test's line rather than +the verb's body — and it reports by returning an error, never by failing the test directly. + + + + +## Why this beats a DSL + +Because your vocabulary is *code*, you get everything a Gherkin step binding gives up (see +[Why Mokkit?](/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. + +## 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.cs` / `Inspect.cs` files per feature; in Go the convention has teeth +worth stating: + +``` +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/All take +_test.go tests — and nothing else +``` + +A verb defined in a scenario file is invisible: the next person writes a second one beside it instead of +reusing the first, and the vocabulary stops compounding. And keep verbs **atomic** — one named condition +each — so a refusal-path test differs from the success path by exactly one verb. The +[project structure](/reference/project-structure/) page shows both layouts; the [guides](/quickstart/) +build real vocabulary for mocked services, databases, message queues and full end-to-end flows. + +## Levelling up your verbs + +As scenarios get richer, a few Mokkit features become vocabulary-authoring techniques rather than test-body +noise: + +- **[Value & context scopes](/concepts/aai/)** — 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](/concepts/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. Go struct literals with option funcs are already that terse, so the Go port + deliberately has no generator. + +Each is covered in its own guide. diff --git a/docs/src/content/docs/guides/bag-container.md b/docs/src/content/docs/guides/bag-container.md index 63afb26..9ef2a7e 100644 --- a/docs/src/content/docs/guides/bag-container.md +++ b/docs/src/content/docs/guides/bag-container.md @@ -3,6 +3,13 @@ title: The Bag container description: A dependency-free container that just holds the instances you give it — perfect for a first test or a stage of pre-built external clients. --- +:::note[In Go] +Go's Bag is the *primary* container, not a fallback — hand-wiring is the language's idiom. +`bag.Instance` shares one value across stages, `bag.Scoped` builds per stage (closed with the stage when +it implements `io.Closer`), and `bag.Alias[Iface, *Impl]` makes one double answer under both its types. +Factories receive a resolver spanning the whole composition, which is the entire mock→DI bridge. +::: + Not every test needs a DI framework. The **Bag** (`Mokkit.Containers.Bag`) is a trivial container that holds instances you hand it — no auto-wiring, no options, no dependency on Microsoft DI. It's the right tool for two situations: your very first test, and a stage that just needs to hold some pre-built clients. diff --git a/docs/src/content/docs/guides/custom-container-adapter.md b/docs/src/content/docs/guides/custom-container-adapter.md index 89e1125..477cb1c 100644 --- a/docs/src/content/docs/guides/custom-container-adapter.md +++ b/docs/src/content/docs/guides/custom-container-adapter.md @@ -3,6 +3,13 @@ title: Write a custom container adapter description: Not using Moq, NSubstitute, FakeItEasy, or Microsoft DI? The adapter contract is tiny — this walks through a real one end to end. --- +:::note[In Go] +The Go contract is three small interfaces — `ContainerBuilder` (build once), `Container` (one scope per +stage), `Scope` (`TryResolveType` + `Close`) — plus optional `PathResolver` if your scope's factories +resolve their own collaborators, so a dependency cycle that crosses containers is reported instead of +deadlocking. The five shipped adapters are each ~150–250 lines and make good templates. +::: + Mokkit ships adapters for Moq, NSubstitute, FakeItEasy, Microsoft DI, Autofac and Castle Windsor. If your stack isn't in that list, you write an adapter — and the contract is small. This page walks the example's own `SubstituteContainerBuilder` (a from-scratch NSubstitute container) to show the whole thing. diff --git a/docs/src/content/docs/guides/ensure.md b/docs/src/content/docs/guides/ensure.md index 532607a..c4f7422 100644 --- a/docs/src/content/docs/guides/ensure.md +++ b/docs/src/content/docs/guides/ensure.md @@ -3,6 +3,11 @@ title: "Ensure: derive, guard, capture" description: Turn "the result's id" into a clean, non-empty capture in one step — so ids flow between phases without null-checks littering the test. --- +:::note[In Go] +`Ensure` has no Go equivalent, because the problems it solves are covered elsewhere: ids flow between +verbs under [tokens](/concepts/tokens/), and the "was it ever produced?" guard is `Of`'s loud failure. +::: + A recurring need: take a value off an artifact (an entity's id), make sure it isn't empty, and thread it into later steps. Done by hand that's `result.ClientId!.Value` plus a guard plus a capture — noise that repeats in every test. **`Ensure`** does all three in one call: **derive → guard-as-non-empty → capture**. diff --git a/docs/src/content/docs/guides/inspect-scopes.md b/docs/src/content/docs/guides/inspect-scopes.md index db11818..1b41503 100644 --- a/docs/src/content/docs/guides/inspect-scopes.md +++ b/docs/src/content/docs/guides/inspect-scopes.md @@ -3,6 +3,12 @@ title: Value & context scopes in Inspect description: Focus a run of assertions on one value, and wrap them in a context — a single Assert.Multiple, a transaction — with inspect scopes. --- +:::note[In Go] +Scopes need no API at all: a value scope is an ordinary vocabulary type carrying the value as a field +(`Retrieved(result).Found().Named("Acme")`), and a context scope is plain Go — eager chains mean +`defer tx.Rollback()` simply works around a chain. +::: + Most inspect verbs read the world and assert. Sometimes, though, several assertions all concern **one value** — a result object, a fetched entity — and you want them grouped. That's an **inspect scope**: a focused block of checks over a single value, opened with `ThenValueScope`. diff --git a/docs/src/content/docs/guides/mock-libraries.md b/docs/src/content/docs/guides/mock-libraries.md index 9771115..836a760 100644 --- a/docs/src/content/docs/guides/mock-libraries.md +++ b/docs/src/content/docs/guides/mock-libraries.md @@ -3,6 +3,14 @@ title: Pick a mock library description: Moq, NSubstitute, or FakeItEasy — choose the mock container that matches your library; the test shape never changes. --- +:::note[In Go] +The Go adapters follow one shape — the mock registers under **two types** (the interface for the subject, +the mock's own type for `EXPECT`-style arranging), one controller per stage bound to that test: +**mokkitgomock** (go.uber.org/mock, plus `Satisfied()` to pull an unmet expectation onto the test's +line), **mokkitmockery** (mockery/testify, same `Satisfied()`), and **mokkitminimock** (reports at +cleanup; use capture-and-inspect for on-the-line interaction asserts). +::: + Mokkit doesn't ship a mocking framework — it *adapts* one. Three adapters come in the box, and swapping between them changes only how you configure and read a double, never the shape of a test. diff --git a/docs/src/content/docs/guides/real-di-container.md b/docs/src/content/docs/guides/real-di-container.md index 601d851..a8a3d2b 100644 --- a/docs/src/content/docs/guides/real-di-container.md +++ b/docs/src/content/docs/guides/real-di-container.md @@ -3,6 +3,11 @@ title: Wire a real DI container + bridge mocks description: Compose the real application from your DI container while a mock container feeds test doubles into it. --- +:::note[In Go] +The same wiring with samber/do or dig is shown in [Containers](/concepts/containers/): `mokkitdo.FromStage` +inside a provider, or `mokkitdig.Bridge`/`Expose` on the builder. +::: + Hand-wiring a service with the [Bag](/guides/bag-container/) is fine for a small SUT, but real code is usually assembled by a DI container — and you want to test the *real* composition, with only the outermost collaborators faked. Mokkit does this by running two containers side by side and **bridging** them: a mock diff --git a/docs/src/content/docs/guides/reporting-allure.md b/docs/src/content/docs/guides/reporting-allure.md new file mode 100644 index 0000000..f1548fb --- /dev/null +++ b/docs/src/content/docs/guides/reporting-allure.md @@ -0,0 +1,90 @@ +--- +title: Report to Allure +description: Turn a Go Mokkit suite's vocabulary into Allure results — every test a scenario, every verb a step. +--- + +Go Mokkit's stages can be **observed**: an observer hears each stage entered, every step that ran — with +its phase, verb name, duration and outcome — and the stage closing with the test's verdict. The step +names arrive exactly as the suite spelled them (`arrange: UserExists[Buyer]`), which is precisely what a +test report wants to show. + +`report/allure` is that observer, writing [Allure 2](https://allurereport.org/) result files. It lives in +the core module and depends on nothing but the standard library — allure-results is just JSON. + +## Wire it in + +```go +import "github.com/GrafGenerator/go-mokkit/report/allure" + +func TestMain(m *testing.M) { + setup, err := mokkit.NewSetup(context.Background(), mocks, app) + if err != nil { + panic(err) + } + + var reporter *allure.Reporter + if dir := os.Getenv("ALLURE_OUTPUT_PATH"); dir != "" { + if reporter, err = allure.New(dir, allure.WithSuite("cards e2e")); err != nil { + panic(err) + } + setup.Observe(reporter) + } + + code := m.Run() + + // An observer never fails a test; an incomplete report is surfaced here. + if reporter != nil { + if err := reporter.Err(); err != nil { + fmt.Fprintf(os.Stderr, "allure report incomplete: %v\n", err) + } + } + + os.Exit(code) +} +``` + +Run with `ALLURE_OUTPUT_PATH=./allure-results go test ./...` and feed the directory to the Allure CLI or +TestOps as usual. + +## What a result looks like + +One file per test, whose steps are the test's own vocabulary, with real timings: + +```json +{ + "name": "TestOrange_AnAllowedPartyActivatesAndDrawsAVirtualCard", + "status": "passed", + "steps": [ + { "name": "arrange: AnOrangeCategory[Orange]", "status": "passed" }, + { "name": "arrange: AFreeVirtualCardInThePool[Orange]", "status": "passed" }, + { "name": "act: RegisterCard[Orange]", "status": "passed" }, + { "name": "inspect: theOutboxPayloadMentions", "status": "passed" } + ] +} +``` + +A step that failed carries its message and trace; a step that **panicked** is reported as `broken` rather +than `failed`, so triage can tell an assertion from a crash. `historyId` is stable across runs (it hashes +the suite and test name), which is what lets TestOps track history and retries. + +## The observer seam itself + +`allure` is ~300 lines over a public seam you can point anywhere else — OpenTelemetry spans, a JUnit +writer, a metrics counter: + +```go +type Observer interface { + StageEntered(test, stageID string) + StepRan(event StepEvent) // test, stage, phase, step, started, duration, err + StageClosed(test, stageID string, failed bool) +} +``` + +Register any number with `setup.Observe(...)`. Two contract points worth knowing: observers must be safe +for concurrent use (`All` branches and parallel tests both emit), and an observer has no way to fail a +test — reporting problems are yours to surface, as `Reporter.Err()` does above. + +:::note[C#] +The .NET side has no observer seam yet; this page is the Go half of the story. Allure's own adapters for +xUnit/NUnit work alongside Mokkit there, at test granularity rather than step granularity. +::: diff --git a/docs/src/content/docs/guides/thenall.md b/docs/src/content/docs/guides/thenall.md index f65f3dd..8792ab6 100644 --- a/docs/src/content/docs/guides/thenall.md +++ b/docs/src/content/docs/guides/thenall.md @@ -3,6 +3,12 @@ title: Parallel inspects with ThenAll description: Run independent observations concurrently — three downstream effects at once — while the chain stays ordered and readable. --- +:::note[In Go] +The counterpart is `All(steps...)`: branches run concurrently, every failure is reported (Inspect fails +soft), and `mokkit.Group("db", step1, step2)` makes a multi-step branch. Branches report by returning an +error — never by failing the test from their own goroutine. +::: + After an act, you often need to check several *independent* effects: the API read, the database row, the published event. Checked one after another they serialise — and each may involve a [poll with a timeout](/guides/eventually-consistent/), so the waits add up. **`ThenAll`** runs a group of diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 44c5cfd..717ea98 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -1,9 +1,9 @@ --- title: Mokkit -description: Write tests that read like a story in your domain's language — as plain, compilable C#. +description: Write tests that read like a story in your domain's language — as plain, compilable C# or Go. template: splash hero: - tagline: The readability of Cucumber/SpecFlow — without a DSL. Your tests read like a story in your domain's language, but they're ordinary, compilable C# with full IDE and compiler support. + tagline: The readability of Cucumber/SpecFlow — without a DSL. Your tests read like a story in your domain's language, but they're ordinary, compilable C# or Go with full IDE and compiler support. actions: - text: Get started link: /introduction/ @@ -11,14 +11,20 @@ hero: - text: Why Mokkit? link: /why-mokkit/ variant: minimal - - text: GitHub + - text: Mokkit for .NET link: https://github.com/GrafGenerator/Mokkit icon: external variant: minimal + - text: Mokkit for Go + link: https://github.com/GrafGenerator/go-mokkit + icon: external + variant: minimal --- -import { Card, CardGrid } from '@astrojs/starlight/components'; +import { Card, CardGrid, Tabs, TabItem } from '@astrojs/starlight/components'; + + ```csharp await Arrange .NewClient(out var client, WithName("Acme Corporation")) @@ -34,19 +40,44 @@ await Inspect b => b.DbClientExists(id), b => b.EventPublished("clients.created", id)); ``` + + +```go +f.Arrange(). + NewClient[Client](WithName("Acme Corporation")). + CacheHasClient(f.Of[Client]()) + +result := f.Act().Create(f.Of[Client]()) + +f.Inspect(). + Created(result). + All( + apiClientMatches(f, result.ID, "Acme Corporation"), + dbClientExists(f, result.ID), + eventPublished(f, "clients.created", result.ID), + ) +``` + + Tests are composed from **domain verbs** you name yourself — `NewClient`, `CacheHasClient`, `EventPublished`. The AAA (Arrange / Act / Inspect) shape keeps every test telling the same story. - - It's just C#. No Gherkin, no step bindings, no generated glue. Refactor a verb and every test that - uses it updates — and still compiles. + + It's just your language. No Gherkin, no step bindings, no generated glue. Refactor a verb and every + test that uses it updates — and still compiles. + + + A .NET original and a Go port that share the shape, the vocabulary discipline and the docs. Each + speaks its language natively — captures and source generators in C#, tokens and generic methods in + Go — and this site teaches both side by side. - Framework-agnostic (xUnit / NUnit / MSTest / TUnit), mock-agnostic (Moq / NSubstitute / FakeItEasy) and - container-agnostic (Microsoft DI / Autofac / Castle Windsor — or the dependency-free Bag). + Framework-, mock- and container-agnostic: xUnit / NUnit / MSTest / TUnit, Moq / NSubstitute / + FakeItEasy, Microsoft DI / Autofac — or gomock / mockery / minimock, samber-do / dig — or the + dependency-free Bag either side ships. The same Arrange / Act / Inspect vocabulary scales from a mocked unit test to a full diff --git a/docs/src/content/docs/installation.mdx b/docs/src/content/docs/installation.mdx index 65cf578..03b6379 100644 --- a/docs/src/content/docs/installation.mdx +++ b/docs/src/content/docs/installation.mdx @@ -8,19 +8,59 @@ import { Tabs, TabItem } from '@astrojs/starlight/components'; You install **three things**: the core, one **container adapter** for your DI container (or the trivial Bag), and — if your tests use mocks — one **mock adapter** for your mocking library. +## Which language? + +Mokkit ships for **.NET** and for **Go**. The package layout follows the same rule in both: a +dependency-free core, one **container adapter** for how your subject is wired, and — if your tests use +mocks — one **mock adapter** for your mocking library. + + + + +Skip ahead — the rest of this page is the .NET package matrix. + + + + +Requires **Go 1.27+** (the token API is built on generic methods). The core module has **zero +dependencies**; each adapter is its own module, so a suite pays only for what it imports: + +```bash +go get github.com/GrafGenerator/go-mokkit # core: chains, stages, tokens, bag, allure +go get github.com/GrafGenerator/go-mokkit/container/mokkitgomock # go.uber.org/mock +go get github.com/GrafGenerator/go-mokkit/container/mokkitmockery # mockery / testify +go get github.com/GrafGenerator/go-mokkit/container/mokkitminimock # gojuno/minimock +go get github.com/GrafGenerator/go-mokkit/container/mokkitdo # samber/do v2 +go get github.com/GrafGenerator/go-mokkit/container/mokkitdig # uber-go/dig +``` + +| Module | You need it when… | +| --- | --- | +| `go-mokkit` | always — the core, the hand-wired **bag** container, and the Allure reporter | +| `…/mokkitgomock` | your mocks are generated by **mockgen** | +| `…/mokkitmockery` | your mocks are **mockery**-generated testify mocks | +| `…/mokkitminimock` | your mocks are **minimock**-generated | +| `…/mokkitdo`, `…/mokkitdig` | your subject is wired by that DI container | + +One tooling note: build tools that parse Go source — golangci-lint, mockery — must themselves be built +with Go 1.27 or newer, or they will refuse the generic-method syntax. + + + + ## A minimal unit-test setup Core + Microsoft DI + NSubstitute is a common starting point: - + ```bash dotnet add package Mokkit dotnet add package Mokkit.Containers.Microsoft.Extensions.DependencyInjection dotnet add package Mokkit.Containers.NSubstitute ``` - - + + ```xml @@ -28,7 +68,7 @@ dotnet add package Mokkit.Containers.NSubstitute ``` - + The `[MokkitCapture]` source generator ships **inside the `Mokkit` package** (under `analyzers/dotnet/cs`), diff --git a/docs/src/content/docs/introduction.md b/docs/src/content/docs/introduction.mdx similarity index 50% rename from docs/src/content/docs/introduction.md rename to docs/src/content/docs/introduction.mdx index 82971d9..ff447a2 100644 --- a/docs/src/content/docs/introduction.md +++ b/docs/src/content/docs/introduction.mdx @@ -1,10 +1,13 @@ --- title: Introduction -description: What Mokkit is, and the one idea it's built around — tests that read like a story, as plain compilable C#. +description: What Mokkit is, and the one idea it's built around — tests that read like a story, as plain compilable code. --- -Mokkit is a **test-orchestration toolkit for .NET**. It doesn't replace your test framework, your mocking -library, or your DI container — it sits on top of them and gives your tests a shape and a language. +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +Mokkit is a **test-orchestration toolkit for .NET and for Go**. It doesn't replace your test framework, +your mocking library, or your DI container — it sits on top of them and gives your tests a shape and a +language. That language is the whole point. @@ -23,6 +26,8 @@ with the code without the compiler noticing. Mokkit takes the other road. The same scenario, as a Mokkit test: + + ```csharp await Arrange .NewClient(out var client, WithName("Acme Corporation")) @@ -37,11 +42,29 @@ await Inspect .DbClientExists(id) .EventPublished("clients.created", id); ``` + + +```go +f.Arrange(). + NewClient[Client](WithName("Acme Corporation")). + CacheHasClient(f.Of[Client]()) + +result := f.Act().Create(f.Of[Client]()) + +f.Inspect(). + Created(result). + ApiClientMatches(result.ID, "Acme Corporation"). + DbClientExists(result.ID). + EventPublished("clients.created", result.ID) +``` + + It reads like the sentence above — but there is **no DSL**. `NewClient`, `CacheHasClient`, `Created`, -`ApiClientMatches`, `EventPublished` are just C# methods *you* wrote: your project's testing vocabulary. -Because it's plain code, you get everything the compiler and IDE give you — autocomplete, go-to-definition, -rename-refactoring, and the guarantee that a test that doesn't make sense **won't compile**. +`ApiClientMatches`, `EventPublished` are just methods *you* wrote, in your language: your project's testing +vocabulary. Because it's plain code, you get everything the compiler and IDE give you — autocomplete, +go-to-definition, rename-refactoring, and the guarantee that a test that doesn't make sense **won't +compile**. ## The shape: Arrange / Act / Inspect @@ -56,15 +79,34 @@ Each phase is a fluent chain of the verbs you defined. See [Arrange / Act / Inspect](/concepts/aai/) for the mechanics and [Building your test vocabulary](/concepts/vocabulary/) for the part that matters most. +## One idea, two languages + +Mokkit began in C# and was ported to Go, and the port is a translation rather than a transliteration: the +shape, the vocabulary discipline and the composition model carry over, while each side speaks its language +natively. + +| | C# | Go | +| --- | --- | --- | +| Chains | deferred, run by `await` | **eager** — a verb has already run when it returns | +| Artifacts | `out var` captures ([Captures](/concepts/captures/)) | typed **tokens** ([Tokens](/concepts/tokens/)) | +| Interaction asserts | mock library's own, plus `Satisfied()`-style pull-forward | same idea, per adapter | +| Failure phases | Inspect wraps `Assert.Multiple` | Inspect fails soft natively | +| API reference | [DocFX](/api/) | [pkg.go.dev](https://pkg.go.dev/github.com/GrafGenerator/go-mokkit) | + +Pages on this site teach both: shared concepts show the same example in a C#/Go tab pair (your choice +sticks as you navigate), and the few ideas that exist in only one language carry a badge in the sidebar. + ## Agnostic by design Mokkit assumes nothing about the rest of your stack: -- **Test framework** — xUnit, NUnit, MSTest, TUnit; Mokkit is just calls inside your test methods. -- **Mocking** — first-class container packages for **Moq**, **NSubstitute** and **FakeItEasy** (or bring - your own). -- **DI container** — **Microsoft.Extensions.DependencyInjection**, **Autofac**, **Castle Windsor**, or the - dependency-free **Bag** container for tests that just need to hold a few instances. +- **Test framework** — xUnit, NUnit, MSTest, TUnit — or Go's `testing`; Mokkit is just calls inside your + test functions. +- **Mocking** — first-class adapters for **Moq**, **NSubstitute** and **FakeItEasy** in C#; **gomock**, + **mockery/testify** and **minimock** in Go (or bring your own). +- **DI container** — **Microsoft.Extensions.DependencyInjection**, **Autofac**, **Castle Windsor** in C#; + **samber/do** and **uber-go/dig** in Go; or the dependency-free **Bag** container both sides ship for + tests that just need to hold a few instances. The same vocabulary and the same test shape carry from a fast, fully-mocked **unit** test to an **integration** test against a real database, up to a black-box **end-to-end** test that boots your whole diff --git a/docs/src/content/docs/quickstart.md b/docs/src/content/docs/quickstart.md deleted file mode 100644 index 02995e0..0000000 --- a/docs/src/content/docs/quickstart.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -title: Quickstart -description: Write a complete Mokkit test — build a Stage, define a verb, and inspect an outcome — in a few minutes. ---- - -This walks through a full (if tiny) Mokkit test with xUnit and NSubstitute. See -[Installation](/installation/) for the packages. - -## 1. The system under test - -A service with one dependency we'll want to substitute: - -```csharp -public interface IEmailSender -{ - Task SendWelcome(string address); -} - -public sealed class SignupService(IEmailSender email) -{ - public async Task Register(string address) - { - await email.SendWelcome(address); - return Guid.NewGuid(); - } -} -``` - -## 2. Build a Stage - -The **Stage** is where your services live during a test. Here we use the dependency-free **Bag** container -to hold a substitute and the service under test — the same substitute instance goes into both, so we can -drive it *and* verify it: - -```csharp -using Mokkit.Containers.Bag; -using Mokkit.Suite; -using NSubstitute; - -public sealed class SignupTests -{ - private static async Task NewStage() - { - var email = Substitute.For(); - - var setup = await TestStageSetup.Create( - new BagContainerBuilder() - .AddInstance(email) - .AddInstance(new SignupService(email))); - - return setup.EnterStage(); - } -} -``` - -:::tip[The real-world setup] -Hand-wiring with Bag is perfect for a first test. In practice you'll usually resolve the *real* service from -your DI container and let a mock container bridge substitutes into it — see -[Wire a real DI container](/concepts/containers/). The test body below doesn't change. -::: - -## 3. Define a verb (your vocabulary) - -An **Inspect verb** is a C# extension method that observes an outcome. This one reads "a welcome email was -sent to…": - -```csharp -using Mokkit.Inspect; - -public static class SignupVocabulary -{ - public static ITestInspect WelcomeEmailSent(this ITestInspect inspect, string toAddress) => - inspect.Then(host => host.Execute(email => - email.Received(1).SendWelcome(toAddress))); -} -``` - -## 4. Write the test - -Now the test reads as Arrange → Act → Inspect. (This one needs no arrange.) - -```csharp -[Fact] -public async Task Registering_a_user_sends_a_welcome_email() -{ - var stage = await NewStage(); - - // ACT — the Act phase resolves the service, runs the one thing under test, and returns its result. - var id = await stage.Act().Returning(host => - host.ExecuteAsync(service => service.Register("acme@example.com"))); - - // INSPECT — observe the outcome through your vocabulary. - await stage.Inspect() - .WelcomeEmailSent("acme@example.com"); - - Assert.NotEqual(Guid.Empty, id); -} -``` - -:::note[Act is a phase too] -`stage.Act()` starts the Act phase, symmetric with `Arrange` and `Inspect`. `.Returning(...)` is the flavor -that hands a result back; there are also void and capture flavors. In a real project you'd wrap this in an -**Act verb** — `await Act.RegisterUser("acme@example.com")` — exactly like the Inspect verb above. See -[Arrange / Act / Inspect](/concepts/aai/#act). -::: - -## What just happened - -- `TestStageSetup.Create(...)` composed your containers; `EnterStage()` gave you a fresh **Stage** for the - test. -- `stage.Act().Returning(...)` started the **Act** phase, **resolved** the service from the stage, ran it, and - returned its result. -- `stage.Inspect().WelcomeEmailSent(...)` ran your **Inspect** verb, which resolved the same `IEmailSender` - from the stage and verified the call. - -The verb `WelcomeEmailSent` is the seed of your project's **vocabulary**. As you add -`Arrange` verbs (to set up state) and more `Inspect` verbs (to observe it), tests become short, readable -compositions of sentences — with full IDE and compile-time support. - -## Next - -- **[Arrange / Act / Inspect](/concepts/aai/)** — the mechanics of each phase. -- **[Building your test vocabulary](/concepts/vocabulary/)** — the idea Mokkit is built around. diff --git a/docs/src/content/docs/quickstart.mdx b/docs/src/content/docs/quickstart.mdx new file mode 100644 index 0000000..3aa214a --- /dev/null +++ b/docs/src/content/docs/quickstart.mdx @@ -0,0 +1,247 @@ +--- +title: Quickstart +description: Write a complete Mokkit test — build a Stage, define a verb, and inspect an outcome — in a few minutes. +--- + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +This walks through a full (if tiny) Mokkit test — with xUnit and NSubstitute in C#, with `testing` and a +hand-rolled fake in Go. See [Installation](/installation/) for the packages. + +## 1. The system under test + +A service with one dependency we'll want to stand in for: + + + +```csharp +public interface IEmailSender +{ + Task SendWelcome(string address); +} + +public sealed class SignupService(IEmailSender email) +{ + public async Task Register(string address) + { + await email.SendWelcome(address); + return Guid.NewGuid(); + } +} +``` + + +```go +type EmailSender interface { + SendWelcome(ctx context.Context, address string) error +} + +type SignupService struct { + Email EmailSender +} + +func (s *SignupService) Register(ctx context.Context, address string) (uuid.UUID, error) { + if err := s.Email.SendWelcome(ctx, address); err != nil { + return uuid.Nil, err + } + + return uuid.New(), nil +} +``` + + + +## 2. Build a Stage + +The **Stage** is where your services live during a test. Here we use the dependency-free **Bag** container +to hold a double and the service under test — the double is reachable both under its own type (to arrange +and observe it) and behind the interface the subject receives: + + + +```csharp +using Mokkit.Containers.Bag; +using Mokkit.Suite; +using NSubstitute; + +public sealed class SignupTests +{ + private static async Task NewStage() + { + var email = Substitute.For(); + + var setup = await TestStageSetup.Create( + new BagContainerBuilder() + .AddInstance(email) + .AddInstance(new SignupService(email))); + + return setup.EnterStage(); + } +} +``` + + +```go +// fakeEmail records what the subject asked it to send. +type fakeEmail struct { + sent []string +} + +func (f *fakeEmail) SendWelcome(_ context.Context, address string) error { + f.sent = append(f.sent, address) + + return nil +} + +var composition *mokkit.Setup + +func TestMain(m *testing.M) { + b := bag.New() + bag.Scoped(b, func(mokkit.Resolver) *fakeEmail { return &fakeEmail{} }) + bag.Alias[EmailSender, *fakeEmail](b) + bag.Scoped(b, func(r mokkit.Resolver) *SignupService { + return &SignupService{Email: mokkit.Resolve[EmailSender](r)} + }) + + setup, err := mokkit.NewSetup(context.Background(), b) + if err != nil { + panic(err) + } + composition = setup + m.Run() +} +``` + +`bag.Scoped` builds a fresh double per stage, so nothing leaks between tests; `bag.Alias` is what makes +one instance answer under both types. + + + + +:::tip[The real-world setup] +Hand-wiring with Bag is perfect for a first test. In practice you'll usually resolve the *real* service from +your DI container and let a mock container bridge substitutes into it — see +[Wire a real DI container](/concepts/containers/). The test body below doesn't change. +::: + +## 3. Define a verb (your vocabulary) + +An **Inspect verb** observes an outcome. This one reads "a welcome email was sent to…": + + + + +In C#, vocabulary is extension methods: + +```csharp +using Mokkit.Inspect; + +public static class SignupVocabulary +{ + public static ITestInspect WelcomeEmailSent(this ITestInspect inspect, string toAddress) => + inspect.Then(host => host.Execute(email => + email.Received(1).SendWelcome(toAddress))); +} +``` + + + +In Go, vocabulary is methods on your own type embedding `*mokkit.Chain`: + +```go +type Inspect struct{ *mokkit.Chain } + +func (i Inspect) WelcomeEmailSent(toAddress string) Inspect { + i.Helper() + i.Add("WelcomeEmailSent", func(_ context.Context, h mokkit.Host) error { + for _, sent := range h.Resolve[*fakeEmail]().sent { + if sent == toAddress { + return nil + } + } + + return fmt.Errorf("no welcome email was sent to %s", toAddress) + }) + + return i +} +``` + +`i.Helper()` is what keeps a failure pointing at the test's line, and returning the error — rather than +failing directly — is what lets the chain report it under the phase and verb name. + + + + +## 4. Write the test + +Now the test reads as Arrange → Act → Inspect. (This one needs no arrange.) + + + +```csharp +[Fact] +public async Task Registering_a_user_sends_a_welcome_email() +{ + var stage = await NewStage(); + + // ACT — the Act phase resolves the service, runs the one thing under test, and returns its result. + var id = await stage.Act().Returning(host => + host.ExecuteAsync(service => service.Register("acme@example.com"))); + + // INSPECT — observe the outcome through your vocabulary. + await stage.Inspect() + .WelcomeEmailSent("acme@example.com"); + + Assert.NotEqual(Guid.Empty, id); +} +``` + + +```go +func TestRegisteringAUserSendsAWelcomeEmail(t *testing.T) { + stage := composition.EnterStage(t) + + // ACT — chains are eager: the verb has run by the time it returns, so an + // Act verb simply hands back its result. + id := Act{stage.Act()}.Register("acme@example.com") + + // INSPECT — observe the outcome through your vocabulary. + Inspect{stage.Inspect()}. + WelcomeEmailSent("acme@example.com") + + if id == uuid.Nil { + t.Error("want a generated id") + } +} +``` + +In a real suite a small **fixture** wraps `EnterStage` and hands out `f.Arrange()` / `f.Act()` / +`f.Inspect()` — see [Building your test vocabulary](/concepts/vocabulary/). + + + + +:::note[Act is a phase too] +`stage.Act()` starts the Act phase, symmetric with `Arrange` and `Inspect`. `.Returning(...)` is the flavor +that hands a result back; there are also void and capture flavors. In a real project you'd wrap this in an +**Act verb** — `await Act.RegisterUser("acme@example.com")` — exactly like the Inspect verb above. See +[Arrange / Act / Inspect](/concepts/aai/#act). +::: + +## What just happened + +- Composing (`TestStageSetup.Create(...)` / `mokkit.NewSetup(...)`) built your containers once; + entering a stage gave the test a fresh, isolated world. +- The **Act** phase resolved the service from the stage, ran the one thing under test, and handed back + its result. +- Your **Inspect** verb resolved the same double from the stage and observed the call. + +The verb `WelcomeEmailSent` is the seed of your project's **vocabulary**. As you add +`Arrange` verbs (to set up state) and more `Inspect` verbs (to observe it), tests become short, readable +compositions of sentences — with full IDE and compile-time support. + +## Next + +- **[Arrange / Act / Inspect](/concepts/aai/)** — the mechanics of each phase. +- **[Building your test vocabulary](/concepts/vocabulary/)** — the idea Mokkit is built around. diff --git a/docs/src/content/docs/reference/conventions.md b/docs/src/content/docs/reference/conventions.md index 5f24653..6e51561 100644 --- a/docs/src/content/docs/reference/conventions.md +++ b/docs/src/content/docs/reference/conventions.md @@ -60,3 +60,25 @@ The short version of the [project structure](/reference/project-structure/) and - Depend on `IDateTimeProvider` / `IIdGenerator`, not `DateTime.UtcNow` / `Guid.NewGuid()`. - Arrange them with `Clock(…)` / `Ids(…)` and shared fixed constants. + +## Go: what changes on the cheat-sheet + +Everything above holds in spirit; these lines replace their C# counterparts: + +- **Vocabulary** — verbs are methods on your own `Arrange` / `Act` / `Inspect` types embedding + `*mokkit.Chain`. First line of every verb: `a.Helper()`. Verbs report by **returning an error**, never + by failing the test — hand assertion libraries `c.TB()`, not the chain. +- **File placement (strict)** — `fixture_test.go` (no verbs) · `arrange_test.go` · `act_test.go` · + `inspect_test.go` · `_test.go` (tests only). A verb in a scenario file stops the vocabulary + compounding. +- **Atomic verbs** — one named condition per verb, so a refusal test differs from the success path by + exactly one verb; a verb that needs an earlier one says so in its error. +- **Artifacts** — no captures. Named roles are [tokens](/concepts/tokens/): `f.New[K]()` to produce, + `f.Of[K]()` to read (a value; prefer it), `f.Ref[K]()` when the artifact has identity. A one-off + artifact is just returned by the verb and bound with `:=`. +- **Stage & fixtures** — compose in `TestMain`, never `init`; `EnterStage(t)` registers its own cleanup. + The fixture embeds `stage.Tokens()`. +- **Failure semantics** — Arrange/Act fail hard, Inspect fails soft; `All(...)` for concurrent + observations, `mokkit.Group` for multi-step branches. +- **Interaction asserts** — stub broadly in Arrange (`AnyTimes`-style), assert interactions in Inspect — + via a captured value or the adapter's `Satisfied()` — so failures land on the test's line. diff --git a/docs/src/content/docs/reference/project-structure.md b/docs/src/content/docs/reference/project-structure.md index 494c6bf..391fd63 100644 --- a/docs/src/content/docs/reference/project-structure.md +++ b/docs/src/content/docs/reference/project-structure.md @@ -111,3 +111,27 @@ is unchanged. Same Mokkit primitives throughout — only the surrounding stack c - **[Conventions cheat-sheet](/reference/conventions/)** — the one-screen version of this. - **[Building your test vocabulary](/concepts/vocabulary/)** — what fills the `Arrange*`/`Act*`/`Inspect*` files. + +## The Go layout + +One test package per system-under-test, colocated with it; the suite files follow the strict placement +rule from the [conventions](/reference/conventions/): + +``` +internal/billing/ + service.go + service_test.go # tests only + fixture_test.go # TestMain, composition, fixture, tokens — no verbs + arrange_test.go # Arrange verbs + act_test.go # Act verbs + inspect_test.go # Inspect verbs + plain-function Steps + fakes_test.go # hand doubles, when not generated +tests/ + harness/ # a real package: containers, migrations, stack bring-up + integration// # suites over real infrastructure + e2e/ # the whole application, driven over HTTP +``` + +Two Go-specific notes: the shared **harness** is a normal (non-`_test`) package so several suites can use +it, and generated mocks live next to the interface they double (`mocks_test.go` in the package, or an +`internal/` fixture package inside the adapter that owns them). diff --git a/docs/src/content/docs/why-mokkit.md b/docs/src/content/docs/why-mokkit.md index f4c063b..ca724fb 100644 --- a/docs/src/content/docs/why-mokkit.md +++ b/docs/src/content/docs/why-mokkit.md @@ -36,7 +36,7 @@ So you carry a second language and a runtime binding layer, and you inherit thei ## Mokkit's answer: it's just code -Mokkit keeps the readable, sentence-like scenario but drops the separate language. The "steps" are C# +Mokkit keeps the readable, sentence-like scenario but drops the separate language. The "steps" are C# or Go extension methods you author — your **domain vocabulary** — and a test simply composes them: ```csharp @@ -50,7 +50,7 @@ Because a Mokkit test is ordinary code, the trade-offs invert: | | BDD / DSL (Cucumber, SpecFlow) | Mokkit | | --- | --- | --- | -| Readability | ✅ Gherkin prose | ✅ Sentence-like C# | +| Readability | ✅ Gherkin prose | ✅ Sentence-like C# / Go | | Separate language to learn | Yes (Gherkin + bindings) | **No** | | Steps bound at | Runtime (regex/strings) | **Compile time** | | IDE navigation / rename / find-usages | Limited | **Full** | From f25988d0388d1ad39320d3892b1f4bb0440217eb Mon Sep 17 00:00:00 2001 From: Nikita Ivanov Date: Sat, 5 Sep 2026 12:56:40 +0700 Subject: [PATCH 2/3] state the Go docs plainly and replace the report sample with the clients example Co-Authored-By: Claude Fable 5 --- docs/src/content/docs/concepts/aai.mdx | 3 +-- docs/src/content/docs/concepts/tokens.md | 20 ++++++------------- docs/src/content/docs/concepts/vocabulary.mdx | 15 ++++++-------- .../content/docs/guides/reporting-allure.md | 16 +++++++-------- docs/src/content/docs/quickstart.mdx | 3 +-- 5 files changed, 22 insertions(+), 35 deletions(-) diff --git a/docs/src/content/docs/concepts/aai.mdx b/docs/src/content/docs/concepts/aai.mdx index 4d38edd..72cc259 100644 --- a/docs/src/content/docs/concepts/aai.mdx +++ b/docs/src/content/docs/concepts/aai.mdx @@ -13,8 +13,7 @@ phase renamed **Inspect** to make its one rule explicit: it only *observes*. The two languages run their chains differently, and most other differences follow from this one: - **C# chains are deferred.** `.Then(...)` records a step; nothing happens until you `await` the chain. - Deferral is what makes captures necessary — an arrange hands back a *placeholder* for a value that - doesn't exist yet. + An arrange hands back a *capture* — a placeholder for a value that doesn't exist yet. - **Go chains are eager.** By the time a verb returns, its step has already run. There is no terminal call, no placeholder to hold, and an Act verb can simply *return* its result. Artifacts travel through [tokens](/concepts/tokens/) instead of captures. diff --git a/docs/src/content/docs/concepts/tokens.md b/docs/src/content/docs/concepts/tokens.md index d6051e0..abfb020 100644 --- a/docs/src/content/docs/concepts/tokens.md +++ b/docs/src/content/docs/concepts/tokens.md @@ -3,12 +3,8 @@ title: "Tokens: roles as types" description: How Go Mokkit tests declare, produce and read artifacts — a typed token instead of a capture, checked by the compiler. --- -Go Mokkit has no captures — eager chains delete the placeholder they existed to make safe. What remains -is the real question captures answered: **how does an artifact travel between phases** without a `var` -declared above the test, and without a stringly-typed lookup? - -The answer is a **token**: a type that names a role, and declares in the same line what that role stands -for. +Go Mokkit has no captures. Artifacts travel between phases through a **token**: a type that names a +role, and declares in the same line what that role stands for. ```go type ( @@ -38,9 +34,8 @@ discount := f.Act().DiscountFor[Cart]() | `f.Of[Buyer]()` | read | `User` — a value | loudly, if no verb produced the role | | `f.Ref[Buyer]()` | read | `*User` — the pointer | loudly, if no verb produced the role | -**Prefer `Of`.** A value cannot be written through by accident, which keeps a read-only phase read-only. -Reach for `Ref` when the artifact has *identity* — a recording double whose state the Act mutates and a -later Inspect observes; a copy there would silently assert on stale state. +Use `Ref` when the artifact has *identity* — a recording double whose state the Act mutates and a later +Inspect observes. Everywhere else use `Of`. Reading a role nobody produced fails at the test's line, naming what *was* arranged: @@ -50,8 +45,6 @@ discount_test.go:23: mokkit: nothing arranged for main_test.Ghost (have: main_te ## What the compiler checks -This is where tokens beat both `out var` captures and any string-keyed registry: - - A **misspelt role** is `undefined: Byer` — a build error. - A **role of the wrong kind** is a build error too: a verb declared `func (a Arrange) UserExists[K mokkit.Token[User]](...)` will not accept `Cart`, because `Cart`'s @@ -91,9 +84,8 @@ client := f.Arrange().AClient(WithName("Acme")) result := f.Act().GetClient(client.ID) ``` -Nothing declared above, no pointer, and go-to-definition lands on the verb that made it. The cost: such a -verb is terminal — its return type ends the chain — which is exactly why the token form exists for tests -with more than one actor. A suite mixes both freely. +Such a verb is terminal — its return type ends the chain — so use the token form for tests with more +than one actor. A suite mixes both freely. Tokens are static by nature: a role is a type, so it cannot be picked at run time. A table-driven loop over "roles" is what the return form is for — bind the artifact to the loop variable. diff --git a/docs/src/content/docs/concepts/vocabulary.mdx b/docs/src/content/docs/concepts/vocabulary.mdx index 0f57e63..3fef690 100644 --- a/docs/src/content/docs/concepts/vocabulary.mdx +++ b/docs/src/content/docs/concepts/vocabulary.mdx @@ -217,8 +217,8 @@ func (i Inspect) EventPublished(topic, clientID string) Inspect { } ``` -A verb's first line is `i.Helper()` — that is what makes a failure report the test's line rather than -the verb's body — and it reports by returning an error, never by failing the test directly. +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. @@ -242,8 +242,7 @@ in seconds. ## 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.cs` / `Inspect.cs` files per feature; in Go the convention has teeth -worth stating: +colocated `Arrange.cs` / `Inspect.cs` files per feature; in Go: ``` fixture_test.go composition, tokens, the fixture. No verbs. @@ -253,9 +252,8 @@ inspect_test.go Inspect verbs, and the plain-function Steps And/All take _test.go tests — and nothing else ``` -A verb defined in a scenario file is invisible: the next person writes a second one beside it instead of -reusing the first, and the vocabulary stops compounding. And keep verbs **atomic** — one named condition -each — so a refusal-path test differs from the success path by exactly one verb. The +Keep verbs **atomic** — one named condition each — so a refusal-path test differs from the success +path by exactly one verb. The [project structure](/reference/project-structure/) page shows both layouts; the [guides](/quickstart/) build real vocabulary for mocked services, databases, message queues and full end-to-end flows. @@ -268,7 +266,6 @@ noise: - **`Ensure`** (C#) — derive, guard-as-non-empty, and capture a value in one step, so ids flow cleanly between verbs. In Go, [tokens](/concepts/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. Go struct literals with option funcs are already that terse, so the Go port - deliberately has no generator. + object" arrange verb. The Go port has no generator; struct literals with option funcs fill the role. Each is covered in its own guide. diff --git a/docs/src/content/docs/guides/reporting-allure.md b/docs/src/content/docs/guides/reporting-allure.md index f1548fb..4e99283 100644 --- a/docs/src/content/docs/guides/reporting-allure.md +++ b/docs/src/content/docs/guides/reporting-allure.md @@ -24,7 +24,7 @@ func TestMain(m *testing.M) { var reporter *allure.Reporter if dir := os.Getenv("ALLURE_OUTPUT_PATH"); dir != "" { - if reporter, err = allure.New(dir, allure.WithSuite("cards e2e")); err != nil { + if reporter, err = allure.New(dir, allure.WithSuite("clients e2e")); err != nil { panic(err) } setup.Observe(reporter) @@ -52,20 +52,20 @@ One file per test, whose steps are the test's own vocabulary, with real timings: ```json { - "name": "TestOrange_AnAllowedPartyActivatesAndDrawsAVirtualCard", + "name": "TestCreateClient_PublishesTheCreatedEvent", "status": "passed", "steps": [ - { "name": "arrange: AnOrangeCategory[Orange]", "status": "passed" }, - { "name": "arrange: AFreeVirtualCardInThePool[Orange]", "status": "passed" }, - { "name": "act: RegisterCard[Orange]", "status": "passed" }, - { "name": "inspect: theOutboxPayloadMentions", "status": "passed" } + { "name": "arrange: NewClient[Client]", "status": "passed" }, + { "name": "arrange: CacheHasClient[Client]", "status": "passed" }, + { "name": "act: CreateClient[Client]", "status": "passed" }, + { "name": "inspect: EventPublished", "status": "passed" } ] } ``` A step that failed carries its message and trace; a step that **panicked** is reported as `broken` rather -than `failed`, so triage can tell an assertion from a crash. `historyId` is stable across runs (it hashes -the suite and test name), which is what lets TestOps track history and retries. +than `failed`. `historyId` is stable across runs — it hashes the suite and test name — so TestOps can +track history and retries. ## The observer seam itself diff --git a/docs/src/content/docs/quickstart.mdx b/docs/src/content/docs/quickstart.mdx index 3aa214a..30836b6 100644 --- a/docs/src/content/docs/quickstart.mdx +++ b/docs/src/content/docs/quickstart.mdx @@ -112,8 +112,7 @@ func TestMain(m *testing.M) { } ``` -`bag.Scoped` builds a fresh double per stage, so nothing leaks between tests; `bag.Alias` is what makes -one instance answer under both types. +`bag.Scoped` builds a fresh double per stage; `bag.Alias` makes one instance answer under both types. From 5855b0ef0bf0702c1b16db1b10dcd54564905232 Mon Sep 17 00:00:00 2001 From: Nikita Ivanov Date: Sun, 6 Sep 2026 22:27:38 +0700 Subject: [PATCH 3/3] DEV: update the Go docs to v0.3: Do, Get, Try, Attempt, Fixture and Enter, the proportional file layout --- docs/src/content/docs/concepts/aai.mdx | 14 ++--- docs/src/content/docs/concepts/stage.mdx | 26 +++++---- docs/src/content/docs/concepts/tokens.md | 11 ++-- docs/src/content/docs/concepts/vocabulary.mdx | 57 ++++++++++--------- docs/src/content/docs/quickstart.mdx | 48 +++++++++------- .../src/content/docs/reference/conventions.md | 16 +++--- .../docs/reference/project-structure.md | 11 ++-- 7 files changed, 99 insertions(+), 84 deletions(-) diff --git a/docs/src/content/docs/concepts/aai.mdx b/docs/src/content/docs/concepts/aai.mdx index 72cc259..5409eb8 100644 --- a/docs/src/content/docs/concepts/aai.mdx +++ b/docs/src/content/docs/concepts/aai.mdx @@ -111,22 +111,18 @@ type and simply hands the result back: func (a Act) SaveClient(cmd SaveCommand) SaveResult { a.Helper() - var out SaveResult - a.Add("SaveClient", func(ctx context.Context, h mokkit.Host) error { - var err error - out, err = h.Resolve[*SaveClientHandler]().Handle(ctx, cmd) - - return err + return a.Get(func(h mokkit.Host) (SaveResult, error) { + return h.Resolve[*SaveClientHandler]().Handle(h.Context(), cmd) }) - - return out } // in the test: result := f.Act().SaveClient(cmd) ``` -Inside a step, `h.Resolve[T]()` pulls any collaborator from the stage. Act fails hard, like Arrange. +Inside a step, `h.Resolve[T]()` pulls any collaborator from the stage. Act fails hard, like Arrange: +`Get` fails the test on an error. A test about a refusal wants the error as its artifact, which is what +`Try` hands back as an `Outcome[T]`, and `Attempt` for an operation that returns only an error. diff --git a/docs/src/content/docs/concepts/stage.mdx b/docs/src/content/docs/concepts/stage.mdx index 7193166..152137f 100644 --- a/docs/src/content/docs/concepts/stage.mdx +++ b/docs/src/content/docs/concepts/stage.mdx @@ -39,11 +39,14 @@ stage.Dispose(); setup, err := mokkit.NewSetup(context.Background(), mocks, app) // Per test. Cleanup is registered with t automatically. -stage := setup.EnterStage(t) +f := setup.Enter[Arrange, Act, Inspect](t) + +// Or, when the subject is cheap to build, compose and enter per test in one call. +f := mokkit.Enter[Arrange, Act, Inspect](t, mocks, app) ``` -`EnterStage(t)` registers the stage's release with `t.Cleanup`, so there is nothing to dispose by hand; a -scoped service that implements `io.Closer` is closed when the stage ends. +`Enter` opens a stage, registers its release with `t.Cleanup`, and hands back a `mokkit.Fixture` typed +with the suite's phases; a scoped service that implements `io.Closer` is closed when the stage ends. @@ -69,17 +72,18 @@ stage.ExecuteAsync(svc => ...); // resolve, run, return a resu ```go -stage.Arrange() // *mokkit.Chain — fails hard -stage.Act() // *mokkit.Chain — fails hard -stage.Inspect() // *mokkit.Chain — fails soft +f.Arrange() // the suite's Arrange — fails hard +f.Act() // the suite's Act — fails hard +f.Inspect() // the suite's Inspect — fails soft -mokkit.Resolve[*SignupService](stage) // resolve straight off the stage -stage.Tokens() // the per-test artifact registry +f.Of[Buyer]() // the per-test artifact registry, promoted onto the fixture +mokkit.Resolve[*SignupService](f.Stage) // resolve straight off the stage ``` -Inside a step, `h.Resolve[T]()` does the resolving; a small per-suite **fixture** embeds -`stage.Tokens()` so tests read `f.Of[Buyer]()`. Stages can also be observed — every step, with phase, -name, duration and outcome — which is how the [Allure reporter](/guides/reporting-allure/) works. +`mokkit.Fixture[A, C, I]` is generic over the three phase types, so the phases come back as the suite's +own vocabulary; inside a step, `h.Resolve[T]()` does the resolving. Stages can also be observed — +every step, with phase, name, duration and outcome — which is how the +[Allure reporter](/guides/reporting-allure/) works. diff --git a/docs/src/content/docs/concepts/tokens.md b/docs/src/content/docs/concepts/tokens.md index abfb020..15124b7 100644 --- a/docs/src/content/docs/concepts/tokens.md +++ b/docs/src/content/docs/concepts/tokens.md @@ -50,8 +50,8 @@ discount_test.go:23: mokkit: nothing arranged for main_test.Ghost (have: main_te `func (a Arrange) UserExists[K mokkit.Token[User]](...)` will not accept `Cart`, because `Cart`'s token declares an `Order`. The role/artifact pairing is enforced by the constraint, not remembered by the reader. -- The role lands in the **step label** — `arrange: UserExists[Buyer]` — via `mokkit.NameOf[K]()`, so a - failure names the actor it was acting for. +- The role lands in the **step label** — `arrange: UserExists[Buyer]` — through the `For` form of the + step runners (`DoFor[K]`, `GetFor[K]`, `TryFor[K]`), so a failure names the actor it was acting for. ## Verbs generic over the token @@ -61,15 +61,12 @@ chain never breaks to get an artifact out: ```go 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 { + + return mokkit.DoFor[K](a, func(h mokkit.Host) { u := newUser(mokkit.NameOf[K](), s) *a.New[K]() = u h.Resolve[*fakeUsers]().add(u) - - return nil }) - - return a } ``` diff --git a/docs/src/content/docs/concepts/vocabulary.mdx b/docs/src/content/docs/concepts/vocabulary.mdx index 3fef690..a83b383 100644 --- a/docs/src/content/docs/concepts/vocabulary.mdx +++ b/docs/src/content/docs/concepts/vocabulary.mdx @@ -87,8 +87,9 @@ fills it. See [Capture vs Trapture](/concepts/captures/). ```go func (a Arrange) NewClient[K mokkit.Token[Client]](fields ...ClientField) Arrange { a.Helper() - a.Add("NewClient["+mokkit.NameOf[K]()+"]", func(ctx context.Context, h mokkit.Host) error { - result, err := createClient(ctx, h.Resolve[*http.Client](), build(fields...)) + + 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 } @@ -97,14 +98,12 @@ func (a Arrange) NewClient[K mokkit.Token[Client]](fields ...ClientField) Arrang return nil }) - - return a } ``` 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 the step label carries the -role. See [Tokens](/concepts/tokens/). +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](/concepts/tokens/). @@ -149,31 +148,35 @@ public static ITestAct ProduceStatusChanged(this ITestAct act, Guid clientId, St ```go -// Return — eager chains mean the verb simply returns its result. +// 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() - var out WriteResult - a.Add("UpdateClient", func(ctx context.Context, h mokkit.Host) error { - var err error - out, err = updateClient(ctx, h.Resolve[*http.Client](), id, build(fields...)) - - return err + return a.Get(func(h mokkit.Host) (WriteResult, error) { + return updateClient(h.Context(), h.Resolve[*http.Client](), id, build(fields...)) }) +} - return out +// 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() - a.Add("ProduceStatusChanged", func(ctx context.Context, h mokkit.Host) error { - return h.Resolve[Producer]().Produce(ctx, "clients.status-changed", serialize(id, msg)) - }) - return a + 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. @@ -205,20 +208,20 @@ public static ITestInspect EventPublished(this ITestInspect inspect, string topi ```go func (i Inspect) EventPublished(topic, clientID string) Inspect { i.Helper() - i.Add("EventPublished", func(ctx context.Context, h mokkit.Host) error { + + 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 }) - - return i } ``` 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. +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. @@ -245,13 +248,15 @@ Keep vocabulary next to what it describes, and out of the files where the tests colocated `Arrange.cs` / `Inspect.cs` files per feature; in Go: ``` -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/All take +fixture_test.go composition and the fixture. No verbs. +vocabulary_test.go the verbs, in Arrange, Act and Inspect sections _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](/reference/project-structure/) page shows both layouts; the [guides](/quickstart/) diff --git a/docs/src/content/docs/quickstart.mdx b/docs/src/content/docs/quickstart.mdx index 30836b6..07dd8df 100644 --- a/docs/src/content/docs/quickstart.mdx +++ b/docs/src/content/docs/quickstart.mdx @@ -97,7 +97,7 @@ var composition *mokkit.Setup func TestMain(m *testing.M) { b := bag.New() - bag.Scoped(b, func(mokkit.Resolver) *fakeEmail { return &fakeEmail{} }) + bag.Fresh[fakeEmail](b) bag.Alias[EmailSender, *fakeEmail](b) bag.Scoped(b, func(r mokkit.Resolver) *SignupService { return &SignupService{Email: mokkit.Resolve[EmailSender](r)} @@ -112,7 +112,7 @@ func TestMain(m *testing.M) { } ``` -`bag.Scoped` builds a fresh double per stage; `bag.Alias` makes one instance answer under both types. +`bag.Fresh` builds a zero-value double per stage; `bag.Alias` makes one instance answer under both types. @@ -145,29 +145,39 @@ public static class SignupVocabulary -In Go, vocabulary is methods on your own type embedding `*mokkit.Chain`: +In Go, vocabulary is methods on your own types embedding `*mokkit.Chain`. `mokkit.Do` runs the step and +hands the type back; an Act verb returns its result through `Get`: ```go -type Inspect struct{ *mokkit.Chain } +type ( + Arrange struct{ *mokkit.Chain } + Act struct{ *mokkit.Chain } + Inspect struct{ *mokkit.Chain } +) + +func (a Act) Register(address string) uuid.UUID { + a.Helper() + + return a.Get(func(h mokkit.Host) (uuid.UUID, error) { + return h.Resolve[*SignupService]().Register(h.Context(), address) + }) +} func (i Inspect) WelcomeEmailSent(toAddress string) Inspect { i.Helper() - i.Add("WelcomeEmailSent", func(_ context.Context, h mokkit.Host) error { - for _, sent := range h.Resolve[*fakeEmail]().sent { - if sent == toAddress { - return nil - } + + return mokkit.Do(i, func(h mokkit.Host) error { + if slices.Contains(h.Resolve[*fakeEmail]().sent, toAddress) { + return nil } return fmt.Errorf("no welcome email was sent to %s", toAddress) }) - - return i } ``` -`i.Helper()` is what keeps a failure pointing at the test's line, and returning the error — rather than -failing directly — is what lets the chain report it under the phase and verb name. +`i.Helper()` keeps a failure pointing at the test's line; the step is named after the verb, so the +failure reads `inspect: WelcomeEmailSent: no welcome email was sent to …`. @@ -199,15 +209,14 @@ public async Task Registering_a_user_sends_a_welcome_email() ```go func TestRegisteringAUserSendsAWelcomeEmail(t *testing.T) { - stage := composition.EnterStage(t) + f := composition.Enter[Arrange, Act, Inspect](t) // ACT — chains are eager: the verb has run by the time it returns, so an // Act verb simply hands back its result. - id := Act{stage.Act()}.Register("acme@example.com") + id := f.Act().Register("acme@example.com") // INSPECT — observe the outcome through your vocabulary. - Inspect{stage.Inspect()}. - WelcomeEmailSent("acme@example.com") + f.Inspect().WelcomeEmailSent("acme@example.com") if id == uuid.Nil { t.Error("want a generated id") @@ -215,8 +224,9 @@ func TestRegisteringAUserSendsAWelcomeEmail(t *testing.T) { } ``` -In a real suite a small **fixture** wraps `EnterStage` and hands out `f.Arrange()` / `f.Act()` / -`f.Inspect()` — see [Building your test vocabulary](/concepts/vocabulary/). +`Enter` opens a stage for the test and hands back a **fixture** typed with the suite's own phases, so +the body reads `f.Arrange()` / `f.Act()` / `f.Inspect()`. A suite usually wraps that one line in a +`newFixture(t)` — see [Building your test vocabulary](/concepts/vocabulary/). diff --git a/docs/src/content/docs/reference/conventions.md b/docs/src/content/docs/reference/conventions.md index 6e51561..a3aadb2 100644 --- a/docs/src/content/docs/reference/conventions.md +++ b/docs/src/content/docs/reference/conventions.md @@ -66,18 +66,20 @@ The short version of the [project structure](/reference/project-structure/) and Everything above holds in spirit; these lines replace their C# counterparts: - **Vocabulary** — verbs are methods on your own `Arrange` / `Act` / `Inspect` types embedding - `*mokkit.Chain`. First line of every verb: `a.Helper()`. Verbs report by **returning an error**, never - by failing the test — hand assertion libraries `c.TB()`, not the chain. -- **File placement (strict)** — `fixture_test.go` (no verbs) · `arrange_test.go` · `act_test.go` · - `inspect_test.go` · `_test.go` (tests only). A verb in a scenario file stops the vocabulary - compounding. + `*mokkit.Chain`. First line of every verb: `a.Helper()`; then one `return mokkit.Do(a, …)`, or + `a.Get(…)` / `a.Try(…)` / `a.Attempt(…)` for an act. Steps are named after the verb; the `For` forms + append a role, the `As` forms take a name. Verbs report by **returning an error**, never by failing the + test — hand assertion libraries `c.TB()`, not the chain. +- **File placement** — `fixture_test.go` (no verbs) · `vocabulary_test.go` · `_test.go` (tests + only); split the vocabulary by phase when it grows past a few hundred lines. A verb in a scenario file + stops the vocabulary compounding. - **Atomic verbs** — one named condition per verb, so a refusal test differs from the success path by exactly one verb; a verb that needs an earlier one says so in its error. - **Artifacts** — no captures. Named roles are [tokens](/concepts/tokens/): `f.New[K]()` to produce, `f.Of[K]()` to read (a value; prefer it), `f.Ref[K]()` when the artifact has identity. A one-off artifact is just returned by the verb and bound with `:=`. -- **Stage & fixtures** — compose in `TestMain`, never `init`; `EnterStage(t)` registers its own cleanup. - The fixture embeds `stage.Tokens()`. +- **Stage & fixtures** — compose in `TestMain`, never `init`; `Enter[Arrange, Act, Inspect](t)` + registers its own cleanup and hands back `mokkit.Fixture`, with `Of`/`New`/`Ref` promoted onto it. - **Failure semantics** — Arrange/Act fail hard, Inspect fails soft; `All(...)` for concurrent observations, `mokkit.Group` for multi-step branches. - **Interaction asserts** — stub broadly in Arrange (`AnyTimes`-style), assert interactions in Inspect — diff --git a/docs/src/content/docs/reference/project-structure.md b/docs/src/content/docs/reference/project-structure.md index 391fd63..ed2aaa1 100644 --- a/docs/src/content/docs/reference/project-structure.md +++ b/docs/src/content/docs/reference/project-structure.md @@ -114,17 +114,15 @@ is unchanged. Same Mokkit primitives throughout — only the surrounding stack c ## The Go layout -One test package per system-under-test, colocated with it; the suite files follow the strict placement -rule from the [conventions](/reference/conventions/): +One test package per system-under-test, colocated with it; the suite files follow the placement rule +from the [conventions](/reference/conventions/): ``` internal/billing/ service.go service_test.go # tests only fixture_test.go # TestMain, composition, fixture, tokens — no verbs - arrange_test.go # Arrange verbs - act_test.go # Act verbs - inspect_test.go # Inspect verbs + plain-function Steps + vocabulary_test.go # the verbs, in Arrange, Act and Inspect sections fakes_test.go # hand doubles, when not generated tests/ harness/ # a real package: containers, migrations, stack bring-up @@ -132,6 +130,9 @@ tests/ e2e/ # the whole application, driven over HTTP ``` +The vocabulary splits by phase (`arrange_test.go`, `act_test.go`, `inspect_test.go`) once it is a few +hundred lines, and a suite of a handful of tests may keep fixture and vocabulary in one `suite_test.go`. + Two Go-specific notes: the shared **harness** is a normal (non-`_test`) package so several suites can use it, and generated mocks live next to the interface they double (`mocks_test.go` in the package, or an `internal/` fixture package inside the adapter that owns them).