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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ files, no step bindings, no runtime glue. The "steps" are just C# extension meth
project's testing vocabulary — so you keep full IDE support (autocomplete, go-to-definition, refactoring) and
a test that doesn't make sense simply won't compile.

It's a thin orchestration layer, not a framework: run it inside xUnit / NUnit / MSTest, mock with
It's a thin orchestration layer, not a framework: run it inside xUnit / NUnit / MSTest / TUnit, mock with
Moq / NSubstitute / FakeItEasy, and wire with Microsoft DI / Autofac / Castle Windsor (or the dependency-free
Bag). The same Arrange / Act / Inspect vocabulary scales from a mocked unit test to a full Testcontainers
end-to-end run.
Expand Down
26 changes: 23 additions & 3 deletions docs/src/content/docs/concepts/stage.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ Your [vocabulary](/concepts/vocabulary/) verbs are thin wrappers over exactly th
## Wiring it to your test framework

Mokkit is framework-agnostic — the Stage is composed in whatever "run once" hook your runner offers and
entered in its "per test" hook. The pattern is identical across xUnit, NUnit and MSTest; only the fixture
attributes differ.
entered in its "per test" hook. The pattern is identical across xUnit, NUnit, MSTest and TUnit; only the
fixture attributes differ.

```csharp
// xUnit — the composition is an IClassFixture (built once); each test enters a fresh stage.
Expand All @@ -69,8 +69,28 @@ public abstract class BaseUnitTest<TFixture> : IClassFixture<TFixture>, IDisposa
}
```

The same shape on **TUnit** (which runs on Microsoft.Testing.Platform) — the composition is a
`[ClassDataSource]`, and the "per test" hooks are `[Before(Test)]` / `[After(Test)]`:

```csharp
// TUnit — the composition is injected once per class; hooks enter/dispose a fresh stage per test.
public abstract class TUnitTestBase
{
[ClassDataSource<CacheServiceFixture>(Shared = SharedType.PerClass)]
public required CacheServiceFixture Fixture { get; init; }

protected TestStage Stage { get; private set; } = null!;
protected ITestArrange Arrange => Stage.Arrange();
protected ITestInspect Inspect => Stage.Inspect();

[Before(Test)] public void Enter() => Stage = Fixture.EnterStage();
[After(Test)] public void Exit() => Stage.Dispose();
}
```

Exposing `Arrange` / `Act` / `Inspect` as properties on a base fixture is what lets a test body read as
`await Arrange.…` / `await Act.…` / `await Inspect.…` with no ceremony.
`await Arrange.…` / `await Act.…` / `await Inspect.…` with no ceremony — the same three lines regardless of
runner.

## One composition per system-under-test

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ await Inspect
uses it updates — and still compiles.
</Card>
<Card title="Bring your own everything" icon="puzzle">
Framework-agnostic (xUnit / NUnit / MSTest), mock-agnostic (Moq / NSubstitute / FakeItEasy) and
Framework-agnostic (xUnit / NUnit / MSTest / TUnit), mock-agnostic (Moq / NSubstitute / FakeItEasy) and
container-agnostic (Microsoft DI / Autofac / Castle Windsor — or the dependency-free Bag).
</Card>
<Card title="From unit to end-to-end" icon="rocket">
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Each phase is a fluent chain of the verbs you defined. See

Mokkit assumes nothing about the rest of your stack:

