Contributing
This page covers the standing policies behind the library's design, plus two walkthroughs for the contributions that come up most: adding a matcher and improving a failure message. Read the relevant section before sending a PR — a change that runs against one of these policies will need to be restructured before it can be merged.
No Aliases
This library has exactly one canonical verb per concept: expects() /
allows() for configuration, returns() / throws() / resolves() for
outcomes, with() for argument constraints, counts (times(),
atLeastOnce(), never()), and received() for spy-style assertions.
There are no aliases for any of these — not for familiarity with Mockery,
PHPUnit's native mocks, Prophecy, or Phake, and not ever. See
One Verb per Concept for the
reasoning, and Migrating from Mockery if
you're looking for the familiar name instead of adding one.
Counts specifically: times() is overloaded (times(3) exact,
times(1, 3) between, times(minimum: 2) at least, times(maximum: 5)
at most) rather than growing into once()/twice()/atMost()/between()
as separate words. never() is the one exception kept as its own verb
rather than folded into times(0).
A PR that adds a convenience alias (e.g. andReturn() next to
returns(), or shouldReceive() next to expects()) will be declined,
even though "add a familiar alias for people migrating from X" reads as
helpful on its face. Every other PHP mocking library accumulated its API
exactly this way — one library, four verbs for the same concept, forever.
Migration help for people coming from Mockery or PHPUnit's native mocks
belongs in documentation (a rosetta-stone table), not in the API surface.
If you think a concept is genuinely missing a verb (not an alias for one that already exists), open an issue to discuss it before sending a PR.
Public API Stability
Two things are frozen as semver-guaranteed public API, not internal
details that merely happen to be reachable: the Matcher interface
(matches(), describe(), explainMismatch()) and the public readonly
fields on every concrete TestDoubleException subclass. This extends to
any value object one of those fields exposes — e.g.
UnsatisfiedExpectationException::$expectations is a
list<Diagnostics\UnsatisfiedExpectation>, and UnsatisfiedExpectation's
own public readonly fields (method, description, expectedMin,
expectedMax, timesCalled, otherObservedCalls) are frozen right along
with it — a consumer reading $exception->expectations[0]->description
is exactly the "structured access for anything that wants it" case this
freeze exists for.
Practically: a PR widening Matcher itself, or renaming/removing/retyping
a field on an existing exception (or on a value object one of those
fields exposes), is a major-version change and needs to be flagged as
one — reach for an additive, optional interface (e.g.
ExplainsWithDetail extends Matcher) instead when a matcher genuinely
needs to convey more. Argument (the static facade) is not covered by
this freeze and can keep growing incrementally.
Module Boundaries
The codebase is split into JMac\Testing\Engine, JMac\Testing\Matching,
JMac\Testing\Diagnostics, and JMac\Testing\Exceptions. Only Engine
is allowed to depend on the others. Diagnostics has zero dependencies on
the rest of the library — it's the shared home for rendering/formatting
logic (ValueFormatter, ArgumentFormatter, Pluralizer) that more than
one other module needs, specifically so that logic has exactly one
implementation instead of being hand-duplicated per module. Matching
and Exceptions each depend only on Diagnostics, nothing else. A PR
that introduces a dependency pointing the wrong direction (e.g.
Matching referencing Engine directly, or Diagnostics referencing
anything) will need to be restructured before it can be merged.
Walkthrough: Adding a Matcher
Every argument constraint — Argument::any(), Argument::same(), a
predicate closure, and any future one — is a class in src/Matching/
implementing the Matcher interface, plus one static entry point on the
Argument facade (src/Matching/Argument.php). Concretely, using
SameMatcher as the reference shape:
- Implement
Matcherin a newsrc/Matching/YourMatcher.php:matches(mixed $actual): bool,describe(): string(used in failure messages and inwith()'s own argument description), andexplainMismatch(mixed $actual): ?string(nullwhen it matched; otherwise prose explaining why it didn't — seeSameMatcher::explainMismatch()for the shape). Depend only onJMac\Testing\Diagnostics\ValueFormatterif you need to render a value — nothing inEngine. - Add a static entry point on
Argument, e.g.public static function yourMatcher(mixed $expected): Matcher { return new YourMatcher($expected); }. Don't add an alias for an existing matcher — see No Aliases above. - Unit test it in
tests/Matching/YourMatcherTest.php: covermatches()(true and false cases),describe(), and both branches ofexplainMismatch()— seeSameMatcherTestfor the pattern. - Optionally, if the matcher's
describe()output should be proven inside a real rendered failure message, add a case totests/Exceptions/CallDescriptionMessagesTest.phpplus its golden file intests/fixtures/exceptions/describe-matcher-*.txt(see Walkthrough: Improving a Message below for how golden files work).
Nothing else needs to change — Matching depends only on Diagnostics,
and ProxyBehavior/MethodExpectation only ever talk to a constraint
through the Matcher interface, never a concrete matcher class.
Walkthrough: Improving a Message
Every exception's wording lives in one place: its *Fields trait in
src/Exceptions/ (e.g. UnexpectedCallFields::renderMessage()), shared
between the plain exception and its
Integrations\PHPUnit\PHPUnitXxxException sibling so both render
identically with no separate edit. To change one:
- Edit the
render()/renderMessage()method on the relevant*Fieldstrait (or the constructor directly, for an exception with no PHPUnit sibling, e.g.ModeConfigurationException). - Update the golden fixture(s) in
tests/fixtures/exceptions/*.txt.GoldenFileTestCase::assertMatchesGolden()does an exact string comparison against that file's contents — a wording change will fail every test asserting against the old text until the corresponding.txtfile is updated to match. There is no--update-snapshotsflag; edit the fixture by hand (or regenerate it by printing the exception's newgetMessage()and pasting it in). - Run the golden-file suite that covers it —
tests/Exceptions/ExceptionMessagesTest.php,CallDescriptionMessagesTest.php, orValidationMessagesTest.php, depending on which exception you touched — to confirm the new fixture is exactly right. - Check
tests/Integrations/PHPUnit/PHPUnitExceptionsTest.phpif the exception has a PHPUnit sibling: it asserts parity between the two, so it should pass without changes once step 1 is done (both read from the same trait), but it's the place a divergence would show up.