Files
lmxopcua/docs/drivers/OpcUaClient-Test-Fixture.md
Joseph Doherty c985c50a96 OpcUaClient integration fixture — opc-plc in Docker closes the wire-level gap (#215). Closes task #215. The OpcUaClient driver had the richest capability matrix in the fleet (reads/writes/subscribe/alarms/history across 11 unit-test classes) + zero wire-level coverage; every test mocked the Session surface. opc-plc is Microsoft Industrial IoT's OPC UA PLC simulator — already containerized, already on MCR, pinned to 2.14.10 here. Wins vs the loopback-against-our-own-server option we'd originally scoped: (a) independent cert chain + user-token handling catches interop bugs loopback can't because both endpoints would share our own cert store; (b) pinned image tag fixes the test surface in a way our evolving server wouldn't; (c) the --alm flag opens the door to real IAlarmSource coverage later without building a custom FakeAlarmDriver. Loss vs loopback: both use the OPCFoundation.NetStandard stack internally so bugs common to that stack don't surface — addressed by a follow-up to add open62541/open62541 as a second independent-stack image (tracked). Docker is the fixture launcher — no PowerShell/Python wrapper like Modbus/pymodbus or S7/python-snap7 because opc-plc ships containerized. Docker/docker-compose.yml pins 2.14.10 + maps port 50000 + command flags --pn=50000 --ut --aa --alm; the healthcheck TCP-probes 50000 so docker ps surfaces ready state. Fixture OpcPlcFixture follows the same shape as Snap7ServerFixture + ModbusSimulatorFixture: collection-scoped, parses OPCUA_SIM_ENDPOINT (default opc.tcp://localhost:50000) into host + port, 2-second TCP probe at init, SkipReason records the failure for Assert.Skip. Forced IPv4 on the probe socket for the same reason those two fixtures do — .NET's dual-stack "localhost" resolves IPv6 ::1 first + hangs the full connect timeout when the target binds 0.0.0.0 (IPv4). OpcPlcProfile holds well-known node identifiers opc-plc exposes (ns=3;s=StepUp, FastUInt1, RandomSignedInt32, AlternatingBoolean) + builds OpcUaClientDriverOptions with SecurityPolicy.None + AutoAcceptCertificates=true since opc-plc regenerates its server cert on every container spin-up + there's no meaningful chain to validate against in CI. Three smoke tests covering what the unit suite couldn't reach: (1) Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack — full Secure Channel + Session + Read on ns=3;s=StepUp (counter that ticks every 1 s); (2) Client_reads_batch_of_varied_types_from_live_simulator — batch Read of UInt32 / Int32 / Boolean to prove typed Variant decoding, with an explicit ShouldBeOfType<bool> assertion on AlternatingBoolean to catch the common "variant gets stringified" regression; (3) Client_subscribe_receives_StepUp_data_changes_from_live_server — real MonitoredItem subscription on FastUInt1 (100 ms cadence) with a SemaphoreSlim gate + 3 s deadline on the first OnDataChange fire, tolerating container warm-up. Driver ran end-to-end against a live 2.14.10 container: all 3 pass; unit suite 78/78 unchanged. Container lifecycle verified (compose up → tests → compose down) clean, no leaked state. Docker/README.md documents install (Docker Desktop already on the dev box per Phase 1 decision #134), run (compose up / compose up -d / compose down), endpoint override (OPCUA_SIM_ENDPOINT), what opc-plc advertises with the current command flags, what's tunable via compose-file tweaks (--daa for username auth tests; --fn/--fr/--ft for subscription-stress nodes), known limitation that opc-plc shares the OPCFoundation stack with our driver. OpcUaClient-Test-Fixture.md updated — TL;DR flipped from "there is no integration fixture" to the new reality; "What it actually covers" gains an Integration section listing the three smoke tests. Follow-up the doc flags: add open62541/open62541 as a second image for fully-independent-stack interop coverage; once #219 (server-side IAlarmSource/IHistoryProvider integration tests) lands, re-run the client-side suite against opc-plc's --alm nodes to close the alarm gap from the client side too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:43:20 -04:00

7.6 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

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.

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