Akka.NET 测试模式
何时使用此技能
使用此技能时:
- 编写 Akka.NET 演员的单元测试
- 测试具有事件溯源的持久性演员
- 验证演员互动和消息流
- 测试演员监督和生命周期
- 在演员测试中模拟外部依赖项
- 本地测试集群分片行为
- 验证演员状态恢复和持久性
选择测试方法
✅ 使用 Akka.Hosting.TestKit(推荐95%的使用案例)
何时:
- 使用
Microsoft.Extensions.DependencyInjection构建现代.NET应用程序 - 在生产中使用 Akka.Hosting 进行演员配置
- 需要将服务注入演员(
IOptions、DbContext、ILogger、HTTP 客户端等) - 测试使用 ASP.NET Core、Worker Services 或 .NET Aspire 的应用程序
- 使用现代 Akka.NET 项目(Akka.NET v1.5+)
优势:
- 原生依赖注入支持 - 在测试中用假对象覆盖服务
- 与生产配置一致(相同的扩展方法在测试中工作)
- 清晰地分离演员逻辑和基础设施
- 与 .NET 生态系统集成更好
- 类型安全的演员注册表,用于检索演员
- 支持本地和集群测试模式
本指南主要关注 Akka.Hosting.TestKit 模式。
⚠️ 使用传统 Akka.TestKit
何时:
- 为 Akka.NET 核心库开发做出贡献
- 在没有
Microsoft.Extensions的环境中工作(控制台应用、遗留系统) - 遗留代码库使用手动
Props创建而没有 DI - 需要直接控制低级别的 ActorSystem 配置
- 与 Akka.NET 项目合作前 v1.5
注意: 如果在 2025+ 开始新项目,除非有特定限制,否则强烈推荐 Akka.Hosting.TestKit。
传统 TestKit 模式在此文档末尾简要介绍。
核心原则(Akka.Hosting.TestKit)
- 继承自
Akka.Hosting.TestKit.TestKit- 这是一个框架基类,而不是用户定义的 - 覆盖
ConfigureServices()- 用假/模拟替换真实服务 - 覆盖
ConfigureAkka()- 使用与生产相同的扩展方法配置演员 - 使用
ActorRegistry- 类型安全的演员检索 - 组合优于继承 - 将假服务作为字段,而不是基类
- 不使用自定义基类 - 使用方法覆盖,而不是继承层次结构
- 一次测试一个演员 - 使用 TestProbes 测试依赖项
- 匹配生产模式 - 相同的扩展方法,不同的
AkkaExecutionMode
必需的 NuGet 包
<ItemGroup>
<!-- 核心测试框架 -->
<PackageReference Include="Akka.Hosting.TestKit" Version="*" />
<!-- xUnit(或您首选的测试框架) -->
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />
<!-- 断言(推荐) -->
<PackageReference Include="FluentAssertions" Version="*" />
<!-- 测试用的内存持久性 -->
<PackageReference Include="Akka.Persistence.Hosting" Version="*" />
<!-- 如果测试集群分片 -->
<PackageReference Include="Akka.Cluster.Hosting" Version="*" />
</ItemGroup>
重要:测试项目文件监视器修复
Akka.Hosting.TestKit 启动真实的 IHost 实例,默认情况下启用配置重载的文件监视器。在运行许多测试时,这会耗尽 Linux 上的文件描述符限制(inotify 监视限制)。
在测试项目中添加此内容 - 它在任何测试执行之前运行:
// TestEnvironmentInitializer.cs
using System.Runtime.CompilerServices;
namespace YourApp.Tests;
internal static class TestEnvironmentInitializer
{
[ModuleInitializer]
internal static void Initialize()
{
// 在测试主机中禁用配置文件监视
// 防止在 Linux 上运行 100+ 测试时出现文件描述符耗尽(inotify 监视限制)
Environment.SetEnvironmentVariable("DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE", "false");
}
}
为什么这很重要:
[ModuleInitializer]在任何测试代码之前自动运行- 为所有
IHost实例全局设置环境变量 - 防止运行 100+ 测试时出现神秘的
inotify错误 - 也适用于使用
IHost的 Aspire 集成测试
模式 1:使用 Akka.Hosting.TestKit 的基本演员测试
using 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>
/// 演示现代 Akka.Hosting.TestKit 模式的 OrderActor 测试。
/// </summary>
public class OrderActorTests : TestKit
{
private readonly FakeOrderRepository _fakeRepository;
private readonly FakeEmailService _fakeEmailService;
public OrderActorTests(ITestOutputHelper output) : base(output: output)
{
// 将假服务作为字段(组合,而不是继承)
_fakeRepository = new FakeOrderRepository();
_fakeEmailService = new FakeEmailService();
}
/// <summary>
/// 覆盖 ConfigureServices 以注入假服务。
/// 这在 ConfigureAkka 之前运行,因此服务对演员可用。
/// </summary>
protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services)
{
// 将假注册为单例(所有演员使用相同的实例)
services.AddSingleton<IOrderRepository>(_fakeRepository);
services.AddSingleton<IEmailService>(_fakeEmailService);
services.AddLogging();
}
/// <summary>
/// 覆盖 ConfigureAkka 以配置测试用的演员系统。
/// 这是您使用与生产相同的扩展方法注册演员的地方。
/// </summary>
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
// 使用 TestScheduler 进行时间控制
builder.AddHocon("akka.scheduler.implementation = \"Akka.TestKit.TestScheduler, Akka.TestKit\"",
HoconAddMode.Prepend);
// 内存持久性(不需要数据库)
builder.WithInMemoryJournal()
.WithInMemorySnapshotStore();
// 使用与生产相同的扩展方法注册演员
builder.WithActors((system, registry, resolver) =>
{
// 使用依赖注入创建演员
var props = resolver.Props<OrderActor>();
var actor = system.ActorOf(props, "order-actor");
// 在 ActorRegistry 中注册,以进行类型安全的检索
registry.Register<OrderActor>(actor);
});
}
[Fact]
public async Task CreateOrder_Success_SavesToRepository()
{
// 安排
var orderActor = ActorRegistry.Get<OrderActor>();
var command = new CreateOrder(OrderId: "ORDER-123", CustomerId: "CUST-456", Amount: 99.99m);
// 行动
var response = await orderActor.Ask<OrderCommandResult>(command, RemainingOrDefault);
// 断言
response.Status.Should().Be(CommandStatus.Success);
// 验证假存储库被调用
_fakeRepository.SaveCallCount.Should().Be(1);
_fakeRepository.LastSavedOrderId.Should().Be("ORDER-123");
}
[Fact]
public async Task CreateOrder_RepositoryFails_ReturnsError()
{
// 安排
_fakeRepository.FailNextSave = true;
var orderActor = ActorRegistry.Get<OrderActor>();
var command = new CreateOrder(OrderId: "ORDER-789", CustomerId: "CUST-456", Amount: 99.99m);
// 行动
var response = await orderActor.Ask<OrderCommandResult>(command, RemainingOrDefault);
// 断言
response.Status.Should().Be(CommandStatus.Failed);
response.ErrorMessage.Should().NotBeNullOrEmpty();
}
}
// ============================================================================
// FAKE SERVICE IMPLEMENTATIONS (组合,而不是继承)
// ============================================================================
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;
}
}
关键要点:
TestKit是一个 框架基类,而不是用户定义的- 假服务是 字段(组合),而不是继承的
ConfigureServices()覆盖 DI 注册ConfigureAkka()使用与生产相同的扩展方法ActorRegistry.Get<T>()提供类型安全的演员检索
模式 2:使用 TestProbes 测试演员互动
使用 TestProbe 验证您的演员向其他演员发送消息,而不需要完整的实现。
public class InvoiceActorTests : TestKit
{
private readonly FakeInvoiceService _fakeInvoiceService;
private TestProbe? _paymentProbe;
public InvoiceActorTests(ITestOutputHelper output) : base(output: output)
{
_fakeInvoiceService = new FakeInvoiceService();
}
/// <summary>
/// 属性,在首次访问时创建 TestProbe(延迟初始化)。
/// </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) =>
{
// 将 TestProbe 注册为 PaymentActor 进行验证
_paymentProbe = CreateTestProbe("payment-probe");
registry.Register<PaymentActor>(_paymentProbe);
// 注册 InvoiceActor(被测试的演员)
var invoiceProps = resolver.Props<InvoiceActor>();
var invoiceActor = system.ActorOf(invoiceProps, "invoice-actor");
registry.Register<InvoiceActor>(invoiceActor);
});
}
[Fact]
public async Task CreateInvoice_Success_SendsPaymentRequest()
{
// 安排
var invoiceActor = ActorRegistry.Get<InvoiceActor>();
var command = new CreateInvoice(InvoiceId: "INV-001", Amount: 100.00m);
// 行动
var response = await invoiceActor.Ask<InvoiceCommandResult>(command, RemainingOrDefault);
// 断言 - 命令成功
response.Status.Should().Be(CommandStatus.Success);
// 断言 - 支付请求已发送到 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()
{
// 安排
var invoiceActor = ActorRegistry.Get<InvoiceActor>();
// 首先创建发票
await invoiceActor.Ask<InvoiceCommandResult>(
new CreateInvoice(InvoiceId: "INV-002", Amount: 50.00m),
RemainingOrDefault);
// 清除 InitiatePayment 消息
await PaymentProbe.ExpectMsgAsync<InitiatePayment>();
// 行动 - 通知发票支付已完成
var notification = new PaymentCompleted(InvoiceId: "INV-002", Amount: 50.00m);
invoiceActor.Tell(notification);
// 断言 - 查询状态以验证更新
var stateQuery = await invoiceActor.Ask<InvoiceState>(
new GetInvoiceState("INV-002"),
RemainingOrDefault);
stateQuery.Status.Should().Be(InvoiceStatus.Paid);
stateQuery.AmountPaid.Should().Be(50.00m);
}
}
关键模式:
- TestProbe 作为延迟属性 - 在首次访问时创建
- 在 ActorRegistry 中注册 TestProbe - 充当假演员
- ExpectMsgAsync<T>() - 验证消息已发送
- 清除消息 - 使用
ExpectMsgAsync()清除预期的消息,然后继续
模式 3:自动响应 TestProbe(避免 Ask 超时)
当演员使用 Ask 与另一个演员交流时,发件人期望收到响应。使用自动响应器防止超时。
/// <summary>
/// 自动响应演员,将所有消息转发到 TestProbe,同时自动回复特定消息类型,以避免 Ask 超时。
/// </summary>
internal sealed class PaymentAutoResponder : ReceiveActor
{
private readonly IActorRef _probe;
public PaymentAutoResponder(IActorRef probe)
{
_probe = probe;
// 自动回复 InitiatePayment 以 PaymentStarted
Receive<InitiatePayment>(msg =>
{
_probe.Tell(msg, Sender); // 转发以供验证
var response = new PaymentStarted(
PaymentId: msg.PaymentId,
InvoiceId: msg.InvoiceId);
Sender.Tell(response, Self); // 自动回复以避免超时
});
// 转发所有其他消息而不自动回复
ReceiveAny(msg => _probe.Tell(msg, Sender));
}
}
// ConfigureAkka 中的使用:
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
builder.WithActors((system, registry, resolver) =>
{
_paymentProbe = CreateTestProbe("payment-probe");
// 创建自动响应器转发到探针
var autoResponder = system.ActorOf(
Props.Create(() => new PaymentAutoResponder(_paymentProbe)),
"payment-auto-responder");
registry.Register<PaymentActor>(autoResponder);
// 注册被测试的演员
var invoiceActor = system.ActorOf(resolver.Props<InvoiceActor>(), "invoice-actor");
registry.Register<InvoiceActor>(invoiceActor);
});
}
何时使用:
- 被测试的演员使用
Ask与依赖项通信 - 您想验证消息已发送(探针)并避免超时
- 具有多个往返的复杂互动模式
模式 4:测试具有事件溯源的持久性演员
public class OrderPersistentActorTests : TestKit
{
public OrderPersistentActorTests(ITestOutputHelper output) : base(output: output)
{
}
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
// 配置 TestScheduler
builder.AddHocon("akka.scheduler.implementation = \"Akka.TestKit.TestScheduler, Akka.TestKit\"",
HoconAddMode.Prepend);
// 内存持久性(事件存储在内存中,测试后清除)
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()
{
// 安排
var actor = ActorRegistry.Get<OrderPersistentActor>();
var command = new CreateOrder(OrderId: "ORDER-123", Amount: 100.00m);
// 行动
var response = await actor.Ask<OrderCommandResult>(command, RemainingOrDefault);
// 断言
response.Status.Should().Be(CommandStatus.Success);
// 查询状态以验证事件已应用
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()
{
// 安排 - 创建订单并持久化事件
var actor = ActorRegistry.Get<OrderPersistentActor>();
await actor.Ask<OrderCommandResult>(
new CreateOrder(OrderId: "ORDER-456", Amount: 200.00m),
RemainingOrDefault);
// 获取实际演员的引用(不是注册表包装器)
var childActorPath = actor.Path / "order-456";
var childActor = await Sys.ActorSelection(childActorPath).ResolveOne(TimeSpan.FromSeconds(3));
// 行动 - 杀死演员以模拟被动化
await WatchAsync(childActor);
childActor.Tell(PoisonPill.Instance);
await ExpectTerminatedAsync(childActor);
// 发送查询,迫使演员从日志中恢复
var state = await actor.Ask<OrderState>(
new GetOrderState("ORDER-456"),
RemainingOrDefault);
// 断言 - 验证状态已正确恢复
state.Should().NotBeNull();
state.OrderId.Should().Be("ORDER-456");
state.Amount.Should().Be(200.00m);
state.Status.Should().Be(OrderStatus.Created);
}
}
关键模式:
- 内存日志 - 不需要数据库,快速测试
- 恢复测试 - 使用
PoisonPill杀死演员,然后查询以强制恢复 - WatchAsync/ExpectTerminatedAsync - 验证演员实际终止后继续
模式 5:重用生产配置扩展方法
当您的生产代码使用自定义 AkkaConfigurationBuilder 扩展方法(用于序列化器、演员、持久性)时,您的测试应该使用相同的扩展方法,而不是重复 HOCON 配置。
反模式:重复配置
// 坏:重复已经存在于扩展方法中的 HOCON 配置
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
}
}
"))
{ }
}
重复配置的问题:
- 当绑定更改时,需要在两个地方更新
- 测试可以通过,而生产失败(或反之)
- 容易忘记在测试中添加新绑定
- 实际上没有测试扩展方法本身
正确模式:重用扩展方法
// 生产扩展方法(在您的主要项目中)
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));
}
}
// 好:测试重用相同的扩展方法
public class DraftSerializerTests : Akka.Hosting.TestKit.TestKit
{
public DraftSerializerTests(ITestOutputHelper output) : base(output: output) { }
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
// 使用与生产相同的扩展方法
builder.AddDraftSerializer();
// 添加测试特定的配置(内存持久性等)
builder.WithInMemoryJournal()
.WithInMemorySnapshotStore();
}
[Fact]
public async Task DraftSerializer_RoundTrips_DraftCreatedEvent()
{
// 安排
var original = new DraftCreated(DraftId.New(), "Test Draft", DateTime.UtcNow);
// 行动 - 通过演员系统序列化和反序列化
var serializer = Sys.Serialization.FindSerializerFor(original);
var bytes = serializer.ToBinary(original);
var deserialized = serializer.FromBinary(bytes, typeof(DraftCreated));
// 断言
deserialized.Should().BeEquivalentTo(original);
}
}
好处
| 好处 | 解释 |
|---|---|
| DRY | 配置的单一来源 |
| 无漂移 | 测试始终使用与生产完全相同的配置 |
| 更易于维护 | 在一个地方添加新绑定,测试自动获取 |
| 更好的覆盖 | 实际测试扩展方法本身 |
| 捕获真实错误 | 如果扩展方法损坏,测试失败 |
应用于其他配置
此模式适用于任何 AkkaConfigurationBuilder 扩展方法:
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
// 重用生产扩展方法
builder
.AddDraftSerializer() // 自定义序列化器
.AddOrderDomainActors(AkkaExecutionMode.LocalTest) // 领域演员
.AddCustomPersistence() // 持久性配置
.AddReminders(); // 提醒系统
// 仅覆盖测试特定内容
builder
.WithInMemoryJournal() // 用内存替换真实数据库
.WithInMemorySnapshotStore();
}
模式 6:本地测试集群分片
使用 AkkaExecutionMode.LocalTest 在没有实际集群的情况下测试集群分片行为。
// 在您的生产代码中(AkkaHostingExtensions.cs):
public static AkkaConfigurationBuilder WithOrderActor(
this AkkaConfigurationBuilder builder,
AkkaExecutionMode executionMode = AkkaExecutionMode.Clustered)
{
if (executionMode == AkkaExecutionMode.LocalTest)
{
// 非集群模式:使用 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
{
// 集群模式:使用 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;
}
// 在您的测试中:
public class OrderShardingTests : TestKit
{
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
builder.WithInMemoryJournal().WithInMemorySnapshotStore();
// 使用与生产相同的扩展方法,但使用 LocalTest 模式
builder.WithOrderActor(AkkaExecutionMode.LocalTest);
}
[Fact]
public async Task ShardedActor_RoutesMessagesByEntityId()
{
// 安排
var orderRegion = ActorRegistry.Get<OrderActor>();
// 行动 - 发送两个不同实体 ID 的命令
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);
// 断言
response1.Status.Should().Be(CommandStatus.Success);
response2.Status.Should().Be(CommandStatus.Success);
// 查询状态以验证路由正确
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);
}
}
关键模式:
- 相同的扩展方法 用于生产和测试
AkkaExecutionMode参数 在集群和本地之间切换GenericChildPerEntityParent本地模拟分片行为- 不需要实际集群 进行测试
模式 6:使用 AwaitAssertAsync 测试异步演员行为
当演员执行异步操作(如调用外部服务)时,使用 AwaitAssertAsync。
[Fact]
public async Task CreateInvoice_CallsReadModelSync()
{
// 安排
var invoiceActor = ActorRegistry.Get<InvoiceActor>();
var command = new CreateInvoice(InvoiceId: "INV-003", Amount: 75.00m);
// 行动
var response = await invoiceActor.Ask<InvoiceCommandResult>(command, RemainingOrDefault);
// 断言 - 命令成功
response.Status.Should().Be(CommandStatus.Success);
// 断言 - 读模型同步被调用(异步操作,需要等待)
await AwaitAssertAsync(() =>
{
_fakeReadModelService.SyncCallCount.Should().BeGreaterOrEqualTo(1);
_fakeReadModelService.LastSyncedInvoiceId.Should().Be("INV-003");
}, TimeSpan.FromSeconds(3));
}
[Fact]
public async Task PaymentRetry_SchedulesReminder()
{
// 安排
var invoiceActor = ActorRegistry.Get<InvoiceActor>();
await CreateAndFailPayment(invoiceActor, "INV-004");
// 行动 - 触发支付失败(安排重试提醒)
var failure = new PaymentFailed(InvoiceId: "INV-004", Reason: "Card declined");
invoiceActor.Tell(failure);
// 断言 - 验证提醒已安排(异步操作)
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));
}
关键模式:
- AwaitAssertAsync - 重试断言,直到通过或超时
- 适用于异步操作 - 读模型同步、提醒调度、外部 API 调用
- 防止测试不稳定 - 给异步操作时间完成
模式 7:基于场景的集成测试
测试具有多个演员和状态转换的完整业务工作流程。
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();
// 注册所有领域演员(订阅、发票、支付)
builder.WithSubscriptionDomainActors(AkkaExecutionMode.LocalTest);
}
[Fact]
public async Task Scenario_FirstTimePurchase_SuccessfulPayment()
{
// 安排
var subscriptionId = "SUB-001";
var subscriptionActor = ActorRegistry.Get<SubscriptionActor>();
// 第 1 步:创建订阅
var createResult = await subscriptionActor.Ask<SubscriptionCommandResult>(
new CreateSubscription(subscriptionId, "CUST-123", 99.99m),
RemainingOrDefault);
createResult.Status.Should().Be(CommandStatus.Success);
// 第 2 步:验证发票已生成
await AwaitAssertAsync(async () =>
{
var state = await subscriptionActor.Ask<SubscriptionState>(
new GetSubscriptionState(subscriptionId),
RemainingOrDefault);
state.CurrentInvoiceId.Should().NotBeNullOrEmpty();
});
// 第 3 步:模拟支付成功
var state = await subscriptionActor.Ask<SubscriptionState>(
new GetSubscriptionState(subscriptionId),
RemainingOrDefault);
var paymentNotification = new PaymentCompleted(
InvoiceId: state.CurrentInvoiceId!,
Amount: 99.99m);
subscriptionActor.Tell(paymentNotification);
// 第 4 步:验证订阅现在是活跃的
await AwaitAssertAsync(async () =>
{
var finalState = await subscriptionActor.Ask<SubscriptionState>(
new GetSubscriptionState(subscriptionId),
RemainingOrDefault);
finalState.Status.Should().Be(SubscriptionStatus.Active);
finalState.BenefitsProvisioned.Should().BeTrue();
});
// 第 5 步:验证服务已配置
_fakeService.ProvisionCallCount.Should().BeGreaterOrEqualTo(1);
_fakeService.LastProvisionedSubscriptionId.Should().Be(subscriptionId);
}
[Fact]
public async Task Scenario_PaymentFailure_RetryAndGracePeriod()
{
// 安排
var subscriptionId = "SUB-002";
var subscriptionActor = ActorRegistry.Get<SubscriptionActor>();
// 第 1 步:创建订阅并生成发票
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!;
// 第 2 步:模拟 3 次支付失败
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)
{
// 验证前两次尝试的软催收通知
await AwaitAssertAsync(async () =>
{
var currentState = await subscriptionActor.Ask<SubscriptionState>(
new GetSubscriptionState(subscriptionId),
RemainingOrDefault);
currentState.PaymentRetryCount.Should().Be(attempt);
});
}
}
// 第 3 步:验证 3 次失败后的硬催收
await AwaitAssertAsync(async () =>
{
var finalState = await subscriptionActor.Ask<SubscriptionState>(
new GetSubscriptionState(subscriptionId),
RemainingOrDefault);
finalState.Status.Should().Be(SubscriptionStatus.PaymentFailed);
finalState.GracePeriodExpiresAt.Should().NotBeNull();
});
// 第 4 步:验证宽限期提醒已安排
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");
});
}
}
关键模式:
- 多步骤工作流程 - 测试完整的业务场景,不仅仅是单个操作
- 每个步骤的状态验证 - 使用
AwaitAssertAsync验证状态转换 - 多个演员 - 注册所有领域演员,测试它们的互动
- 业务为重点的命名 -
Scenario_FirstTimePurchase_SuccessfulPayment
常见模式总结
| 模式 | 使用案例 |
|---|---|
| 基本演员测试 | 具有注入服务的单个演员 |
| TestProbe | 验证演员向依赖项发送消息 |
| 自动响应器 | 测试时避免 Ask 超时 |
| 持久性演员 | 测试事件溯源和恢复 |
| 集群分片 | 本地测试分片行为 |
| AwaitAssertAsync | 处理演员中的异步操作 |
| 场景测试 | 端到端业务工作流程 |
避免的反模式
❌ 不要:创建自定义测试基类
// 坏:自定义基类以实现 "DRY" 设置
public abstract class BaseAkkaTest : TestKit
{
protected IActorRef OrderActor { get; private set; }
protected FakeOrderRepository FakeRepository { get; private set; }
protected override void ConfigureAkka(...)
{
// 跨所有测试共享的设置
}
}
public class OrderActorTests : BaseAkkaTest
{
// 现在与 BaseAkkaTest 设置耦合
}
为什么不好:
- 测试之间的紧密耦合
- 隐藏依赖(注册了哪些服务?)
- 每个测试难以定制
- 违反测试隔离原则
✅ 做:使用方法覆盖
每个测试类使用 ConfigureServices() 和 ConfigureAkka() 覆盖它需要的确切内容。
❌ 不要:在测试之间共享状态
// 坏:在测试之间重用相同的演员实例
public class OrderActorTests : TestKit
{
private readonly IActorRef _orderActor;
public OrderActorTests()
{
_orderActor = /* 创建一次 */;
}
[Fact] public void Test1() { /* 使用 _orderActor */ }
[Fact] public void Test2() { /* 使用 _orderActor */ }
}
为什么不好:
- Test1 和 Test2 共享状态
- 测试执行顺序很重要
- 由于副作用导致测试不稳定
✅ 做:使用 xUnit 类固定或获取新的演员
// 好:每个测试获得干净的 ActorSystem
public class OrderActorTests : TestKit
{
[Fact]
public async Task Test1()
{
var actor = ActorRegistry.Get<OrderActor>(); // 新系统
// 测试
}
[Fact]
public async Task Test2()
{
var actor = ActorRegistry.Get<OrderActor>(); // 新系统
// 测试
}
}
❌ 不要:使用真实的外部依赖项
// 坏:在测试中使用真实的数据库
protected override void ConfigureServices(...)
{
services.AddDbContext<OrderDbContext>(options =>
options.UseSqlServer(connectionString)); // 真正的 DB!
}
✅ 做:使用假或内存替代品
// 好:假存储库
protected override void ConfigureServices(...)
{
services.AddSingleton<IOrderRepository>(_fakeRepository);
}
使用 Akka.Reminders 进行测试
如果您的演员使用 Akka.Reminders 进行调度,请在测试中配置本地提醒:
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
{
builder.AddHocon("akka.scheduler.implementation = \"Akka.TestKit.TestScheduler, Akka.TestKit\"",
HoconAddMode.Prepend);
builder.WithInMemoryJournal().WithInMemorySnapshotStore();
// 为测试配置本地提醒
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);
// 在启动后用提醒解析器注册分片区域
builder.AddStartup(async (system, registry) =>
{
var invoicingRegion = await registry.GetAsync<InvoicingActor>();
shardResolver.RegisterShardRegion("invoicing", invoicingRegion);
});
}
[Fact]
public async Task PaymentFailure_SchedulesRetryReminder()
{
// 安排
var invoiceId = "INV-001";
var actor = ActorRegistry.Get<InvoicingActor>();
// 行动 - 触发支付失败
var failure = new PaymentFailed(invoiceId, "Card declined");
actor.Tell(failure);
// 断言 - 验证提醒已安排
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));
}
传统 Akka.TestKit(遗留/核心开发)
为了完整性,这是传统的 TestKit 方法(只有在您不能使用 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()
{
// 安排 - 手动创建演员与 Props
var orderActor = Sys.ActorOf(Props.Create<OrderActor>(), "order-actor");
// 行动
orderActor.Tell(new CreateOrder("ORDER-001", 100m));
// 断言
var confirmation = ExpectMsg<OrderCreated>();
Assert.Equal("ORDER-001", confirmation.OrderId);
}
[Fact]
public void OrderActor_RespondsToQuery()
{
// 安排
var orderActor = Sys.ActorOf(Props.Create<OrderActor>());
// 行动
orderActor.Tell(new CreateOrder("ORDER-002", 200m));
ExpectMsg<OrderCreated>(); // 清除创建消息
// 查询
orderActor.Tell(new GetOrderState("ORDER-002"));
// 断言
var state = ExpectMsg<OrderState>();
Assert.Equal("ORDER-002", state.OrderId);
Assert.Equal(200m, state.Amount);
}
}
主要区别:
- 手动
Props.Create<T>()而不是 DI - 没有服务注入(演员必须在内部创建依赖项或使用
Context) ExpectMsg<T>()而不是Ask模式- 构造函数接受 HOCON 配置字符串
何时使用:
- 为 Akka.NET 核心做出贡献
- 没有 Microsoft.Extensions 的遗留项目
- 不使用 DI 的控制台应用程序
最佳实践
- 每个测试类一个演员 - 保持测试专注
- 覆盖 ConfigureServices/ConfigureAkka - 不要创建基类
- 使用假,而不是模拟 - 更简单,更易于维护
- 一次测试一个演员 - 使用 TestProbes 测试依赖项
- 匹配生产模式 - 相同的扩展方法,不同的
AkkaExecutionMode - 使用 AwaitAssertAsync 处理异步 - 防止测试不稳定
- 测试恢复 - 杀死并重新启动演员以验证持久性
- 场景测试工作流程 - 测试完整的业务流程端到端
- 保持测试快速 - 内存持久性,没有真实数据库
- 使用有意义的名称 -
Scenario_FirstTimePurchase_SuccessfulPayment
调试提示
- 启用调试日志 - 将
LogLevel.Debug传递给 TestKit 构造函数 - 使用 ITestOutputHelper - 在测试输出中查看演员系统日志
- 检查 TestProbe - 查看
probe.Messages了解发送了什么 - 查询演员状态 - 添加状态查询消息进行调试
- 使用 AwaitAssertAsync 进行日志记录 - 查看断言失败的原因
- 检查 ActorRegistry - 验证演员是否注册正确
// 构造函数,启用调试日志
public OrderActorTests(ITestOutputHelper output)
: base(output: output, logLevel: LogLevel.Debug)
{
}
// 检查探针收到的消息
[Fact]
public void DebugTest()
{
// ... 测试代码 ...
// 检查探针收到的所有消息
_paymentProbe.Messages.Should().NotBeEmpty();
foreach (var msg in _paymentProbe.Messages)
{
Output?.WriteLine($"Received: {msg}");
}
}
CI/CD 集成
GitHub Actions 示例
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"
额外资源
- Akka.NET 文档:https://getakka.net/
- Akka.Hosting 文档:https://github.com/akkadotnet/Akka.Hosting
- Petabridge Bootcamp:https://petabridge.com/bootcamp/ (全面的 Akka.NET 培训)
- Akka.TestKit 指南:https://getakka.net/articles/testing/testing-actor-systems.html