Files
scadalink-design/AkkaDotNet/16-TestKit.md
Joseph Doherty de636b908b Add Akka.NET reference documentation
Notes and documentation covering actors, remoting, clustering, persistence,
streams, serialization, hosting, testing, and best practices for the Akka.NET
framework used throughout the ScadaLink system.
2026-03-16 09:08:17 -04:00

7.2 KiB

16 — TestKit (Akka.TestKit)

Overview

Akka.TestKit provides infrastructure for unit and integration testing of actors. It creates a controlled test environment with a test ActorSystem, TestProbe actors for asserting message flows, TestActorRef for synchronous actor access, and EventFilter for asserting log output. Test-framework-specific adapters are available for xUnit, NUnit, and MSTest.

In the SCADA system, TestKit is essential for validating device actor behavior, protocol abstraction correctness, command handling logic, and supervision strategies — all without connecting to real equipment.

When to Use

  • Unit testing individual actor behavior (device actors, command handlers, alarm processors)
  • Testing message flows between actors (Device Manager → Device Actor → Protocol Adapter)
  • Verifying supervision strategies (what happens when a device actor throws a CommunicationException)
  • Testing actor state transitions (ConnectingOnlineFaulted)
  • Asserting that specific messages are sent in response to inputs

When Not to Use

  • Testing non-actor code (protocol parsing logic, tag value conversions) — use standard unit tests
  • Full integration tests with DI, hosting, and configuration — use Akka.Hosting.TestKit (see 17-HostingTestKit.md)
  • Multi-node failover tests — use MultiNodeTestRunner (see 18-MultiNodeTestRunner.md)

Design Decisions for the SCADA System

Test Framework: xUnit

Use Akka.TestKit.Xunit2 to integrate with xUnit, which is the standard test framework for modern .NET projects:

NuGet: Akka.TestKit.Xunit2

Testing the Protocol Abstraction

The protocol abstraction (common message contract with OPC-UA and custom protocol implementations) is the most critical code to test. Create tests that verify both implementations respond identically to the same message contract:

public class OpcUaDeviceActorTests : TestKit
{
    [Fact]
    public void Should_respond_with_DeviceState_when_asked()
    {
        var deviceActor = Sys.ActorOf(Props.Create(() =>
            new OpcUaDeviceActor(TestDeviceConfig.Create())));

        deviceActor.Tell(new GetDeviceState());

        var state = ExpectMsg<DeviceState>();
        Assert.Equal(ConnectionStatus.Connecting, state.Status);
    }
}

public class CustomProtocolDeviceActorTests : TestKit
{
    [Fact]
    public void Should_respond_with_DeviceState_when_asked()
    {
        var deviceActor = Sys.ActorOf(Props.Create(() =>
            new CustomProtocolDeviceActor(TestDeviceConfig.Create())));

        deviceActor.Tell(new GetDeviceState());

        var state = ExpectMsg<DeviceState>();
        Assert.Equal(ConnectionStatus.Connecting, state.Status);
    }
}

TestProbe for Interaction Testing

Use TestProbe to verify that actors send the correct messages to their collaborators:

[Fact]
public void DeviceManager_should_forward_command_to_correct_device()
{
    var probe = CreateTestProbe();

    // Create a DeviceManager that uses the probe as a device actor
    var manager = Sys.ActorOf(Props.Create(() =>
        new TestableDeviceManager(deviceActorOverride: probe)));

    manager.Tell(new SendCommand("cmd-1", "machine-001", "StartMotor", true));

    // Verify the command was forwarded to the device actor
    var forwarded = probe.ExpectMsg<SendCommand>();
    Assert.Equal("cmd-1", forwarded.CommandId);
}

Testing Supervision Strategies

Verify that the Device Manager restarts device actors on communication failures:

[Fact]
public void Should_restart_device_actor_on_communication_exception()
{
    var probe = CreateTestProbe();
    var deviceActor = Sys.ActorOf(Props.Create(() =>
        new FailingDeviceActor(failOnFirst: true)));

    Watch(deviceActor);

    // Send a message that triggers the CommunicationException
    deviceActor.Tell(new ConnectToDevice());

    // The actor should be restarted, not stopped
    ExpectNoMsg(TimeSpan.FromSeconds(1)); // No Terminated message

    // After restart, the actor should accept messages again
    deviceActor.Tell(new GetDeviceState());
    ExpectMsg<DeviceState>();
}

Common Patterns

ExpectMsg with Timeout

Always specify reasonable timeouts for message expectations. In CI environments, use the Dilated method to account for slower machines:

ExpectMsg<CommandAcknowledged>(TimeSpan.FromSeconds(5));
// or
ExpectMsg<CommandAcknowledged>(Dilated(TimeSpan.FromSeconds(3)));

ExpectNoMsg for Negative Testing

Verify that an actor does NOT send a message in certain conditions (e.g., a filtered tag update should not be forwarded):

deviceActor.Tell(new TagUpdate("temp", 20.0)); // Within deadband
ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // Should be filtered

WithinAsync for Timing Assertions

Verify that a sequence of messages arrives within a time window:

await WithinAsync(TimeSpan.FromSeconds(5), async () =>
{
    deviceActor.Tell(new ConnectToDevice());
    await ExpectMsgAsync<Connected>();
    deviceActor.Tell(new SubscribeToTag("temperature"));
    await ExpectMsgAsync<TagSubscribed>();
});

Mock Protocol Adapters

Create test doubles for protocol adapters that simulate equipment behavior:

public class MockOpcUaClient : IOpcUaClient
{
    private readonly Dictionary<string, object> _tagValues = new();

    public Task<object> ReadTagAsync(string tagName) =>
        Task.FromResult(_tagValues.GetValueOrDefault(tagName, 0.0));

    public void SimulateTagChange(string tagName, object value) =>
        _tagValues[tagName] = value;
}

Anti-Patterns

Testing Internal Actor State Directly

Avoid using TestActorRef to inspect internal actor state. Test behavior through messages — send an input, assert the output. This keeps tests decoupled from implementation details.

Shared Test ActorSystem

Each test class gets its own ActorSystem (TestKit creates one per fixture). Do not share an ActorSystem across test classes — actor names and state leak between tests.

Ignoring Dead Letters in Tests

Dead letters in tests often indicate bugs (messages sent to stopped actors, wrong actor references). Subscribe to dead letters in tests and fail on unexpected ones:

Sys.EventStream.Subscribe(TestActor, typeof(DeadLetter));

Flaky Time-Dependent Tests

Avoid tests that depend on exact timing (Thread.Sleep(100)). Use ExpectMsg with generous timeouts and Dilated for CI compatibility.

Configuration Guidance

Test-Specific Configuration

Pass custom configuration to TestKit for specific test scenarios:

public class DeviceActorTests : TestKit
{
    public DeviceActorTests()
        : base(ConfigurationFactory.ParseString(@"
            akka.loglevel = DEBUG
            akka.actor.debug.receive = on
        "))
    {
    }
}

CI/CD Time Factor

Set the time factor for CI environments where machines are slower:

akka.test {
  timefactor = 3.0  # Multiply all timeouts by 3 in CI
}

References