- **Test framework** — xUnit, NUnit, MSTest; Mokkit is just calls inside your test methods.
- **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
Expand Down
13 changes: 9 additions & 4 deletions docs/src/content/docs/reference/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ protected ITestInspect Inspect => Stage.Inspect();
```

Each test enters a fresh stage over that fixed composition and disposes it afterwards. This is identical across
xUnit / NUnit / MSTest — only the fixture attributes differ.
xUnit / NUnit / MSTest / TUnit — only the fixture attributes differ.

## One container build → one fixture per SUT

Expand Down Expand Up @@ -69,7 +69,7 @@ No raw setup or assertions in a test body: every value is a business-named arran
inspect, and the act returns (or captures) the artifact the inspects observe. If an inspect is doing the thing
under test, lift it into Act; if an act asserts, move that into Inspect.

## Three real layouts
## Four real layouts

The example organises each suite by whatever axis fits it — all following the rules above:

Expand All @@ -96,11 +96,16 @@ E2E.Tests/ # organised BY EXTERNAL SURFACE (xUnit + Testcontainers, n
├── Clients/ { ClientApi, ArrangeClientApi, ArrangeMessages, ActClientApi,
│ InspectClientApi, *FlowTests, ClientLifecycleScenarioTests }
└── Contracts/ # suite-owned wire DTOs — not the service's internal types

TUnit.Tests/ # PORTABILITY PROOF #2 (TUnit + FakeItEasy · Microsoft.Testing.Platform · dotnet run)
├── TUnitTestBase.cs # [Before(Test)]/[After(Test)] instead of IClassFixture
└── Cache/ { CacheServiceFixture, ArrangeCache, InspectCache, …Tests } # same Cache tests, new stack
```

Unit organises **by SUT** (one fixture each), integration **by feature** (cross-feature helpers hoisted to the
root), E2E **by external surface** (infra plumbing at the root, its own black-box contracts). Same Mokkit
primitives throughout — only the surrounding stack changes.
root), E2E **by external surface** (infra plumbing at the root, its own black-box contracts). The small
**TUnit** suite re-runs the unit `Cache` tests on a fourth stack (TUnit + FakeItEasy) to prove the Mokkit code
is unchanged. Same Mokkit primitives throughout — only the surrounding stack changes.

## Next

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/why-mokkit.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ test project as code — not as a separate artifact a non-engineer edits.

## What Mokkit is *not*

- Not a test framework — it runs inside xUnit / NUnit / MSTest.
- Not a test framework — it runs inside xUnit / NUnit / MSTest / TUnit.
- Not a mocking library — it wraps Moq / NSubstitute / FakeItEasy.
- Not a DI container — it wraps yours (or ships a trivial one).

