From 8d72d3b6966b8e2218d68481de03fd79239e41cc Mon Sep 17 00:00:00 2001
From: Nikita Ivanov
Date: Sat, 15 Aug 2026 18:23:33 +0700
Subject: [PATCH 1/4] update docs with new Ensure/Capture API
---
docs/src/content/docs/concepts/captures.md | 49 ++++++++++++++++++-
docs/src/content/docs/guides/ensure.md | 47 ++++++++++++++++--
docs/src/content/docs/guides/kafka.md | 2 +-
.../docs/guides/unit-mocked-dependency.md | 8 +--
4 files changed, 94 insertions(+), 12 deletions(-)
diff --git a/docs/src/content/docs/concepts/captures.md b/docs/src/content/docs/concepts/captures.md
index 091d570..f841a0c 100644
--- a/docs/src/content/docs/concepts/captures.md
+++ b/docs/src/content/docs/concepts/captures.md
@@ -49,6 +49,50 @@ await Inspect.ApiClient(clientId, c => c.Name.ShouldBe("Acme Holdings"));
Reach for `Capture` when you *want* the read to stand out — for instance a result whose `.Value` you unpack
and assert on deliberately.
+## Guarded reads: `EnsureValue` and `Prop`
+
+`Value` is nullable, because a capture legitimately starts out empty. Read it before the chain has filled it
+and you get a `NullReferenceException` from somewhere deep in the test — so in practice a null-forgiving
+operator ends up in every read:
+
+```csharp
+await StoreClient(client.Value!); // the ! is load-bearing and unchecked
+var result = await GetClient(client.Value!.Id);
+```
+
+`ICapture` offers guarded versions of both — so they work on `Capture` and `Trapture` alike:
+
+```csharp
+await StoreClient(client.EnsureValue); // the whole value
+var result = await GetClient(client.Prop(c => c.Id)); // a member off it
+```
+
+`EnsureValue` is the capture-level member of the [`Ensure`](/guides/ensure/) family and uses that family's
+definition of "empty": an unfilled capture, or one holding `null` / `""` / `0` / `Guid.Empty` / an empty
+collection, fails loudly right at the read. That's what lets it catch an unfilled **value-type** capture too,
+where there is no `null` to check.
+
+`Prop` is exactly `propFn(EnsureValue)`: the *capture* is guarded, and the projected member comes back as-is,
+so a nullable member may still be `null`. It nests as far as you like — `message.Prop(m => m.Message.Value)`.
+
+Reach for `Prop` on a single member read, and `EnsureValue` when a step needs the whole artifact or several
+members off it:
+
+```csharp
+// One guarded read, then plain member access — better than five separate Prop calls.
+var expected = message.EnsureValue;
+
+handler.Received(1).Handle(Arg.Is(c =>
+ c.ClientData.Id == expected.ClientId &&
+ c.ClientData.Name == expected.Name &&
+ c.ClientData.Email == expected.Email), Arg.Any());
+```
+
+:::note
+`Value` stays useful where empty is a legitimate outcome — snapshotting a `Capture` that models
+"not found", for instance. Use it deliberately there; use the guarded pair everywhere else.
+:::
+
## Producing a capture from a verb
An Arrange (or Act) verb that creates an artifact starts a capture, then sets it inside the deferred step:
@@ -69,8 +113,9 @@ public static ITestArrange NewClient(
}
```
-The interfaces behind this are small: `ICapture` exposes `Value`; `ICaptureInitializer` exposes
-`Set(T)`. A verb takes the *initializer* to write and hands back the *capture* to read.
+The interfaces behind this are small: `ICapture` exposes `Value`, `EnsureValue` and `Prop`;
+`ICaptureInitializer` exposes `Set(T)`. A verb takes the *initializer* to write and hands back the
+*capture* to read.
## Deriving one value from another: `Ensure`
diff --git a/docs/src/content/docs/guides/ensure.md b/docs/src/content/docs/guides/ensure.md
index 015c4d3..532607a 100644
--- a/docs/src/content/docs/guides/ensure.md
+++ b/docs/src/content/docs/guides/ensure.md
@@ -24,6 +24,41 @@ await Inspect
a clear failure instead of letting a bogus value flow onward. There's also a direct form when you already hold
the value — `.Ensure(someValue, out var captured)`.
+The selector form comes in three shapes, picked by what you project:
+
+| Source | Selector | Use for |
+| --- | --- | --- |
+| a plain object | `r => r.ClientId` (`Guid?`) | unwrapping a nullable **struct** member |
+| a plain object | `r => r.Name` (`string?`) | guarding a nullable **reference** member |
+| a [capture](/concepts/captures/) | `c => c.Id` (anything) | projecting off a capture Arrange already filled |
+
+The capture-shaped form is the one to reach for when the same id is read several times in one chain — guard it
+once, then hand the plain value to every step that follows:
+
+```csharp
+await Inspect
+ .Ensure(seeded, c => c.Id, out var clientId) // one guarded read ...
+ .GetResult(result).Found(clientId) // ... reused from here on
+ .CacheUpdated(clientId);
+```
+
+It reports an uninitialized capture as its own failure, rather than as a merely "empty" value. For a **single**
+read, [`Prop`](/concepts/captures/) is lighter: `.CacheQueried(client.Prop(c => c.Id))`.
+
+## On the capture itself
+
+`Ensure` is a *chain* verb — it guards a value and threads it onward. The same guard is available directly on
+any [capture](/concepts/captures/), for when you just need to read it:
+
+| | Guards | Hands back |
+| --- | --- | --- |
+| `capture.EnsureValue` | the captured value, non-empty | the whole value |
+| `capture.Prop(c => c.Id)` | the capture | the projected member, as-is |
+| `.Ensure(capture, c => c.Id, out var id)` | both, inside the chain | a reusable, guarded local |
+
+All three share one definition of "empty", so `client.EnsureValue` rejects exactly what
+`.Ensure(client.Value, out _)` would.
+
## In Arrange (deferred)
The arrange overloads are **deferred** — they capture a `Trapture` that's filled when the chain runs, so you
@@ -32,27 +67,29 @@ can derive from a value another arrange step produces:
```csharp
public static ITestArrange Ensure(
this ITestArrange arrange, ICapture source, Func selector,
- out Trapture captured, string? because = null) where TSource : class
+ out Trapture captured, string? because = null)
{
var initializer = Trapture.Start(out captured);
return arrange.Then(_ =>
{
- var value = source.Value ?? throw new InvalidOperationException("Ensure: source capture is not initialized.");
+ if (source.Value is not { } value) throw new InvalidOperationException("Ensure: uninitialized capture.");
initializer.Set(EnsureGuard.NotEmpty(selector(value), because));
});
}
```
Use it to guard a derived id before later arranges consume it. There's also a thunk form —
-`.Ensure(() => client.Value!.Id, out var id)` — for values built from more than one capture. Both hand back a
-[`Trapture`](/concepts/captures/), so the id flows transparently.
+`.Ensure(() => client.Prop(c => c.Id) + suffix, out var key)` — for values built from more than one capture.
+Both hand back a [`Trapture`](/concepts/captures/), so the id flows transparently. The source capture can
+hold anything, a value type included: `.Ensure(clientId, g => g.ToString(), out var key)`.
## Why it's worth a verb
Without `Ensure`, ids arrive as `result.ClientId!.Value` — a null-forgiving operator and an unchecked
assumption in every test. `Ensure` replaces that with a single, self-documenting step that fails loudly and
early if the precondition ("there *is* an id") doesn't hold. It's the idiomatic bridge between an artifact and
-the [captures](/concepts/captures/) that carry it forward.
+the [captures](/concepts/captures/) that carry it forward — `Ensure` being the guarded *derivation*, and
+[`Prop`](/concepts/captures/) the guarded *read*.
## Next
diff --git a/docs/src/content/docs/guides/kafka.md b/docs/src/content/docs/guides/kafka.md
index 34df221..ac6cf5b 100644
--- a/docs/src/content/docs/guides/kafka.md
+++ b/docs/src/content/docs/guides/kafka.md
@@ -23,7 +23,7 @@ public async Task ValidMessage_UpdatesClient_AndPublishesConfirmation()
// ACT — run the real processor over the raw payload.
await Stage.Act().Then(host =>
- host.ExecuteAsync(p => p.ProcessAsync(KafkaMessageFaker.ToJson(message.Value!))));
+ host.ExecuteAsync(p => p.ProcessAsync(KafkaMessageFaker.ToJson(message.EnsureValue))));
// INSPECT — it dispatched an Update and published the confirmation.
await Inspect
diff --git a/docs/src/content/docs/guides/unit-mocked-dependency.md b/docs/src/content/docs/guides/unit-mocked-dependency.md
index a4da36e..02829ff 100644
--- a/docs/src/content/docs/guides/unit-mocked-dependency.md
+++ b/docs/src/content/docs/guides/unit-mocked-dependency.md
@@ -43,7 +43,7 @@ public static ITestArrange HandlerSucceedsFor(
this ITestArrange arrange, Capture message) =>
arrange.Then(host => host.Execute>(handler =>
handler.Handle(Arg.Any(), Arg.Any())
- .Returns(new SaveClientCommandResult(true, message.Value!.ClientId))));
+ .Returns(new SaveClientCommandResult(true, message.Prop(m => m.ClientId)))));
```
**Inspect verbs** verify how the SUT drove those doubles — they only *read*:
@@ -55,13 +55,13 @@ public static ITestInspect HandledUpdate(
handler.Received(1).Handle(
Arg.Is(c =>
c.Operation == SaveOperationKind.Update &&
- c.ClientData.Id == message.Value!.ClientId),
+ c.ClientData.Id == message.Prop(m => m.ClientId)),
Arg.Any())));
public static ITestInspect ConfirmationPublishedFor(
this ITestInspect inspect, Capture message) =>
inspect.Then(host => host.Execute(publisher =>
- publisher.Received(1).PublishClientEventAsync(message.Value!.ClientId, "updated", Arg.Any())));
+ publisher.Received(1).PublishClientEventAsync(message.Prop(m => m.ClientId), "updated", Arg.Any())));
```
`host.Execute(...)` resolves `T` from the stage. When `T` is a substituted type, you get the substitute — the
@@ -87,7 +87,7 @@ public sealed class ClientStatusChangedProcessorTests : BaseUnitTest
- host.ExecuteAsync(p => p.ProcessAsync(KafkaMessageFaker.ToJson(message.Value!))));
+ host.ExecuteAsync(p => p.ProcessAsync(KafkaMessageFaker.ToJson(message.EnsureValue))));
// INSPECT — it dispatched an Update and published the confirmation.
await Inspect
From ddc4e999494d83ff5c9ca3f6f83d16e7835eafcb Mon Sep 17 00:00:00 2001
From: Nikita Ivanov
Date: Sat, 15 Aug 2026 18:34:49 +0700
Subject: [PATCH 2/4] update test examples
---
example/Example1/Directory.Packages.props | 10 ++++-----
.../GetClient/GetClientQueryHandlerTests.cs | 12 ++++++-----
.../SaveClientCommandHandlerTests.cs | 11 +++++-----
.../Cache/ClientCacheServiceTests.cs | 10 ++++-----
.../Cache/ClientCacheServiceTests.cs | 10 ++++-----
.../Messaging/Consumer/InspectConsumer.cs | 4 ++--
.../Messaging/Processor/ArrangeProcessor.cs | 4 ++--
.../ClientStatusChangedProcessorTests.cs | 2 +-
.../Messaging/Processor/InspectProcessor.cs | 21 ++++++++++++-------
9 files changed, 46 insertions(+), 38 deletions(-)
diff --git a/example/Example1/Directory.Packages.props b/example/Example1/Directory.Packages.props
index c370c80..195b138 100644
--- a/example/Example1/Directory.Packages.props
+++ b/example/Example1/Directory.Packages.props
@@ -44,11 +44,11 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/example/Example1/src/Mokkit.Example1.Integration.Tests/Features/Client/GetClient/GetClientQueryHandlerTests.cs b/example/Example1/src/Mokkit.Example1.Integration.Tests/Features/Client/GetClient/GetClientQueryHandlerTests.cs
index 6d88a8f..aa95253 100644
--- a/example/Example1/src/Mokkit.Example1.Integration.Tests/Features/Client/GetClient/GetClientQueryHandlerTests.cs
+++ b/example/Example1/src/Mokkit.Example1.Integration.Tests/Features/Client/GetClient/GetClientQueryHandlerTests.cs
@@ -1,3 +1,4 @@
+using Mokkit.Inspect;
using Mokkit.Example1.Application.Features.Client.GetClient;
using Mokkit.Example1.Common;
@@ -14,11 +15,11 @@ await Arrange
.CachedClient(out var cached);
// ACT
- var result = await Act.GetClient(new GetClientQuery { ClientId = cached.Value!.Id });
+ var result = await Act.GetClient(new GetClientQuery { ClientId = cached.Prop(c => c.Id) });
// INSPECT — served from cache, so the handler does not write the cache again.
await Inspect
- .GetResult(result).Found(cached.Value!.Id)
+ .GetResult(result).Found(cached.Prop(c => c.Id))
.CacheNotUpdated();
}
@@ -31,12 +32,13 @@ await Arrange
.DbClient(out var seeded);
// ACT
- var result = await Act.GetClient(new GetClientQuery { ClientId = seeded.Value!.Id });
+ var result = await Act.GetClient(new GetClientQuery { ClientId = seeded.Prop(c => c.Id) });
// INSPECT — served from the database and written back into the cache.
await Inspect
- .GetResult(result).Found(seeded.Value!.Id)
- .CacheUpdated(seeded.Value!.Id);
+ .Ensure(seeded, c => c.Id, out var clientId)
+ .GetResult(result).Found(clientId)
+ .CacheUpdated(clientId);
}
[Test]
diff --git a/example/Example1/src/Mokkit.Example1.Integration.Tests/Features/Client/SaveClient/SaveClientCommandHandlerTests.cs b/example/Example1/src/Mokkit.Example1.Integration.Tests/Features/Client/SaveClient/SaveClientCommandHandlerTests.cs
index 38fc50d..bd90693 100644
--- a/example/Example1/src/Mokkit.Example1.Integration.Tests/Features/Client/SaveClient/SaveClientCommandHandlerTests.cs
+++ b/example/Example1/src/Mokkit.Example1.Integration.Tests/Features/Client/SaveClient/SaveClientCommandHandlerTests.cs
@@ -70,7 +70,7 @@ await Arrange
await Arrange
.Clock(updatedAt)
- .UpdateClientCommand(out var command, existing.Value!.Id,
+ .UpdateClientCommand(out var command, existing.Prop(c => c.Id),
WithName("Renamed Corporation"),
WithStatus((int)ClientStatus.Suspended));
@@ -79,8 +79,9 @@ await Arrange
// INSPECT — fields changed, CreatedAt preserved, UpdatedAt advanced, event published.
await Inspect
- .SaveResult(result).IsSuccess(existing.Value!.Id)
- .DbClientById(existing.Value!.Id, out var saved, c =>
+ .Ensure(existing, e => e.Id, out var clientId)
+ .SaveResult(result).IsSuccess(clientId)
+ .DbClientById(clientId, out var saved, c =>
{
Assert.That(c!.Name, Is.EqualTo("Renamed Corporation"));
Assert.That(c.Status, Is.EqualTo(ClientStatus.Suspended));
@@ -88,8 +89,8 @@ await Inspect
Assert.That(c.UpdatedAt, Is.EqualTo(updatedAt));
})
.Verify(saved)
- .CacheUpdated(existing.Value!.Id)
- .EventPublished(existing.Value!.Id, "updated");
+ .CacheUpdated(clientId)
+ .EventPublished(clientId, "updated");
}
[Test]
diff --git a/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ClientCacheServiceTests.cs b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ClientCacheServiceTests.cs
index 975401a..11c78e3 100644
--- a/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ClientCacheServiceTests.cs
+++ b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ClientCacheServiceTests.cs
@@ -24,12 +24,12 @@ public async Task GetClient_WhenCached_ReturnsDeserializedClient()
await Arrange.CacheHasClient(out var client);
// ACT
- var result = await GetClient(client.Value!.Id);
+ var result = await GetClient(client.Prop(c => c.Id));
// INSPECT
await Inspect
- .RetrievedClientMatching(result, client.Value!)
- .CacheQueried(client.Value!.Id);
+ .RetrievedClientMatching(result, client.EnsureValue)
+ .CacheQueried(client.Prop(c => c.Id));
}
[Test]
@@ -68,10 +68,10 @@ public async Task SetClient_SerializesAndStoresWithExpiry()
await Arrange.AClient(out var client);
// ACT
- await StoreClient(client.Value!);
+ await StoreClient(client.EnsureValue);
// INSPECT
- await Inspect.CacheStored(client.Value!);
+ await Inspect.CacheStored(client.EnsureValue);
}
[Test]
diff --git a/example/Example1/src/Mokkit.Example1.Unit.Tests/Cache/ClientCacheServiceTests.cs b/example/Example1/src/Mokkit.Example1.Unit.Tests/Cache/ClientCacheServiceTests.cs
index 76904cc..3091da9 100644
--- a/example/Example1/src/Mokkit.Example1.Unit.Tests/Cache/ClientCacheServiceTests.cs
+++ b/example/Example1/src/Mokkit.Example1.Unit.Tests/Cache/ClientCacheServiceTests.cs
@@ -21,12 +21,12 @@ public async Task GetClient_WhenCached_ReturnsDeserializedClient()
await Arrange.CacheHasClient(out var client);
// ACT
- var result = await GetClient(client.Value!.Id);
+ var result = await GetClient(client.Prop(c => c.Id));
// INSPECT
await Inspect
- .RetrievedClientMatching(result, client.Value!)
- .CacheQueried(client.Value!.Id);
+ .RetrievedClientMatching(result, client.EnsureValue)
+ .CacheQueried(client.Prop(c => c.Id));
}
[Fact]
@@ -65,10 +65,10 @@ public async Task SetClient_SerializesAndStoresWithExpiry()
await Arrange.AClient(out var client);
// ACT
- await StoreClient(client.Value!);
+ await StoreClient(client.EnsureValue);
// INSPECT
- await Inspect.CacheStored(client.Value!);
+ await Inspect.CacheStored(client.EnsureValue);
}
[Fact]
diff --git a/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Consumer/InspectConsumer.cs b/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Consumer/InspectConsumer.cs
index 9ca363c..6222d07 100644
--- a/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Consumer/InspectConsumer.cs
+++ b/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Consumer/InspectConsumer.cs
@@ -26,7 +26,7 @@ public static ITestInspect ForwardedToProcessor(this ITestInspect inspect, Captu
return inspect.Then(host =>
{
host.Execute(processor =>
- processor.Received(1).ProcessAsync(message.Value!.Message.Value, Arg.Any()));
+ processor.Received(1).ProcessAsync(message.Prop(m => m.Message.Value), Arg.Any()));
});
}
@@ -35,7 +35,7 @@ public static ITestInspect OffsetCommitted(this ITestInspect inspect, Capture
{
host.Execute>(consumer =>
- consumer.Received(1).Commit(message.Value!));
+ consumer.Received(1).Commit(message.EnsureValue));
});
}
diff --git a/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Processor/ArrangeProcessor.cs b/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Processor/ArrangeProcessor.cs
index 38c4269..d02b511 100644
--- a/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Processor/ArrangeProcessor.cs
+++ b/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Processor/ArrangeProcessor.cs
@@ -39,7 +39,7 @@ public static ITestArrange HandlerSucceedsFor(
{
host.Execute>(handler =>
handler.Handle(Arg.Any(), Arg.Any())
- .Returns(new SaveClientCommandResult(true, message.Value!.ClientId)));
+ .Returns(new SaveClientCommandResult(true, message.Prop(m => m.ClientId))));
});
}
@@ -51,7 +51,7 @@ public static ITestArrange HandlerFailsFor(
{
host.Execute>(handler =>
handler.Handle(Arg.Any(), Arg.Any())
- .Returns(new SaveClientCommandResult(false, message.Value!.ClientId,
+ .Returns(new SaveClientCommandResult(false, message.Prop(m => m.ClientId),
new InvalidOperationException("update failed"))));
});
}
diff --git a/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Processor/ClientStatusChangedProcessorTests.cs b/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Processor/ClientStatusChangedProcessorTests.cs
index 609d12d..5771226 100644
--- a/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Processor/ClientStatusChangedProcessorTests.cs
+++ b/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Processor/ClientStatusChangedProcessorTests.cs
@@ -76,7 +76,7 @@ await Arrange
}
private Task Process(Capture message) =>
- Process(KafkaMessageFaker.ToJson(message.Value!));
+ Process(KafkaMessageFaker.ToJson(message.EnsureValue));
private Task Process(string json) =>
Stage.ExecuteAsync(processor => processor.ProcessAsync(json));
diff --git a/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Processor/InspectProcessor.cs b/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Processor/InspectProcessor.cs
index cd7146c..3cb7528 100644
--- a/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Processor/InspectProcessor.cs
+++ b/example/Example1/src/Mokkit.Example1.Unit.Tests/Messaging/Processor/InspectProcessor.cs
@@ -18,15 +18,18 @@ public static ITestInspect HandledUpdate(this ITestInspect inspect, Capture
{
+ // One guarded read of the capture; the predicate then reads plain members off it.
+ var expected = message.EnsureValue;
+
host.Execute>(handler =>
handler.Received(1).Handle(
Arg.Is(c =>
c.Operation == SaveOperationKind.Update &&
- c.ClientData.Id == message.Value!.ClientId &&
- c.ClientData.Name == message.Value!.Name &&
- c.ClientData.Email == message.Value!.Email &&
- c.ClientData.Phone == message.Value!.Phone &&
- c.ClientData.Status == message.Value!.Status),
+ c.ClientData.Id == expected.ClientId &&
+ c.ClientData.Name == expected.Name &&
+ c.ClientData.Email == expected.Email &&
+ c.ClientData.Phone == expected.Phone &&
+ c.ClientData.Status == expected.Status),
Arg.Any()));
});
}
@@ -36,14 +39,16 @@ public static ITestInspect HandledWithEmptyContact(this ITestInspect inspect, Ca
{
return inspect.Then(host =>
{
+ var expected = message.EnsureValue;
+
host.Execute>(handler =>
handler.Received(1).Handle(
Arg.Is(c =>
- c.ClientData.Id == message.Value!.ClientId &&
+ c.ClientData.Id == expected.ClientId &&
c.ClientData.Name == string.Empty &&
c.ClientData.Email == string.Empty &&
c.ClientData.Phone == string.Empty &&
- c.ClientData.Status == message.Value!.Status),
+ c.ClientData.Status == expected.Status),
Arg.Any()));
});
}
@@ -64,7 +69,7 @@ public static ITestInspect ConfirmationPublishedFor(this ITestInspect inspect, C
return inspect.Then(host =>
{
host.Execute(publisher =>
- publisher.Received(1).PublishClientEventAsync(message.Value!.ClientId, "updated", Arg.Any()));
+ publisher.Received(1).PublishClientEventAsync(message.Prop(m => m.ClientId), "updated", Arg.Any()));
});
}
From bd8f2afe7879fdc0bb062176857f29bb37ea3eb9 Mon Sep 17 00:00:00 2001
From: Nikita Ivanov
Date: Sat, 15 Aug 2026 18:53:50 +0700
Subject: [PATCH 3/4] update docs
---
docs/src/content/docs/installation.mdx | 17 ++++++-----------
1 file changed, 6 insertions(+), 11 deletions(-)
diff --git a/docs/src/content/docs/installation.mdx b/docs/src/content/docs/installation.mdx
index 4b7b660..65cf578 100644
--- a/docs/src/content/docs/installation.mdx
+++ b/docs/src/content/docs/installation.mdx
@@ -8,11 +8,6 @@ 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.
-:::note[Prerelease]
-Mokkit is currently published as a prerelease (`0.3.0-preview.1`). Add `--prerelease` (CLI) or reference the
-exact version so NuGet will resolve it.
-:::
-
## A minimal unit-test setup
Core + Microsoft DI + NSubstitute is a common starting point:
@@ -20,17 +15,17 @@ Core + Microsoft DI + NSubstitute is a common starting point:
```bash
-dotnet add package Mokkit --prerelease
-dotnet add package Mokkit.Containers.Microsoft.Extensions.DependencyInjection --prerelease
-dotnet add package Mokkit.Containers.NSubstitute --prerelease
+dotnet add package Mokkit
+dotnet add package Mokkit.Containers.Microsoft.Extensions.DependencyInjection
+dotnet add package Mokkit.Containers.NSubstitute
```
```xml
-
-
-
+
+
+
```
From cfb5750c07dbbb274653deb6eb3246dd05f2343e Mon Sep 17 00:00:00 2001
From: Nikita Ivanov
Date: Sat, 15 Aug 2026 18:54:44 +0700
Subject: [PATCH 4/4] update README
---
README.md | 122 +++++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 106 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index bde1e7a..8dc0eb1 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-[](https://www.nuget.org/packages/Mokkit)
+[](https://www.nuget.org/packages/Mokkit)
[](https://github.com/GrafGenerator/mokkit/actions/workflows/ci.yml)
[](LICENSE)
@@ -19,32 +19,111 @@ end-to-end run.
```csharp
// A Mokkit test reads like the scenario it describes.
[Fact]
-public async Task Suspending_a_client_reflects_everywhere()
+public async Task CalculateDiscount_ForVipUser_AppliesTieredRate()
{
await Arrange
- .NewClient(out var clientId, WithName("Acme Corporation"))
- .CacheHasClient(clientId);
+ .UserExists(out var user, WithStatus(UserStatus.Vip)) // build the user, set up its repository
+ .DiscountRateIs(UserStatus.Vip, rate: 0.15m); // set up the rates repository
- await Act(clientId, ClientStatus.Suspended);
+ var result = await Act.CalculateDiscount(user, orderTotal: 100m);
await Inspect
- .ApiClientEventually(clientId, c => c.Status == Suspended)
- .DbClient(clientId, c => c!.Status.ShouldBe(Suspended))
- .EventPublished("clients.updated", clientId);
+ .OkResult(result).DiscountAppliedFor(user, expectedAmount: 15m)
+ .Ensure(result, r => r.UserId, out var userId) // guard the id, then reuse it
+ .ThenAll( // these three run in parallel
+ b => b.UserRepositoryQueried(userId),
+ b => b.UserCalculationRepositoryQueried(userId),
+ b => b.RateRepositoryQueried(UserStatus.Vip));
}
```
-`NewClient`, `CacheHasClient`, `ApiClientEventually`, `DbClient`, `EventPublished` aren't Mokkit APIs — they're
-your verbs. Mokkit provides the Arrange / Act / Inspect shape and the machinery underneath.
+`UserExists`, `DiscountRateIs`, `UserRepositoryQueried` aren't Mokkit APIs — they're **your verbs**. Mokkit
+provides the Arrange / Act / Inspect shape and the machinery underneath.
+
+## What a verb costs you
+
+That's the fair question, so here's the whole answer. A verb is an extension method that does the coupled
+setup (or the coupled verification) once, and hides it behind a name from your domain:
+
+```csharp
+public static class ArrangeDiscount
+{
+ public static ITestArrange UserExists(
+ this ITestArrange arrange,
+ out Capture userCapture,
+ Action? mutate = null)
+ {
+ var capture = Capture.Start(out userCapture);
+
+ return arrange.Then(host =>
+ {
+ var user = new User { Id = Guid.NewGuid() };
+ mutate?.Invoke(user);
+
+ host.Execute(repo =>
+ repo.GetByIdAsync(user.Id, Arg.Any()).Returns(user));
+
+ capture.Set(user);
+ });
+ }
+}
+
+public static class InspectDiscount
+{
+ public static ITestInspect UserRepositoryQueried(this ITestInspect inspect, Guid userId)
+ {
+ return inspect.Then(host =>
+ host.Execute(repo =>
+ repo.Received(1).GetByIdAsync(userId, Arg.Any())));
+ }
+}
+```
+
+Written once per fixture and living next to the tests. They start paying off quickly — most get reused across
+every test that touches the same area, and a good half end up reused far beyond it.
+
+Your mocking and assertion libraries stay yours: the snippets above use NSubstitute, but Moq, FakeItEasy,
+Shouldly, FluentAssertions or plain `Assert` all work the same way.
+
+## The same vocabulary, from unit to end-to-end
+
+Because the verbs are just extension methods over a context Mokkit hands them, the *same* test can run against
+mocks or against real infrastructure — you swap the helper implementations, not the test:
+
+```csharp
+[Fact]
+public async Task CalculateDiscount_ForVipUser_AppliesTieredRate()
+{
+ await Arrange
+ .UserExists(out var user, WithStatus(UserStatus.Vip)) // ← now creates the user via the API
+ .DiscountRateIs(UserStatus.Vip, 0.15m); // ← now sets the rate via the API
+
+ var result = await Act.CalculateDiscount(user, orderTotal: 100m); // ← now calls the real endpoint
+
+ await Inspect
+ .OkResult(result).DiscountAppliedFor(user, expectedAmount: 15m)
+ .Ensure(result, r => r.UserId, out var userId)
+ .Ensure(result, r => r.CalculationId, out var calculationId)
+ .UserCalculationStored(userId, calculationId, 15m) // e2e-only: assert against the database
+ .DiscountEventPublishedFor(user, 15m); // e2e-only: assert against the broker
+}
+```
+
+The Arrange and Act lines are identical; end-to-end just adds the inspects that only make sense against real
+infrastructure.
+
+A worked three-tier example — the same feature covered by unit, integration (Testcontainers + Postgres) and
+end-to-end (API + Kafka) suites, across xUnit, NUnit and TUnit — lives in
+**[`example/Example1`](example/Example1)**.
## Install
-Core + a DI adapter + a mock adapter (prerelease for now):
+Core + a DI adapter + a mock adapter:
```bash
-dotnet add package Mokkit --prerelease
-dotnet add package Mokkit.Containers.Microsoft.Extensions.DependencyInjection --prerelease
-dotnet add package Mokkit.Containers.NSubstitute --prerelease
+dotnet add package Mokkit
+dotnet add package Mokkit.Containers.Microsoft.Extensions.DependencyInjection
+dotnet add package Mokkit.Containers.NSubstitute
```
## Packages
@@ -56,6 +135,19 @@ dotnet add package Mokkit.Containers.NSubstitute --prerelease
| `Mokkit.Containers.Moq` · `.NSubstitute` · `.FakeItEasy` | Mock library adapters |
| `Mokkit.Containers.Bag` | Dependency-free "hold a few instances" container |
+No package is needed for your test framework — Mokkit runs inside xUnit, NUnit, MSTest or TUnit as-is.
+
+## Status
+
+**v0.4.0**, MIT, targets .NET Standard 2.0. It has been in daily use on a production codebase for two months,
+and it is actively developed: expect API changes before 1.0, with verbose output and test-report integration
+among the things still missing. Adapters for other containers and mock libraries are straightforward to add.
+
+What it most needs right now is feedback that isn't mine. Would you write your tests this way — and if not,
+what puts you off? [Discussions](https://github.com/GrafGenerator/mokkit/discussions) is the place for that,
+and for anything open-ended; a reproducible bug or a concrete feature request is best as an
+[issue](https://github.com/GrafGenerator/mokkit/issues).
+
## Documentation
Full guides, concepts and API reference: **[mokkit.net](https://mokkit.net)**
@@ -63,8 +155,6 @@ Full guides, concepts and API reference: **[mokkit.net](https://mokkit.net)**
- [Introduction](https://mokkit.net/introduction/) · [Why Mokkit? (vs BDD/DSL)](https://mokkit.net/why-mokkit/) · [Quickstart](https://mokkit.net/quickstart/)
- [Building your test vocabulary](https://mokkit.net/concepts/vocabulary/) — the idea Mokkit is built around
-A worked, three-tier example (unit / integration / e2e) lives in [`example/Example1`](example/Example1).
-
## License
[MIT](LICENSE) © Nikita Ivanov