-
Notifications
You must be signed in to change notification settings - Fork 0
Testing Strategy
The package's own test suite is unit + integration with no mocking framework. This page documents the patterns it uses, so you can extend the suite with the same shapes or borrow them for your own server / client code.
tests/
├─ Unit/ # no I/O, no real sockets
│ ├─ Channel/ StreamChannelTest, TcpChannelTest, UdpChannelTest
│ ├─ Client/ AbstractClientTest, AbstractStreamClientTest
│ ├─ Enum/ TransportTest, DomainTest, CryptoMethodTest
│ ├─ Exception/ HierarchyTest
│ ├─ Server/ AbstractServerBroadcastTest, AbstractStreamServerTest,
│ │ ServerConnectionTest, TcpServerOptionsTest,
│ │ UdpServerOptionsTest
│ └─ SocketFactoryTest
└─ Integration/ # real loopback sockets
├─ IntegrationTestCase.php # shared helpers
├─ ConnectionErrorsTest.php
├─ ServerLifecycleTest.php
├─ StreamClientIoTest.php
├─ TcpDisconnectTest.php
├─ TcpEchoTest.php
├─ TcpServerCloseTest.php
├─ TlsEchoTest.php # forks the client into a child process
├─ UdpClientReuseTest.php
├─ UdpEchoTest.php
└─ UdpServerCloseTest.php
Numbers as of the 2.0 release: 96 tests · 357 assertions · ≥ 90% line coverage.
composer install
composer test # PHPUnit, unit + integration
composer test-coverage # same, with Clover output for CI
composer qa # cs-check + stan + testcomposer test-coverage needs pcov (or Xdebug). Install pcov via PECL:
pecl install pcov # may need brew install pcre2 on macOS for headersThe ServerConnection class is just identity + delegation, so a FakeChannel is enough to exercise broadcasting, registry mapping and lifecycle logic:
final class FakeChannel implements ChannelInterface
{
public ?string $readReturn = null;
public ?int $writeReturn = null;
public bool $alive = true;
public bool $closed = false;
/** @var array<int, array{0:int, 1:?int}> */ public array $readCalls = [];
/** @var array<int, array{0:string}> */ public array $writeCalls = [];
public function read(int $length = 1024, ?int $flag = null): ?string
{ $this->readCalls[] = [$length, $flag]; return $this->readReturn; }
public function write(string $data): ?int
{ $this->writeCalls[] = [$data]; return $this->writeReturn; }
public function close(): bool { $this->closed = true; return true; }
public function isAlive(): bool { return $this->alive && !$this->closed; }
public function getResource(): mixed { return null; }
}Then assert against writeCalls / readCalls in tests. No fork, no port, no race.
addClient() / evict() are protected. The unit test surfaces them via a friend subclass that lives in the same file:
final class TestableServer extends AbstractServer
{
public function attach(SocketConnectionInterface $client): int { return $this->addClient($client); }
public function forceEvict(int $key): void { $this->evict($key); }
public function listen(): static { return $this; }
public function close(): bool { return true; }
public function tick(callable $callback, float $waitSeconds = 0.0): int { return 0; }
public function getSocket(): mixed { return null; }
}This lets you exercise broadcast() / register() / getClients() without any real network.
For TcpChannel, a socket_create_pair gives you two connected, real sockets — perfect for testing reads, writes and MSG_PEEK-based liveness without standing up a server:
$pair = [];
socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $pair);
[$a, $b] = $pair;
socket_set_nonblock($a);
socket_set_nonblock($b);
$channel = new TcpChannel($a);
$channel->write('hi');
$buf = '';
socket_recv($b, $buf, 1024, MSG_DONTWAIT);
self::assertSame('hi', $buf);For StreamChannel, fopen('php://memory', 'r+') is enough.
Tests pick a free port by binding to 0 and reading the assigned port back:
protected function findFreePort(int $type = SOCK_STREAM): int
{
$proto = $type === SOCK_STREAM ? SOL_TCP : SOL_UDP;
$sock = socket_create(AF_INET, $type, $proto);
socket_bind($sock, '127.0.0.1', 0);
$addr = ''; $port = 0;
socket_getsockname($sock, $addr, $port);
socket_close($sock);
return $port;
}There is a tiny race between this call and the caller's bind, acceptable for loopback test scaffolding.
TCP and UDP connect over loopback complete at the kernel level, so the test process can be both client and server. Drive the server with tick() between client actions:
$server = new TcpServer('127.0.0.1', $port); $server->listen();
$client = new TcpClient('127.0.0.1', $port); $client->connect();
$server->tick(fn() => null, 0.2); // accept the queued client
$client->write('hello');
$server->tick(function ($srv, $conn) use (&$received) {
$received = $conn->read(1024);
$conn->write('echo:' . $received);
}, 0.2);
self::assertSame('hello', $received);The TLS handshake needs concurrency. Coverage tools live in one process, so the server stays in the parent and the client is forked into a child:
$pid = pcntl_fork();
if ($pid === 0) {
usleep(150_000);
$client = Socket::client(Transport::TLS, '127.0.0.1', $port, 2.0)
->option('verify_peer', false)
->option('allow_self_signed', true);
$client->connect();
$client->write('hello-tls');
// ... read echo ...
$client->disconnect();
exit(0);
}
$server = Socket::server(Transport::TLS, '127.0.0.1', $port, 2.0)
->option('local_cert', $certPath)
->option('allow_self_signed', true);
$server->listen();
while ($received === null && microtime(true) < $deadline) {
$server->tick($echoCallback, 0.1);
}
pcntl_waitpid($pid, $status);The package's TlsEchoTest is exactly this. Coverage attributes every server line because the server runs in the parent (the PHPUnit process).
The package's IntegrationTestCase ships a selfSignedCertPath() helper that mints a fresh cert into a temp file and registers it for cleanup:
$pkey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$csr = openssl_csr_new(['commonName' => 'localhost'], $pkey);
$x509 = openssl_csr_sign($csr, null, $pkey, 1);
openssl_x509_export($x509, $certPem);
openssl_pkey_export($pkey, $keyPem);
$path = tempnam(sys_get_temp_dir(), 'initphp-socket-tls-') . '.pem';
file_put_contents($path, $certPem . $keyPem);You can exercise the entire stream-client code path (connect, read, write, timeout, blocking, crypto-toggle) without TLS by talking to a tcp:// stream server and using a tiny subclass of AbstractStreamClient:
final class PlainStreamClient extends AbstractStreamClient
{
public function __construct(string $host, int $port, ?float $timeout = null)
{
parent::__construct($host, $port, Transport::TCP, $timeout);
}
}
$server = stream_socket_server("tcp://127.0.0.1:{$port}");
$client = new PlainStreamClient('127.0.0.1', $port);
$client->connect();
$peer = stream_socket_accept($server, 1.0);
$client->write('hello');
self::assertSame('hello', fread($peer, 1024));No fork, no certs, no TLS handshake — just the stream-client I/O paths.
The CI workflow runs composer test-coverage with pcov on PHP 8.3 and uploads the Clover report to Codecov. The 2.0 release ships at:
| Class | Lines |
|---|---|
Channel\StreamChannel |
100% |
Channel\TcpChannel |
100% |
Channel\UdpChannel |
95% |
Client\AbstractClient |
100% |
Client\AbstractStreamClient |
98.5% |
Client\TCP |
93.1% |
Client\TLS / SSL
|
100% |
Client\UDP |
90% |
Enum\* |
100% |
Server\AbstractServer |
88.1% |
Server\AbstractStreamServer |
86.3% |
Server\ServerConnection |
100% |
Server\TCP |
~85% |
Server\UDP |
~85% |
Socket (factory) |
88.9% |
Overall: ≥ 90% line coverage. The uncovered lines are mostly OS-call error paths (socket_create returning false, socket_select errors other than EINTR, …) that can't be triggered deterministically without mocking the socket extension.
-
Event Loop Integration —
tick()is what makes deterministic tests possible. - Recipe Custom Channel — fakes that look like real channels.
-
Connection and Channel — what a
ServerConnectionactually exposes to test code.
initphp/socket · MIT · PHP 8.1+ · part of the InitPHP family · file issues at InitPHP/Socket/issues
Getting started
Transports
Concepts
Reference
Recipes
Operational