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:
[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:
[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:
dotnet test --project tests/Acme.Shop.Tests/Acme.Shop.Tests.csprojWhat the Kit guarantees
| Guarantee | Stated in |
|---|---|
| What the fixture starts, migrates and stops | The fixture |
| The reset between tests | Each test |
| What a wait returns, and when it fails | Deliveries |
| Reading and replaying dead letters | Dead letters |
| The structure rules every Module follows, and how to write them | Structure tests |
Failure messages
Next steps
Add a migration
Change a Module's schema with dotnet-ef, from its own migrations folder, and apply it before the Host starts.
Compose the service defaults
Telemetry export, liveness and readiness, service discovery, one resilience pipeline and Serilog for a Host, from the service defaults project your solution owns.