Skip to content

Testing Email

TestEmailService is a built-in test double for IEmailService with call recording, configurable responses, and exception injection. No mocking framework needed.

Setup

var emailService = new TestEmailService();
var runtime = TestAppRuntime.Create()
    .WithEmailService(emailService);

Or use the auto-configured runtime (wires TestEmailService automatically):

var runtime = TestAppRuntime.CreateConfigured();

Call Recording

Every SendAsync and SendBatchAsync call is recorded:

await program.RunAsync(runtime);

// Assert individual sends
await Assert.That(emailService.SendCalls).HasCount().EqualTo(1);
await Assert.That(emailService.SendCalls[0].Subject).Contains("Welcome");
await Assert.That(emailService.SendCalls[0].To[0].Value).IsEqualTo("alice@example.com");

// Assert batch sends
await Assert.That(emailService.BatchCalls).HasCount().EqualTo(1);
await Assert.That(emailService.BatchCalls[0]).HasCount().EqualTo(10);

// Combined totals
await Assert.That(emailService.TotalSendCount).IsEqualTo(11);
await Assert.That(emailService.AllMessages).HasCount().EqualTo(11);

Recording Properties

Property Type Description
SendCalls IReadOnlyList<EmailMessage> Individual SendAsync calls
BatchCalls IReadOnlyList<IReadOnlyList<EmailMessage>> Each SendBatchAsync call (the batch)
AllMessages IReadOnlyList<EmailMessage> All messages combined (individual + batch)
TotalSendCount int Total messages sent across all calls

Custom Responses

Override the default "accepted" response:

emailService.OnSend = msg => new EmailResult
{
    Id = EmailMessageId.New(),
    ProviderMessageId = "custom-123",
    Status = EmailStatus.Rejected,
    ErrorCode = "INVALID_ADDRESS",
    ErrorMessage = "Recipient address does not exist"
};

For batch sends:

emailService.OnSendBatch = messages => messages.Select(m => new EmailResult
{
    Id = EmailMessageId.New(),
    ProviderMessageId = $"batch-{Guid.NewGuid():N}",
    Status = m.To[0].Value.Contains("invalid") ? EmailStatus.Rejected : EmailStatus.Accepted
}).ToList();

Exception Injection

Test error handling by making sends throw:

emailService.SendException = new HttpRequestException("Provider unavailable");

var result = await program.RunAsync(runtime);
await Assert.That(result).IsFail();

Reset

Clear all recorded state between test phases:

emailService.Reset();
// SendCalls, BatchCalls cleared
// OnSend, OnSendBatch, SendException reset to null

Full Example

[Test]
public async Task OrderConfirmation_SendsEmail()
{
    var emailService = new TestEmailService();
    var runtime = TestAppRuntime.Create()
        .WithEmailService(emailService)
        .WithStubOrderStore(stub => stub
            .OnGetById(_ => Task.FromResult<Order?>(TestData.SampleOrder)));

    await OrderWorkflows.ConfirmOrder(TestData.OrderId).RunAsync(runtime);

    await Assert.That(emailService.SendCalls).HasCount().EqualTo(1);

    var sent = emailService.SendCalls[0];
    await Assert.That(sent.Subject).Contains("Order #");
    await Assert.That(sent.To[0]).IsEqualTo(new EmailAddress("customer@example.com"));
    await Assert.That(sent.Tags!["order_id"]).IsEqualTo(TestData.OrderId.ToString());
}