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
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: docs

# The family documentation site (_plans/UNDERSTUDY-DOCS-SITE-PLAN.md).
# The family documentation site.
# One site for five packages: this repository holds it, and the API reference
# is reflected out of all five `src/` trees at build time.
#
Expand Down
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,52 @@
# Changelog

## Unreleased

- **`Understudy::for()` no longer kills the process on five built-in
interfaces.** `Throwable`, `UnitEnum`, `BackedEnum`, `DateTimeInterface` and
`Traversable` walked past every refusal in the factory and were answered by
the compiler instead — a fatal out of `eval()`, uncatchable by `try` or by an
adapter, and fatal to the whole suite run rather than to one test. Each is now
an `UnsupportedTarget` naming the way through. `Iterator`,
`IteratorAggregate`, `Stringable` and `Countable` keep doubling: it was never
about being built in.
- **A duplicated contract is accepted rather than fatal.**
`Understudy::for(A::class, A::class)` — a list assembled programmatically —
produced the same uncatchable fatal, because `implements A, A` does not
compile.
- **`#[\SensitiveParameter]` is honoured.** The value of such a parameter is
rendered as its type — `login('user', string SensitiveParameter)` — in
failure messages and in `transcript()`, the way PHP redacts it in its own
stack traces. It used to go into both verbatim, which is to say into a CI log.
- **Three public paths now throw an `UnderstudyError`.** `times(5, 2)`, a
negative count and `returns()` with no values threw a bare
`\InvalidArgumentException`, while `UnderstudyError` declares itself
implemented by every exception this library throws — so a `catch
(UnderstudyError $e)`, which `llms.txt` recommends, walked past them. The new
`InvalidSpecificationArgument` extends `\InvalidArgumentException`, so a catch
by the SPL type keeps working.
- **`Arg::instanceOf()` refuses a class that is not loadable.** It used to
match nothing, forever, and say so nowhere — the reader saw only "expected …
but it was never called" and looked for the cause in the subject under test.
- **`NAN` no longer raises a PHP warning while a failure message is rendered.**
On PHP 8.5 `(string) NAN` warns, from inside the library, during the render of
a report about a failure — which under `failOnWarning` turns the report into a
different failure.
- **Control bytes and binary strings are escaped in messages.** A NUL or half a
broken UTF-8 sequence travelled into the failure text and the transcript as
the raw byte, breaking the single line the escaping exists to keep. Valid
multibyte text is untouched.
- `CannotWire` and `InvalidDefaultValue` say "has type `array`" and "produced a
value of type `array`" instead of "is a `array`".
- Documentation: `checkpoint()` clears only the **settled** calls, not the call
log (the text promised otherwise, and the code was right); a declared property
default is kept while a promoted one is not; a built-in interface as a return
type does not become a nested double; `Arg::string()` uses PCRE semantics for
`$`; both Security sections say that arguments are printed verbatim except
sensitive ones. A broken cookbook link in `examples/README.md` and a dead plan
reference in `docs.yml` are gone, and the committed API pages are regenerated
from the current `src/` (they were built from a v0.5.0 snapshot).

## 0.7.2 — 2026-09-04

- **Documentation review fixes.** llms.txt no longer claims `bypassFinals()`
Expand Down
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ What a class double does and does not do:
| public and protected methods | overridden and dispatched; a protected one shows up in the transcript and under strict mode, but PHP's own visibility keeps it out of a setup closure |
| private and static methods | untouched — the target keeps them, because there is no instance state to intercept |
| the destructor | replaced with an empty one, so nothing is torn down that was never built |
| writable public properties | start at an empty value of their type; object-typed, hooked, `final`, `readonly` and `private(set)` ones are left uninitialized, and reading one raises PHP's own error |
| writable public properties | a declared default is kept; everything else starts at an empty value of its type — including a property **promoted** through the constructor, because the constructor is skipped, so `$double->promoted` differs from the real object. Object-typed, hooked, `final`, `readonly` and `private(set)` ones are left uninitialized, and reading one raises PHP's own error |
| `clone` | produces a double of its own: same contracts, no expectations, no call log, owned by the context that cloned it |

A `readonly` target produces a `readonly` double, which PHP requires and which
Expand Down Expand Up @@ -316,9 +316,15 @@ value, which is the point in a codebase that runs with `strict_types`.

