Skip to main content

Testing Adapters

How to unit test your IDeviceAdapter implementation and verify SDK integration.

The five-layer test model

The reference rig organises tests in five concentric layers — pick the smallest layer that exercises the surface you care about.

ProjectScopeTooling
tests/BeaconTower.ProviderSdk.TestsPure SDK unit testsxUnit + NSubstitute / Moq + FluentAssertions
tests/BeaconTower.ProviderMqtt.TestsAdapter unit testsxUnit + NSubstitute
tests/BeaconTower.MqttProvider.TestsTop-level rig unit tests (kept thin)xUnit
tests/BeaconTower.MqttProvider.IntegrationTestsIn-process integrationWebApplicationFactory<Program> + Testcontainers (PostgreSQL + NATS + broker)
e2e/BeaconTower.MqttProvider.E2E (+.Cli)Live-deployment e2eCustom harness, optional REST mode (serve)

When mirroring this for a new provider, keep adapter logic in the adapter unit tests layer and put cross-cutting flows in integration tests. The top-level rig tests should stay thin — most behaviour belongs in the SDK or the adapter.

Unit-testing the adapter

Mock the SDK clients and exercise your adapter's logic directly.

Test setup

using NSubstitute;
using FluentAssertions;
using BeaconTower.ProviderSdk.Adapters;

public class MyAdapterTests
{
private readonly MyAdapter _adapter;
private readonly ITelemetryClient _telemetry;
private readonly IPropertiesClient _properties;
private readonly IDiagnosticsClient _diagnostics;

public MyAdapterTests()
{
_telemetry = Substitute.For<ITelemetryClient>();
_properties = Substitute.For<IPropertiesClient>();
_diagnostics = Substitute.For<IDiagnosticsClient>();

var context = new AdapterContext
{
Telemetry = _telemetry,
Properties = _properties,
Diagnostics = _diagnostics,
};

_adapter = new MyAdapter(context, /* additional collaborators */ );
}
}

OnProvisionAsync

[Fact]
public async Task OnProvisionAsync_SymmetricKey_CreatesProtocolRecords()
{
var ctx = new ProvisionContext
{
DeviceId = "test-device",
AuthMethod = DeviceAuthMethod.SymmetricKey,
PrimaryKey = "plaintext-key",
PrimaryKeyHash = "$2a$10$hashedkey...",
};

var result = await _adapter.OnProvisionAsync(ctx, CancellationToken.None);

result.Success.Should().BeTrue();
// Verify protocol-side records were created via your collaborator mocks.
}

[Fact]
public async Task OnProvisionAsync_ProtocolFailure_ReturnsFailedResult()
{
_broker.When(x => x.CreateUser(Arg.Any<string>()))
.Throw(new BrokerException("Connection refused"));

var ctx = new ProvisionContext
{
DeviceId = "test-device",
AuthMethod = DeviceAuthMethod.SymmetricKey,
};

var result = await _adapter.OnProvisionAsync(ctx, CancellationToken.None);

result.Success.Should().BeFalse();
result.Error.Should().Contain("Connection refused");
}

Telemetry ingestion

Verify the adapter forwards bytes correctly. Note the payload type is ReadOnlyMemory<byte>, not byte[].

[Fact]
public void IngestTelemetry_ForwardsBytesToPipeline()
{
var payload = """{"temperature": 22.5}"""u8.ToArray();

_adapter.IngestTelemetry("device-001", payload);

_telemetry.Received(1).Send("device-001", Arg.Is<TelemetryMessage>(m =>
m.Payload.Span.SequenceEqual(payload) && !m.IsReportedProperties));
}

SendCommandAsync

CommandResult has no static factories — construct it explicitly. Always echo CorrelationId.

[Fact]
public async Task SendCommandAsync_DeviceResponds_ReturnsOk()
{
var command = new AdapterCommand
{
DeviceId = "device-001",
MethodName = "reboot",
CorrelationId = "corr-1",
Timeout = TimeSpan.FromSeconds(5),
};

var result = await _adapter.SendCommandAsync(command, CancellationToken.None);

result.Status.Should().Be(CommandStatus.Ok);
result.StatusCode.Should().Be(200);
result.CorrelationId.Should().Be("corr-1");
}

