Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/asserting-on-events-when-there-are-no-getters && read --section="top" 0%
.NET

Asserting on Events When There Are No Getters

Two test files, one estate, one deliberate contrast - and the answer to the question a fully encapsulated aggregate forces on you. Six test methods in 14,559 lines, by design.

By Kumar Chandrachooda 29 Nov 2025 6 min read
An assertion arrow bending away from a sealed box towards the event it emitted

The standard objection to a fully encapsulated aggregate arrives about ninety seconds into the first code review. How do I test it? You called RejectChange(), the object swallowed the call, and there is no property to look at. Nothing came back. Did anything happen?

Part 4 established the asymmetry: Sales' Reservation has sixteen public getters, Postsale's ReservationChangeRequest has one. GroupFlights' README puts a test file beside each of them and says, in effect, this is the difference. It is the estate's cleanest piece of teaching, and it is worth reading both files whole.

Style A: the public-getter model

// src/Sales/GroupFlights.Sales.Domain.UnitTests/Offers/OfferDraftTests.cs:26-47
[Fact]
public void OfferDraft_AddNewVariant_DoesNotAllowOverdueVariant()
{
    _clock.SetUtcNow(DateTime.UtcNow.AddDays(31));

    var draft = PrepareDraft();
    var flightSegment = PrepareSegmentFromRequestedTravel(draft.RequestedTravel);

    Assert.Throws<CannotAddAlreadyOverdueVariant>(() =>
    {
        draft.AddNewVariant(
            AirlineType.Traditional,
            "Imaginary Airlines",
            new[] { flightSegment },
            null,
            new("IMG/OFF/123"),
            _clock,
            _salesConfiguration,
            _bufferConfiguration,
            _clock.UtcNow.AddHours(-1));
    });
}

The test project references Sales.Domain and nothing else — no mocking library at all. Time is faked by an eleven-line nested TestClock implementing the estate's IClock, which works because GroupFlights threads the clock into domain methods as a parameter rather than injecting it into entities. That single choice is what keeps the aggregate constructible with new and therefore testable without a container.

The arrangement is the expensive part. PrepareDraft builds an OfferSource, a Client, an Email, a RequestedTravel with a nested Flight, two Airports, a PassengersData and two PriorityChoices — nine validating value objects to exercise one rule. Each one enforces its own invariants, so the fixture is really a small integration test of the value-object layer that happens to end in an aggregate.

And here is the part I did not expect. The file has public getters available and never asserts on one. draft.Variants is public; Assert.Single(draft.Variants) would be a one-liner. The single fact asserts an exception type and stops. The getters are used in the arrangePrepareSegmentFromRequestedTravel(draft.RequestedTravel) reads three of them to build a consistent input — which is a genuinely different job from assertion.

Style B: the sealed model

// src/Postsale/GroupFlights.Postsale.UnitTests/Changes/ReservationChangeRequestTests.cs:64-76
[Fact]
public async Task ReservationChangeRequest_ChangesStatus_OnReject()
{
    var changeRequest = await PrepareChangeRequest();

    changeRequest.RejectChange();

    var emittedEvent = changeRequest.DomainEvents
        .OfType<ReservationChangeRequestFinalized>().SingleOrDefault();

    Assert.Equal(expected: ReservationChangeRequest.CompletionStatus.ChangeRejectedByRequester,
        emittedEvent?.CompletionStatus);
}

There it is. _completionStatus is a private field with no accessor, so the test does not read the aggregate's state — it reads the aggregate's output. DomainEvents is inherited from DomainEventsSource, the aggregate enqueues a ReservationChangeRequestFinalized on every terminal transition, and the test filters the queue by type and inspects the payload.

The assertion target moved from what the object is to what the object said. Three consequences follow, and they are the substance of the technique.

It asserts the contract, not the implementation. _completionStatus could be renamed, retyped, replaced with a state object or split into three booleans, and this test keeps passing — because what it verifies is the published fact, and the published fact is what the rest of the system actually consumes. That is a strictly better coupling than asserting on a property name.

