PHP Classes

File: doc/13-testing.md

Recommend this page to a friend!
  Packages of Gianfrancesco Aurecchia   OPC UA Client   doc/13-testing.md   Download  
File: doc/13-testing.md
Role: Auxiliary data
Content type: text/markdown
Description: Auxiliary data
Class: OPC UA Client
Control devices that support the OPC UA protocol
Author: By
Last change:
Date: 16 days ago
Size: 11,447 bytes
 

Contents

Class file image Download

Testing

MockClient

The library ships with a MockClient that implements OpcUaClientInterface without any TCP connection. Use it to unit test your application code that depends on the OPC UA client.

use PhpOpcua\Client\Testing\MockClient;
use PhpOpcua\Client\Types\DataValue;
use PhpOpcua\Client\Types\StatusCode;

$mock = MockClient::create()
    ->onRead('i=2259', fn() => DataValue::ofInt32(0))
    ->onRead('ns=2;i=1001', fn() => DataValue::ofDouble(23.5))
    ->onWrite('ns=2;i=1001', fn($value, $type) => StatusCode::Good);

$service = new MyPlcService($mock);
$this->assertEquals(23.5, $service->readTemperature());

> Tip: MockClient accepts both NodeId objects and OPC UA string format ('i=2259', 'ns=2;i=1001') for handler registration ? same as the real client.

Registering Handlers

Read

use PhpOpcua\Client\Types\DataValue;

$mock->onRead('i=2259', fn() => DataValue::ofInt32(0));
$mock->onRead('ns=2;i=1001', fn() => DataValue::ofDouble(23.5));

$dv = $mock->read('i=2259');
// $dv->getValue() === 0

Unregistered reads return an empty DataValue (null value, status Good).

Write

use PhpOpcua\Client\Types\BuiltinType;
use PhpOpcua\Client\Types\StatusCode;

$mock->onWrite('ns=2;i=1001', function(mixed $value, BuiltinType $type): int {
    return $value > 100 ? StatusCode::BadTypeMismatch : StatusCode::Good;
});

$status = $mock->write('ns=2;i=1001', 42, BuiltinType::Int32);
// $status === StatusCode::Good

Unregistered writes return 0 (Good).

Browse

use PhpOpcua\Client\Types\ReferenceDescription;

$mock->onBrowse('i=85', fn() => [
    // return ReferenceDescription[] or any array
]);

$refs = $mock->browse('i=85');

Unregistered browses return [].

Call

use PhpOpcua\Client\Types\CallResult;
use PhpOpcua\Client\Types\Variant;
use PhpOpcua\Client\Types\BuiltinType;

$mock->onCall('i=2253', 'i=11492', function(array $args): CallResult {
    return new CallResult(0, [], [new Variant(BuiltinType::Int32, 42)]);
});

$result = $mock->call('i=2253', 'i=11492', [new Variant(BuiltinType::UInt32, 1)]);
// $result->statusCode === 0
// $result->outputArguments[0]->value === 42

Unregistered calls return CallResult(0, [], []).

Resolve NodeId

use PhpOpcua\Client\Types\NodeId;

$mock->onResolveNodeId('/Objects/Server', fn() => NodeId::numeric(0, 2253));

$nodeId = $mock->resolveNodeId('/Objects/Server');
// $nodeId->identifier === 2253

Unregistered paths return NodeId::numeric(0, 0).

Call Tracking

Every method call on the mock is recorded. Use the tracking API to assert your code called the right operations.

$mock = MockClient::create()
    ->onRead('i=2259', fn() => DataValue::ofInt32(0));

$mock->read('i=2259');
$mock->read('i=2259');
$mock->browse('i=85');

$mock->callCount('read');         // 2
$mock->callCount('browse');       // 1
$mock->callCount('write');        // 0

$mock->getCallsFor('read');       // [{method: 'read', args: [...]}, ...]
$mock->getCalls();                // all calls in order

$mock->resetCalls();              // clear history
$mock->callCount('read');         // 0

Connection Lifecycle

