From 359aa20a11aee03334c8faa7135e682cb78ad09a Mon Sep 17 00:00:00 2001 From: Rudolph Gottesheim Date: Tue, 10 Mar 2026 14:01:08 +0100 Subject: [PATCH 1/3] Prepare package for public release under MIT license - Add MIT LICENSE file (copyright Datascroll Eventsupport GmbH) - Add README.md with installation instructions and quick-start examples - Add doc/TestSoapClient.md documentation - Update composer.json: MIT license, description, keywords, remove private repo - Inline and split CI workflow into per-tool jobs with PHP matrix for tests - Pin phpVersion to 8.3 in PHPStan and Psalm configs for deterministic analysis Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/checks.yml | 128 +++++++++++++++++++++++++- LICENSE | 21 +++++ README.md | 84 +++++++++++++++++ composer.json | 11 +-- doc/TestSoapClient.md | 146 ++++++++++++++++++++++++++++++ phpstan.neon.dist | 1 + psalm.xml | 1 + tests/unit/TestSoapClientTest.php | 3 +- 8 files changed, 381 insertions(+), 14 deletions(-) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 doc/TestSoapClient.md diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0fa4e74..c69d91f 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,8 +1,126 @@ -name: PHP +name: CI -on: [pull_request] +on: + pull_request: + push: + branches: [master] jobs: - qa: - uses: eventjet/ci/.github/workflows/php.yml@main - secrets: inherit + deps: + name: Dependency Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + - uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - run: composer install --prefer-dist --no-progress + - run: composer check-deps + + cs: + name: Code Style + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + - uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - run: composer install --prefer-dist --no-progress + - run: composer cs-check + + psalm: + name: Psalm + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: soap + - uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - run: composer install --prefer-dist --no-progress + - run: composer psalm + + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: soap + - uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - run: composer install --prefer-dist --no-progress + - run: composer phpstan + + tests: + name: Tests (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + matrix: + php: ['8.3', '8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: soap, pcov + coverage: pcov + - uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-php${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + restore-keys: ${{ runner.os }}-php${{ matrix.php }}-composer- + - run: composer install --prefer-dist --no-progress + - name: Run tests + run: composer phpunit + if: matrix.php != '8.3' + - name: Run tests with coverage + run: composer phpunit -- --coverage-xml=coverage --log-junit=coverage/junit.xml + if: matrix.php == '8.3' + - uses: actions/upload-artifact@v4 + if: matrix.php == '8.3' + with: + name: coverage + path: coverage/ + + mutation: + name: Mutation Testing + runs-on: ubuntu-latest + needs: tests + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: soap + - uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-php8.3-composer-${{ hashFiles('composer.json') }} + restore-keys: ${{ runner.os }}-php8.3-composer- + - run: composer install --prefer-dist --no-progress + - uses: actions/download-artifact@v4 + with: + name: coverage + path: coverage/ + - run: composer infection -- --coverage=coverage --skip-initial-tests diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9037496 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Datascroll Eventsupport GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..49ebea0 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# eventjet/test-double + +Reusable PSR-compliant test doubles for PHP — drop-in fakes for HTTP clients, loggers, and SOAP clients with a fluent matcher API and descriptive failure messages. + +## Requirements + +- PHP 8.3+ + +## Installation + +```bash +composer require --dev eventjet/test-double +``` + +## Test Doubles + +| Class | Implements | Documentation | +|---|---|---| +| `TestLogger` | PSR-3 `LoggerInterface` | [doc/TestLogger.md](doc/TestLogger.md) | +| `TestHttpClient` | PSR-18 `ClientInterface` | [doc/TestHttpClient.md](doc/TestHttpClient.md) | +| `TestSoapClient` | Custom SOAP client | [doc/TestSoapClient.md](doc/TestSoapClient.md) | + +Reusable matchers (`Str::regex()`, `Val::eq()`) are documented in [doc/Matchers.md](doc/Matchers.md). + +## Quick Start + +### TestLogger + +```php +use Eventjet\TestDouble\TestLogger; +use PHPUnit\Framework\TestCase; +use Psr\Log\LogLevel; + +final class MyServiceTest extends TestCase +{ + public function testLogsWarningOnFailure(): void + { + $logger = new TestLogger(); + $service = new MyService($logger); + + $service->doSomething(); + + $result = $logger->once( + TestLogger::and( + TestLogger::level(LogLevel::WARNING), + TestLogger::message('Operation failed') + ) + ); + self::assertTrue($result); + } +} +``` + +### TestHttpClient + +```php +use Eventjet\TestDouble\TestHttpClient; +use GuzzleHttp\Psr7\HttpFactory; +use PHPUnit\Framework\TestCase; + +final class MyApiClientTest extends TestCase +{ + public function testFetchesUser(): void + { + $factory = new HttpFactory(); + $httpClient = new TestHttpClient(); + $httpClient->map( + TestHttpClient::path('/api/users/1'), + $factory->createResponse(200)->withBody( + $factory->createStream('{"id":1,"name":"John"}') + ) + ); + + $apiClient = new MyApiClient($httpClient); + $user = $apiClient->getUser(1); + + self::assertSame('John', $user->name); + } +} +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/composer.json b/composer.json index d727dbd..f32b737 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,9 @@ { "name": "eventjet/test-double", "type": "library", - "description": "", - "license": "proprietary", + "description": "Reusable PSR-compliant test doubles for HTTP clients, loggers, and SOAP clients", + "keywords": ["testing", "test-double", "mock", "stub", "psr-3", "psr-18", "http-client", "logger", "phpunit"], + "license": "MIT", "require": { "php": ">=8.3", "ext-soap": "*", @@ -42,12 +43,6 @@ "Eventjet\\Test\\Unit\\TestDouble\\": "tests/unit" } }, - "repositories": [ - { - "type": "composer", - "url": "https://pack.eventjet.at/" - } - ], "minimum-stability": "stable", "scripts": { "check": [ diff --git a/doc/TestSoapClient.md b/doc/TestSoapClient.md new file mode 100644 index 0000000..ee2bdcc --- /dev/null +++ b/doc/TestSoapClient.md @@ -0,0 +1,146 @@ +# TestSoapClient Documentation + +## Overview + +`TestSoapClient` is a test double for PHP's built-in `SoapClient`. It extends `SoapClient` directly, so it can be injected anywhere a `SoapClient` is expected. Use it to stub SOAP method responses and simulate exceptions without a real SOAP server. + +## Basic Usage + +### Setup + +Pass a WSDL file path to the constructor (required by `SoapClient`), then inject the instance into your code under test: + +```php +use Eventjet\TestDouble\TestSoapClient; +use PHPUnit\Framework\TestCase; + +final class MyServiceTest extends TestCase +{ + private TestSoapClient $soapClient; + private MyService $service; + + protected function setUp(): void + { + $this->soapClient = new TestSoapClient(__DIR__ . '/Fixtures/Service.wsdl'); + $this->service = new MyService($this->soapClient); + } +} +``` + +### Basic Stubbing + +Use `map()` to register a matcher and the response object to return when it matches: + +```php +public function testReturnsOrder(): void +{ + $response = new stdClass(); + $response->orderId = 42; + + $this->soapClient->map(TestSoapClient::any(), $response); + + $result = $this->service->getOrder(42); + + self::assertSame(42, $result->orderId); +} +``` + +## The `map()` Method + +```php +public function map(callable $matcher, object $response, int $maxMatches = 1): void +``` + +| Parameter | Type | Description | +|---|---|---| +| `$matcher` | `callable` | Called with `($name, $args)` — return `true` to match, or a string describing why it didn't | +| `$response` | `object` | Returned from `__call()` when the matcher matches. If it implements `Throwable`, it is thrown instead | +| `$maxMatches` | `int` | How many times this mapping can be used before it is removed (default: `1`) | + +Each call to a SOAP method on `TestSoapClient` must match **exactly one** registered mapping, or a `LogicException` is thrown. + +### Reusing a Mapping + +Pass a `$maxMatches` greater than 1 to allow the same mapping to match multiple times: + +```php +$this->soapClient->map(TestSoapClient::any(), $response, 3); +``` + +After 3 matches the mapping is removed, and subsequent calls will throw unless another mapping matches. + +## Matchers + +### `any()` + +Matches every call, regardless of method name or arguments: + +```php +$this->soapClient->map(TestSoapClient::any(), $response); +``` + +### `argValue(string $key, mixed $value)` + +Matches when a named argument equals the expected value (strict comparison): + +```php +$this->soapClient->map( + TestSoapClient::argValue('orderId', 42), + $response +); +``` + +### Custom Matchers + +A matcher is any `callable(string $name, array $args): true|string`. Return `true` to match, or a string explaining why the call didn't match: + +```php +$matcher = static function (string $name, array $args): true|string { + if ($name !== 'GetOrder') { + return "Expected method 'GetOrder', got '$name'"; + } + return true; +}; + +$this->soapClient->map($matcher, $response); +``` + +## Simulating Exceptions + +Pass any `Throwable` as the response to have it thrown instead of returned: + +```php +use SoapFault; + +$this->soapClient->map( + TestSoapClient::any(), + new SoapFault('Server', 'Service unavailable') +); + +$this->expectException(SoapFault::class); +$this->expectExceptionMessage('Service unavailable'); + +$this->service->getOrder(1); +``` + +## Error Messages + +`TestSoapClient` throws a `LogicException` in three situations: + +**No mappings registered:** +``` +No responses mapped +``` + +**No mapping matched:** +``` +No matching response found + +Response #0: +Arg with key orderId has not expected value +``` + +**Multiple mappings matched:** +``` +Expected exactly one matching response, but found 2 +``` diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e691e35..d479c05 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,6 @@ parameters: level: max + phpVersion: 80300 paths: - src/ - tests/ diff --git a/psalm.xml b/psalm.xml index 422357b..6c108f8 100644 --- a/psalm.xml +++ b/psalm.xml @@ -2,6 +2,7 @@ [ - static function (): void {}, + static function (): void { + }, LogicException::class, 'No responses mapped', ]; From ce06fc51f716321abd7f308988f01afc70488633 Mon Sep 17 00:00:00 2001 From: Rudolph Gottesheim Date: Tue, 10 Mar 2026 14:07:10 +0100 Subject: [PATCH 2/3] Add Dependabot for Composer and GitHub Actions Co-Authored-By: Claude Sonnet 4.6 --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a6fc01e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: composer + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly From 04f90343f682d0a890348ed3c78a7557a288d225 Mon Sep 17 00:00:00 2001 From: Rudolph Gottesheim Date: Tue, 10 Mar 2026 14:08:28 +0100 Subject: [PATCH 3/3] Add ext-soap to deps and cs CI jobs, document ext-soap requirement Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/checks.yml | 2 ++ README.md | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index c69d91f..d5b1b4c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -14,6 +14,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: '8.3' + extensions: soap - uses: actions/cache@v4 with: path: vendor @@ -30,6 +31,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: '8.3' + extensions: soap - uses: actions/cache@v4 with: path: vendor diff --git a/README.md b/README.md index 49ebea0..547e10f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Reusable PSR-compliant test doubles for PHP — drop-in fakes for HTTP clients, ## Requirements - PHP 8.3+ +- `ext-soap` (only required when using `TestSoapClient`) ## Installation