A matcher that could never match anything is refused where it is written, not
left to fail an expectation in teardown: `Arg::int(min: 5, max: 1)` and its
`float`/`count` siblings describe an empty range, and `Arg::string('/[unclosed')`
`float`/`count` siblings describe an empty range, `Arg::string('/[unclosed')`
is not a pattern PCRE compiles — the latter would also raise a warning inside
the code under test on every call.
the code under test on every call — and `Arg::instanceOf()` needs a class or
interface that is loadable, because a name that is not would simply never
match and say so nowhere.

The pattern is yours and is used as written, PCRE semantics included: `$`
matches before a trailing newline, so `Arg::string('/^ord-\d+$/')` accepts
`"ord-1\n"`. Anchor with `\z` (or add the `D` modifier) where that matters.

`Arg::which()` calls only a public, non-static method that needs no arguments.
A getter that throws counts as a mismatch, never as an error — matching runs
Expand Down Expand Up @@ -503,7 +509,13 @@ echo Understudy::transcript($repository); // every call and its outcome
Understudy::idle(); // true when the test holds no doubles, in any context
```

`transcript()` retains every invocation until `reset()` or `checkpoint()`.
`transcript()` retains every invocation until `reset()`. `checkpoint()` clears
only the calls it **settled** — the ones a matching `expect()` or a successful
`verify()` claimed — because an unclaimed call is still what `nothingElse()`
reads. A call covered by a `when()` stub alone is never claimed, so it, and the
value it holds, survive every checkpoint of a long test. Use `reset()`,
`scope()` or `lean()` to let go of everything.

Avoid unbounded hot loops through a double when the arguments or results hold
large object graphs; use a real fake for load-sized workloads.

Expand Down Expand Up @@ -601,6 +613,14 @@ back is another understudy: a return type that can itself be doubled becomes
one, one level deep, which the same test can configure. That double is a
generated stand-in, not the target with its constructor skipped.

A built-in interface is the exception: a method declared `: Stringable`,
`: Countable`, `: JsonSerializable`, `: ArrayAccess` or `: IteratorAggregate`
answers `NoDefaultValue` and names the way out, even though `Understudy::for()`
doubles all five. Such a return type almost always means a concrete
implementation, and standing a stub in for it would answer a question the test
did not ask. An interface of your own that extends one of them does get a
nested double.

One level, and no further — a double created this way refuses to produce
another, so `$a->b()->c()` says so rather than inventing a third collaborator
the test never asked for. Registering a factory for `C` is how you say you meant
Expand Down Expand Up @@ -848,6 +868,15 @@ process. It never loads code from user input, never touches the filesystem, and
holds all state in `WeakMap`s keyed by the double object — never by
`spl_object_id()`, which PHP reuses after collection.

**Arguments are printed verbatim in failure messages and in `transcript()`,
with one exception.** A parameter the contract marks `#[\SensitiveParameter]`
is rendered as its type and nothing else — `login('user', string
SensitiveParameter)` — the way PHP redacts such a parameter in its own stack
traces. Everything else goes into the message as written, and a failure message
is read from a CI log: mark the parameter, or keep the secret out of the
argument. The literals your own specification passes are not redacted; they are
in your test file already.

It is a development dependency. Do not install it in production.

## Examples
Expand Down
39 changes: 34 additions & 5 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ $repository = Understudy::for(DoctrineBookRepository::class, Countable::class);
| public- и protected-методы | переопределяются и диспетчеризуются; protected виден в transcript и в strict-режиме, но нативная видимость PHP не пускает его в setup-замыкание |
| private- и static-методы | не трогаются — у них нет instance-состояния, которое можно перехватить |
| деструктор | заменяется пустым, чтобы не сносить то, что никогда не создавалось |
| записываемые public-свойства | стартуют с пустого значения своего типа; объектные, hooked, `final`, `readonly` и `private(set)` остаются неинициализированными, и чтение даёт нативную ошибку PHP |
| записываемые public-свойства | объявленное умолчание сохраняется; всё остальное стартует с пустого значения своего типа — в том числе свойство, **продвинутое** через конструктор: конструктор пропущен, поэтому `$double->promoted` отличается от настоящего объекта. Объектные, hooked, `final`, `readonly` и `private(set)` остаются неинициализированными, и чтение даёт нативную ошибку PHP |
| `clone` | даёт самостоятельный дубль: те же контракты, без ожиданий и без журнала вызовов, владелец — контекст, который клонировал |

