Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 22 additions & 23 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ on: [pull_request]
jobs:
install:
strategy:
fail-fast: false
matrix:
include:
- php-version: 8.4
- php-version: 8.5
php-version: ['8.4', '8.5']
phpunit-version: ['12', '13']

runs-on: ubuntu-latest
steps:
Expand All @@ -17,9 +17,9 @@ jobs:
uses: actions/cache@v4
with:
path: vendor/
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }}
restore-keys: |
${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}
${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }}

- name: PHP Setup
uses: shivammathur/setup-php@v2
Expand All @@ -34,17 +34,15 @@ jobs:
composer global require --no-progress --no-scripts --no-plugins symfony/flex && composer global config --no-plugins allow-plugins.symfony/flex true

- name: Install
env:
SYMFONY_REQUIRE: ${{ matrix.symfony-version }}
run: |
COMPOSER_NO_SECURITY_BLOCKING=1 composer update --no-interaction --no-scripts --ansi --no-progress --prefer-dist
COMPOSER_NO_SECURITY_BLOCKING=1 composer update --no-interaction --no-scripts --ansi --no-progress --prefer-dist --with "phpunit/phpunit:^${{ matrix.phpunit-version }}"

phpstan:
strategy:
fail-fast: false
matrix:
include:
- php-version: 8.4
- php-version: 8.5
php-version: ['8.4', '8.5']
phpunit-version: ['12', '13']

runs-on: ubuntu-latest
needs: install
Expand All @@ -56,9 +54,9 @@ jobs:
uses: actions/cache@v4
with:
path: vendor/
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }}
restore-keys: |
${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}
${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }}

- name: PHP Setup
uses: shivammathur/setup-php@v2
Expand All @@ -76,7 +74,8 @@ jobs:
strategy:
matrix:
include:
- php-version: 8.5
- php-version: '8.5'
phpunit-version: '13'

runs-on: ubuntu-latest
needs: install
Expand All @@ -88,9 +87,9 @@ jobs:
uses: actions/cache@v4
with:
path: vendor/
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }}
restore-keys: |
${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}
${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }}

- name: PHP Setup
uses: shivammathur/setup-php@v2
Expand All @@ -106,12 +105,12 @@ jobs:

test:
concurrency:
group: ${{ github.head_ref || github.ref }}
group: ${{ github.head_ref || github.ref }}-php${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }}
strategy:
fail-fast: false
matrix:
include:
- php-version: 8.4
- php-version: 8.5
php-version: ['8.4', '8.5']
phpunit-version: ['12', '13']

runs-on: ubuntu-latest
needs:
Expand All @@ -125,9 +124,9 @@ jobs:
uses: actions/cache@v4
with:
path: vendor/
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }}
restore-keys: |
${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}
${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php-version }}-phpunit${{ matrix.phpunit-version }}

- name: PHP Setup
uses: shivammathur/setup-php@v2
Expand All @@ -139,4 +138,4 @@ jobs:

- name: PHPUnit
run: |
vendor/bin/phpunit --coverage-cobertura artifacts/code-coverage-cobertura.xml
vendor/bin/phpunit --coverage-cobertura artifacts/code-coverage-cobertura.xml
17 changes: 11 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,21 @@ vendor/bin/phpunit

This is a single-file PHP library: `src/ServiceMockHelperTrait.php` (namespace `Pkly`).

The trait is designed to be used inside PHPUnit `TestCase` subclasses. It auto-wires constructor dependencies and Symfony `#[Required]`-attributed setter methods with `createMock()` instances, so tests don't need to manually maintain constructor argument lists.
The trait is designed to be used inside PHPUnit `TestCase` subclasses. It auto-wires constructor dependencies and Symfony `#[Required]`-attributed setter methods with test doubles, so tests don't need to manually maintain constructor argument lists.

**Internal state:** `$this->mocks[$serviceClass][$dependencyClass]` stores every auto-created mock, keyed first by the service being tested, then by the dependency's class name. Custom-provided parameters (via `$constructor`/`$required` arrays) are not stored in `$this->mocks` and cannot be retrieved via `getMockedService()`.
**Stubs by default, mocks on demand:** PHPUnit emits a notice for every `MockObject` without expectations, so dependencies are `createStub()` instances unless the test asks for them via `getMockedService()`, which returns a `createMock()` instance. Mock-vs-stub is baked into the generated double class and cannot be changed afterwards, so `createRealMockedServiceInstance()` returns a **lazy ghost** (`ReflectionClass::newLazyGhost()`): the service is constructed the first time it is touched, by which point the test has declared which dependencies it wants as mocks. `#[Required]` setters run inside the initializer, since calling them from the outside would write a property and initialize the ghost early.

**Internal state:** `$this->serviceStates` is an `SplObjectStorage` keyed by the created service instance, holding its class, its mockable parameters (`name` + `type`), the mocks and stubs created for it, and whether it has been initialized. `$this->serviceInstances` maps a class to its most recently created instance and `$this->currentServiceInstance` points at the last one created overall. Doubles are keyed by dependency type, or by `Type$parameterName` when a specific parameter is targeted; parameters supplied via `$constructor`/`$required` are not doubled and cannot be retrieved.

