Skip to content

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;
// Test code
[TestRuntime<AppRuntime>]
public partial class TestAppRuntime;

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:

var runtime = TestAppRuntime.Create()
    .WithEmailService(myCustomEmailService);

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 Func delegate)
  • 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);
}
[TestRuntime<AppRuntime>]
public partial class TestAppRuntime
{
    partial void OnConfigure() =>
        WithStubPaymentGateway(stub => stub
            .OnChargeAsync((_, _) =>
                Task.FromResult(new PaymentResult(true))))
        .WithStubOrderStore(stub => stub);
}
[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 Email
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