Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 106 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<img src="https://raw.githubusercontent.com/GrafGenerator/mokkit/main/assets/banner.png" alt="Mokkit — write tests that read like a story, in plain C#" width="820">
</p>

[![NuGet](https://img.shields.io/nuget/vpre/Mokkit.svg)](https://www.nuget.org/packages/Mokkit)
[![NuGet](https://img.shields.io/nuget/v/Mokkit.svg)](https://www.nuget.org/packages/Mokkit)
[![CI](https://github.com/GrafGenerator/mokkit/actions/workflows/ci.yml/badge.svg)](https://github.com/GrafGenerator/mokkit/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

Expand All @@ -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<User> userCapture,
Action<User>? mutate = null)
{
var capture = Capture.Start(out userCapture);

return arrange.Then(host =>
{
var user = new User { Id = Guid.NewGuid() };
mutate?.Invoke(user);

host.Execute<IUserRepository>(repo =>
repo.GetByIdAsync(user.Id, Arg.Any<CancellationToken>()).Returns(user));

capture.Set(user);
});
}
}

public static class InspectDiscount
{
public static ITestInspect UserRepositoryQueried(this ITestInspect inspect, Guid userId)
{
return inspect.Then(host =>
host.Execute<IUserRepository>(repo =>
repo.Received(1).GetByIdAsync(userId, Arg.Any<CancellationToken>())));
}
}
```

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
Expand All @@ -56,15 +135,26 @@ 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)**

- [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
49 changes: 47 additions & 2 deletions docs/src/content/docs/concepts/captures.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,50 @@ await Inspect.ApiClient(clientId, c => c.Name.ShouldBe("Acme Holdings"));
Reach for `Capture<T>` 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<T>` offers guarded versions of both — so they work on `Capture<T>` and `Trapture<T>` 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<SaveClientCommand>(c =>
c.ClientData.Id == expected.ClientId &&
c.ClientData.Name == expected.Name &&
c.ClientData.Email == expected.Email), Arg.Any<CancellationToken>());
```

:::note
`Value` stays useful where empty is a legitimate outcome — snapshotting a `Capture<Client?>` 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:
Expand All @@ -69,8 +113,9 @@ public static ITestArrange NewClient(
}
```

The interfaces behind this are small: `ICapture<out T>` exposes `Value`; `ICaptureInitializer<T>` exposes
`Set(T)`. A verb takes the *initializer* to write and hands back the *capture* to read.
The interfaces behind this are small: `ICapture<out T>` exposes `Value`, `EnsureValue` and `Prop`;
`ICaptureInitializer<T>` exposes `Set(T)`. A verb takes the *initializer* to write and hands back the
*capture* to read.

## Deriving one value from another: `Ensure`

Expand Down
47 changes: 42 additions & 5 deletions docs/src/content/docs/guides/ensure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` that's filled when the chain runs, so you
Expand All @@ -32,27 +67,29 @@ can derive from a value another arrange step produces:
```csharp
public static ITestArrange Ensure<TSource, T>(
this ITestArrange arrange, ICapture<TSource> source, Func<TSource, T> selector,
out Trapture<T> captured, string? because = null) where TSource : class
out Trapture<T> 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<T>`](/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<T>`](/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

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/kafka.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<IClientStatusChangedProcessor>(p => p.ProcessAsync(KafkaMessageFaker.ToJson(message.Value!))));
host.ExecuteAsync<IClientStatusChangedProcessor>(p => p.ProcessAsync(KafkaMessageFaker.ToJson(message.EnsureValue))));

// INSPECT — it dispatched an Update and published the confirmation.
await Inspect
Expand Down
8 changes: 4 additions & 4 deletions docs/src/content/docs/guides/unit-mocked-dependency.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public static ITestArrange HandlerSucceedsFor(
this ITestArrange arrange, Capture<ClientStatusChangedMessage> message) =>
arrange.Then(host => host.Execute<IRequestHandler<SaveClientCommand, SaveClientCommandResult>>(handler =>
handler.Handle(Arg.Any<SaveClientCommand>(), Arg.Any<CancellationToken>())
.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*:
Expand All @@ -55,13 +55,13 @@ public static ITestInspect HandledUpdate(
handler.Received(1).Handle(
Arg.Is<SaveClientCommand>(c =>
c.Operation == SaveOperationKind.Update &&
c.ClientData.Id == message.Value!.ClientId),
c.ClientData.Id == message.Prop(m => m.ClientId)),
Arg.Any<CancellationToken>())));

public static ITestInspect ConfirmationPublishedFor(
this ITestInspect inspect, Capture<ClientStatusChangedMessage> message) =>
inspect.Then(host => host.Execute<IKafkaEventPublisher>(publisher =>
publisher.Received(1).PublishClientEventAsync(message.Value!.ClientId, "updated", Arg.Any<CancellationToken>())));
publisher.Received(1).PublishClientEventAsync(message.Prop(m => m.ClientId), "updated", Arg.Any<CancellationToken>())));
```

`host.Execute<T>(...)` resolves `T` from the stage. When `T` is a substituted type, you get the substitute — the
Expand All @@ -87,7 +87,7 @@ public sealed class ClientStatusChangedProcessorTests : BaseUnitTest<ProcessorFi

// ACT — run the real processor over the message.
await Stage.Act().Then(host =>
host.ExecuteAsync<IClientStatusChangedProcessor>(p => p.ProcessAsync(KafkaMessageFaker.ToJson(message.Value!))));
host.ExecuteAsync<IClientStatusChangedProcessor>(p => p.ProcessAsync(KafkaMessageFaker.ToJson(message.EnsureValue))));

// INSPECT — it dispatched an Update and published the confirmation.
await Inspect
Expand Down
17 changes: 6 additions & 11 deletions docs/src/content/docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,24 @@ 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:

<Tabs>
<TabItem label="dotnet CLI">
```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
```
</TabItem>
<TabItem label="PackageReference">
```xml
<ItemGroup>
<PackageReference Include="Mokkit" Version="0.3.0-preview.1" />
<PackageReference Include="Mokkit.Containers.Microsoft.Extensions.DependencyInjection" Version="0.3.0-preview.1" />
<PackageReference Include="Mokkit.Containers.NSubstitute" Version="0.3.0-preview.1" />
<PackageReference Include="Mokkit" Version="0.4.0" />
<PackageReference Include="Mokkit.Containers.Microsoft.Extensions.DependencyInjection" Version="0.4.0" />
<PackageReference Include="Mokkit.Containers.NSubstitute" Version="0.4.0" />
</ItemGroup>
```
</TabItem>
Expand Down
10 changes: 5 additions & 5 deletions example/Example1/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@
<PackageVersion Include="Testcontainers.Kafka" Version="4.6.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
<PackageVersion Include="Testcontainers.Redis" Version="4.6.0" />
<PackageVersion Include="Mokkit" Version="0.3.0-preview.1.2" />
<PackageVersion Include="Mokkit.Containers.Bag" Version="0.3.0-preview.1.2" />
<PackageVersion Include="Mokkit.Containers.FakeItEasy" Version="0.3.0-preview.1.2" />
<PackageVersion Include="Mokkit.Containers.Microsoft.Extensions.DependencyInjection" Version="0.3.0-preview.1.2" />
<PackageVersion Include="Mokkit.Containers.Moq" Version="0.3.0-preview.1.2" />
<PackageVersion Include="Mokkit" Version="0.4.0" />
<PackageVersion Include="Mokkit.Containers.Bag" Version="0.4.0" />
<PackageVersion Include="Mokkit.Containers.FakeItEasy" Version="0.4.0" />
<PackageVersion Include="Mokkit.Containers.Microsoft.Extensions.DependencyInjection" Version="0.4.0" />
<PackageVersion Include="Mokkit.Containers.Moq" Version="0.4.0" />
<!-- TUnit (Microsoft.Testing.Platform) + FakeItEasy — for the standalone TUnit example suite. -->
<PackageVersion Include="TUnit" Version="1.58.0" />
<PackageVersion Include="FakeItEasy" Version="9.0.1" />
Expand Down
Loading
Loading