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
14 changes: 7 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,17 @@ This is a single-file PHP library: `src/ServiceMockHelperTrait.php` (namespace `

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.

**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.
**Unregistered doubles:** PHPUnit emits a notice for every `MockObject` without expectations, so every dependency is built through `MockGenerator::testDouble()` directly (`__createUnregisteredMock()`) instead of `createMock()`/`createStub()`. Such a double is a full `MockObject` — `expects()` works on it — but the `TestCase` never learns about it, so PHPUnit neither verifies it nor complains. `getMockedService()` is what registers it (`registerMockObject()` + a `testCreatedMockObject` event), handing ownership to the test; `getStubbedService()` returns the same object unregistered, narrowed to `Stub`. Services are constructed eagerly, so doubles can be configured before or after the service is used.

**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.
**Internal state:** `$this->serviceStates` is an `SplObjectStorage` keyed by the created service instance, holding its class, its mockable parameters (`name` + `type`), the doubles created for it, and which of them have been registered. `$this->serviceInstances` maps a class to its most recently created instance and `$this->currentServiceInstance` points at the last one created overall. Doubles are keyed `Type$parameterName` — one per parameter, so two parameters of the same type get two distinct doubles and must be addressed by name. Parameters supplied via `$constructor`/`$required` are not doubled and cannot be retrieved.

**Key methods:**
- `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.
- `createRealMockedServiceInstance(class, constructor[], required[])` — returns a real instance with doubles injected into all constructor params and `#[Required]` methods.
- `createRealPartialMockedServiceInstance(class, methods[], constructor[], required[])` — returns a `MockObject&T` with specified methods overridden (uses `MockBuilder`, plus a manual `testCreatedPartialMockObject` event since `MockBuilder` emits none).
- `getMockedService(DependencyClass::class, ?parameterName, ?serviceClass)` — registers and returns the mock for a dependency. Defaults to the most recently created service, falling back to the other trait-created services when that one has no such dependency (throws when several match).
- `getStubbedService(DependencyClass::class, ?parameterName, ?serviceClass)` — same double, left unregistered, 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`, as must enums. Internal classes are passed to PHPUnit like anything else and mostly double fine. Nullable class parameters (`?Foo`) still receive a double.

**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.

Expand Down
39 changes: 20 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,39 +48,37 @@ This allows you to write complex tests without wasting time updating your constr

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:
single dependency would drown your test run in those notices, so every dependency is created as a double
that PHPUnit does not know about - it is never verified and never complained about. Fetching one is what
hands it over to the test:

- a dependency you fetch with `getMockedService()` is a `MockObject`
- everything else is a plain `Stub`
- `getMockedService()` registers the double with the test case and returns it as a `MockObject`, so
`expects()` is verified for you (and PHPUnit does tell you off if you then configure nothing on it)
- `getStubbedService()` returns the very same object as a `Stub`, still unregistered, for when you only
need to configure return values
- everything you never fetch stays invisible to PHPUnit

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:
The service itself is constructed immediately, so the order does not matter - expectations can be declared
before or after you use it:

```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:
Every parameter gets its own double, so a service depending on the same type twice receives two distinct
ones. Fetching such a type without saying which parameter you mean throws a `LogicException` - pass the
parameter name as the second argument:

```php
$first = $this->getMockedService(BasicService::class, 'first');
Expand All @@ -104,7 +102,10 @@ That will use your object instead of creating one for you, keep in mind you cann
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
- enums, those cannot be doubled at all

Internal classes (`\DateInterval` and friends) are handed to PHPUnit like any other type - most of them
double just fine, and the ones that do not produce a precise error from PHPUnit telling you to provide it.

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

Expand All @@ -113,9 +114,9 @@ A nullable dependency (`?Foo`) still receives a double, pass `null` explicitly i
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.
Its dependencies behave exactly like the ones of a normal service - unregistered until you fetch them.
The partial mock itself is a regular PHPUnit mock though, so the methods you override are subject to the
usual expectation rules.

### Feature requests?

Expand Down
Loading
Loading