Expand Down
4 changes: 4 additions & 0 deletions example/Example1/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,12 @@
<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" />
<!-- 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" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="NUnit" Version="4.3.2" />
<PackageVersion Include="NUnit.Analyzers" Version="4.6.0">
Expand Down
15 changes: 15 additions & 0 deletions example/Example1/Mokkit.Example1.sln
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mokkit.Example1.E2E.Tests", "src\Mokkit.Example1.E2E.Tests\Mokkit.Example1.E2E.Tests.csproj", "{7E2E5367-189C-42A8-9C12-FDF2E4CC55E2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mokkit.Example1.TUnit.Tests", "src\Mokkit.Example1.TUnit.Tests\Mokkit.Example1.TUnit.Tests.csproj", "{0FE84AFB-DD15-462A-862D-C4142B30833C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -146,12 +148,25 @@ Global
{7E2E5367-189C-42A8-9C12-FDF2E4CC55E2}.Release|x64.Build.0 = Release|Any CPU
{7E2E5367-189C-42A8-9C12-FDF2E4CC55E2}.Release|x86.ActiveCfg = Release|Any CPU
{7E2E5367-189C-42A8-9C12-FDF2E4CC55E2}.Release|x86.Build.0 = Release|Any CPU
{0FE84AFB-DD15-462A-862D-C4142B30833C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0FE84AFB-DD15-462A-862D-C4142B30833C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0FE84AFB-DD15-462A-862D-C4142B30833C}.Debug|x64.ActiveCfg = Debug|Any CPU
{0FE84AFB-DD15-462A-862D-C4142B30833C}.Debug|x64.Build.0 = Debug|Any CPU
{0FE84AFB-DD15-462A-862D-C4142B30833C}.Debug|x86.ActiveCfg = Debug|Any CPU
{0FE84AFB-DD15-462A-862D-C4142B30833C}.Debug|x86.Build.0 = Debug|Any CPU
{0FE84AFB-DD15-462A-862D-C4142B30833C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0FE84AFB-DD15-462A-862D-C4142B30833C}.Release|Any CPU.Build.0 = Release|Any CPU
{0FE84AFB-DD15-462A-862D-C4142B30833C}.Release|x64.ActiveCfg = Release|Any CPU
{0FE84AFB-DD15-462A-862D-C4142B30833C}.Release|x64.Build.0 = Release|Any CPU
{0FE84AFB-DD15-462A-862D-C4142B30833C}.Release|x86.ActiveCfg = Release|Any CPU
{0FE84AFB-DD15-462A-862D-C4142B30833C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{7E2E5367-189C-42A8-9C12-FDF2E4CC55E2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0FE84AFB-DD15-462A-862D-C4142B30833C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {98BAB5F4-7786-4F58-B848-7BA0BD026F0B}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<ItemGroup>
<InternalsVisibleTo Include="Mokkit.Example1.Tests" />
<InternalsVisibleTo Include="Mokkit.Example1.Unit.Tests" />
<InternalsVisibleTo Include="Mokkit.Example1.TUnit.Tests" />
<!-- Lets NSubstitute/Castle DynamicProxy fake internal interfaces (e.g. IClientStatusChangedProcessor). -->
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using Mokkit.Arrange;
using Mokkit.Example1.Domain.Entities;
using Capture = Mokkit.Capture;

namespace Mokkit.Example1.TUnit.Tests.Cache;

/// <summary>
/// Arrange building blocks for the cache service: build the data to act on and shape what the faked
/// <see cref="IDistributedCache"/> returns. (<c>GetStringAsync</c>/<c>SetStringAsync</c> are extension methods
/// FakeItEasy can't intercept, so we configure the real members <c>GetAsync</c>/<c>SetAsync</c>.)
/// </summary>
public static class ArrangeCache
{
public static string KeyFor(Guid clientId) => $"client:{clientId}";

/// <summary>Builds and captures a client to act on (no cache interaction).</summary>
public static ITestArrange AClient(
this ITestArrange arrange,
out Capture<Client> clientCapture,
Action<Client>? mutate = null)
{
var capture = Capture.Start(out clientCapture);
return arrange.Then(_ => capture.Set(ClientFaker.NewClient(mutate)));
}

/// <summary>Cache hit: the client's key resolves to its serialized form. The client is captured.</summary>
public static ITestArrange CacheHasClient(
this ITestArrange arrange,
out Capture<Client> clientCapture,
Action<Client>? mutate = null)
{
var capture = Capture.Start(out clientCapture);
return arrange.Then(host =>
{
var client = ClientFaker.NewClient(mutate);
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(client));

host.Execute<IDistributedCache>(cache =>
A.CallTo(() => cache.GetAsync(KeyFor(client.Id), A<CancellationToken>._)).Returns(bytes));

capture.Set(client);
});
}

/// <summary>Cache miss for every key.</summary>
public static ITestArrange CacheHasNoClient(this ITestArrange arrange)
{
return arrange.Then(host =>
{
host.Execute<IDistributedCache>(cache =>
A.CallTo(() => cache.GetAsync(A<string>._, A<CancellationToken>._)).Returns((byte[]?)null));
});
}

/// <summary>The cache read fails, exercising the service's graceful-degradation path.</summary>
public static ITestArrange CacheReadFails(this ITestArrange arrange)
{
return arrange.Then(host =>
{
host.Execute<IDistributedCache>(cache =>
A.CallTo(() => cache.GetAsync(A<string>._, A<CancellationToken>._))
.ThrowsAsync(new InvalidOperationException("cache unavailable")));
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Mokkit.Containers.Common;
using Mokkit.Containers.FakeItEasy;
using Mokkit.Containers.Microsoft.Extensions.DependencyInjection;
using Mokkit.Example1.Application.Logic.Persistence;
using Mokkit.Example1.Infrastructure.Logic.Cache;
using Mokkit.Suite;
using TUnit.Core.Interfaces;

namespace Mokkit.Example1.TUnit.Tests.Cache;

/// <summary>
/// The stage composition, built once per test class (TUnit drives <see cref="IAsyncInitializer"/> /
/// <see cref="IAsyncDisposable"/> for a <c>[ClassDataSource]</c>). SUT: the real <c>ClientCacheService</c>;
/// dependency: a <b>FakeItEasy</b> fake <see cref="IDistributedCache"/>, bridged into the real Microsoft DI
/// graph via <c>ResolveFromStage</c> — the same mock→DI bridge the xUnit/NUnit suites use, different mock lib.
/// </summary>
public sealed class CacheServiceFixture : IAsyncInitializer, IAsyncDisposable
{
private TestStageSetup _setup = null!;

public async Task InitializeAsync()
{
var fakes = new FakeItEasyContainerBuilder()
.UseInit(fakeCollection =>
{
fakeCollection.AddFake<IDistributedCache>();
return Task.CompletedTask;
});

var services = new ServiceProviderContainerBuilder()
.UseInit(collection =>
{
collection.AddScoped<ILogger, NullLogger>();
collection.AddScoped(typeof(ILogger<>), typeof(NullLogger<>));
collection.AddScoped<IClientCacheService, ClientCacheService>();
return Task.CompletedTask;
})
.UsePreBuild<IMockCollection<object>>(InjectFakes);

_setup = await TestStageSetup.Create(fakes, services);
}

public ValueTask DisposeAsync() => ValueTask.CompletedTask;

/// <summary>Enters a fresh, isolated stage (new fakes) for a single test.</summary>
public TestStage EnterStage() => _setup.EnterStage();

private static Task InjectFakes(IServiceCollection services, IMockCollection<object> fakes)
{
foreach (var registration in fakes.Registrations)
{
services.ResolveFromStage(registration.InnerType);
}

return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using Mokkit.Example1.Application.Logic.Persistence;
using Mokkit.Example1.Domain.Entities;
using Mokkit.Suite;

namespace Mokkit.Example1.TUnit.Tests.Cache;

/// <summary>
/// The same <c>ClientCacheService</c> tests as the xUnit suite, running under <b>TUnit</b>. The bodies are
/// byte-for-byte the Mokkit Arrange / Act / Inspect flow; only the framework wiring differs — the fixture is
/// injected with <c>[ClassDataSource]</c> and each test is a <c>[Test]</c>, with the fresh stage entered by
/// the base's <c>[Before(Test)]</c> hook.
/// </summary>
public sealed class ClientCacheServiceTests : TUnitTestBase
{
[ClassDataSource<CacheServiceFixture>(Shared = SharedType.PerClass)]
public required CacheServiceFixture Fixture { get; init; }

protected override TestStage EnterStage() => Fixture.EnterStage();

[Test]
public async Task GetClient_WhenCached_ReturnsDeserializedClient()
{
// ARRANGE
await Arrange.CacheHasClient(out var client);

// ACT
var result = await GetClient(client.Value!.Id);

// INSPECT
await Inspect
.RetrievedClientMatching(result, client.Value!)
.CacheQueried(client.Value!.Id);
}

[Test]
public async Task GetClient_WhenMiss_ReturnsNull()
{
// ARRANGE
var clientId = Guid.NewGuid();
await Arrange.CacheHasNoClient();

// ACT
var result = await GetClient(clientId);

// INSPECT
await Inspect
.RetrievedNothing(result)
.CacheQueried(clientId);
}

[Test]
public async Task GetClient_WhenCacheThrows_DegradesToNull()
{
// ARRANGE
await Arrange.CacheReadFails();

// ACT
var result = await GetClient(Guid.NewGuid());

// INSPECT
await Inspect.RetrievedNothing(result);
}

[Test]
public async Task SetClient_SerializesAndStoresWithExpiry()
{
// ARRANGE
await Arrange.AClient(out var client);

// ACT
await StoreClient(client.Value!);

// INSPECT
await Inspect.CacheStored(client.Value!);
}

[Test]
public async Task RemoveClient_RemovesKey()
{
// ARRANGE
var clientId = Guid.NewGuid();

// ACT
await RemoveClient(clientId);

// INSPECT
await Inspect.CacheRemoved(clientId);
}

private Task<Client?> GetClient(Guid clientId) =>
Stage.ExecuteAsync<IClientCacheService, Client?>(cache => cache.GetClientAsync(clientId));

private Task StoreClient(Client client) =>
Stage.ExecuteAsync<IClientCacheService>(cache => cache.SetClientAsync(client));

private Task RemoveClient(Guid clientId) =>
Stage.ExecuteAsync<IClientCacheService>(cache => cache.RemoveClientAsync(clientId));
}
Loading
Loading