diff --git a/README.md b/README.md index 3ceeb83..bde1e7a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/src/content/docs/concepts/stage.md b/docs/src/content/docs/concepts/stage.md index 7d7315a..c77a878 100644 --- a/docs/src/content/docs/concepts/stage.md +++ b/docs/src/content/docs/concepts/stage.md @@ -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. @@ -69,8 +69,28 @@ public abstract class BaseUnitTest : IClassFixture, 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(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 diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 5a2685d..8493553 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -45,7 +45,7 @@ await Inspect uses it updates — and still compiles. - 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). diff --git a/docs/src/content/docs/introduction.md b/docs/src/content/docs/introduction.md index 3fb9c0e..82971d9 100644 --- a/docs/src/content/docs/introduction.md +++ b/docs/src/content/docs/introduction.md @@ -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 diff --git a/docs/src/content/docs/reference/project-structure.md b/docs/src/content/docs/reference/project-structure.md index 8b079a5..494c6bf 100644 --- a/docs/src/content/docs/reference/project-structure.md +++ b/docs/src/content/docs/reference/project-structure.md @@ -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 @@ -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: @@ -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 diff --git a/docs/src/content/docs/why-mokkit.md b/docs/src/content/docs/why-mokkit.md index fe024a0..f4c063b 100644 --- a/docs/src/content/docs/why-mokkit.md +++ b/docs/src/content/docs/why-mokkit.md @@ -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). diff --git a/example/Example1/Directory.Packages.props b/example/Example1/Directory.Packages.props index 9870761..c370c80 100644 --- a/example/Example1/Directory.Packages.props +++ b/example/Example1/Directory.Packages.props @@ -46,8 +46,12 @@ + + + + diff --git a/example/Example1/Mokkit.Example1.sln b/example/Example1/Mokkit.Example1.sln index 4d26f4e..1ccf74c 100644 --- a/example/Example1/Mokkit.Example1.sln +++ b/example/Example1/Mokkit.Example1.sln @@ -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 @@ -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} diff --git a/example/Example1/src/Mokkit.Example1.Infrastructure/Mokkit.Example1.Infrastructure.csproj b/example/Example1/src/Mokkit.Example1.Infrastructure/Mokkit.Example1.Infrastructure.csproj index a3ec62a..c32e673 100644 --- a/example/Example1/src/Mokkit.Example1.Infrastructure/Mokkit.Example1.Infrastructure.csproj +++ b/example/Example1/src/Mokkit.Example1.Infrastructure/Mokkit.Example1.Infrastructure.csproj @@ -25,6 +25,7 @@ + diff --git a/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ArrangeCache.cs b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ArrangeCache.cs new file mode 100644 index 0000000..4dd0ef3 --- /dev/null +++ b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ArrangeCache.cs @@ -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; + +/// +/// Arrange building blocks for the cache service: build the data to act on and shape what the faked +/// returns. (GetStringAsync/SetStringAsync are extension methods +/// FakeItEasy can't intercept, so we configure the real members GetAsync/SetAsync.) +/// +public static class ArrangeCache +{ + public static string KeyFor(Guid clientId) => $"client:{clientId}"; + + /// Builds and captures a client to act on (no cache interaction). + public static ITestArrange AClient( + this ITestArrange arrange, + out Capture clientCapture, + Action? mutate = null) + { + var capture = Capture.Start(out clientCapture); + return arrange.Then(_ => capture.Set(ClientFaker.NewClient(mutate))); + } + + /// Cache hit: the client's key resolves to its serialized form. The client is captured. + public static ITestArrange CacheHasClient( + this ITestArrange arrange, + out Capture clientCapture, + Action? 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(cache => + A.CallTo(() => cache.GetAsync(KeyFor(client.Id), A._)).Returns(bytes)); + + capture.Set(client); + }); + } + + /// Cache miss for every key. + public static ITestArrange CacheHasNoClient(this ITestArrange arrange) + { + return arrange.Then(host => + { + host.Execute(cache => + A.CallTo(() => cache.GetAsync(A._, A._)).Returns((byte[]?)null)); + }); + } + + /// The cache read fails, exercising the service's graceful-degradation path. + public static ITestArrange CacheReadFails(this ITestArrange arrange) + { + return arrange.Then(host => + { + host.Execute(cache => + A.CallTo(() => cache.GetAsync(A._, A._)) + .ThrowsAsync(new InvalidOperationException("cache unavailable"))); + }); + } +} diff --git a/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/CacheServiceFixture.cs b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/CacheServiceFixture.cs new file mode 100644 index 0000000..b98f6de --- /dev/null +++ b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/CacheServiceFixture.cs @@ -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; + +/// +/// The stage composition, built once per test class (TUnit drives / +/// for a [ClassDataSource]). SUT: the real ClientCacheService; +/// dependency: a FakeItEasy fake , bridged into the real Microsoft DI +/// graph via ResolveFromStage — the same mock→DI bridge the xUnit/NUnit suites use, different mock lib. +/// +public sealed class CacheServiceFixture : IAsyncInitializer, IAsyncDisposable +{ + private TestStageSetup _setup = null!; + + public async Task InitializeAsync() + { + var fakes = new FakeItEasyContainerBuilder() + .UseInit(fakeCollection => + { + fakeCollection.AddFake(); + return Task.CompletedTask; + }); + + var services = new ServiceProviderContainerBuilder() + .UseInit(collection => + { + collection.AddScoped(); + collection.AddScoped(typeof(ILogger<>), typeof(NullLogger<>)); + collection.AddScoped(); + return Task.CompletedTask; + }) + .UsePreBuild>(InjectFakes); + + _setup = await TestStageSetup.Create(fakes, services); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + /// Enters a fresh, isolated stage (new fakes) for a single test. + public TestStage EnterStage() => _setup.EnterStage(); + + private static Task InjectFakes(IServiceCollection services, IMockCollection fakes) + { + foreach (var registration in fakes.Registrations) + { + services.ResolveFromStage(registration.InnerType); + } + + return Task.CompletedTask; + } +} diff --git a/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ClientCacheServiceTests.cs b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ClientCacheServiceTests.cs new file mode 100644 index 0000000..975401a --- /dev/null +++ b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ClientCacheServiceTests.cs @@ -0,0 +1,98 @@ +using Mokkit.Example1.Application.Logic.Persistence; +using Mokkit.Example1.Domain.Entities; +using Mokkit.Suite; + +namespace Mokkit.Example1.TUnit.Tests.Cache; + +/// +/// The same ClientCacheService tests as the xUnit suite, running under TUnit. The bodies are +/// byte-for-byte the Mokkit Arrange / Act / Inspect flow; only the framework wiring differs — the fixture is +/// injected with [ClassDataSource] and each test is a [Test], with the fresh stage entered by +/// the base's [Before(Test)] hook. +/// +public sealed class ClientCacheServiceTests : TUnitTestBase +{ + [ClassDataSource(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 GetClient(Guid clientId) => + Stage.ExecuteAsync(cache => cache.GetClientAsync(clientId)); + + private Task StoreClient(Client client) => + Stage.ExecuteAsync(cache => cache.SetClientAsync(client)); + + private Task RemoveClient(Guid clientId) => + Stage.ExecuteAsync(cache => cache.RemoveClientAsync(clientId)); +} diff --git a/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ClientFaker.cs b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ClientFaker.cs new file mode 100644 index 0000000..5642213 --- /dev/null +++ b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/ClientFaker.cs @@ -0,0 +1,30 @@ +using Bogus; +using Mokkit.Example1.Domain.Entities; + +namespace Mokkit.Example1.TUnit.Tests.Cache; + +/// +/// Generates realistic data with Bogus. A fixed seed keeps generation deterministic so +/// tests are reproducible; callers can pin specific fields via . +/// +public static class ClientFaker +{ + public static readonly DateTime FixedUtcNow = new(2026, 1, 15, 9, 30, 0, DateTimeKind.Utc); + + private static readonly Faker Faker = new Faker() + .UseSeed(20260115) + .RuleFor(c => c.Id, f => f.Random.Guid()) + .RuleFor(c => c.Name, f => f.Company.CompanyName()) + .RuleFor(c => c.Email, f => f.Internet.Email()) + .RuleFor(c => c.Phone, f => f.Phone.PhoneNumber("+1##########")) + .RuleFor(c => c.Status, ClientStatus.Active) + .RuleFor(c => c.CreatedAt, FixedUtcNow) + .RuleFor(c => c.UpdatedAt, FixedUtcNow); + + public static Client NewClient(Action? mutate = null) + { + var client = Faker.Generate(); + mutate?.Invoke(client); + return client; + } +} diff --git a/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/InspectCache.cs b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/InspectCache.cs new file mode 100644 index 0000000..74a65c5 --- /dev/null +++ b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Cache/InspectCache.cs @@ -0,0 +1,83 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using Mokkit.Example1.Domain.Entities; +using Mokkit.Inspect; + +namespace Mokkit.Example1.TUnit.Tests.Cache; + +/// +/// Inspect building blocks that verify how the service interacted with the faked cache. Value assertions use +/// Shouldly (identical to the xUnit suite — only the framework changed); interaction assertions use FakeItEasy's +/// MustHaveHappened / MustNotHaveHappened. +/// +public static class InspectCache +{ + private static readonly TimeSpan ExpectedExpiration = TimeSpan.FromMinutes(30); + + /// Asserts the retrieved client equals the expected one (deep value comparison). + public static ITestInspect RetrievedClientMatching(this ITestInspect inspect, Client? result, Client expected) + { + return inspect.Then(_ => result.ShouldBeEquivalentTo(expected)); + } + + /// Asserts nothing was retrieved (cache miss / degraded read). + public static ITestInspect RetrievedNothing(this ITestInspect inspect, Client? result) + { + return inspect.Then(_ => result.ShouldBeNull()); + } + + /// Verifies the cache was read once for the client's key. + public static ITestInspect CacheQueried(this ITestInspect inspect, Guid clientId) + { + return inspect.Then(host => + { + host.Execute(cache => + A.CallTo(() => cache.GetAsync(ArrangeCache.KeyFor(clientId), A._)) + .MustHaveHappenedOnceExactly()); + }); + } + + /// Verifies the client was written to the cache, serialized, with the 30-minute expiry. + public static ITestInspect CacheStored(this ITestInspect inspect, Client expected) + { + var expectedJson = JsonSerializer.Serialize(expected); + + return inspect.Then(host => + { + host.Execute(cache => + A.CallTo(() => cache.SetAsync( + ArrangeCache.KeyFor(expected.Id), + A.That.Matches(b => Encoding.UTF8.GetString(b) == expectedJson), + A.That.Matches(o => o.AbsoluteExpirationRelativeToNow == ExpectedExpiration), + A._)) + .MustHaveHappenedOnceExactly()); + }); + } + + /// Verifies the client's key was removed from the cache. + public static ITestInspect CacheRemoved(this ITestInspect inspect, Guid clientId) + { + return inspect.Then(host => + { + host.Execute(cache => + A.CallTo(() => cache.RemoveAsync(ArrangeCache.KeyFor(clientId), A._)) + .MustHaveHappenedOnceExactly()); + }); + } + + /// Verifies nothing was ever written to the cache. + public static ITestInspect NothingStored(this ITestInspect inspect) + { + return inspect.Then(host => + { + host.Execute(cache => + A.CallTo(() => cache.SetAsync( + A._, + A._, + A._, + A._)) + .MustNotHaveHappened()); + }); + } +} diff --git a/example/Example1/src/Mokkit.Example1.TUnit.Tests/Mokkit.Example1.TUnit.Tests.csproj b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Mokkit.Example1.TUnit.Tests.csproj new file mode 100644 index 0000000..5c5696f --- /dev/null +++ b/example/Example1/src/Mokkit.Example1.TUnit.Tests/Mokkit.Example1.TUnit.Tests.csproj @@ -0,0 +1,41 @@ + + + + + Exe + net10.0 + enable + enable + + false + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/Example1/src/Mokkit.Example1.TUnit.Tests/TUNIT-CONVENTIONS.md b/example/Example1/src/Mokkit.Example1.TUnit.Tests/TUNIT-CONVENTIONS.md new file mode 100644 index 0000000..5199f9f --- /dev/null +++ b/example/Example1/src/Mokkit.Example1.TUnit.Tests/TUNIT-CONVENTIONS.md @@ -0,0 +1,64 @@ +# Mokkit TUnit-test conventions (a fourth stack) + +This suite exists to prove one thing: **the Mokkit Arrange / Act / Inspect flow is identical no matter the +test framework or mock library.** It re-runs the unit suite's `ClientCacheService` tests on a *completely* +different stack — **TUnit** instead of xUnit, **FakeItEasy** instead of NSubstitute — with the Mokkit code +unchanged. + +| Concern | Unit suite | **This suite** | +|---|---|---| +| Test framework | xUnit (VSTest) | **TUnit** (Microsoft.Testing.Platform) | +| Mocking | NSubstitute (custom container) | **FakeItEasy** (`Mokkit.Containers.FakeItEasy`) | +| DI / bridge | Microsoft DI + `ResolveFromStage` | **same** | +| Assertions | Shouldly | Shouldly (values) + FakeItEasy (interactions) | +| Infrastructure | none | **none** — pure unit, one faked dependency | + +The `Cache/` tests, arranges and inspects are byte-for-byte the same Mokkit calls as +[`Unit.Tests/Cache/`](../Mokkit.Example1.Unit.Tests/Cache/) — compare them side by side. + +--- + +## 1. TUnit ≠ VSTest — how this project is wired + +TUnit runs on **Microsoft.Testing.Platform (MTP)**, so the project is a console **executable**, not a VSTest +library. That means the csproj differs from the other suites: + +- `Exe` is **required** (omit it → `hostfxr.dll could not be found`). +- It must **not** reference `Microsoft.NET.Test.Sdk` or `coverlet.collector` (they break MTP discovery). +- The `TUnit` package pulls in the runner, core and assertions. + +## 2. Lifecycle — the only Mokkit-adjacent code that changes + +TUnit has no `IClassFixture`/`IAsyncLifetime`. The mapping to the same "build once, fresh stage per test" +shape ([`TUnitTestBase`](TUnitTestBase.cs) + [`CacheServiceFixture`](Cache/CacheServiceFixture.cs)): + +- **Composition, once per class** — the fixture implements TUnit's `IAsyncInitializer` (`InitializeAsync`) + + `IAsyncDisposable`, and the test class injects it with `[ClassDataSource(Shared = SharedType.PerClass)]`. +- **Fresh stage per test** — the base's `[Before(Test)]` hook calls `Fixture.EnterStage()`; `[After(Test)]` + disposes it. Base-class hooks are inherited, so concrete classes stay clean. +- Tests are `[Test]` methods; the bodies are ordinary `await Arrange… / await …Act… / await Inspect…`. + +`TestStageSetup.Create(...)` / `EnterStage()` / `Stage.Dispose()` are **identical** to the other suites. + +## 3. FakeItEasy through the stage + +The fake is registered with `Mokkit.Containers.FakeItEasy` (`fakeCollection.AddFake()`) and +bridged into the real Microsoft DI graph with `ResolveFromStage` (same loop the integration suite uses for +Moq). Because a FakeItEasy fake **is** the interface, the fake the arrange configures, the dependency the real +`ClientCacheService` receives, and the handle the inspect verifies are the **same object** — configured with +`A.CallTo(() => cache.GetAsync(…)).Returns(…)` and verified with `.MustHaveHappenedOnceExactly()`. + +(`GetStringAsync`/`SetStringAsync` are extension methods FakeItEasy can't intercept, so the arranges/inspects +target the real members `GetAsync`/`SetAsync` — same note as the NSubstitute version.) + +## 4. Running + +MTP and VSTest can't share a `dotnet test` run, so run this suite on its **own** — no Docker/DB needed: + +```bash +dotnet run --project src/Mokkit.Example1.TUnit.Tests +``` + +(The other suites keep running as before with `dotnet test src/Mokkit.Example1..Tests`.) +Do **not** run `dotnet test` over the whole solution — mixing MTP (this suite) and VSTest (the others) in one +run is unsupported. diff --git a/example/Example1/src/Mokkit.Example1.TUnit.Tests/TUnitTestBase.cs b/example/Example1/src/Mokkit.Example1.TUnit.Tests/TUnitTestBase.cs new file mode 100644 index 0000000..4c84a1e --- /dev/null +++ b/example/Example1/src/Mokkit.Example1.TUnit.Tests/TUnitTestBase.cs @@ -0,0 +1,28 @@ +using Mokkit.Arrange; +using Mokkit.Inspect; +using Mokkit.Suite; + +namespace Mokkit.Example1.TUnit.Tests; + +/// +/// Base for the TUnit suite. TUnit has no IClassFixture/IAsyncLifetime: a per-class fixture is +/// injected via [ClassDataSource] on the concrete class, and per-test setup/teardown use +/// [Before(Test)] / [After(Test)] hooks (inherited from this base). The Mokkit primitives are +/// identical to the xUnit/NUnit suites — only these framework hooks differ. +/// +public abstract class TUnitTestBase +{ + protected TestStage Stage { get; private set; } = null!; + + protected ITestArrange Arrange => Stage.Arrange(); + protected ITestInspect Inspect => Stage.Inspect(); + + /// Concrete classes return a fresh stage from their injected fixture. + protected abstract TestStage EnterStage(); + + [Before(Test)] + public void CreateStage() => Stage = EnterStage(); + + [After(Test)] + public void DisposeStage() => Stage.Dispose(); +} diff --git a/example/Example1/src/Mokkit.Example1.Unit.Tests/UNIT-CONVENTIONS.md b/example/Example1/src/Mokkit.Example1.Unit.Tests/UNIT-CONVENTIONS.md index d1c11dc..343ad4c 100644 --- a/example/Example1/src/Mokkit.Example1.Unit.Tests/UNIT-CONVENTIONS.md +++ b/example/Example1/src/Mokkit.Example1.Unit.Tests/UNIT-CONVENTIONS.md @@ -15,6 +15,10 @@ completely different surrounding stack. The Mokkit pieces (`Stage`, `Arrange`, `Inspect`, `Capture`, `host.Execute`) are identical to the integration suite — only the libraries around them changed. +> Taken further: the [`TUnit.Tests`](../Mokkit.Example1.TUnit.Tests/TUNIT-CONVENTIONS.md) suite re-runs this +> suite's `Cache` tests on yet a **fourth** stack — **TUnit** (Microsoft.Testing.Platform) + **FakeItEasy** — +> with the Mokkit code byte-for-byte identical. Compare `Cache/` in the two suites side by side. + --- ## 1. Bring-your-own mock library: the custom NSubstitute container