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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## 1.1.0 — Unreleased

- **Added.** `where()`: a predicate over the whole argument list, narrowing a
specification by a constraint no positional matcher can express —
`when(fn () => $ledger->transfer(Arg::any(), Arg::any()))->where(fn ($from,
$to) => $from <= $to)->returns(true)`. The closure receives the arguments
positionally and is asked only after every position matched; a call it
refuses falls through to broader expectations. `expect()` takes one through
the same verb, `verify()` through its `where:` parameter. One guard per
expectation (a second is refused), `description:` names it in failure
messages, and a guard that refused a positionally-matching call is marked
as the refusing part. The Mockery migration table gains its
`withArgs(closure)` row. (#151)
- **Added.** `Understudy::verifyAll()` returns `int<0, max>` — how many checks
it verified — instead of `void`, so runner adapters count real assertions
instead of a flat one per test. One check is: an expectation carrying a
Expand Down
1 change: 1 addition & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ here, and the sections after it cover what a mechanical translation gets wrong.
| `->andReturn($a, $b)` | `->returns($a, $b)` | one per call, last repeats |
| `->with(123, Mockery::any())` | inside the closure: `find(123, Arg::any())` | |
| `Mockery::on(fn ($x) => …)` | `Arg::satisfies(fn ($x) => …)` | |
| `->withArgs(fn ($from, $to) => …)` | `->where(fn ($from, $to) => …)` | a joint constraint over the whole argument list |
| `Mockery::type(Foo::class)` | `Arg::instanceOf(Foo::class)` | |
| `->withAnyArgs()` | inside the closure: `method(Arg::rest())` | also the escape from spelling a wide signature: `method('svc', Arg::rest())` |
| `Mockery::capture($x)` | `Arg::captor(X::class)` + `$captor->capture()` | `last()` / `all()` are typed through the class-string — no `instanceof` at the read site |
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ here. Two rows are traps, marked ⚠.
| `->andThrow(new NotFound())` | `->throws(new NotFound())` | |
| `->with(123, Mockery::any())` | inside the closure: `find(123, Arg::any())` | |
| `Mockery::on(fn ($x) => ...)` | `Arg::satisfies(fn ($x) => ...)` | |
| `->withArgs(fn ($from, $to) => ...)` | `->where(fn ($from, $to) => ...)` | a joint constraint over the whole argument list — see Stubbing |
| `$mock->shouldNotHaveReceived('save')` | `verify(fn () => $mock->save(...), never: true)` | `Understudy::unused($mock)` when the double must not have been touched at all |
| `$mock->shouldHaveReceived('save')` | `verify(fn () => $mock->save(...))` | after the fact; add `nothingElse()` — see below |
| `Mockery::close()` | adapter's `reset()`, or your own teardown | |
Expand Down Expand Up @@ -394,6 +395,40 @@ A getter that throws counts as a mismatch, never as an error — matching runs
while the code under test is executing, and a matcher must not be the thing
that breaks it.

A positional matcher sees one argument. A constraint over two or more of them
together — `transfer($from, $to)` where `$from <= $to` must hold, a resize
whose aspect ratio matters — is what `where()` is:

```php
when(fn () => $ledger->transfer(Arg::any(), Arg::any()))
->where(fn ($from, $to) => $from <= $to)
->returns(true);

verify(
fn () => $ledger->transfer(Arg::any(), Arg::any()),
times: 1,
where: fn ($from, $to) => $from <= $to,
);
```

The closure receives the arguments the call carried, positionally, so it reads
like the method's own signature; it is asked only after every position already
matched, and a call it refuses simply does not match — a broader expectation
can still answer it. `expect()` takes one through the same `->where()`. The
guard runs wherever matching runs, which is inside the code under test: it is
the test's own code, and an exception it raises is the test's own error. One
guard per expectation — a second `where()` is refused with
`InvalidCallSpecification` rather than silently replacing the first. In a
failure message the guard renders as `where(…)`, or as the `description` given:

```php
->where(fn ($w, $h) => $w / $h === 2, description: 'where(16:9)')
```

and when the positions accepted a call but the guard refused it, the report
marks the guard itself — `transfer(any(), any()) *where(…)*` — rather than
blaming a position that did accept.

### Capturing an argument

`Arg::captor()` is the typed replacement for reading `args[N]` out of the call
Expand Down
34 changes: 34 additions & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ when(fn () => $repository->find(123))->returns($book);
| `->andThrow(new NotFound())` | `->throws(new NotFound())` | |
| `->with(123, Mockery::any())` | в замыкании: `find(123, Arg::any())` | |
| `Mockery::on(fn ($x) => ...)` | `Arg::satisfies(fn ($x) => ...)` | |
| `->withArgs(fn ($from, $to) => ...)` | `->where(fn ($from, $to) => ...)` | совместное ограничение на весь список аргументов — см. «Стаббинг» |
| `$mock->shouldNotHaveReceived('save')` | `verify(fn () => $mock->save(...), never: true)` | `Understudy::unused($mock)` — когда дубль не должны были трогать вообще |
| `$mock->shouldHaveReceived('save')` | `verify(fn () => $mock->save(...))` | пост-фактум; добавьте `nothingElse()` — см. ниже |
| `Mockery::close()` | reset() адаптера или свой teardown | |
Expand Down Expand Up @@ -393,6 +394,39 @@ teardown: `Arg::int(min: 5, max: 1)` и его собратья
ошибкой: сопоставление идёт во время работы тестируемого кода, и матчер не
должен быть тем, что его ломает.

Позиционный матчер видит один аргумент. Ограничение на два и более аргументов
вместе — `transfer($from, $to)`, где должно выполняться `$from <= $to`, — это
то, для чего есть `where()`:

```php
when(fn () => $ledger->transfer(Arg::any(), Arg::any()))
->where(fn ($from, $to) => $from <= $to)
->returns(true);

verify(
fn () => $ledger->transfer(Arg::any(), Arg::any()),
times: 1,
where: fn ($from, $to) => $from <= $to,
);
```

Замыкание получает аргументы вызова позиционно и читается как собственная
сигнатура метода; его спрашивают, только когда все позиции уже совпали, и
вызов, который он отверг, просто не совпадает — более широкое ожидание всё
ещё может ответить на него. `expect()` принимает его тем же `->where()`.
Guard работает везде, где идёт сопоставление — то есть внутри тестируемого
кода: это код самого теста, и исключение из него — ошибка самого теста. Один
guard на ожидание: второй `where()` отвергается с `InvalidCallSpecification`,
а не молча заменяет первый. В сообщении о провале guard читается как
`where(…)` либо как переданный `description`:

```php
->where(fn ($w, $h) => $w / $h === 2, description: 'where(16:9)')
```

а когда позиции приняли вызов, а guard — нет, отчёт помечает сам guard —
`transfer(any(), any()) *where(…)*` — а не обвиняет принявшую позицию.

### Захват аргумента

`Arg::captor()` — типизированная замена чтению `args[N]` из лога вызовов: без
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@
"@php examples/wiring.php",
"@php examples/protocol.php",
"@php examples/defaults-registry.php",
"@php examples/structured-failures.php"
"@php examples/structured-failures.php",
"@php examples/where-guard.php"
],
"mutation": "infection --threads=max",
"psalm": "psalm --no-cache",
Expand Down
Loading
Loading