symfony-opcua · master
Docs · Testing

Using MockClient

MockClient — the in-memory OpcUaClientInterface from opcua-client. Faithful behaviour without a network. The recommended approach for complex multi-call flows.

MockClient is a full in-memory OpcUaClientInterface. It behaves like a real client (reads, writes, browses, subscriptions, errors) — but everything happens in PHP memory.

When to reach for it

Scenario MockClient appropriate?
Unit-test a service with 5+ reads/writes Yes
Test subscription callbacks Yes
Test code that handles write failures Yes
Functional test for a controller Usually facade mock instead
Integration test against a real server No — use real server

MockClient shines when the test exercises multi-step flows.

Basic usage

php basic
use PhpOpcua\Client\Testing\MockClient;
use PhpOpcua\Client\Types\DataValue;

it('captures a reading', function () {
    $client = MockClient::create()
        ->onRead('ns=2;s=Speed', fn() => DataValue::ofDouble(75.0));

    $service = new \App\Service\SpeedService($client);

    expect($service->current())->toBe(75.0);
});

MockClient implements OpcUaClientInterface — anywhere your production code accepts that interface, the mock plugs in.

Programmable behaviour

php responses
$client = MockClient::create();

// Reads
$client->onRead('ns=2;s=Speed', fn() => DataValue::ofDouble(75.0));
$client->onRead('ns=2;s=Temperature', fn() => DataValue::ofDouble(22.5));

// Writes — capture for assertion, optional success/fail
$client->onWrite('ns=2;s=Setpoint', fn(mixed $value) => true);

// Browse — return a fixture set
$client->onBrowse('ns=2;s=Folder', fn() => [
    referenceFor('ns=2;s=Folder.Speed'),
    referenceFor('ns=2;s=Folder.Temperature'),
]);

// Method calls
$client->onCall('ns=2;s=Recipe', 'ns=2;s=Recipe.Load',
    fn(array $inputs) => [0, [true]]);

Simulating errors

php errors
use PhpOpcua\Client\Exception\ConnectionException;

$client->onRead('ns=2;s=Speed', function () {
    throw new ConnectionException('Network unreachable');
});

$client->onWrite('ns=2;s=Setpoint', fn() => false);   // raises ServiceException

Subscriptions

MockClient does not expose a fluent subscribe() / monitor() surface — those methods don't exist on OpcUaClientInterface. Drive subscription-style tests by directly dispatching the real event classes onto the EventDispatcher under test:

php dispatching events under test
use PhpOpcua\Client\Event\DataChangeReceived;
use PhpOpcua\Client\Testing\MockClient;
use PhpOpcua\Client\Types\DataValue;
use Symfony\Component\EventDispatcher\EventDispatcher;

$client = MockClient::create();
$dispatcher = new EventDispatcher();
// Register your application listener(s) on $dispatcher here.

$event = new DataChangeReceived(
    client:          $client,
    subscriptionId:  1,
    sequenceNumber:  1,
    clientHandle:    7,
    dataValue:       DataValue::ofDouble(75.0),
);
$dispatcher->dispatch($event);

Recorded calls

MockClient exposes a call recorder (getCalls, getCallsFor, callCount, resetCalls) rather than per-operation getRecordedReads() / getRecordedWrites() helpers:

php recording
$client->callCount('read');             // int
$client->getCallsFor('write');          // each call's args
$client->resetCalls();                  // start fresh

In a test:

php assert recorded
it('reads speed before writing setpoint', function () {
    $client = MockClient::create()
        ->onRead(function (string|\PhpOpcua\Client\Types\NodeId $nodeId) {
            if ((string) $nodeId === 'ns=2;s=Speed') {
                return DataValue::ofDouble(70.0);
            }
        })
        ->onWrite(fn() => 0);

    (new \App\Service\RecipeService($client))->bumpSetpoint();

    expect($client->callCount('read'))->toBe(1);
    expect($client->callCount('write'))->toBe(1);
    $writeArgs = $client->getCallsFor('write')[0];
    expect((string) $writeArgs[0])->toBe('ns=2;s=Setpoint');
});

Binding to the container

In a WebTestCase, swap into the container:

php container binding
use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\Client\Testing\MockClient;

protected function setUp(): void
{
    parent::setUp();
    static::createClient();

    $this->mock = MockClient::create();
    static::getContainer()->set(OpcUaClientInterface::class, $this->mock);
}

Now any controller injecting OpcUaClientInterface gets the mock.

A reusable test trait

php HasMockOpcua trait
namespace App\Tests;

use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\Client\Testing\MockClient;

trait HasMockOpcua
{
    protected MockClient $mock;

    protected function setUpOpcuaMock(): void
    {
        $this->mock = MockClient::create();
        static::getContainer()->set(OpcUaClientInterface::class, $this->mock);
    }
}

Usage:

php usage
final class SomeControllerTest extends WebTestCase
{
    use HasMockOpcua;

    public function testIt(): void
    {
        static::createClient();
        $this->setUpOpcuaMock();

        $this->mock->onRead('ns=2;s=Speed', fn() => DataValue::ofDouble(42.0));

        // exercise + assert
    }
}

Mock vs manager — when to pick which

Test Pick
Single OPC UA call, exact interaction known Manager mock
3+ calls, complex flow MockClient
Need recorded-call assertions MockClient
Test a controller, not a service Manager mock
Test a service with OpcUaClientInterface injection MockClient

Mix freely. Most apps end up with both styles.

Comparing values

Don't compare DataValue instances with == — timestamps differ. Compare fields:

php comparison
expect($dv->getValue())->toBe(75.0);
expect($dv->statusCode)->toBe(0);

Or a custom expectation:

php custom expectation
// tests/Pest.php
expect()->extend('toBeGoodReading', function (mixed $expectedValue) {
    expect($this->value)->statusCode->toBe(0);
    expect($this->value->getValue())->toBe($expectedValue);
});

// usage
expect($dv)->toBeGoodReading(75.0);
Documentation