Test Runtime¶
The [TestRuntime<TRuntime>] attribute generates a test-friendly implementation of your production runtime. It discovers all capabilities from your production runtime — both auto-discovered local modules and explicit [Uses] declarations — and emits settable properties, builder methods, and configurable stub records.
Declaring a Test Runtime¶
Point the attribute at your production runtime:
// Production code
[EffectsModule(typeof(IEmailService))]
[EffectsModule(typeof(ISlackService))]
public sealed partial class NotificationEffects;
[Runtime]
public sealed partial class AppRuntime;
One test runtime per production runtime
Each [TestRuntime<T>] class mirrors exactly one production [Runtime]. If you have multiple runtimes (e.g., AppRuntime and WorkerRuntime), create a separate test runtime for each.
Generated Members¶
The generator produces the following on the partial class:
Factory Methods¶
Two paths, two purposes:
Create() |
CreateConfigured() |
|
|---|---|---|
| Returns | TestAppRuntime |
AppRuntime (production type) |
| Auto-wires | Test* stores (spy/seed), stubs, EventQueueContexts | Test* stores, stubs (no EventQueueContexts) |
| Calls hook | No | Yes (OnConfigure or OnConfigureIntegration) |
| Use for | Unit tests with spy/seed access | Behavior tests that work in both modes |
// Unit test — spy access, seed, event assertions
var runtime = TestAppRuntime.Create();
runtime.OrderStore.Seed(new Order { ... });
await program.RunAsync(runtime);
runtime.DomainEvents.AssertContains<OrderPlaced>();
// Behavior test — production runtime, dispatch-based seeding
var runtime = TestAppRuntime.CreateConfigured();
var fin = await (
from _ in AppDispatch.PlaceOrder(cmd)
from orders in AppDispatch.GetOrders()
select orders
).RunAsync(runtime);
await Assert.That(fin).IsSuccMatching(r => r.Data.Count == 1);
Create() auto-wires Test* stores and stubs via .With*() builders. You get full spy access (.SaveCalls, .Seed()) and EventQueueContext for asserting events. No hooks are called — configure via builders.
CreateConfigured() auto-wires the same defaults, calls OnConfigure() (unit) or OnConfigureIntegration() (integration), then returns the production runtime via ToAppRuntime(). No spy access, no event assertions — verify behavior via dispatch queries and Fin<T> results. This test works identically against InMemory stores and real Postgres.
dotnet test # all tests — InMemory stores
INTEGRATION=true dotnet test # CreateConfigured tests hit real Postgres
Partial Methods for Configuration¶
The generator emits two partial methods. Implement them in your partial class to wire up unit/integration defaults:
[TestRuntime<AppRuntime>]
public partial class TestAppRuntime
{
partial void OnConfigure() =>
WithStubEmailService(stub => stub)
.WithStubOrderStore(stub => stub);
partial void OnConfigureIntegration()
{
OnConfigure(); // start from unit defaults
IntegrationDefaults.Configure(this); // override with real infra
}
}
OnConfigure() is called when TestMode.IsIntegration is false (default). OnConfigureIntegration() is called when INTEGRATION=true.
Capability Properties¶
For each capability discovered from the production runtime (auto-discovered local modules and explicit [Uses]), the generator emits a backing field and property:
private IEmailService? _emailService;
public IEmailService EmailService =>
_emailService ?? throw new InvalidOperationException(
"Service 'EmailService' has not been configured on TestAppRuntime. " +
"Use .WithEmailService() to configure it.");
Builder Methods¶
Two builder methods are generated per capability:
With{Name}() — Direct Implementation¶
Pass any implementation of the capability interface:
WithStub{Name}() — Stub Configuration¶
Configure the generated stub via a builder delegate:
var runtime = TestAppRuntime.Create()
.WithStubEmailService(stub => stub
.OnSendAsync((to, subject, body) => Task.CompletedTask));
Stub Records¶
For each interface-based capability, the generator emits an inner stub record with:
- A primary constructor parameter per non-generic method (nullable
Funcdelegate) - An
On{Method}()method per non-generic method returning a new stub with the delegate configured - Explicit interface implementation delegating to the configured delegate or returning a default value
public partial record StubEmailService(
Func<string, string, string, Task>? onSendAsync = null
) : IEmailService
{
Task IEmailService.SendAsync(string to, string subject, string body) =>
onSendAsync?.Invoke(to, subject, body) ?? Task.CompletedTask;
public StubEmailService OnSendAsync(
Func<string, string, string, Task> configure) =>
this with { onSendAsync = configure };
}
Default behavior
Unconfigured stub methods return sensible defaults:
| Return Type | Default |
|---|---|
Task |
Task.CompletedTask |
ValueTask |
default |
Task<T> |
Task.FromResult(default(T)) |
ValueTask<T> |
ValueTask.FromResult(default(T)) |
| Other | default! |
CorrelationContext Support¶
Every test runtime includes correlation context support:
var runtime = TestAppRuntime.Create()
.WithCorrelationContext(new CorrelationContext
{
CorrelationId = "test-correlation-id",
CausationId = "test-causation-id"
});
Production Runtime Bridge¶
The generated implicit conversion operator lets you pass a test runtime anywhere a production runtime is expected:
// Implicit — just pass it to RunAsync
var result = await AppDispatch.CreateOrder(cmd).RunAsync(testRuntime);
// Explicit — when you need the production type
AppRuntime appRuntime = testRuntime;
This constructs the production runtime's primary constructor with all configured capabilities.
Usage Patterns¶
Behavior Test (CreateConfigured)¶
Most tests use CreateConfigured(). It returns the production runtime with in-memory stores. Seed data via dispatch, verify outcomes via queries:
[Test]
public async Task PlaceOrder_Succeeds()
{
var runtime = TestAppRuntime.CreateConfigured();
var result = await AppDispatch.PlaceOrder(new CreateOrder("item-1", 2))
.RunAsync(runtime);
await Assert.That(result).IsSucc();
}
Spy Test (Create)¶
Use Create() when you need to capture or assert on side effects — events published, saves recorded, specific method calls:
[Test]
public async Task SendsWelcomeEmail_OnRegistration()
{
string? capturedTo = null;
var runtime = TestAppRuntime.Create()
.WithStubEmailService(stub => stub
.OnSendAsync((to, subject, body) =>
{
capturedTo = to;
return Task.CompletedTask;
}))
.WithStubOrderStore(stub => stub);
var result = await AppDispatch.Register("alice@example.com")
.RunAsync(runtime);
await Assert.That(result).IsSucc();
await Assert.That(capturedTo).IsEqualTo("alice@example.com");
}
Testing Error Paths¶
[Test]
public async Task ReturnsError_WhenEmailFails()
{
var runtime = TestAppRuntime.Create()
.WithStubEmailService(stub => stub
.OnSendAsync((to, subject, body) =>
throw new SmtpException("Connection refused")))
.WithStubOrderStore(stub => stub);
var result = await AppDispatch.Register("alice@example.com")
.RunAsync(runtime);
await Assert.That(result).IsFail();
}
Swapping a Single Capability for Integration¶
Use With{Name}() to inject a real implementation while keeping everything else stubbed:
[Test]
public async Task PersistsOrder_ToRealDatabase()
{
var runtime = TestAppRuntime.Create()
.WithOrderStore(realPgStore) // real Postgres
.WithStubEmailService(stub => stub) // stubbed (default behavior)
.WithStubSlackService(stub => stub); // stubbed
var result = await AppDispatch.PlaceOrder(command)
.RunAsync(runtime);
await Assert.That(result).IsSucc();
}
EventQueue Testing¶
When your runtime includes event queue capabilities, test that events are enqueued correctly without starting the background worker:
[Test]
public async Task PlaceOrder_EnqueuesOrderPlacedEvent()
{
var channel = new EventQueueChannel<OrderEvent>();
var runtime = TestAppRuntime.Create()
.WithOrderChannel(channel)
.WithStubOrderStore(stub => stub
.OnSaveAsync(order => Task.CompletedTask));
await OrderWorkflows.PlaceOrder(new CreateOrder("item-1", 2), runtime);
// Verify the enqueued event
var success = channel.TryRead(out var evt);
await Assert.That(success).IsTrue();
await Assert.That(evt).IsTypeOf<OrderPlaced>();
}
Use DrainAll() when multiple events are expected:
[Test]
public async Task BulkImport_EnqueuesAllEvents()
{
var channel = new EventQueueChannel<ImportEvent>();
var runtime = TestAppRuntime.Create()
.WithImportChannel(channel);
await ImportWorkflows.BulkImport(items, runtime);
var events = channel.DrainAll();
await Assert.That(events).HasCount().EqualTo(items.Count);
}
Full Example¶
This end-to-end example shows the progression from production code to test runtime to test:
// Services
public interface IOrderStore
{
Task<Order?> GetByIdAsync(string id);
Task SaveAsync(Order order);
}
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(string customerId, decimal amount);
}
// Effects module
[EffectsModule(typeof(IOrderStore))]
[EffectsModule(typeof(IPaymentGateway))]
public sealed partial class OrderEffects;
// Runtime
[Runtime]
public sealed partial class AppRuntime;
// Workflow
public static class OrderWorkflows
{
public static Eff<AppRuntime, OrderPlaced> PlaceOrder(CreateOrder cmd) =>
from payment in OrderEffects.PaymentGateway.ChargeAsync<AppRuntime>(
cmd.CustomerId, cmd.Total)
from _ in OrderEffects.OrderStore.SaveAsync<AppRuntime>(
new Order(cmd.OrderId, cmd.CustomerId, cmd.Total))
select new OrderPlaced(cmd.OrderId);
}
[Test]
public async Task PlaceOrder_ChargesAndSaves()
{
var runtime = TestAppRuntime.CreateConfigured();
var result = await OrderWorkflows
.PlaceOrder(new CreateOrder("order-1", "cust-1", 99.99m))
.RunAsync(runtime);
await Assert.That(result).IsSucc();
}
[Test]
public async Task PlaceOrder_SavesOrder()
{
Order? savedOrder = null;
var runtime = TestAppRuntime.Create()
.WithStubPaymentGateway(stub => stub
.OnChargeAsync((_, _) =>
Task.FromResult(new PaymentResult(true))))
.WithStubOrderStore(stub => stub
.OnSaveAsync(order =>
{
savedOrder = order;
return Task.CompletedTask;
}));
await OrderWorkflows
.PlaceOrder(new CreateOrder("order-1", "cust-1", 99.99m))
.RunAsync(runtime);
await Assert.That(savedOrder).IsNotNull();
await Assert.That(savedOrder!.CustomerId).IsEqualTo("cust-1");
}
Built-In Test Doubles¶
Beyond generated stubs, the runtime provides pre-built Test{Feature} classes for every module interface. These follow a consistent API across all modules:
API Surface¶
Every test double provides:
| API | Purpose | Example |
|---|---|---|
{Method}Calls |
Records every invocation for assertion | testEmail.SendCalls |
Next{Result} |
Seeds the next return value | testEmail.NextResult = new EmailResult(...) |
On{Method} callback |
Customize behavior per test | testEmail.OnSend = msg => Task.FromResult(...) |
ThrowOn{Method} |
Inject exceptions for error paths | testEmail.ThrowOnSend = new TimeoutException() |
Seed{State}() |
Pre-populate state for test scenarios | testStore.Seed("key", entity) |
Reset() |
Clean state between tests | testEmail.Reset() |
Available Test Doubles¶
| Test Double | Interface | Module |
|---|---|---|
TestEmailService |
IEmailService |
|
TestSmsService |
ISmsService |
SMS |
TestIdempotencyStore |
IIdempotencyStore |
Idempotency |
TestJobScheduler |
IJobScheduler |
Jobs |
TestJobStore |
IJobStore |
Jobs |
TestAuditStore |
IAuditStore |
Audit |
TestNotificationChannel |
INotificationChannel |
Notifications |
TestNotificationStore |
INotificationStore |
Notifications |
TestSearchIndex |
ISearchIndex |
Search |
TestVectorIndex |
IVectorIndex |
Vectors |
TestFileStore |
IFileStore |
Storage |
TestCacheStore |
ICacheStore |
Cache |
Usage Examples¶
Call recording:
var emailService = new TestEmailService();
await emailService.SendAsync(new EmailMessage { ... });
await Assert.That(emailService.SendCalls).HasCount().EqualTo(1);
await Assert.That(emailService.SendCalls[0].Subject).IsEqualTo("Welcome!");
Callback customization:
var smsService = new TestSmsService();
smsService.OnSend = msg =>
Task.FromResult(new SmsResult
{
Id = SmsMessageId.New(),
ProviderMessageId = "test-123",
Status = SmsStatus.Accepted
});
Exception injection:
var store = new TestIdempotencyStore();
store.ThrowOnTryClaim = new TimeoutException("Redis unavailable");
// Test that your code handles the timeout gracefully
Seed + query:
var jobStore = new TestJobStore();
jobStore.Seed(new JobInfo { Id = jobId, Status = JobStatus.Completed });
var result = await jobStore.GetAsync(jobId);
await Assert.That(result?.Status).IsEqualTo(JobStatus.Completed);
All test doubles are marked [DevelopmentOnly] and will be caught by DevelopmentServiceCheck if they accidentally leak into production. See Startup Validation.
Diagnostics¶
| ID | Severity | Description |
|---|---|---|
| DSEFX07 | Info | Capability available on test runtime — code fix scaffolds WithXxxSuccess() / WithXxxError() helper methods |