The mock simulates connection state without any TCP:

$mock = MockClient::create();

$mock->isConnected();             // false
$mock->getConnectionState();      // ConnectionState::Disconnected

$mock->connect('opc.tcp://fake:4840');
$mock->isConnected();             // true

$mock->disconnect();
$mock->isConnected();             // false

> Note: The mock does not require connect() before operations ? reads, writes, and browses work regardless of connection state. This is intentional to simplify test setup.

Fluent Builder Compatibility

The mock fully supports the fluent builder API:

$mock = MockClient::create()
    ->onRead('i=2259', fn() => DataValue::ofInt32(42))
    ->onRead('i=2267', fn() => DataValue::ofInt32(255));

$results = $mock->readMulti()
    ->node('i=2259')->value()
    ->node('i=2267')->value()
    ->execute();

// $results[0]->getValue() === 42
// $results[1]->getValue() === 255

Same for writeMulti(), createMonitoredItems(), and translateBrowsePaths().

DataValue Factory Methods

For quick test fixture creation, DataValue provides static factory methods:

use PhpOpcua\Client\Types\DataValue;
use PhpOpcua\Client\Types\StatusCode;

DataValue::ofInt32(42);
DataValue::ofDouble(3.14);
DataValue::ofString('hello');
DataValue::ofBoolean(true);
DataValue::ofFloat(1.5);
DataValue::ofUInt32(100);
DataValue::ofInt16(-100);
DataValue::ofUInt16(100);
DataValue::ofInt64(999);
DataValue::ofUInt64(999);
DataValue::ofDateTime(new DateTimeImmutable());

// Custom type + status
DataValue::of('raw', BuiltinType::ByteString, StatusCode::Good);

// Bad status (no value)
DataValue::bad(StatusCode::BadNodeIdUnknown);

Module Introspection

The mock supports hasMethod() and hasModule() for testing code that checks module availability:

$mock = MockClient::create();

$mock->hasMethod('read');                    // true (all built-in methods)
$mock->hasMethod('customMethod');            // false
$mock->hasModule('ReadWriteModule');         // true (all built-in modules)
$mock->hasModule('MyCustomModule');          // false

These methods always return true for built-in methods/modules and false for unregistered custom ones. This matches the behavior of a real Client with only the default modules loaded.

Configuration

All configuration methods work on the mock and store values:

$mock = MockClient::create()
    ->setTimeout(10.0)
    ->setAutoRetry(3)
    ->setBatchSize(50)
    ->setDefaultBrowseMaxDepth(20);

$mock->getTimeout();              // 10.0
$mock->getAutoRetry();            // 3
$mock->getBatchSize();            // 50
$mock->getDefaultBrowseMaxDepth();// 20

Event Dispatcher

The mock supports setEventDispatcher() / getEventDispatcher(). Use NullEventDispatcher (default) or inject a test dispatcher to verify event behavior:

use PhpOpcua\Client\Event\NullEventDispatcher;

$mock = MockClient::create();
$mock->getEventDispatcher(); // NullEventDispatcher

// Inject a custom dispatcher for assertions
$mock->setEventDispatcher($testDispatcher);

Default Behaviors

Operations without registered handlers return sensible defaults:

| Operation | Default | |-----------|---------| | read() | Empty DataValue (null value, status 0) | | write() | 0 (Good) | | browse() / browseAll() | [] | | browseRecursive() | [] | | call() | CallResult(0, [], []) | | resolveNodeId() | NodeId::numeric(0, 0) | | createSubscription() | SubscriptionResult(1, ...) | | createMonitoredItems() | Auto-generated MonitoredItemResult[] | | publish() | PublishResult(1, 1, false, [], []) | | discoverDataTypes() | 0 | | getEndpoints() | [] | | historyRead*() | [] | | transferSubscriptions() | [TransferResult(0, [])] per subscription ID | | republish() | [] | | addNodes() | AddNodesResult[] with Good status and echoed NodeIds | | deleteNodes() | [0, ...] (Good) | | addReferences() | [0, ...] (Good) | | deleteReferences() | [0, ...] (Good) | | getServerProductName() | null (delegates to read('i=2262')) | | getServerManufacturerName() | null (delegates to read('i=2263')) | | getServerSoftwareVersion() | null (delegates to read('i=2264')) | | getServerBuildNumber() | null (delegates to read('i=2265')) | | getServerBuildDate() | null (delegates to read('i=2266')) | | getServerBuildInfo() | BuildInfo(null, null, null, null, null) (delegates to readMulti()) |

