Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Testing the Generated Stack

A framework that emits 1,400 lines of code from 35 lines of attributes raises an obvious question: what is there to test? The generated code is presumed correct by construction — the generator's own test suite covers it once, and every consumer benefits — but there are still four layers that must be tested in every project: the invariants that the developer wrote inside aggregates, the wiring that depends on the developer's specific configuration, the UI behavior that emerges from the interaction of generated components with hand-written customizations, and the requirement chain that links business intent to executable assertions.

This part shows how each layer is tested with the same dotnet test command, the same xUnit fixtures, and the same Result types as the rest of the application. There are no bespoke testing harnesses; the CMF treats tests as ordinary code that the analyzers and generators understand.

What the CMF Generates for Tests

Before discussing test code, it is worth listing what the generator emits to make tests easier to write. None of these files are tests themselves — they are scaffolding that the test project consumes:

Generated artifact Where Used by
In-Memory<Aggregate>Repository.g.cs MyStore.Lib.Testing Unit tests for command handlers
<Aggregate>TestBuilder.g.cs MyStore.Lib.Testing Tests that need a valid aggregate without typing every property
<Workflow>Transitions.g.cs MyStore.Lib.Testing Workflow tests that exhaustively walk the state machine
Fake<EventBus>.g.cs MyStore.Lib.Testing Capture domain events for assertion
RequirementCoverageManifest.g.cs MyStore.Lib.Testing The map from [TestFor("FEATURE-101")] to test method
MyStoreTestHostBuilder.g.cs MyStore.Lib.Testing A pre-wired IHost for integration tests

The presence of MyStore.Lib.Testing as its own project is deliberate: test fixtures are first-class citizens in the solution and ship under their own namespace, not buried inside MyStore.Tests. Production code never references them (CMF310 analyzer enforces this), but every test project does.

Layer 1: Aggregate Invariants

Aggregates are pure data + behavior — no DI container, no database, no HTTP. The unit-test pattern is correspondingly simple: instantiate via the generated builder, mutate, assert.

public class OrderInvariantTests
{
    [Fact]
    public void An_order_with_no_lines_cannot_be_built()
    {
        var result = new Order.Builder()
            .WithPlacedAt(DateTime.UtcNow)
            .WithStatus(OrderStatus.Pending)
            .WithCustomerId(new CustomerId(Guid.NewGuid()))
            .WithShippingAddress(ShippingAddress.Sample())
            .Build();

        result.IsSuccess.Should().BeFalse();
        result.AsFailure().Code.Should().Be("ORD-001");
    }

    [Fact]
    public void Adding_a_negative_quantity_line_fails_the_invariant_immediately()
    {
        var order = OrderTestBuilder.Valid().Build().Value;

        var result = order.AddLine(new OrderLine(new ProductId(Guid.NewGuid()), -3, Money.Eur(10)));

        result.IsSuccess.Should().BeFalse();
        result.AsFailure().Code.Should().Be("ORD-002");
        order.Lines.Should().HaveCount(1, "the original line, the failed addition was rejected");
    }
}

OrderTestBuilder.Valid() is generated from the [AggregateRoot] declaration. It produces an instance whose every required property is filled with a deterministic sample value, so a test that only cares about one invariant does not have to type the other twenty. When the developer adds a new [Property], the builder is regenerated and existing tests keep compiling.

The key property of invariant tests is that they do not need a DbContext. The aggregate is the unit; everything around it (repositories, command bus, EF) is irrelevant to the invariant under test. This keeps invariant tests at a few milliseconds each, which is the right speed for the inner TDD loop.

Layer 2: Command Handlers

Command handlers are slightly larger than aggregates because they coordinate a repository and an event bus. The CMF supplies in-memory implementations of both so handlers can be tested without a database:

public class CreateOrderHandlerTests
{
    private readonly InMemoryOrderRepository _repo = new();
    private readonly FakeEventBus _events = new();
    private readonly CreateOrderCommandHandler _handler;

    public CreateOrderHandlerTests()
    {
        _handler = new CreateOrderCommandHandler(_repo, _events);
    }

    [Fact]
    public async Task Creates_the_order_and_publishes_a_domain_event()
    {
        var cmd = OrderCommandBuilder.CreateOrder().Build();

        var result = await _handler.HandleAsync(cmd, CancellationToken.None);

        result.IsSuccess.Should().BeTrue();
        _repo.Saved.Should().HaveCount(1);
        _events.Published.Should().ContainSingle(e => e is OrderPlacedEvent);
    }

    [Fact]
    public async Task Failure_does_not_publish_any_events_or_save_state()
    {
        var cmd = OrderCommandBuilder.CreateOrder().WithNoLines().Build();

        var result = await _handler.HandleAsync(cmd, CancellationToken.None);

        result.IsSuccess.Should().BeFalse();
        _repo.Saved.Should().BeEmpty();
        _events.Published.Should().BeEmpty();
    }
}

