From dotnet-skills
Write unit and integration tests for Akka.NET actors using Akka.Hosting.TestKit patterns. Covers DI, TestProbes, persistence testing, actor verification, and traditional TestKit guidance.
How this skill is triggered — by the user, by Claude, or both
Slash command
/dotnet-skills:testing-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use this skill when:
Use this skill when:
When:
Microsoft.Extensions.DependencyInjectionIOptions, DbContext, ILogger, HTTP clients, etc.)Advantages:
This guide focuses primarily on Akka.Hosting.TestKit patterns.
When:
Microsoft.Extensions (console apps, legacy systems)Props creation without DINote: If starting a new project in 2025+, strongly prefer Akka.Hosting.TestKit unless you have specific constraints.
Traditional TestKit patterns are covered briefly at the end of this document.
Akka.Hosting.TestKit.TestKit - This is a framework base class, not a user-defined oneConfigureServices() - Replace real services with fakes/mocksConfigureAkka() - Configure actors using the same extension methods as productionActorRegistry - Type-safe retrieval of actor referencesAkkaExecutionMode<ItemGroup>
<!-- Core testing framework -->
<PackageReference Include="Akka.Hosting.TestKit" Version="*" />
<!-- xUnit (or your preferred test framework) -->
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />
<!-- Assertions (recommended) -->
<PackageReference Include="FluentAssertions" Version="*" />
<!-- In-memory persistence for testing -->
<PackageReference Include="Akka.Persistence.Hosting" Version="*" />
<!-- If testing cluster sharding -->
<PackageReference Include="Akka.Cluster.Hosting" Version="*" />
</ItemGroup>
Akka.Hosting.TestKit spins up real IHost instances, which by default enable file watchers for configuration reload. When running many tests, this exhausts file descriptor limits on Linux (inotify watch limit).
Add this to your test project - it runs before any tests execute:
// TestEnvironmentInitializer.cs
using System.Runtime.CompilerServices;
namespace YourApp.Tests;
internal static class TestEnvironmentInitializer
{
[ModuleInitializer]
internal static void Initialize()
{
// Disable config file watching in test hosts
// Prevents file descriptor exhaustion (inotify watch limit) on Linux
Environment.SetEnvironmentVariable("DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE", "false");
}
}
Why this matters:
[ModuleInitializer] runs automatically before any test codeIHost instancesinotify errors when running 100+ testsIHostusing Akka.Actor;
using Akka.Hosting;
using Akka.Hosting.TestKit;
using Akka.Persistence.Hosting;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
using Xunit.Abstractions;
namespace MyApp.Tests;
/// <summary>
/// Tests for OrderActor demonstrating modern Akka.Hosting.TestKit patterns.
/// </summary>
public class OrderActorTests : TestKit
{
private readonly FakeOrderRepository _fakeRepository;
private readonly FakeEmailService _fakeEmailService;
public OrderActorTests(ITestOutputHelper output) : base(output: output)
{
// Create fake services as fields (composition, not inheritance)
_fakeRepository = new FakeOrderRepository();
_fakeEmailService = new FakeEmailService();
}
/// <summary>
/// Override ConfigureServices to inject fake services.
/// This runs BEFORE ConfigureAkka, so services are available to actors.
/// </summary>
protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services)
{
// Register fakes as singletons (same instance used across all actors)
services.AddSingleton<IOrderRepository>(_fakeRepository);
services.AddSingleton<IEmailService>(_fakeEmailService);
services.AddLogging();
}
/// <summary>
/// Override ConfigureAkka to configure actor system for testing.
/// This is where you register actors using the same extension methods as production.
/// </summary>
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
// Use TestScheduler for time control
builder.AddHocon("akka.scheduler.implementation = \"Akka.TestKit.TestScheduler, Akka.TestKit\"",
HoconAddMode.Prepend);
// In-memory persistence (no database needed)
builder.WithInMemoryJournal()
.WithInMemorySnapshotStore();
// Register actors using the same extension methods as production
builder.WithActors((system, registry, resolver) =>
{
// Create actor with dependency injection
var props = resolver.Props<OrderActor>();
var actor = system.ActorOf(props, "order-actor");
// Register in ActorRegistry for type-safe retrieval
registry.Register<OrderActor>(actor);
});
}
[Fact]
public async Task CreateOrder_Success_SavesToRepository()
{
// Arrange
var orderActor = ActorRegistry.Get<OrderActor>();
var command = new CreateOrder(OrderId: "ORDER-123", CustomerId: "CUST-456", Amount: 99.99m);
// Act
var response = await orderActor.Ask<OrderCommandResult>(command, RemainingOrDefault);
// Assert
response.Status.Should().Be(CommandStatus.Success);
// Verify fake repository was called
_fakeRepository.SaveCallCount.Should().Be(1);
_fakeRepository.LastSavedOrderId.Should().Be("ORDER-123");
}
[Fact]
public async Task CreateOrder_RepositoryFails_ReturnsError()
{
// Arrange
_fakeRepository.FailNextSave = true;
var orderActor = ActorRegistry.Get<OrderActor>();
var command = new CreateOrder(OrderId: "ORDER-789", CustomerId: "CUST-456", Amount: 99.99m);
// Act
var response = await orderActor.Ask<OrderCommandResult>(command, RemainingOrDefault);
// Assert
response.Status.Should().Be(CommandStatus.Failed);
response.ErrorMessage.Should().NotBeNullOrEmpty();
}
}
// ============================================================================
// FAKE SERVICE IMPLEMENTATIONS (Composition, not inheritance)
// ============================================================================
public sealed class FakeOrderRepository : IOrderRepository
{
public int SaveCallCount { get; private set; }
public string? LastSavedOrderId { get; private set; }
public bool FailNextSave { get; set; }
public Task SaveOrderAsync(string orderId, decimal amount)
{
SaveCallCount++;
LastSavedOrderId = orderId;
if (FailNextSave)
{
FailNextSave = false;
throw new InvalidOperationException("Simulated repository failure");
}
return Task.CompletedTask;
}
}
public sealed class FakeEmailService : IEmailService
{
public int SendCallCount { get; private set; }
public string? LastEmailRecipient { get; private set; }
public Task SendEmailAsync(string recipient, string subject, string body)
{
SendCallCount++;
LastEmailRecipient = recipient;
return Task.CompletedTask;
}
}
Key Takeaways:
TestKit is a framework base class, not a user-defined oneConfigureServices() overrides DI registrationsConfigureAkka() uses same extension methods as productionActorRegistry.Get<T>() provides type-safe actor retrievalUse TestProbe to verify that your actor sends messages to other actors without needing the full implementation.
public class InvoiceActorTests : TestKit
{
private readonly FakeInvoiceService _fakeInvoiceService;
private TestProbe? _paymentProbe;
public InvoiceActorTests(ITestOutputHelper output) : base(output: output)
{
_fakeInvoiceService = new FakeInvoiceService();
}
/// <summary>
/// Property that creates TestProbe on first access (lazy initialization).
/// </summary>
private TestProbe PaymentProbe => _paymentProbe ??= CreateTestProbe("payment-probe");
protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services)
{
services.AddSingleton<IInvoiceService>(_fakeInvoiceService);
}
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
builder.WithInMemoryJournal().WithInMemorySnapshotStore();
builder.WithActors((system, registry, resolver) =>
{
// Register TestProbe as PaymentActor for verification
_paymentProbe = CreateTestProbe("payment-probe");
registry.Register<PaymentActor>(_paymentProbe);
// Register InvoiceActor (actor under test)
var invoiceProps = resolver.Props<InvoiceActor>();
var invoiceActor = system.ActorOf(invoiceProps, "invoice-actor");
registry.Register<InvoiceActor>(invoiceActor);
});
}
[Fact]
public async Task CreateInvoice_Success_SendsPaymentRequest()
{
// Arrange
var invoiceActor = ActorRegistry.Get<InvoiceActor>();
var command = new CreateInvoice(InvoiceId: "INV-001", Amount: 100.00m);
// Act
var response = await invoiceActor.Ask<InvoiceCommandResult>(command, RemainingOrDefault);
// Assert - Command succeeded
response.Status.Should().Be(CommandStatus.Success);
// Assert - Payment request was sent to PaymentActor
var paymentRequest = await PaymentProbe.ExpectMsgAsync<InitiatePayment>(TimeSpan.FromSeconds(3));
paymentRequest.InvoiceId.Should().Be("INV-001");
paymentRequest.Amount.Should().Be(100.00m);
}
[Fact]
public async Task PaymentCompleted_UpdatesInvoiceState()
{
// Arrange
var invoiceActor = ActorRegistry.Get<InvoiceActor>();
// Create invoice first
await invoiceActor.Ask<InvoiceCommandResult>(
new CreateInvoice(InvoiceId: "INV-002", Amount: 50.00m),
RemainingOrDefault);
// Drain the InitiatePayment message
await PaymentProbe.ExpectMsgAsync<InitiatePayment>();
// Act - Notify invoice that payment completed
var notification = new PaymentCompleted(InvoiceId: "INV-002", Amount: 50.00m);
invoiceActor.Tell(notification);
// Assert - Query state to verify update
var stateQuery = await invoiceActor.Ask<InvoiceState>(
new GetInvoiceState("INV-002"),
RemainingOrDefault);
stateQuery.Status.Should().Be(InvoiceStatus.Paid);
stateQuery.AmountPaid.Should().Be(50.00m);
}
}
Key Patterns:
ExpectMsgAsync() to clear expected messages before proceedingWhen an actor uses Ask to talk to another actor, the sender expects a response. Use an auto-responder to prevent timeouts.
/// <summary>
/// Auto-responding actor that forwards all messages to a TestProbe while automatically
/// replying to specific message types to avoid Ask timeouts.
/// </summary>
internal sealed class PaymentAutoResponder : ReceiveActor
{
private readonly IActorRef _probe;
public PaymentAutoResponder(IActorRef probe)
{
_probe = probe;
// Auto-respond to InitiatePayment with PaymentStarted
Receive<InitiatePayment>(msg =>
{
_probe.Tell(msg, Sender); // Forward to probe for verification
var response = new PaymentStarted(
PaymentId: msg.PaymentId,
InvoiceId: msg.InvoiceId);
Sender.Tell(response, Self); // Auto-reply to avoid timeout
});
// Forward all other messages without auto-responding
ReceiveAny(msg => _probe.Tell(msg, Sender));
}
}
// Usage in ConfigureAkka:
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
builder.WithActors((system, registry, resolver) =>
{
_paymentProbe = CreateTestProbe("payment-probe");
// Create auto-responder that forwards to probe
var autoResponder = system.ActorOf(
Props.Create(() => new PaymentAutoResponder(_paymentProbe)),
"payment-auto-responder");
registry.Register<PaymentActor>(autoResponder);
// Register actor under test
var invoiceActor = system.ActorOf(resolver.Props<InvoiceActor>(), "invoice-actor");
registry.Register<InvoiceActor>(invoiceActor);
});
}
When to Use:
Ask to communicate with dependenciespublic class OrderPersistentActorTests : TestKit
{
public OrderPersistentActorTests(ITestOutputHelper output) : base(output: output)
{
}
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
// Configure TestScheduler
builder.AddHocon("akka.scheduler.implementation = \"Akka.TestKit.TestScheduler, Akka.TestKit\"",
HoconAddMode.Prepend);
// In-memory persistence (events stored in memory, cleared after test)
builder.WithInMemoryJournal()
.WithInMemorySnapshotStore();
builder.WithActors((system, registry, resolver) =>
{
var props = resolver.Props<OrderPersistentActor>("order-123");
var actor = system.ActorOf(props, "order-persistent-actor");
registry.Register<OrderPersistentActor>(actor);
});
}
[Fact]
public async Task CreateOrder_PersistsEvent()
{
// Arrange
var actor = ActorRegistry.Get<OrderPersistentActor>();
var command = new CreateOrder(OrderId: "ORDER-123", Amount: 100.00m);
// Act
var response = await actor.Ask<OrderCommandResult>(command, RemainingOrDefault);
// Assert
response.Status.Should().Be(CommandStatus.Success);
// Query state to verify event was applied
var state = await actor.Ask<OrderState>(new GetOrderState("ORDER-123"), RemainingOrDefault);
state.OrderId.Should().Be("ORDER-123");
state.Amount.Should().Be(100.00m);
state.Status.Should().Be(OrderStatus.Created);
}
[Fact]
public async Task ActorRecovery_AfterPassivation_RestoresState()
{
// Arrange - Create order and persist events
var actor = ActorRegistry.Get<OrderPersistentActor>();
await actor.Ask<OrderCommandResult>(
new CreateOrder(OrderId: "ORDER-456", Amount: 200.00m),
RemainingOrDefault);
// Get reference to the actual actor (not the registry wrapper)
var childActorPath = actor.Path / "order-456";
var childActor = await Sys.ActorSelection(childActorPath).ResolveOne(TimeSpan.FromSeconds(3));
// Act - Kill the actor to simulate passivation
await WatchAsync(childActor);
childActor.Tell(PoisonPill.Instance);
await ExpectTerminatedAsync(childActor);
// Send a query which forces the actor to recover from journal
var state = await actor.Ask<OrderState>(
new GetOrderState("ORDER-456"),
RemainingOrDefault);
// Assert - Verify state was recovered correctly
state.Should().NotBeNull();
state.OrderId.Should().Be("ORDER-456");
state.Amount.Should().Be(200.00m);
state.Status.Should().Be(OrderStatus.Created);
}
}
Key Patterns:
PoisonPill to kill actor, then query to force recoveryWhen your production code uses custom AkkaConfigurationBuilder extension methods (for serializers, actors, persistence), your tests should use those same extension methods rather than duplicating HOCON configuration.
// BAD: Duplicating HOCON config that already exists in an extension method
public class DraftSerializerTests : Akka.TestKit.Xunit2.TestKit
{
public DraftSerializerTests() : base(ConfigurationFactory.ParseString(@"
akka.actor {
serializers {
proto = ""MyApp.Serialization.DraftSerializer, MyApp""
}
serialization-bindings {
""MyApp.Messages.IDraftEvent, MyApp"" = proto
""MyApp.Actors.DraftState, MyApp"" = proto
}
}
"))
{ }
}
Problems with duplicated config:
// Production extension method (in your main project)
public static class AkkaSerializerExtensions
{
public static AkkaConfigurationBuilder AddDraftSerializer(
this AkkaConfigurationBuilder builder)
{
return builder.WithCustomSerializer(
serializerIdentifier: "draft-proto",
boundTypes: [typeof(IDraftEvent), typeof(DraftState)],
serializerFactory: system => new DraftSerializer(system));
}
}
// GOOD: Test reuses the same extension method
public class DraftSerializerTests : Akka.Hosting.TestKit.TestKit
{
public DraftSerializerTests(ITestOutputHelper output) : base(output: output) { }
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
// Use the SAME extension method as production
builder.AddDraftSerializer();
// Add test-specific config (in-memory persistence, etc.)
builder.WithInMemoryJournal()
.WithInMemorySnapshotStore();
}
[Fact]
public async Task DraftSerializer_RoundTrips_DraftCreatedEvent()
{
// Arrange
var original = new DraftCreated(DraftId.New(), "Test Draft", DateTime.UtcNow);
// Act - serialize and deserialize through the actor system
var serializer = Sys.Serialization.FindSerializerFor(original);
var bytes = serializer.ToBinary(original);
var deserialized = serializer.FromBinary(bytes, typeof(DraftCreated));
// Assert
deserialized.Should().BeEquivalentTo(original);
}
}
| Benefit | Explanation |
|---|---|
| DRY | Single source of truth for configuration |
| No Drift | Tests always use the exact same config as production |
| Easier Maintenance | Add a new binding in one place, tests automatically pick it up |
| Better Coverage | Actually tests the extension method itself |
| Catches Real Bugs | If the extension method is broken, tests fail |
This pattern applies to any AkkaConfigurationBuilder extension method:
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
// Reuse production extension methods
builder
.AddDraftSerializer() // Custom serializer
.AddOrderDomainActors(AkkaExecutionMode.LocalTest) // Domain actors
.AddCustomPersistence() // Persistence config
.AddReminders(); // Reminder system
// Override only what's test-specific
builder
.WithInMemoryJournal() // Replace real DB with in-memory
.WithInMemorySnapshotStore();
}
Use AkkaExecutionMode.LocalTest to test cluster sharding behavior without an actual cluster.
// In your production code (AkkaHostingExtensions.cs):
public static AkkaConfigurationBuilder WithOrderActor(
this AkkaConfigurationBuilder builder,
AkkaExecutionMode executionMode = AkkaExecutionMode.Clustered)
{
if (executionMode == AkkaExecutionMode.LocalTest)
{
// Non-clustered mode: Use GenericChildPerEntityParent
builder.WithActors((system, registry, resolver) =>
{
var parent = system.ActorOf(
GenericChildPerEntityParent.CreateProps(
new OrderMessageExtractor(),
entityId => resolver.Props<OrderActor>(entityId)),
"orders");
registry.Register<OrderActor>(parent);
});
}
else
{
// Clustered mode: Use ShardRegion
builder.WithShardRegion<OrderActor>(
"orders",
(system, registry, resolver) => entityId => resolver.Props<OrderActor>(entityId),
new OrderMessageExtractor(),
new ShardOptions
{
StateStoreMode = StateStoreMode.DData,
Role = "order-service"
});
}
return builder;
}
// In your tests:
public class OrderShardingTests : TestKit
{
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
builder.WithInMemoryJournal().WithInMemorySnapshotStore();
// Use the same extension method as production, but with LocalTest mode
builder.WithOrderActor(AkkaExecutionMode.LocalTest);
}
[Fact]
public async Task ShardedActor_RoutesMessagesByEntityId()
{
// Arrange
var orderRegion = ActorRegistry.Get<OrderActor>();
// Act - Send commands for two different entity IDs
var response1 = await orderRegion.Ask<OrderCommandResult>(
new CreateOrder(OrderId: "ORDER-001", Amount: 100m),
RemainingOrDefault);
var response2 = await orderRegion.Ask<OrderCommandResult>(
new CreateOrder(OrderId: "ORDER-002", Amount: 200m),
RemainingOrDefault);
// Assert
response1.Status.Should().Be(CommandStatus.Success);
response2.Status.Should().Be(CommandStatus.Success);
// Query state to verify routing worked correctly
var state1 = await orderRegion.Ask<OrderState>(
new GetOrderState("ORDER-001"),
RemainingOrDefault);
var state2 = await orderRegion.Ask<OrderState>(
new GetOrderState("ORDER-002"),
RemainingOrDefault);
state1.Amount.Should().Be(100m);
state2.Amount.Should().Be(200m);
}
}
Key Patterns:
AkkaExecutionMode parameter switches between clustered and localGenericChildPerEntityParent simulates sharding behavior locallyUse AwaitAssertAsync when actors perform async operations (like calling external services).
[Fact]
public async Task CreateInvoice_CallsReadModelSync()
{
// Arrange
var invoiceActor = ActorRegistry.Get<InvoiceActor>();
var command = new CreateInvoice(InvoiceId: "INV-003", Amount: 75.00m);
// Act
var response = await invoiceActor.Ask<InvoiceCommandResult>(command, RemainingOrDefault);
// Assert - Command succeeded
response.Status.Should().Be(CommandStatus.Success);
// Assert - Read model sync was called (async operation, need to wait)
await AwaitAssertAsync(() =>
{
_fakeReadModelService.SyncCallCount.Should().BeGreaterOrEqualTo(1);
_fakeReadModelService.LastSyncedInvoiceId.Should().Be("INV-003");
}, TimeSpan.FromSeconds(3));
}
[Fact]
public async Task PaymentRetry_SchedulesReminder()
{
// Arrange
var invoiceActor = ActorRegistry.Get<InvoiceActor>();
await CreateAndFailPayment(invoiceActor, "INV-004");
// Act - Trigger payment failure (which schedules retry reminder)
var failure = new PaymentFailed(InvoiceId: "INV-004", Reason: "Card declined");
invoiceActor.Tell(failure);
// Assert - Verify reminder was scheduled (async operation)
var reminderClient = Sys.ReminderClient().CreateClient(
new ReminderEntity("invoicing", "INV-004"));
await AwaitAssertAsync(async () =>
{
var reminders = await reminderClient.ListRemindersAsync();
reminders.Reminders.Should().HaveCount(1);
reminders.Reminders.First().Key.Name.Should().Be("payment-retry");
}, TimeSpan.FromSeconds(3));
}
Key Patterns:
Test complete business workflows end-to-end with multiple actors and state transitions.
public class SubscriptionScenarioTests : TestKit
{
private readonly FakeSubscriptionService _fakeService;
public SubscriptionScenarioTests(ITestOutputHelper output)
: base(output: output, logLevel: LogLevel.Debug)
{
_fakeService = new FakeSubscriptionService();
}
protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services)
{
services.AddSingleton<ISubscriptionService>(_fakeService);
services.AddSingleton<IInvoiceService, FakeInvoiceService>();
services.AddSingleton<IPaymentService, FakePaymentService>();
}
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
builder.AddHocon("akka.scheduler.implementation = \"Akka.TestKit.TestScheduler, Akka.TestKit\"",
HoconAddMode.Prepend);
builder.WithInMemoryJournal().WithInMemorySnapshotStore();
// Register all domain actors (subscription, invoice, payment)
builder.WithSubscriptionDomainActors(AkkaExecutionMode.LocalTest);
}
[Fact]
public async Task Scenario_FirstTimePurchase_SuccessfulPayment()
{
// Arrange
var subscriptionId = "SUB-001";
var subscriptionActor = ActorRegistry.Get<SubscriptionActor>();
// Step 1: Create subscription
var createResult = await subscriptionActor.Ask<SubscriptionCommandResult>(
new CreateSubscription(subscriptionId, "CUST-123", 99.99m),
RemainingOrDefault);
createResult.Status.Should().Be(CommandStatus.Success);
// Step 2: Verify invoice was generated
await AwaitAssertAsync(async () =>
{
var state = await subscriptionActor.Ask<SubscriptionState>(
new GetSubscriptionState(subscriptionId),
RemainingOrDefault);
state.CurrentInvoiceId.Should().NotBeNullOrEmpty();
});
// Step 3: Simulate payment success
var state = await subscriptionActor.Ask<SubscriptionState>(
new GetSubscriptionState(subscriptionId),
RemainingOrDefault);
var paymentNotification = new PaymentCompleted(
InvoiceId: state.CurrentInvoiceId!,
Amount: 99.99m);
subscriptionActor.Tell(paymentNotification);
// Step 4: Verify subscription is now active
await AwaitAssertAsync(async () =>
{
var finalState = await subscriptionActor.Ask<SubscriptionState>(
new GetSubscriptionState(subscriptionId),
RemainingOrDefault);
finalState.Status.Should().Be(SubscriptionStatus.Active);
finalState.BenefitsProvisioned.Should().BeTrue();
});
// Step 5: Verify service was provisioned
_fakeService.ProvisionCallCount.Should().BeGreaterOrEqualTo(1);
_fakeService.LastProvisionedSubscriptionId.Should().Be(subscriptionId);
}
[Fact]
public async Task Scenario_PaymentFailure_RetryAndGracePeriod()
{
// Arrange
var subscriptionId = "SUB-002";
var subscriptionActor = ActorRegistry.Get<SubscriptionActor>();
// Step 1: Create subscription and generate invoice
await subscriptionActor.Ask<SubscriptionCommandResult>(
new CreateSubscription(subscriptionId, "CUST-456", 199.99m),
RemainingOrDefault);
var state = await subscriptionActor.Ask<SubscriptionState>(
new GetSubscriptionState(subscriptionId),
RemainingOrDefault);
var invoiceId = state.CurrentInvoiceId!;
// Step 2: Simulate 3 payment failures
for (int attempt = 1; attempt <= 3; attempt++)
{
var failure = new PaymentFailed(
InvoiceId: invoiceId,
Reason: "Insufficient funds",
CanRetry: true,
AttemptNumber: attempt);
subscriptionActor.Tell(failure);
if (attempt < 3)
{
// Verify soft dunning notification for attempts 1-2
await AwaitAssertAsync(async () =>
{
var currentState = await subscriptionActor.Ask<SubscriptionState>(
new GetSubscriptionState(subscriptionId),
RemainingOrDefault);
currentState.PaymentRetryCount.Should().Be(attempt);
});
}
}
// Step 3: Verify hard dunning after 3 failures
await AwaitAssertAsync(async () =>
{
var finalState = await subscriptionActor.Ask<SubscriptionState>(
new GetSubscriptionState(subscriptionId),
RemainingOrDefault);
finalState.Status.Should().Be(SubscriptionStatus.PaymentFailed);
finalState.GracePeriodExpiresAt.Should().NotBeNull();
});
// Step 4: Verify grace period reminder scheduled
var reminderClient = Sys.ReminderClient().CreateClient(
new ReminderEntity("subscription", subscriptionId));
await AwaitAssertAsync(async () =>
{
var reminders = await reminderClient.ListRemindersAsync();
reminders.Reminders.Should().ContainSingle(r =>
r.Key.Name == "grace-period-expiration");
});
}
}
Key Patterns:
AwaitAssertAsync to verify state transitionsScenario_FirstTimePurchase_SuccessfulPayment| Pattern | Use Case |
|---|---|
| Basic Actor Test | Single actor with injected services |
| TestProbe | Verify actor sends messages to dependencies |
| Auto-Responder | Avoid Ask timeouts when testing |
| Persistent Actor | Test event sourcing and recovery |
| Cluster Sharding | Test sharding behavior locally |
| AwaitAssertAsync | Handle async operations in actors |
| Scenario Tests | End-to-end business workflows |
// BAD: Custom base class for "DRY" setup
public abstract class BaseAkkaTest : TestKit
{
protected IActorRef OrderActor { get; private set; }
protected FakeOrderRepository FakeRepository { get; private set; }
protected override void ConfigureAkka(...)
{
// Setup shared across all tests
}
}
public class OrderActorTests : BaseAkkaTest
{
// Now coupled to BaseAkkaTest setup
}
Why it's bad:
✅ DO: Use Method Overrides
Each test class overrides ConfigureServices() and ConfigureAkka() with exactly what it needs.
// BAD: Reusing same actor instance across tests
public class OrderActorTests : TestKit
{
private readonly IActorRef _orderActor;
public OrderActorTests()
{
_orderActor = /* create once */;
}
[Fact] public void Test1() { /* uses _orderActor */ }
[Fact] public void Test2() { /* uses _orderActor */ }
}
Why it's bad:
✅ DO: Use xUnit Class Fixtures or Get Fresh Actors
// GOOD: Each test gets clean ActorSystem
public class OrderActorTests : TestKit
{
[Fact]
public async Task Test1()
{
var actor = ActorRegistry.Get<OrderActor>(); // Fresh system
// Test
}
[Fact]
public async Task Test2()
{
var actor = ActorRegistry.Get<OrderActor>(); // Fresh system
// Test
}
}
// BAD: Using real database in tests
protected override void ConfigureServices(...)
{
services.AddDbContext<OrderDbContext>(options =>
options.UseSqlServer(connectionString)); // Real DB!
}
✅ DO: Use Fakes or In-Memory Alternatives
// GOOD: Fake repository
protected override void ConfigureServices(...)
{
services.AddSingleton<IOrderRepository>(_fakeRepository);
}
If your actors use Akka.Reminders for scheduling, configure local reminders in tests:
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
builder.AddHocon("akka.scheduler.implementation = \"Akka.TestKit.TestScheduler, Akka.TestKit\"",
HoconAddMode.Prepend);
builder.WithInMemoryJournal().WithInMemorySnapshotStore();
// Configure local reminders for testing
var shardResolver = new TestShardRegionResolver();
builder.WithLocalReminders(reminders => reminders
.WithInMemoryStorage()
.WithResolver(shardResolver)
.WithSettings(new ReminderSettings
{
MaxDeliveryAttempts = 5,
RetryBackoffBase = TimeSpan.FromSeconds(1),
MaxSlippage = TimeSpan.FromSeconds(60)
}));
builder.WithInvoicingActor(AkkaExecutionMode.LocalTest);
// Register shard region with reminder resolver after startup
builder.AddStartup(async (system, registry) =>
{
var invoicingRegion = await registry.GetAsync<InvoicingActor>();
shardResolver.RegisterShardRegion("invoicing", invoicingRegion);
});
}
[Fact]
public async Task PaymentFailure_SchedulesRetryReminder()
{
// Arrange
var invoiceId = "INV-001";
var actor = ActorRegistry.Get<InvoicingActor>();
// Act - Trigger payment failure
var failure = new PaymentFailed(invoiceId, "Card declined");
actor.Tell(failure);
// Assert - Verify reminder was scheduled
var reminderClient = Sys.ReminderClient().CreateClient(
new ReminderEntity("invoicing", invoiceId));
await AwaitAssertAsync(async () =>
{
var reminders = await reminderClient.ListRemindersAsync();
reminders.Reminders.Should().HaveCount(1);
reminders.Reminders.First().Key.Name.Should().Be("payment-retry");
}, TimeSpan.FromSeconds(3));
}
For completeness, here's the traditional TestKit approach (use only when you can't use Microsoft.Extensions):
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Xunit;
public class OrderActorTests_Traditional : TestKit
{
public OrderActorTests_Traditional()
: base(@"akka.loglevel = DEBUG")
{
}
[Fact]
public void CreateOrder_SendsConfirmation()
{
// Arrange - Create actor manually with Props
var orderActor = Sys.ActorOf(Props.Create<OrderActor>(), "order-actor");
// Act
orderActor.Tell(new CreateOrder("ORDER-001", 100m));
// Assert
var confirmation = ExpectMsg<OrderCreated>();
Assert.Equal("ORDER-001", confirmation.OrderId);
}
[Fact]
public void OrderActor_RespondsToQuery()
{
// Arrange
var orderActor = Sys.ActorOf(Props.Create<OrderActor>());
// Act
orderActor.Tell(new CreateOrder("ORDER-002", 200m));
ExpectMsg<OrderCreated>(); // Drain creation message
// Query
orderActor.Tell(new GetOrderState("ORDER-002"));
// Assert
var state = ExpectMsg<OrderState>();
Assert.Equal("ORDER-002", state.OrderId);
Assert.Equal(200m, state.Amount);
}
}
Key Differences:
Props.Create<T>() instead of DIContext)ExpectMsg<T>() instead of Ask patternsWhen to use:
AkkaExecutionModeScenario_FirstTimePurchase_SuccessfulPaymentLogLevel.Debug to TestKit constructorprobe.Messages to see what was sent// Constructor with debug logging
public OrderActorTests(ITestOutputHelper output)
: base(output: output, logLevel: LogLevel.Debug)
{
}
// Check what messages TestProbe received
[Fact]
public void DebugTest()
{
// ... test code ...
// Inspect all messages sent to probe
_paymentProbe.Messages.Should().NotBeEmpty();
foreach (var msg in _paymentProbe.Messages)
{
Output?.WriteLine($"Received: {msg}");
}
}
name: Akka.NET Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 9.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore -c Release
- name: Run Akka.NET tests
run: |
dotnet test tests/MyApp.Domain.Tests \
--no-build \
-c Release \
--logger trx \
--collect:"XPlat Code Coverage"
- name: Publish test results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: "**/TestResults/*.trx"
2plugins reuse this skill
First indexed Jul 7, 2026
npx claudepluginhub thijscornelis/claude-code-dotnetGuides unit and integration tests for Akka.NET actors using Akka.Hosting.TestKit patterns. Covers DI, TestProbes, persistence, supervision, and actor verification.
Testing strategy for .NET 10 applications using xUnit v3, WebApplicationFactory for integration tests, Testcontainers for real database testing, Verify for snapshot testing, and the AAA pattern.
Provides C# and .NET testing patterns using xUnit, FluentAssertions, mocking, and integration tests. Useful for writing, reviewing, and debugging test suites.