Example: Testing a Service Class

class TemperatureService
{
    public function __construct(
        private OpcUaClientInterface $client,
    ) {}

    public function getCurrentTemperature(): float
    {
        $dv = $this->client->read('ns=2;i=1001');
        return $dv->getValue();
    }

    public function setSetpoint(float $value): bool
    {
        $status = $this->client->write('ns=2;i=1002', $value, BuiltinType::Double);
        return StatusCode::isGood($status);
    }
}

// In your test:
it('reads the current temperature', function () {
    $mock = MockClient::create()
        ->onRead('ns=2;i=1001', fn() => DataValue::ofDouble(23.5));

    $service = new TemperatureService($mock);

    expect($service->getCurrentTemperature())->toBe(23.5);
    expect($mock->callCount('read'))->toBe(1);
});

it('sets the setpoint', function () {
    $mock = MockClient::create()
        ->onWrite('ns=2;i=1002', fn($v, $t) => StatusCode::Good);

    $service = new TemperatureService($mock);

    expect($service->setSetpoint(25.0))->toBeTrue();
    expect($mock->callCount('write'))->toBe(1);
});

Integration Tests

The integration suite in tests/Integration/ runs against real OPC UA servers over TCP. Two stacks are involved:

| Server | Scope | Provisioning | |---|---|---| | UA-.NETStandard (OPC Foundation reference impl.) | Everything except NodeManagement ? 8 endpoints covering every security policy, user token type, subscription scenario, history read, alarm, event, custom structure, ECC policy variant | php-opcua/uanetstandard-test-suite ? docker compose up -d | | open62541 (ci_server, built with UA_ENABLE_NODEMANAGEMENT=ON) | NodeManagement only (AddNodes, DeleteNodes, AddReferences, DeleteReferences) ? UA-.NETStandard does not implement this service set | php-opcua/extra-test-suite, port 24840 |

Both suites share the same assumption: start them once, tests take the endpoints as given. TestHelper::ENDPOINT_* constants encode the port map; there is no environment-variable indirection.

NodeManagement Integration Tests

Six tests in tests/Integration/NodeManagementTest.php exercise the full NodeManagement service set against a server that actually implements it. They are regular ->group('integration') tests ? no dedicated group, no env-var gate. They connect to opc.tcp://localhost:24840 via TestHelper::ENDPOINT_NODE_MANAGEMENT; the server is assumed to be up, the same way the UA-.NETStandard endpoints are assumed to be up on 4840-4849.

In CI, the php-opcua/extra-test-suite@v1.0.0 composite action is a mandatory step of the integration job (every PHP matrix leg) ? identical treatment to php-opcua/uanetstandard-test-suite@v1.2.0.

Running Integration Tests Locally

# Start both test suites once (they keep running across reboots)
cd ../uanetstandard-test-suite && docker compose up -d
cd ../extra-test-suite && docker compose up -d

# From opcua-client/
./vendor/bin/pest --group=integration

Smoke Probe

When validating a candidate server for future CI wiring, use scripts/prosys-nodemanagement-smoke.php:

PROSYS_ENDPOINT='opc.tcp://<host>:<port>' \
PROSYS_PARENT='i=85' \
PROSYS_NAMESPACE=1 \
  php scripts/prosys-nodemanagement-smoke.php

Exit code 0 means all four NodeManagement services returned Good; 1 means at least one failed or the server replied with ServiceFault; 2 means the connection itself failed. Useful to decide whether a server is viable before adding it to CI.