`readonly`-цель даёт `readonly`-дубль — этого требует PHP, и это ничего не
Expand Down Expand Up @@ -314,9 +314,16 @@ when(fn () => $storage->recordOutcome('svc', Arg::rest()))

Матчер, который не смог бы совпасть ни с чем, отвергается там, где написан, а
не проваливает ожидание в teardown: `Arg::int(min: 5, max: 1)` и его собратья
`float`/`count` описывают пустой диапазон, а `Arg::string('/[unclosed')` — не
`float`/`count` описывают пустой диапазон, `Arg::string('/[unclosed')` — не
компилируемый PCRE-паттерн, который вдобавок поднимал бы warning внутри
тестируемого кода на каждом вызове.
тестируемого кода на каждом вызове, а `Arg::instanceOf()` требует загружаемый
класс или интерфейс: имя, которого нет, просто не совпало бы никогда и нигде
об этом не сказало.

Паттерн — ваш и используется как написан, вместе с семантикой PCRE: `$`
совпадает и перед завершающим переводом строки, поэтому
`Arg::string('/^ord-\d+$/')` принимает `"ord-1\n"`. Где это важно — якорить
`\z` (или добавить модификатор `D`).

`Arg::which()` вызывает только публичный нестатический метод без обязательных
аргументов. Геттер, бросивший исключение, считается несовпадением, а не
Expand Down Expand Up @@ -504,8 +511,14 @@ echo Understudy::transcript($repository); // все вызовы и их
Understudy::idle(); // true, когда тест не держит дублей ни в одном контексте
```

`transcript()` хранит каждый вызов до `reset()` или `checkpoint()`. Не
используйте дубль в неограниченном горячем цикле, если аргументы или ответы
`transcript()` хранит каждый вызов до `reset()`. `checkpoint()` очищает только
**учтённые** вызовы — те, что забрал сработавший `expect()` или успешный
`verify()`, — потому что неучтённый вызов всё ещё нужен `nothingElse()`. Вызов,
покрытый только `when()`-заглушкой, не учитывается никогда, так что он и
удержанное им значение переживают любой checkpoint длинного теста. Отпустить
всё — это `reset()`, `scope()` или `lean()`.

Не используйте дубль в неограниченном горячем цикле, если аргументы или ответы
удерживают большие графы объектов; для нагрузочных сценариев лучше fake.

Удержание распространяется на **возвращённые значения**, а `reset()` у
Expand Down Expand Up @@ -602,6 +615,13 @@ Loose-дубль никогда не выдумывает значение, за
становится дублем — на один уровень, и тот же тест может его настроить. Это
сгенерированный заменитель, а не цель с пропущенным конструктором.

Исключение — встроенные интерфейсы: метод, объявленный `: Stringable`,
`: Countable`, `: JsonSerializable`, `: ArrayAccess` или `: IteratorAggregate`,
отвечает `NoDefaultValue` и называет выход, хотя сам `Understudy::for()`
дублирует все пять. Такой возвращаемый тип почти всегда означает конкретную
реализацию, и подстановка заглушки ответила бы на вопрос, которого тест не
задавал. Свой интерфейс, расширяющий один из них, вложенный дубль получает.

Ровно один уровень: созданный так дубль откажется породить следующий, поэтому
`$a->b()->c()` скажет об этом, а не выдумает третью зависимость, о которой тест
не просил. Зарегистрировать фабрику для `C` — способ сказать, что вы этого
Expand Down Expand Up @@ -847,6 +867,15 @@ Understudy генерирует по классу на набор контрак
файловой системе и держит всё состояние в `WeakMap` по ключу-объекту — а не по
`spl_object_id()`, который PHP переиспользует после сборки мусора.

**Аргументы попадают в сообщение о падении и в `transcript()` дословно, с
одним исключением.** Параметр, помеченный контрактом как
`#[\SensitiveParameter]`, печатается одним своим типом — `login('user', string
SensitiveParameter)`, — так же, как PHP редактирует такой параметр в
собственных трассах. Всё остальное уходит в сообщение как есть, а сообщение о
падении читают из CI-лога: помечайте параметр или не кладите секрет в аргумент.
Литералы, которые передаёт ваша собственная спецификация, не редактируются —
они и так лежат в файле теста.

Это dev-зависимость. В production её ставить не нужно.

## Примеры
Expand Down
Loading
Loading