[Fact]
public async Task SendCommandAsync_Timeout_ReturnsMethodTimeout()
{
var command = new AdapterCommand
{
DeviceId = "device-001",
MethodName = "reboot",
CorrelationId = "corr-2",
Timeout = TimeSpan.FromMilliseconds(1),
};

var result = await _adapter.SendCommandAsync(command, CancellationToken.None);

result.Status.Should().Be(CommandStatus.MethodTimeout);
result.ErrorCode.Should().Be(nameof(CommandStatus.MethodTimeout));
result.Retryable.Should().BeTrue();
result.StatusCode.Should().BeNull(); // Only set for Ok / DeviceError
}

Diagnostics

[Fact]
public void OnDeviceConnect_ReportsConnectedWithMetadata()
{
_adapter.RaiseConnected("device-001", "192.0.2.10", "MQTT 5.0");

_diagnostics.Received(1).ReportConnected("device-001",
Arg.Is<ConnectionMetadata>(m =>
m.RemoteAddress == "192.0.2.10" &&
m.ProtocolVersion == "MQTT 5.0"));
}

Integration testing with WebApplicationFactory

For testing the full SDK registration and wiring, use WebApplicationFactory<Program>. Set the environment to Test (or Testing) so the SDK skips background services and DbUp migrations:

public class ProviderIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;

public ProviderIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(b => b.UseEnvironment("Test"));
}

[Fact]
public async Task HealthCheck_ReturnsHealthy()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/health");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}

For tests that need to drive the pipeline manually, use Test/Testing and resolve TelemetryPipeline directly from the service provider — AddBeaconTowerProvider skips its IHostedService registration in those environments.

For tests that need real persistence, use Testcontainers to spin up PostgreSQL, NATS, and your broker. The reference integration project (BeaconTower.MqttProvider.IntegrationTests) demonstrates this pattern.

End-to-end and loadtest

e2e/BeaconTower.MqttProvider.E2E is a custom harness with optional REST API mode (serve). It is driven by environment variables — E2E_PROFILE (e2e or k3s) and E2E_SIMULATOR (e.g. EnergyMeterSimulator, SimpleSensorSimulator). It covers cert-flow smoke and onboarding-client paths.

Loadtest is separate, in k3s/:

./k3s/loadtest-run.sh
./k3s/loadtest-report.sh

It orchestrates the device simulator against a real k3s cluster, captures per-pod CPU/memory CSVs, and emits JSON pass/fail records.

What to test

AreaLayerWhat to verify
CapabilitiesUnitFlags reflect transport behaviour
OnProvisionAsync / OnUnprovisionAsyncUnitProtocol-side records created / cleaned
SendCommandAsyncUnitAll CommandStatus paths, CorrelationId echo, Retryable per status
SendDesiredPropertiesAsyncUnitPushed via transport or no-op for pull-only protocols
IsDeviceConnectedUnitReturns true only on the replica that owns the live session
StartAsync / StopAsyncUnitLifecycle transitions, idempotent stop, cancellation token honoured
Telemetry ingestionUnitReadOnlyMemory<byte> forwarded with correct IsReportedProperties flag
DiagnosticsUnitConnectionMetadata populated, disconnect reasons surfaced
Controllers / HTTPIntegrationStatus codes, request/response mapping, JWT enforcement
Health checksIntegration/alive, /ready, /health shape
Provisioning idempotencyIntegrationConcurrent provision → loser sees UniqueViolation, idempotent caller re-reads
Twin PATCH semanticsIntegrationRFC 7396 merge-patch, version increment, ?component= filter behaviour on non-object
Bus-api schemaVersionIntegrationRequests without schemaVersion: 1 rejected

What NOT to test

  • SDK internals (pipeline batching, NATS publish, DB queries) — covered by the SDK's own test suite.
  • AddBeaconTowerProvider<T> registration — covered by the SDK.
  • DbUp migrations — covered by the SDK's integration tests.
  • bcrypt-threadpool offload behaviour — covered by the SDK; just don't disable it.