symfony-opcua · master
Docs · Testing

Mocking the manager

Replace OpcuaManager / OpcUaClientInterface in the Symfony container per test. Mockery and Prophecy patterns; container::set; container::override.

In Symfony tests, replace the bundle's services in the test container. Two approaches:

  • static::getContainer()->set() — the modern Symfony idiom.
  • Mockery / PHPUnit createMock() — for service-isolated unit tests.

Symfony's test container allows overriding any service. Per test, swap in a mock:

php container set
use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\Client\Testing\MockClient;
use PhpOpcua\Client\Types\DataValue;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class SpeedControllerTest extends WebTestCase
{
    public function testShow(): void
    {
        $client = static::createClient();

        $mock = MockClient::create()
            ->onRead('ns=2;s=Speed', fn() => DataValue::ofDouble(75.0));

        // Replace OpcUaClientInterface in the container with the mock
        static::getContainer()->set(OpcUaClientInterface::class, $mock);

        $client->request('GET', '/api/plc/speed');

        $this->assertResponseIsSuccessful();
    }
}

This works because the controller injects OpcUaClientInterface — and the container now returns the mock.

Replacing OpcuaManager

When the code under test uses the manager directly:

php manager replacement
use PhpOpcua\Client\Testing\MockClient;
use PhpOpcua\Client\Types\DataValue;
use PhpOpcua\SymfonyOpcua\OpcuaManager;

public function testService(): void
{
    $client = static::createClient();

    $mock = MockClient::create()
        ->onRead('ns=2;s=Speed', fn() => DataValue::ofDouble(75.0));

    // Anonymous subclass returns the mock for any connection name
    $manager = new class($mock) extends OpcuaManager {
        public function __construct(private MockClient $m) {
            parent::__construct(['default' => 'd', 'connections' => ['d' => []]]);
        }
        public function connect(?string $name = null): \PhpOpcua\Client\OpcUaClientInterface { return $this->m; }
    };

    static::getContainer()->set(OpcuaManager::class, $manager);

    $client->request('GET', '/api/plc/speed');
    $this->assertResponseIsSuccessful();
}

The anonymous subclass keeps the test inline; for several tests that share the pattern, extract a TestableOpcuaManager class.

Multi-connection mocking

When the code switches between connections:

php multi-connection
$lineAMock = MockClient::create()
    ->onRead('ns=2;s=Speed', fn() => DataValue::ofDouble(75.0));

$lineBMock = MockClient::create()
    ->onRead('ns=2;s=Speed', fn() => DataValue::ofDouble(80.0));

$manager = new class($lineAMock, $lineBMock) extends OpcuaManager {
    public function __construct(
        private MockClient $a,
        private MockClient $b,
    ) {
        parent::__construct([
            'default' => 'plc-line-a',
            'connections' => [
                'plc-line-a' => [],
                'plc-line-b' => [],
            ],
        ]);
    }

    public function connect(?string $name = null): \PhpOpcua\Client\OpcUaClientInterface
    {
        return match ($name ?? 'plc-line-a') {
            'plc-line-a' => $this->a,
            'plc-line-b' => $this->b,
            default      => throw new \InvalidArgumentException("Unknown: $name"),
        };
    }
};

static::getContainer()->set(OpcuaManager::class, $manager);

Mockery — when MockClient isn't enough

MockClient covers most cases, but for complex assertion needs (call count, argument matching), use Mockery:

php mockery
use Mockery as M;
use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\Client\Types\DataValue;

protected function tearDown(): void
{
    M::close();
    parent::tearDown();
}

public function testWriteCallsServer(): void
{
    $client = static::createClient();

    $mock = M::mock(OpcUaClientInterface::class);
    $mock->shouldReceive('write')
        ->once()
        ->with('ns=2;s=Setpoint', 75.0)
        ->andReturn(0);   // OPC UA Good status

    static::getContainer()->set(OpcUaClientInterface::class, $mock);

    $client->request('PUT', '/api/plc/ns=2;s=Setpoint', [], [], [], json_encode(['value' => 75.0]));
    $this->assertResponseIsSuccessful();
}

PHPUnit's createMock()

For service-isolated unit tests (no container):

php createMock
use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\Client\Types\DataValue;
use PHPUnit\Framework\TestCase;

final class SpeedServiceTest extends TestCase
{
    public function testCurrent(): void
    {
        $dv = $this->createMock(DataValue::class);
        $dv->method('getValue')->willReturn(75.0);
        $dv->statusCode = 0;

        $client = $this->createMock(OpcUaClientInterface::class);
        $client->method('read')->with('ns=2;s=Speed')->willReturn($dv);

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

        $this->assertSame(75.0, $service->current());
    }
}

Pest expectations

php pest
use App\Service\SpeedService;
use PhpOpcua\Client\Testing\MockClient;
use PhpOpcua\Client\Types\DataValue;

it('returns the current speed', function () {
    $client = MockClient::create()
        ->onRead('ns=2;s=Speed', fn() => DataValue::ofDouble(75.0));

    $service = new SpeedService($client);

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

Throwing exceptions

For testing error paths:

php exception
use PhpOpcua\Client\Exception\ConnectionException;
use PhpOpcua\Client\Testing\MockClient;

$mock = MockClient::create()
    ->onRead('ns=2;s=Speed', function () {
        throw new ConnectionException('PLC unreachable');
    });

static::getContainer()->set(OpcUaClientInterface::class, $mock);

$client->request('GET', '/api/plc/speed');
$this->assertResponseStatusCodeSame(502);

Spying on events

Test that the bundle dispatched an event:

php event spy
use PhpOpcua\Client\Event\DataChangeReceived;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

$dispatcher = static::getContainer()->get('event_dispatcher');
$received = [];

$dispatcher->addListener(DataChangeReceived::class, function (DataChangeReceived $e) use (&$received) {
    $received[] = $e;
});

// trigger code path that should emit
$this->assertCount(1, $received);
$this->assertSame(1, $received[0]->clientHandle);   // event has clientHandle, not nodeId

When manager mocking is wrong

Two cases:

  1. Testing the manager itself. Don't — it's tested upstream.
  2. Complex multi-call flows. A test with 8 shouldReceive calls signals too much coupling — refactor the production code first.

For complex flows, prefer MockClient — see Using MockClient.

Container override permissions

Replacing services requires that the service is non-public turned public for tests. Symfony's test container does this automatically for any service listed in config/services_test.yaml:

text services_test.yaml
services:
    test.OpcUaClientInterface:
        alias: PhpOpcua\Client\OpcUaClientInterface
        public: true
    test.OpcuaManager:
        alias: PhpOpcua\SymfonyOpcua\OpcuaManager
        public: true

Modern Symfony defaults to making all services public in the test container — usually you don't need this.

Documentation