laravel-opcua · master
Docs · Testing

Integration tests

Integration tests against a real test server and a real daemon. Docker-Compose fixtures, CI patterns, and the cost/benefit trade-off vs mocked tests.

Integration tests boot a real OPC UA server, a real daemon, and exercise the full stack. The cost is real (Docker fixtures, slow tests, CI complexity). The benefit is real too — the only way to catch wire-level regressions.

Note

Run integration tests separately from unit / feature. They're slow and need fixtures. Most apps run them on PR merge, not on every commit.

Three things you need

Component Source
OPC UA test server ghcr.io/php-opcua/uanetstandard-test-suite:latest
Daemon Boot via php artisan opcua:session as a fixture
Laravel test harness IntegrationTestCase you define

Docker-Compose fixture

A docker-compose.test.yml for the test server:

text docker-compose.test.yml
services:
  opcua-test-server:
    image: ghcr.io/php-opcua/uanetstandard-test-suite:latest
    ports:
      - "4840:4840"      # unsecured endpoint
      - "4841:4841"      # secured (Basic256Sha256)
      - "4842:4842"      # secured + username/password
    healthcheck:
      test: ["CMD", "sh", "-c", "nc -z localhost 4840"]
      interval: 5s
      timeout: 2s
      retries: 10

Run:

bash terminal — fixture up
docker compose -f docker-compose.test.yml up -d

The test suite from php-opcua/uanetstandard-test-suite exposes 8 different endpoints covering every security policy and auth flow. For most Laravel tests, the :4840 (unsecured) endpoint is enough.

IntegrationTestCase

php tests/IntegrationTestCase.php
namespace Tests;

class IntegrationTestCase extends TestCase
{
    protected ?int $daemonPid = null;
    protected string $socketPath;

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

        $this->socketPath = sys_get_temp_dir() . '/opcua-test-' . getmypid() . '.sock';

        config([
            'opcua.session_manager.enabled' => true,
            'opcua.session_manager.socket_path' => $this->socketPath,
            'opcua.connections.default' => [
                'endpoint'        => 'opc.tcp://localhost:4840',
                'security_policy' => 'None',
                'security_mode'   => 'None',
                'timeout'         => 5.0,
            ],
        ]);

        $this->startDaemon();
    }

    protected function tearDown(): void
    {
        $this->stopDaemon();
        parent::tearDown();
    }

    private function startDaemon(): void
    {
        // opcua:session has no --socket-path flag — set the socket
        // path via OPCUA_SOCKET_PATH in the environment instead.
        $cmd = sprintf(
            'OPCUA_SOCKET_PATH=%s php %s/artisan opcua:session > /tmp/daemon.log 2>&1 & echo $!',
            escapeshellarg($this->socketPath),
            base_path(),
        );
        $this->daemonPid = (int) trim(shell_exec($cmd));

        // Wait for socket
        $timeout = microtime(true) + 5;
        while (! file_exists($this->socketPath) && microtime(true) < $timeout) {
            usleep(50_000);
        }
    }

    private function stopDaemon(): void
    {
        if ($this->daemonPid) {
            posix_kill($this->daemonPid, SIGTERM);
            // Wait briefly for cleanup
            usleep(200_000);
        }
        @unlink($this->socketPath);
    }
}

A typical pattern — start the daemon per test class, point Laravel at it, clean up at the end.

Test examples

Server liveness

php liveness test
uses(Tests\IntegrationTestCase::class)->in('Integration');

it('the test server is up', function () {
    $dv = Opcua::read('i=2256');     // Server_ServerStatus_State

    expect($dv->statusCode)->toBe(0);
    expect($dv->getValue())->toBe(0);  // 0 = Running
});

Read / write round-trip

php round-trip
it('writes and reads back', function () {
    Opcua::write('ns=2;s=TestWritableInt', 42);

    $dv = Opcua::read('ns=2;s=TestWritableInt');

    expect($dv->statusCode)->toBe(0);
    expect((int) $dv->getValue())->toBe(42);
});

Subscription

