Mocking the facade
Opcua::shouldReceive, Opcua::partialMock, Opcua::spy — the Mockery-based facade testing surface. The patterns that make controller and listener tests cheap.
The Opcua facade plays naturally with Mockery. The three primary
patterns are all inherited from Laravel's base
Illuminate\Support\Facades\Facade — they aren't package methods:
Opcua::shouldReceive(...)— strict expectationOpcua::partialMock()— mock some methods, pass through othersOpcua::spy()— observe calls without imposing expectations
There is no Opcua::fake() / "recording fake" method. Use
spy() if you want assertion-after-the-fact, or bind a manual mock
to the container.
shouldReceive — strict expectation
For controllers / listeners that have a known interaction:
use PhpOpcua\LaravelOpcua\Facades\Opcua;
use PhpOpcua\Client\Types\DataValue;
it('returns the speed', function () {
Opcua::shouldReceive('read')
->once()
->with('ns=2;s=Speed')
->andReturn(DataValue::ofDouble(75.0));
$response = $this->get('/tags/speed');
$response->assertOk()->assertJson(['speed' => 75.0]);
});
The test fails if read() is called zero times, more than once,
or with a different argument. Strict.
Multiple calls
Opcua::shouldReceive('read')
->with('ns=2;s=Speed')
->andReturn(DataValue::ofDouble(75.0));
Opcua::shouldReceive('read')
->with('ns=2;s=Temperature')
->andReturn(DataValue::ofDouble(22.5));
Order doesn't matter unless you add ->ordered(). For typical
tests, leave order unconstrained.
Returning a sequence
Opcua::shouldReceive('read')
->with('ns=2;s=Speed')
->andReturn(
DataValue::ofDouble(70.0),
DataValue::ofDouble(72.0),
DataValue::ofDouble(75.0),
);
The first call returns 70.0, the second 72.0, the third 75.0, the fourth (and beyond) 75.0.
Throwing
use PhpOpcua\Client\Exception\ConnectionException;
Opcua::shouldReceive('read')
->with('ns=2;s=Speed')
->andThrow(new ConnectionException('PLC unreachable'));
Useful for testing error paths.
connection() — chained mocks
use PhpOpcua\Client\OpcUaClientInterface;
it('reads from the historian', function () {
$historian = Mockery::mock(OpcUaClientInterface::class);
$historian->shouldReceive('read')
->with('ns=4;s=DailyAvg')
->andReturn(DataValue::ofDouble(64.2));
Opcua::shouldReceive('connection')
->with('historian')
->andReturn($historian);
$service = new HistorianService();
expect($service->getDailyAverage())->toBe(64.2);
});
Chained calls — Opcua::connection('historian')->read(...) — need
two mocks: the facade returns a mocked client, the client returns
the value.
partialMock — mock some, pass through
For tests where only one method needs mocking:
Opcua::partialMock();
Opcua::shouldReceive('write')
->once()
->with('ns=2;s=Setpoint', 75.0)
->andReturn(true);
// Other Opcua::* calls go to the real implementation
$dv = Opcua::read('i=2256'); // real read — but daemon is disabled
Common where the test asserts write() happens but doesn't want
to mock the test's setup reads.
spy — observe without expecting
Opcua::spy();
// Code under test runs
Opcua::write('ns=2;s=Setpoint', 75.0);
// Assert after the fact
Opcua::shouldHaveReceived('write')
->with('ns=2;s=Setpoint', 75.0)
->once();
Useful when you want assertion-after-the-fact rather than expectation-before. Reads cleaner in some test shapes.
Closures for argument matching
For complex arguments, a closure matcher:
Opcua::shouldReceive('write')
->withArgs(function (string $node, mixed $value) {
return str_starts_with($node, 'ns=2;') && is_float($value);
})
->andReturn(true);
Captures any write to a ns=2 node with a float value.
Asserting no calls
For routes that should not touch OPC UA:
Opcua::shouldReceive('read')->never();
Opcua::shouldReceive('write')->never();
$response = $this->get('/health'); // pure liveness route
$response->assertOk();
Listener tests
For a listener that depends on DataChangeReceived:
use PhpOpcua\Client\Event\DataChangeReceived;
use PhpOpcua\Client\OpcUaClientInterface;
it('stores speed readings', function () {
$client = Mockery::mock(OpcUaClientInterface::class);
$event = new DataChangeReceived(
client: $client,
subscriptionId: 1,
sequenceNumber: 1,
clientHandle: 1,
dataValue: DataValue::ofDouble(75.0),
);
(new StoreReadings())->handle($event);
expect(PlcReading::first()->value)->toBe(75.0);
});
The listener takes the event as input; no mocking of Opcua
needed because the listener doesn't call back into OPC UA.
Job tests
Same shape:
it('reads the speed', function () {
Opcua::shouldReceive('read')
->with('ns=2;s=Speed')
->andReturn(DataValue::ofDouble(75.0));
(new SamplePlc('ns=2;s=Speed'))->handle(app(OpcuaManager::class));
expect(PlcReading::first()->value)->toBe(75.0);
});
For jobs that use the manager directly (via handle($manager)),
binding a mock to the container works too:
$mock = Mockery::mock(OpcuaManager::class);
$mock->shouldReceive('read')->andReturn(DataValue::ofDouble(75.0));
$this->app->instance(OpcuaManager::class, $mock);
(new SamplePlc('ns=2;s=Speed'))->handle($mock);
Combining with Bus::fake() / Queue::fake()
use Illuminate\Support\Facades\{Bus, Queue};
beforeEach(function () {
Bus::fake();
Queue::fake();
});
it('queues a sample job', function () {
Opcua::shouldReceive('read')
->andReturn(DataValue::ofDouble(75.0));
$this->post('/sample', ['node' => 'ns=2;s=Speed']);
Bus::assertDispatched(SamplePlc::class);
});
The fake captures the dispatch without running it.
Mockery cleanup
Pest automatically tears down Mockery containers between tests.
You don't need Mockery::close() explicitly.
If you see Mockery::close() errors in CI but not locally,
ensure --strict-coverage and a clean vendor/.
When facade mocking is wrong
Two cases where facade mocking is the wrong tool:
- Testing the facade itself. Don't — it's covered by the package's tests.
- Testing complex multi-call flows. A test with 8
shouldReceivecalls is signalling that the code under test has too many touchpoints. Refactor the production code first.
Prefer MockClient for these cases — it captures interactions
faithfully without manually stubbing each one. See
Using MockClient.
Where to read next
- Using MockClient — the recommended alternative for complex flows.
- Integration tests — when mocking isn't enough.