Skip to content

How to structure a Mokkit test project

Mokkit doesn’t prescribe a folder layout, but a good one makes your vocabulary discoverable and your fixtures obvious. This is the structure the example uses across three suites.

Colocate vocabulary with what it describes

Section titled “Colocate vocabulary with what it describes”

Keep the verbs for a feature (or system-under-test) next to that feature’s tests, in the same namespace — so they’re in scope with no extra usings. The canonical unit of organisation is a folder holding four files:

Features/Client/SaveClient/
├── ArrangeSaveClient.cs # arrange verbs (build commands, seed state)
├── ActSaveClient.cs # act verbs (the operation under test)
├── InspectSaveClient.cs # inspect verbs (observe results & effects)
└── SaveClientCommandHandlerTests.cs

Arrange<Feature>.cs / Act<Feature>.cs / Inspect<Feature>.cs mirror the three phases. A test in this folder reads as await Arrange.…, await Act.…, await Inspect.… using words defined right beside it.

Genuinely cross-feature verbs (a Clock/Ids arrange, a shared DbClient seed) go in a root-namespace Helpers/ folder, so enclosing-namespace lookup makes them visible to every feature test.

Put the Stage composition in a base fixture that builds once and exposes the three phases as properties:

protected TestStage Stage { get; }
protected ITestArrange Arrange => Stage.Arrange();
protected ITestAct Act => Stage.Act();
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 / TUnit — only the fixture attributes differ.

One container build → one fixture per SUT

Section titled “One container build → one fixture per SUT”

Because containers are built once, a type is either the real SUT or a mock in a given composition — not both. So each system-under-test gets its own small fixture, declaring its real type plus mocks for its direct dependencies:

public sealed class ProcessorFixture : BaseStageFixture
{
protected override void ConfigureSubstitutes(ISubstituteCollection s)
{
s.AddSubstitute<IRequestHandler<SaveClientCommand, SaveClientCommandResult>>();
s.AddSubstitute<IKafkaEventPublisher>();
}
protected override void ConfigureServices(IServiceCollection services) =>
services.AddScoped<IClientStatusChangedProcessor, ClientStatusChangedProcessor>();
}

One rule underpins the whole layout:

Arrange and Act produce artifacts. Inspect only observes them — reads are fine, mutations are not.

No raw setup or assertions in a test body: every value is a business-named arrange, every check a business-named 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.

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

Unit.Tests/ # organised BY SUT/concern (portability proof: xUnit + NSubstitute)
├── BaseStageFixture.cs BaseUnitTest.cs
├── Containers/ # a bring-your-own NSubstitute adapter
├── Cache/ { CacheServiceFixture, ArrangeCache, InspectCache, …Tests }
├── Validation/ { ValidatorFixture, ArrangeCommand, InspectValidation, …Tests }
└── Messaging/ # one concern, several SUTs → subfolder per SUT
├── Processor/ { ProcessorFixture, ArrangeProcessor, InspectProcessor, …Tests }
├── Publisher/ { … }
└── Consumer/ { … }
Integration.Tests/ # organised BY FEATURE (NUnit + Moq + Postgres/Respawn)
├── BaseIntegrationTest.cs
├── Helpers/ { ArrangeClient, InspectClient, VerifierSetup } # cross-feature, root namespace
└── Features/Client/
├── GetClient/ { ArrangeGetClient, ActGetClient, InspectGetClient, …Tests }
└── SaveClient/ { ArrangeSaveClient, ActSaveClient, InspectSaveClient, …Tests, *.verified.txt }
E2E.Tests/ # organised BY EXTERNAL SURFACE (xUnit + Testcontainers, nothing mocked)
├── BaseE2ETest.cs E2EStack.cs E2ECollection.cs KafkaProbe.cs Poll.cs
├── 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). 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.

One test package per system-under-test, colocated with it; the suite files follow the placement rule from the conventions:

internal/billing/
service.go
service_test.go # tests only
fixture_test.go # TestMain, composition, fixture, tokens — no verbs
vocabulary_test.go # the verbs, in Arrange, Act and Inspect sections
fakes_test.go # hand doubles, when not generated
tests/
harness/ # a real package: containers, migrations, stack bring-up
integration/<feature>/ # suites over real infrastructure
e2e/ # the whole application, driven over HTTP

The vocabulary splits by phase (arrange_test.go, act_test.go, inspect_test.go) once it is a few hundred lines, and a suite of a handful of tests may keep fixture and vocabulary in one suite_test.go.

Two Go-specific notes: the shared harness is a normal (non-_test) package so several suites can use it, and generated mocks live next to the interface they double (mocks_test.go in the package, or an internal/ fixture package inside the adapter that owns them).