The negative test (the second one) is the more interesting one. It asserts a property the generator maintains: when a command handler returns a failure Result, no side effects occur. The test exists not because the developer might break it — they cannot, because the side effects live inside the generated handler — but to detect a regression in the generator itself when the project is upgraded to a newer Cmf.Generators version. These "guard tests" are the canonical pattern for catching generator drift.

Layer 3: EF Core Configurations and Repositories

EF mappings are generated, but the schema they produce against a real database is the only ground truth. A small set of integration tests against an actual Postgres instance (Docker Compose, brought up by the test fixture) catches subtle problems like enum value casing, owned-type column names, and jsonb round-tripping for StreamFields.

public class OrderRepositoryIntegrationTests : IClassFixture<PostgresFixture>
{
    private readonly PostgresFixture _db;
    public OrderRepositoryIntegrationTests(PostgresFixture db) { _db = db; }

    [Fact]
    public async Task Round_trip_preserves_owned_value_objects_and_lines()
    {
        await using var ctx = _db.NewContext();
        var repo = new OrderRepository(ctx);

        var original = OrderTestBuilder.Valid().WithLines(3).Build().Value;
        await repo.SaveAsync(original);
        ctx.ChangeTracker.Clear();

        var loaded = await repo.GetAsync(original.Id);

        loaded.Should().NotBeNull();
        loaded!.Lines.Should().HaveCount(3);
        loaded.ShippingAddress.Should().Be(original.ShippingAddress);
        loaded.Status.Should().Be(original.Status);
    }
}

PostgresFixture uses Testcontainers for .NET so the suite is hermetic — no shared CI database. The fixture runs cmf migrate --apply once per test class to materialize the schema, which exercises the generated migration alongside the generated configuration. This single test covers Stages 2 (config), 4 (migration), and the runtime EF behavior in one shot.

Layer 4: Blazor Components with bUnit

Generated Blazor components (admin lists, forms, public widgets) are tested with bUnit, the standard Blazor component testing library. The pattern is to render the component with realistic services, dispatch a user interaction, and assert the resulting markup or callback.

public class ProductListWidgetTests : TestContext
{
    [Fact]
    public void Renders_one_card_per_product_in_the_search_result()
    {
        Services.AddSingleton<IProductsApi>(new FakeProductsApi(
            ProductDtoBuilder.SampleList(count: 5)));

        var cut = RenderComponent<ProductListWidget>(p => p
            .Add(c => c.MaxPerPage, 12)
            .Add(c => c.CategoryFilter, new CategoryId(Guid.NewGuid())));

        cut.FindAll(".product-card").Should().HaveCount(5);
        cut.Find(".pager").TextContent.Should().Contain("1–5 of 5");
    }

    [Fact]
    public async Task Clicking_a_card_navigates_to_the_product_detail_page()
    {
        var nav = Services.AddMockNavigationManager();
        Services.AddSingleton<IProductsApi>(new FakeProductsApi(ProductDtoBuilder.SampleList(1)));
        var cut = RenderComponent<ProductListWidget>();

        await cut.Find(".product-card").ClickAsync();

        nav.History.Should().ContainSingle(h => h.Uri.EndsWith("/catalog/sample-product"));
    }
}

The interesting part is FakeProductsApi. This is not a mock — it is a real implementation of the same IProductsApi interface that the generator emits, backed by an in-memory list. Because IProductsApi lives in MyStore.Shared, the test references the same contract the production WASM client uses, so a breaking change in the API surface trips the test rather than only manifesting in the browser.

Layer 5: Workflow State Machines

Workflow tests have an obligation that ordinary tests do not: they must cover every transition, because an unreached transition is dead code that will surprise someone in production. The generator helps by emitting an EditorialWorkflowTransitions.g.cs that lists every legal (from, event) pair as a TheoryData:

public class EditorialWorkflowTransitionTests
{
    [Theory]
    [MemberData(nameof(EditorialWorkflowTransitions.AllLegal),
                MemberType = typeof(EditorialWorkflowTransitions))]
    public void Every_legal_transition_with_a_satisfying_actor_succeeds(
        WorkflowStage from, WorkflowEvent evt, string requiredRole)
    {
        var product = ProductTestBuilder.InStage(from).Build().Value;
        var actor   = TestActor.WithRole(requiredRole);
        var wf      = new EditorialWorkflow(actor);

        var result = wf.Apply(product, evt);

        result.IsSuccess.Should().BeTrue();
        product.WorkflowStage.Should().NotBe(from, "the transition should have moved the product");
    }

    [Theory]
    [MemberData(nameof(EditorialWorkflowTransitions.AllIllegal),
                MemberType = typeof(EditorialWorkflowTransitions))]
    public void Every_illegal_transition_returns_a_typed_failure(
        WorkflowStage from, WorkflowEvent evt)
    {
        var product = ProductTestBuilder.InStage(from).Build().Value;
        var wf      = new EditorialWorkflow(TestActor.Admin);

        var result = wf.Apply(product, evt);

        result.IsSuccess.Should().BeFalse();
        result.AsFailure().Code.Should().StartWith("WF-");
    }
}