php subscription test
it('receives a data change', function () {
    $received = null;

    \Event::listen(function (\PhpOpcua\Client\Event\DataChangeReceived $event) use (&$received) {
        $received = $event->dataValue->getValue();
    });

    $client = app(\PhpOpcua\LaravelOpcua\OpcuaManager::class)->connection();
    $sub = $client->createSubscription(publishingInterval: 100.0);

    $client->createMonitoredItems($sub->subscriptionId)
        ->add('ns=0;i=2258', clientHandle: 1)   // CurrentTime — always changing
        ->execute();

    // Drain publish responses for 1 second
    $end = microtime(true) + 1.0;
    while (microtime(true) < $end && $received === null) {
        $client->publish();
        usleep(50_000);
    }

    expect($received)->not->toBeNull();
});

Error handling

php bad node error
it('raises on a bad node', function () {
    expect(fn() => Opcua::read('ns=99;s=DoesNotExist'))
        ->toThrow(\PhpOpcua\Client\Exception\ServiceException::class);
});

Per-policy tests

For tests that exercise each security policy, parameterise:

php policy matrix
dataset('policies', [
    ['None',             'None',             4840],
    ['Basic256Sha256',   'SignAndEncrypt',   4841],
    ['Basic256Sha256',   'SignAndEncrypt',   4842, 'user', 'pass'],
]);

it('connects with policy', function (
    string $policy, string $mode, int $port,
    ?string $user = null, ?string $pass = null,
) {
    config([
        'opcua.connections.default.endpoint'        => "opc.tcp://localhost:{$port}",
        'opcua.connections.default.security_policy' => $policy,
        'opcua.connections.default.security_mode'   => $mode,
        'opcua.connections.default.username'        => $user,
        'opcua.connections.default.password'        => $pass,
        'opcua.connections.default.client_cert_path' => __DIR__ . '/fixtures/client.pem',
        'opcua.connections.default.client_key_path' => __DIR__ . '/fixtures/client.key',
    ]);

    $dv = Opcua::read('i=2256');
    expect($dv->statusCode)->toBe(0);
})->with('policies');

The fixture certs in tests/fixtures/ need to be pre-trusted on the test server — typically baked into the Docker image.

CI integration

text .github/workflows/integration.yml
name: Integration

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  integration:
    runs-on: ubuntu-latest

    services:
      opcua:
        image: ghcr.io/php-opcua/uanetstandard-test-suite:latest
        ports:
          - 4840:4840
          - 4841:4841
          - 4842:4842
        options: >-
          --health-cmd "nc -z localhost 4840"
          --health-interval 5s
          --health-timeout 2s
          --health-retries 10

    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: pcntl, sockets, openssl
      - run: composer install --prefer-dist
      - run: vendor/bin/pest tests/Integration --testdox

The services.opcua block boots the test server as a CI service. GitHub Actions waits for the healthcheck to pass before running the test step.

Performance budgets

Integration tests are slow. Set an expectation:

Layer Per-test budget
Unit < 50 ms
Feature < 200 ms
Integration < 5 s

If integration tests run > 5 s each, the suite gets unusable fast. Aggressive teardown / startup parallelism keeps it tight.

Parallel integration testing

Pest 3 supports parallel execution:

bash terminal — parallel
vendor/bin/pest tests/Integration --parallel --processes=4

For OPC UA integration, parallel needs separate daemon sockets per process. The IntegrationTestCase above already uses getmypid() in the socket path — that's the trick that makes parallel safe.

What integration tests catch

Class of bug Caught by integration?
Daemon ↔ Laravel IPC framing changes Yes
Subscription publish-loop regressions Yes
TypeSerializer wire round-trips Yes
Cert/policy negotiation mismatches Yes (real handshake)
Reconnect after server restart Yes (with fixture)
Listener business logic bugs No — that's unit/feature

The rule: integration tests catch what unit tests can't see because it lives at the wire. Unit tests catch what integration tests can't economically cover (every business-logic branch).

When NOT to write integration tests

  • For pure business-logic flows — too slow, too brittle.
  • For UI / view rendering — Laravel browser tests are better.
  • For permission/policy logic — they're orthogonal to OPC UA.

The integration suite is small by design. 20-50 tests, covering the critical happy paths and the most likely regressions.

You've finished Testing. Next: Integrations · Octane and FrankenPHP — the runtime-specific patterns.

Documentation