**Key methods:**
- `createRealMockedServiceInstance(class, constructor[], required[])` — instantiates a real object, injecting mocks for all constructor params and `#[Required]` methods.
- `createRealPartialMockedServiceInstance(class, methods[], constructor[], required[])` — same but returns a `MockObject&T` with specified methods overridden (uses `MockBuilder`).
- `getMockedService(DependencyClass::class, ?serviceClass)` — retrieves a previously auto-created mock; defaults to the first stored service if `$service` is omitted.
- `createRealMockedServiceInstance(class, constructor[], required[])` — returns a lazy ghost of a real object; on first use it injects doubles for all constructor params and `#[Required]` methods.
- `createRealPartialMockedServiceInstance(class, methods[], constructor[], required[])` — returns a `MockObject&T` with specified methods overridden (uses `MockBuilder`). A generated class cannot be a ghost, so this stays eager and its dependencies are all mocks.
- `getMockedService(DependencyClass::class, ?parameterName, ?serviceClass)` — declares (or returns) a mock for a dependency; throws once the service has been initialized. Defaults to the most recently created service.
- `getStubbedService(DependencyClass::class, ?parameterName, ?serviceClass)` — same for the stub a dependency would get anyway, for configuring return values without expectations.

**Constraints:** Only supports single-type parameters (union types throw). Built-in typed parameters must have a default value or be supplied explicitly via `$constructor`/`$required`, as must internal classes and enums. Nullable class parameters (`?Foo`) still receive a double.

**Constraints:** Only supports single-type parameters (union types throw). Built-in typed parameters must have a default value or be supplied explicitly via `$constructor`/`$required`.
**Tests:** `tests/Service` holds the fixtures, `tests/Unit` the test cases. `phpunit.xml.dist` sets `failOnPhpunitNotice`, so a dependency that is needlessly mocked fails the suite.

## Code style notes

Expand Down
67 changes: 65 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Simply run
composer require --dev pkly/phpunit-service-create-trait
```

Currently compatible only with PHPUnit 12+
Compatible with PHPUnit 12 and 13, requires PHP 8.4+

## Usage

Expand Down Expand Up @@ -41,19 +41,82 @@ class MyTestCase extends \PHPUnit\Framework\TestCase {
}
```

Any dependencies in the constructor as well as methods marked with Symfony's `#[Required]` attribute will be automatically plugged in with mocks.
Any dependencies in the constructor as well as methods marked with Symfony's `#[Required]` attribute will be automatically plugged in with test doubles.
This allows you to write complex tests without wasting time updating your construct calls each time you modify something.

### Stubs by default, mocks on demand

PHPUnit is separating mocks from stubs and complains about every mock object that has no expectations
configured (`No expectations were configured for the mock object for X ...`). Creating a mock for every
single dependency would drown your test run in those notices, so the trait only creates what you ask for:

- a dependency you fetch with `getMockedService()` is a `MockObject`
- everything else is a plain `Stub`

To make that possible the returned service is a **lazy ghost** - it is only really constructed the first
time you use it. That means expectations have to be declared *before* you touch the service:

```php
public function testSomething(): void
{
$service = $this->createRealMockedServiceInstance(AnyClass::class);

// declare first ...
$this->getMockedService(EntityManagerInterface::class)
->expects($this->once())
->method('flush');

// ... then use the service, this is where it gets constructed
$service->doSomething();
}
```

Asking for a mock after the service has been used throws a `LogicException`, because such a mock could
never end up inside the already constructed service.

If you only need to configure return values without setting any expectation, use `getStubbedService()`,
which returns the very same stub the service will receive.

### Several dependencies of the same type

Doubles are addressed by type, and a service depending on the same type twice simply receives the same
double for both parameters. Pass a parameter name as the second argument when you need them apart:

```php
$first = $this->getMockedService(BasicService::class, 'first');
$second = $this->getMockedService(BasicService::class, 'second');
```

### Several services in one test

`getMockedService()` and `getStubbedService()` use the most recently created service by default. Pass the
service class as the third argument to address any other one:

```php
$this->getMockedService(BasicService::class, service: OtherService::class);
```

### Okay, but what if I need to use something custom?

Simply assign the proper parameter name in either `$constructor` or `$required` in the appropriate methods.
That will use your object instead of creating one for you, keep in mind you cannot retrieve it via `$this->getMockedService()`.

Some parameters always have to be provided that way:

- scalar parameters without a default value
- internal classes (`\DateInterval` and friends) and enums, doubling those tends to break in confusing ways

A nullable dependency (`?Foo`) still receives a double, pass `null` explicitly if that is what your test needs.

### Partial objects?

Sure, works the same, just use `createRealPartialMockedServiceInstance` instead of `createRealMockedServiceInstance`, in that case you must
also specify the methods to override in your mock. Returned instance is `T&MockObject`.

A partial mock is a generated class and cannot be created lazily, so it is built immediately and all of its
dependencies are mocks, exactly like they used to be. Provide them explicitly or configure them if you want
to keep your test run notice free.

### Feature requests?

Sure, hit me up with an issue if you wish.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
],
"require": {
"php": ">=8.4",
"phpunit/phpunit": ">=12"
"phpunit/phpunit": "^12 || ^13"
},
"require-dev": {
"symfony/service-contracts": "^3",
"phpstan/phpstan": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpstan/phpstan-phpunit": "^2",
Expand Down
2 changes: 1 addition & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php"
displayDetailsOnAllIssues="true"
failOnPhpunitNotice="true"
failOnPhpunitDeprecation="true"
>
<php>
<ini name="error_reporting" value="-1" />
Expand Down
Loading
Loading