The generator emits both AllLegal and AllIllegal data sources from the [Transition] declarations, so a developer who adds a new transition does not have to remember to add a test for it — the theory automatically picks it up. A removed transition removes the corresponding row, so dead-code tests cannot accumulate.

Layer 6: Generated REST and GraphQL Endpoints

The CMF emits WebApplicationFactory<TEntryPoint>-compatible test hosts. A generated MyStoreApiTests base class hides the boilerplate:

public class OrdersApiTests : MyStoreApiTestBase
{
    [Fact]
    public async Task POST_api_orders_with_a_valid_payload_returns_201_and_a_location_header()
    {
        var cmd = OrderCommandBuilder.CreateOrder().AsHttpJson();

        var response = await Client.PostAsync("/api/orders", cmd);

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        response.Headers.Location.Should().NotBeNull();
        var dto = await response.ReadAsJsonAsync<OrderDto>();
        dto.Lines.Should().NotBeEmpty();
    }

    [Fact]
    public async Task GraphQL_query_returns_the_same_order_via_the_graph()
    {
        var seeded = await Seed(OrderTestBuilder.Valid().Build().Value);

        var resp = await GraphQL("{ order(id: \"" + seeded.Id.Value + "\") { status lines { quantity } } }");

        resp["data"]!["order"]!["status"]!.GetValue<string>().Should().Be("Pending");
    }
}

Both layers (REST and GraphQL) hit the same generated handlers, so a single semantic test (e.g. "creating an order makes it queryable") often runs against both surfaces with two assertions instead of two separate test paths.

Layer 7: The Requirements Chain

This is the layer that has no equivalent in conventional CMS testing. Every test that ships in the suite is decorated with [TestFor("FEATURE-XXX")], and a build-time analyzer cross-references the manifest against the requirements registry. The crucial properties to verify are:

Property Test pattern
Every feature with Lifecycle = Done has at least one [TestFor] The build fails (CMF402 promoted to error) if any does not
Every test method's [TestFor] references an existing feature ID CMF403
Every acceptance criterion declared on a feature has a [TestForAcceptance] CMF421 (warning by default, error in CI)
Tests that target a Critical feature run on every PR (no [Trait("Slow", "true")]) CMF422

Inspecting the manifest at runtime is also useful during local development:

$ dotnet test --list-tests | cmf coverage --features
  FEATURE-101  Editors can publish products              ✓ 4 tests
  FEATURE-102  Storefront shows published products only  ✓ 2 tests
  FEATURE-103  Products have at least one variant        ✓ 1 test
  FEATURE-104  Inventory is decremented on order placed  ✗ 0 tests

The last row is a build-time failure under the default CI configuration, which is what makes the chain real rather than aspirational.

Test Pyramid in Practice

Combining the seven layers gives a familiar pyramid, but the proportions are skewed by the fact that most of the production code is generated and therefore tested once, in the generator's own suite:

                 ▲
               ╱   ╲      ~5%   end-to-end (Playwright through the WASM client)
              ╱─────╲
             ╱       ╲    ~15%  integration (Postgres, API, workflow E2E)
            ╱─────────╲
           ╱           ╲  ~20%  component (bUnit on Blazor, generated components)
          ╱─────────────╲
         ╱               ╲ ~60%  unit (invariants, command handlers, validators)
        ╱─────────────────╲

The pyramid is intentionally heavier at the bottom than a typical .NET project's pyramid, because most of the code that would normally live higher up — controllers, mappers, EF configurations, DTO converters — is generated and verified in the generator's own suite. The application's tests can therefore concentrate on what the generator cannot know: the business invariants, the workflow guards, and the user-visible behavior.

Test Performance Targets

Suite Target Mechanism
Unit (invariants + handlers) < 5s for 1,000 tests Pure C#, no DI container, in-memory fakes
Component (bUnit) < 30s for 200 tests Single TestContext per test, no JSInterop boots
Integration (Postgres, API) < 90s for 100 tests One Testcontainer per fixture class, parallel classes
End-to-end (Playwright) < 5min for 30 scenarios Sequential, runs once per PR

Anything that drifts above these targets is treated as a regression, not a budget overrun, because slow tests are the canonical reason CMS projects stop being TDD'd around their second year. The CMF actively defends the inner loop.

Where the Generator's Own Tests Live

Everything above describes tests for application code. The generator itself is tested in the Cmf.Generators.Tests suite, which uses Roslyn's CSharpGeneratorDriver to feed sample inputs and snapshot the outputs with Verify. Those snapshots are the contract: a PR that changes a generator must update the snapshots, the diff is reviewable, and downstream projects can pin a generator version with confidence. The snapshot tests are documented in the Cmf.Generators repository and are out of scope for this article — but their existence is the reason application authors do not have to test what the generator emits.

⬇ Download