Akka.NETTestingPatternsSkill akka-net-testing-patterns

本指南提供了一套全面的测试模式,用于对 Akka.NET 演员进行单元和集成测试。涵盖了依赖注入、TestProbes、持久性测试、演员互动验证等多个方面,并提供了何时使用传统 TestKit 的指导。

测试 0 次安装 0 次浏览 更新于 2/26/2026

Akka.NET 测试模式

何时使用此技能

使用此技能时:

  • 编写 Akka.NET 演员的单元测试
  • 测试具有事件溯源的持久性演员
  • 验证演员互动和消息流
  • 测试演员监督和生命周期
  • 在演员测试中模拟外部依赖项
  • 本地测试集群分片行为
  • 验证演员状态恢复和持久性

选择测试方法

✅ 使用 Akka.Hosting.TestKit(推荐95%的使用案例)

何时:

  • 使用 Microsoft.Extensions.DependencyInjection 构建现代.NET应用程序
  • 在生产中使用 Akka.Hosting 进行演员配置
  • 需要将服务注入演员(IOptionsDbContextILogger、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)

  1. 继承自 Akka.Hosting.TestKit.TestKit - 这是一个框架基类,而不是用户定义的
  2. 覆盖 ConfigureServices() - 用假/模拟替换真实服务
  3. 覆盖 ConfigureAkka() - 使用与生产相同的扩展方法配置演员
  4. 使用 ActorRegistry - 类型安全的演员检索
  5. 组合优于继承 - 将假服务作为字段,而不是基类
  6. 不使用自定义基类 - 使用方法覆盖,而不是继承层次结构
  7. 一次测试一个演员 - 使用 TestProbes 测试依赖项
  8. 匹配生产模式 - 相同的扩展方法,不同的 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 的控制台应用程序

最佳实践

  1. 每个测试类一个演员 - 保持测试专注
  2. 覆盖 ConfigureServices/ConfigureAkka - 不要创建基类
  3. 使用假,而不是模拟 - 更简单,更易于维护
  4. 一次测试一个演员 - 使用 TestProbes 测试依赖项
  5. 匹配生产模式 - 相同的扩展方法,不同的 AkkaExecutionMode
  6. 使用 AwaitAssertAsync 处理异步 - 防止测试不稳定
  7. 测试恢复 - 杀死并重新启动演员以验证持久性
  8. 场景测试工作流程 - 测试完整的业务流程端到端
  9. 保持测试快速 - 内存持久性,没有真实数据库
  10. 使用有意义的名称 - Scenario_FirstTimePurchase_SuccessfulPayment

调试提示

  1. 启用调试日志 - 将 LogLevel.Debug 传递给 TestKit 构造函数
  2. 使用 ITestOutputHelper - 在测试输出中查看演员系统日志
  3. 检查 TestProbe - 查看 probe.Messages 了解发送了什么
  4. 查询演员状态 - 添加状态查询消息进行调试
  5. 使用 AwaitAssertAsync 进行日志记录 - 查看断言失败的原因
  6. 检查 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"

额外资源