Only event-emitting transitions are testable. SetUpChangeFeasibility writes _newCost, _newTravel and _paymentRequiredToApplyChange. Those are observable only through the payload of ReservationChangeFeasibilitySet. Anything the aggregate changes and does not announce is untestable by construction. That cuts both ways: it is real design pressure towards meaningful events, and it is a permanent blind spot wherever the event model is thinner than the state model.

emittedEvent?.CompletionStatus weakens the diagnosis. If no event is emitted at all, the null-conditional yields null, the assertion still fails, and the failure message reads “expected ChangeRejectedByRequester, actual null” rather than “no event was emitted”. Two quite different bugs report identically. A separate Assert.NotNull(emittedEvent) would cost one line and split them.

Where the cost really lands

Not in the assertion. In the arrangement.

The aggregate's only usable constructor is internal (ReservationChangeRequest.cs:30), and Postsale.UnitTests is a separate assembly with no InternalsVisibleTo — the estate uses that attribute exactly once, in a different module. So the test cannot construct the object it is named after. It has to go through ReservationChangeRequestDomainService, which means stubbing ISalesApi and IReservationChangeRequestRepository with NSubstitute, which means building a full ReservationToChangeDto graph: a cost, a flight segment with two airports and a flight time, a required payment with a deadline, and a passenger-names deadline.

That block runs to about thirty lines, and it is duplicated verbatim across both facts — once inline in the first test, once inside PrepareChangeRequest for the second.

Which gives the file its sharpest property. ReservationChangeRequestTests is named after the aggregate, but its first fact tests the domain service, and its second reaches the aggregate only by going through the service first. The most encapsulated aggregate in the estate is the one you can never test in isolation.

The ledger

Style A — public getters Style B — sealed
Arrange new on the aggregate; nine value-object constructors route through a domain service; two NSubstitute doubles
Test project references Sales.Domain only Postsale.Domain + NSubstitute
Assertion surface properties (available, unused here) emitted domain events only
Coupled to field and property names the published event contract
The “unit” under test is a unit a domain service with doubles
Structurally untestable nothing any state change that emits no event
Reads state via getters the read model

The distilled version: encapsulation did not remove the need to observe the aggregate; it relocated observation from getters to events, and relocated the cost from assertion to arrangement. If your events are rich, that trade is excellent. If they are thin, you have bought silence.

Six test methods, and why that is fine

Across 14,559 lines and 35 projects, GroupFlights has six test methods. One covers Email in Shared.UnitTests, one is OfferDraftTests, two are ReservationChangeRequestTests, and the remainder are equally sparse.

Of AddNewVariant's five rules, one is tested. ConfirmVariant, RevealToClient, the whole of Offer, both reservation aggregates, all six specifications, the haul-based fee policy and all four event factories have none. The mini-framework — dispatchers, module registration, access control, the scheduler — has none.

Calling that “under-tested” would be a category error. The README frames the two domain test files as a contrast between two testing styles, not as a safety net, and once you accept that framing the coverage number is irrelevant: they are worked examples with [Fact] attributes on them.

There is also a structural reason no integration tests exist, and it is not a choice anybody made. Modules holds two static mutable dictionaries and registers via Dictionary.Add (RegistrationExtensions.cs:8-20). A WebApplicationFactory test booting a second host in the same process hits a duplicate key and throws before the first request. Two static fields foreclosed an entire test category, and nothing in the repository records that.

The fair summary is the one I would want applied to my own teaching code: the tests here are documentation that compiles. Judge them as documentation. They are good at it — and the technique in Style B is worth stealing whole, regardless of how few times this repository uses it.

Next, the persistence arc begins. The aggregate as one column — Sales serialises entire aggregates into jsonb, and the reflection resolver that makes it possible bypasses every invariant on the way back in.