PouryaKit
Guides

Test a Feature

Run a Feature's Operations against the hosted application on a real PostgreSQL, through its Module's Gateway or over HTTP, and assert its Result, its rows and its deliveries.

What you build

Tests against the hosted application: the same composition Program runs, on PostgreSQL in a container, with the data reset between tests. PouryaKit.Testing provides the fixture, HostFixture, and the base class every test derives from, HostTest<TFixture>.

Docker must run, and tests run one at a time

The fixture starts PostgreSQL in Testcontainers before the first test. Tests that share a fixture run one at a time, and each starts with no rows and no stored deliveries.

When to use it

For every Feature, and for each guarantee your Module relies on. A business or validation failure is asserted on the Result's Errors, never on an exception. A generated solution already holds a test project and its fixture, whose smoke tests run the generated Host: see The smoke test.

Steps

Derive one fixture for the test assembly

Derive one HostFixture and register it with AssemblyFixture. Its CreateHost builds the application's Host with the same builder and pipeline methods Program uses, served in memory for HTTP tests and with deliveries at test speed. The fixture applies every Module's migrations, then starts the Host. The fixture shows the Proving Sample's.

Send Operations through the Gateway

Derive each test class from HostTest<TFixture> and send with Fixture.Gateway<TModule>(), each call one Operation in its own scope, exactly as the application does. Assert on the Result and read rows back with your own SQL:

DomainEventTests.cs
[Fact]
public async Task A_category_counts_a_new_product_in_the_same_commit_and_a_failed_count_leaves_no_product()
{
    var catalog = Fixture.Gateway<CatalogModule>();
    var lamps = Guid.CreateVersion7();
    await catalog.SendAsync(
        new CreateCategoryCommand(lamps, "Lamps"),
        TestContext.Current.CancellationToken
    );

    var created = await catalog.SendAsync(
        new CreateProductCommand(Guid.CreateVersion7(), lamps, "Desk lamp"),
        TestContext.Current.CancellationToken
    );
    var refused = await catalog.SendAsync(
        new CreateProductCommand(Guid.CreateVersion7(), Guid.CreateVersion7(), "Floor lamp"),
        TestContext.Current.CancellationToken
    );

    Assert.True(created.IsSuccess);
    Assert.Equal(CategoryErrors.NotFound, Assert.Single(refused.Errors));
    Assert.Equal(
        [
            [lamps, 1],
        ],
        await Fixture.RowsAsync("select id, product_count from catalog.categories")
    );
    Assert.Equal(
        ["Desk lamp"],
        (await Fixture.RowsAsync("select name from catalog.products")).Select(row => row[0])
    );
}

A Gateway call without a context runs as the test Actor with no tenant. A tenant-scoped Module refuses it, so pass a context with a tenant there: see The Operation Context in tests.

Wait for and assert deliveries

Fixture.WaitForDeliveriesAsync runs the Operation and waits until every delivery it caused is handled or set aside, deliveries those deliveries caused included, then returns one entry per delivery:

NotificationTests.cs
[Fact]
public async Task A_new_product_is_announced_by_the_host_after_it_is_committed()
{
    var catalog = Fixture.Gateway<CatalogModule>();
    var category = Guid.CreateVersion7();
    var product = Guid.CreateVersion7();
    await catalog.SendAsync(
        new CreateCategoryCommand(category, "Lamps"),
        TestContext.Current.CancellationToken
    );

    var (created, deliveries) = await Fixture.WaitForDeliveriesAsync(
        () =>
            catalog.SendAsync(
                new CreateProductCommand(product, category, "Lamp"),
                TestContext.Current.CancellationToken
            ),
        TestContext.Current.CancellationToken
    );

    Assert.True(created.IsSuccess);
    Assert.Equal(
        [
            new Delivery("billing.product-created", "catalog.product-created.v1", false),
            new Delivery(
                "catalog.product-created.announce-new-product",
                "catalog.product-created",
                false
            ),
            new Delivery(
                "catalog.product-created.publish-product-created",
                "catalog.product-created",
                false
            ),
            new Delivery("inventory.catalog-products", "catalog.product-snapshot.v1", false),
            new Delivery("inventory.product-categories", "catalog.product-created.v2", false),
            new Delivery("inventory.product-created", "catalog.product-created.v1", false),
        ],
        deliveries.OrderBy(delivery => delivery.Subscription, StringComparer.Ordinal)
    );
    Assert.Single(
        Fixture.Logs.Entries,
        entry => entry.Message == $"New in the catalog: Lamp ({product})"
    );
}

Fixture.AssertNothingSentAsync runs an Operation and fails unless it sent nothing, which is what a rolled-back Operation leaves.

Test over HTTP

Call the Host's endpoints with Fixture.Host.GetTestClient(), authenticated as the caller the test names, and assert the status, the problem response and the rows. Make a command idempotent and Secure an Operation each end with such a test.

Run the tests

In a generated solution, with Docker running:

Terminal
dotnet test --project tests/Acme.Shop.Tests/Acme.Shop.Tests.csproj

What the Kit guarantees

GuaranteeStated in
What the fixture starts, migrates and stopsThe fixture
The reset between testsEach test
What a wait returns, and when it failsDeliveries
Reading and replaying dead lettersDead letters
The structure rules every Module follows, and how to write themStructure tests

Failure messages

Next steps

On this page