Files
lmxopcua/docs/drivers/OpcUaClient-Test-Fixture.md
2026-04-26 09:46:33 -04:00

9.9 KiB
Raw Blame History

OPC UA Client test fixture

Coverage map + gap inventory for the OPC UA Client (gateway / aggregation) driver.

TL;DR: Wire-level coverage now exists via opc-plc — Microsoft Industrial IoT's OPC UA PLC simulator running in Docker (task #215). Real Secure Channel, real Session, real MonitoredItem exchange against an independent server implementation. Unit tests still carry the exhaustive capability matrix (cert auth / security policies / reconnect / failover / attribute mapping). Gaps remaining: upstream-server-specific quirks (historian aggregates, typed ConditionType events, SDK-publish-queue edge behavior under load) — opc-plc uses the same OPCFoundation stack internally so fully-independent-stack coverage needs open62541/open62541 as a second image (follow-up).

What the fixture is

Integration layer (task #215): tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ stands up mcr.microsoft.com/iotedge/opc-plc:2.14.10 via Docker/docker-compose.yml on opc.tcp://localhost:50000. OpcPlcFixture probes the port at collection init + skips tests with a clear message when the container's not running (matches the Modbus/pymodbus + S7/python-snap7 skip pattern). Docker is the launcher — no PowerShell wrapper needed because opc-plc ships pre-containerized. Compose-file flags: --ut (unsecured transport advertised), --aa (auto-accept client certs — opc-plc's cert trust store resets on each spin-up), --alm (alarm simulation for IAlarmSource follow-up coverage), --pn=50000 (port).

Unit layer: tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ is still the primary coverage. Tests inject fakes through the driver's construction path; the OPCFoundation.NetStandard Session surface is wrapped behind an interface the tests mock.

What it actually covers

Integration (opc-plc Docker, task #215)

  • OpcUaClientSmokeTests.Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack — full Secure Channel + Session + ns=3;s=StepUp Read round-trip
  • OpcUaClientSmokeTests.Client_reads_batch_of_varied_types_from_live_simulator — batch Read of UInt32 / Int32 / Boolean; asserts bool-specific Variant decoding to catch a common attribute-mapping regression
  • OpcUaClientSmokeTests.Client_subscribe_receives_StepUp_data_changes_from_live_server — real MonitoredItem subscription against ns=3;s=FastUInt1 (ticks every 100 ms); asserts OnDataChange fires within 3 s of subscribe
  • OpcUaClientReverseConnectSmokeTests.Driver_accepts_reverse_connect_from_opc_plc_rc_simulator — reverse-connect (server-initiated) coverage. Driver binds opc.tcp://0.0.0.0:4844, the opc-plc-rc docker service dials in via --rc opc.tcp://host.docker.internal:4844, and a Read round-trips over the inbound socket. Gated on OPCUA_RC_SIM=1 because the simulator requires host.docker.internal resolution which not every CI runner exposes.

Wire-level surfaces verified: IDriver + IReadable + ISubscribable + IHostConnectivityProbe (via the Secure Channel exchange).

Unit

The surface is broad because OpcUaClientDriver is the richest-capability driver in the fleet (it's a gateway for another OPC UA server, so it mirrors the full capability matrix):

  • OpcUaClientDriverScaffoldTestsIDriver lifecycle
  • OpcUaClientReadWriteTests — read + write lifecycle
  • OpcUaClientSubscribeAndProbeTests — monitored-item subscription + probe state transitions
  • OpcUaClientDiscoveryTestsGetEndpoints + endpoint selection
  • OpcUaClientAttributeMappingTests — OPC UA node attribute → driver value mapping
  • OpcUaClientSecurityPolicyTestsSignAndEncrypt / Sign / None policy negotiation contract
  • OpcUaClientCertAuthTests — cert store paths, revocation-list config
  • OpcUaClientReconnectTests — SDK reconnect hook + TransferSubscriptions across the disconnect boundary
  • OpcUaClientFailoverTests — primary → secondary session fallback per driver config
  • OpcUaClientAlarmTests — A&E severity bucket (11000 → Low / Medium / High / Critical), subscribe / unsubscribe / ack contract
  • OpcUaClientHistoryTests — historical data read + interpolation contract

Capability surfaces whose contract is verified: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider.

What it does NOT cover

1. Real stack exchange

No UA Secure Channel is ever opened. Every test mocks Session.ReadAsync, Session.CreateSubscription, Session.AddItem, etc. — the SDK itself is trusted. Certificate validation, signing, nonce handling, chunk assembly, keep-alive cadence — all SDK-internal and untested here.

2. Subscription transfer across reconnect

Contract test: "after a simulated reconnect, TransferSubscriptions is called with the right handles." Real behavior: SDK re-publishes against the new channel and some events can be lost depending on publish-queue state. The lossy window is not characterized.

3. Large-scale subscription stress

100+ monitored items with heterogeneous publish intervals under a single session — the shape that breaks publish-queue-size tuning in the wild — is not exercised.

4. Real historian mappings

IHistoryProvider.ReadRawAsync + ReadProcessedAsync + ReadAtTimeAsync + ReadEventsAsync are contract-mocked. Against a real historian (AVEVA Historian, Prosys historian, Kepware LocalHistorian) each has specific interpolation + bad-quality-handling quirks the contract test doesn't see.

5. Real A&E events

Alarm subscription is mocked via filtered monitored items; the actual EventFilter select-clause behavior against a server that exposes typed ConditionType events (non-base BaseEventType) is not verified.

6. Authentication variants

  • Anonymous, UserName/Password, X509 cert tokens — each is contract-tested but not exchanged against a server that actually enforces each.
  • LDAP-backed UserName (matching this repo's server-side LdapUserAuthenticator) requires a live LDAP round-trip; not tested.

When to trust OpcUaClient tests, when to reach for a server

Question Unit tests Real upstream server
"Does severity 750 bucket as High?" yes yes
"Does the driver call TransferSubscriptions after reconnect?" yes yes
"Does a real OPC UA read/write round-trip work?" no yes (required)
"Does event-filter-based alarm subscription return ConditionType events?" no yes (required)
"Does history read from AVEVA Historian return correct aggregates?" no yes (required)
"Does the SDK's publish queue lose notifications under load?" no yes (stress)

Follow-up candidates

The easiest win here is to wire the client driver tests against this repo's own server. The integration test project tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs already stands up a real OPC UA server on a non-default port with a seeded FakeDriver. An OpcUaClientLiveLoopbackTests that connects the client driver to that server would give:

  • Real Secure Channel negotiation
  • Real Session / Subscription / MonitoredItem exchange
  • Real read/write round-trip
  • Real certificate validation (the integration test already sets up PKI)

It wouldn't cover upstream-server-specific quirks (AVEVA Historian, Kepware, Prosys), but it would cover 80% of the SDK surface the driver sits on top of.

Beyond that:

  1. Prosys OPC UA Simulation Server — free, Windows-available, scriptable.
  2. UaExpert Server-Side Simulator — Unified Automation's sample server; good coverage of typed ConditionType events.
  3. Dedicated historian integration lab — only path for historian-specific coverage.

HistoryRead aggregate coverage

PR-13 (issue #285) extended HistoryAggregateType from 5 to ~30 values matching the OPC UA Part 13 §5 catalog. The mapping itself (OpcUaClientDriver.MapAggregateToNodeId) is unit-tested via OpcUaClientAggregateMappingTests:

  • The full enum is swept with Enum.GetValues<HistoryAggregateType>() — every value must resolve to a non-null namespace-0 numeric NodeId.
  • The 25 new aggregates each assert against a reflection-resolved Opc.Ua.ObjectIds.AggregateFunction_* field by name, so a future SDK upgrade that renames a constant trips the test loudly.
  • The original 5 ordinals stay pinned to their pre-PR-13 NodeIds so existing config files / persisted enums keep working.

This is the well-known-NodeId test path — the standard Part 13 NodeIds are stable across SDK versions; round-tripping each one against a live upstream is the integration suite's job and doesn't add coverage to the mapping table itself.

OpcUaClientAggregateSweepTests is the integration counterpart. It loops every enum value against a real opc-plc upstream and asserts the wire path doesn't crash even when the simulator returns BadAggregateNotSupported for an aggregate it doesn't honour. opc-plc's default profile doesn't enable HistoryRead on the well-known nodes, so the test currently Assert.Skips — re-enables when the fixture image is upgraded to a history-sim profile (--useslowtypes --ut=10 or similar) and a known-good historized NodeId is wired into OpcPlcProfile.

Key fixture / config files

  • tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ — unit tests with mocked Session
  • src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs — ctor + session-factory seam tests mock through
  • tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs — the server-side integration harness a future loopback client test could piggyback on
  • tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAggregateMappingTests.cs — Part 13 aggregate enum-to-NodeId mapping coverage (PR-13)
  • tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientAggregateSweepTests.cs — wire-side aggregate sweep against opc-plc (build-only scaffold; PR-13)