Skip to content

date: 2026-06-07 tags: [testing, pest, expectations, dx] status: active graduated_to:

Pest expect()->extend ignores the closure's return value — fluent DSLs need a matcher(closure) shape

Symptom — Wanted an accessor-style fluent DSL: expect($page)->plannedDay()->withQuickWins(...)->..., where plannedDay() returns a custom fluent object. The chain broke — after ->plannedDay() the next method ran against the page (the expectation value), not the custom object.

Root causeExpectation::__call runs a registered extension through a pipeline and then return $this; — the extension closure's return value is discarded (vendor/pestphp/pest/src/Expectation.php:367-372). So a custom expectation can never hand back a foreign fluent object to chain on; expect() always re-wraps to itself (and for an unknown method it proxies to $this->value, which is why the broken chain silently called the page).

Fix — Shape a fluent test DSL as a matcher that TAKES a build closure, does the work inside, and returns nothing: expect($page)->toRenderPlannedDay(fn ($day) => $day->withX(...)->...). The closure populates a spec object that the extension then asserts. See tests/Pest.php (toRenderPlannedDay) + tests/Fixtures/PlannedDayAssertions.php.

Guard — The toRenderPlannedDay pattern in tests/Pest.php. Sibling: [[2026-06-07-pest-browser-no-assert-see-in-order]].