From 9948ee00dbfd9589321e594dbd2baf493de93054 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 24 Aug 2026 14:49:54 +0400 Subject: [PATCH 1/3] feat(bridge-double): add Double test-double bridge Bridges jasonmccreary/double into Testo the way testo/bridge-mockery bridges Mockery: DoublePlugin registers an innermost interceptor that arms Double::armAutoVerify() before each test and runs Double::verifyAll() afterwards, so unmet expects()/received() assertions fail the test with no per-test verify() call. verifyAll()'s return count is reported to the Assert plugin so a double-only test still counts as making assertions. Under fiber-scheduled execution the interceptor parks each test's in-flight auto-verify state with Double::captureAutoVerifyScope() on suspension and reinstalls it with restoreAutoVerifyScope() on resume, so interleaving tests never sweep each other's doubles into the wrong teardown. Wired into composer.json and the self-test suites only; release infra and the PHP 8.3-vs-8.2 CI question are deferred until Double reaches a stable release (see #296). Assisted-By: Claude Opus 4.8 (1M context) --- bridge/double/.github/workflows/close-prs.yml | 14 ++ bridge/double/CHANGELOG.md | 3 + bridge/double/README.md | 62 ++++++++ bridge/double/composer.json | 55 +++++++ bridge/double/src/DoublePlugin.php | 37 +++++ .../double/src/Internal/DoubleInterceptor.php | 144 ++++++++++++++++++ .../tests/Acceptance/DoubleBridgeTest.php | 55 +++++++ .../DoubleInterleaveAttributionTest.php | 75 +++++++++ .../double/tests/Feature/DoubleStatusTest.php | 94 ++++++++++++ .../Self/DoubleAndAssertCombinations.php | 87 +++++++++++ .../tests/Self/DoubleUnderFibersTest.php | 53 +++++++ .../Stub/DoubleAssertConcurrencyScenarios.php | 50 ++++++ .../Stub/DoubleExpectConcurrencyScenarios.php | 52 +++++++ .../tests/Stub/DoubleResetScenarios.php | 35 +++++ bridge/double/tests/Stub/DoubleScenarios.php | 61 ++++++++ bridge/double/tests/suites.php | 33 ++++ composer.json | 4 + testo.php | 2 + 18 files changed, 916 insertions(+) create mode 100644 bridge/double/.github/workflows/close-prs.yml create mode 100644 bridge/double/CHANGELOG.md create mode 100644 bridge/double/README.md create mode 100644 bridge/double/composer.json create mode 100644 bridge/double/src/DoublePlugin.php create mode 100644 bridge/double/src/Internal/DoubleInterceptor.php create mode 100644 bridge/double/tests/Acceptance/DoubleBridgeTest.php create mode 100644 bridge/double/tests/Feature/DoubleInterleaveAttributionTest.php create mode 100644 bridge/double/tests/Feature/DoubleStatusTest.php create mode 100644 bridge/double/tests/Self/DoubleAndAssertCombinations.php create mode 100644 bridge/double/tests/Self/DoubleUnderFibersTest.php create mode 100644 bridge/double/tests/Stub/DoubleAssertConcurrencyScenarios.php create mode 100644 bridge/double/tests/Stub/DoubleExpectConcurrencyScenarios.php create mode 100644 bridge/double/tests/Stub/DoubleResetScenarios.php create mode 100644 bridge/double/tests/Stub/DoubleScenarios.php create mode 100644 bridge/double/tests/suites.php diff --git a/bridge/double/.github/workflows/close-prs.yml b/bridge/double/.github/workflows/close-prs.yml new file mode 100644 index 00000000..7640d59f --- /dev/null +++ b/bridge/double/.github/workflows/close-prs.yml @@ -0,0 +1,14 @@ +name: Close PRs + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + close: + uses: php-testo/gh-actions/.github/workflows/close-foreign-prs.yml@v1 + with: + upstream-url: https://github.com/php-testo/testo diff --git a/bridge/double/CHANGELOG.md b/bridge/double/CHANGELOG.md new file mode 100644 index 00000000..417f2d60 --- /dev/null +++ b/bridge/double/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +## Changelog diff --git a/bridge/double/README.md b/bridge/double/README.md new file mode 100644 index 00000000..441a1b98 --- /dev/null +++ b/bridge/double/README.md @@ -0,0 +1,62 @@ +

+ TESTO +

+ +

Double bridge

+ +
+ +[![Documentation](https://img.shields.io/badge/Documentation-blue?style=for-the-badge&logo=gitbook&logoColor=white)](https://php-testo.github.io) +[![Support on Boosty](https://img.shields.io/static/v1?style=for-the-badge&label=&message=Sponsorship&logo=Boosty&logoColor=white&color=%23F15F2C)](https://boosty.to/roxblnfk) + +
+ +
+ +> [!IMPORTANT] +> ## 🪞 This is a read-only mirror. +> +> Active development of the Testo project lives in [**php-testo/testo**](https://github.com/php-testo/testo) under `bridge/double/`. This repository is **automatically synchronized** from there on every release. +> +> File issues and pull requests in the [main monorepo](https://github.com/php-testo/testo/issues), not here. + +## About + +[Double](https://github.com/jasonmccreary/double) is a modern PHP test-double library — one unified `Double` type covers mocks, stubs and spies. This bridge wires its verification into Testo: register `DoublePlugin` and `Double::verifyAll()` is called after every test, so `expects()` and `received()` assertions are always verified and the pending doubles are cleared between tests — no per-test `verify()` boilerplate. + +```php +// testo.php +use Testo\Application\Config\ApplicationConfig; +use Testo\Application\Config\SuiteConfig; +use Testo\Bridge\Double\DoublePlugin; + +return new ApplicationConfig( + plugins: [new DoublePlugin()], + suites: [new SuiteConfig(name: 'Unit', location: ['tests/Unit'])], +); +``` + +```php +use JMac\Testing\Double; + +$repository = Double::for(BookRepository::class); +$repository->expects('find')->with(123)->returns($book); + +$service = new CatalogService($repository); +$service->lookup(123); +// The plugin verifies `find` was called as expected once the test returns. +``` + +## Install + +```bash +composer require --dev testo/bridge-double +``` + +[![PHP](https://img.shields.io/packagist/php-v/testo/bridge-double.svg?style=flat-square&logo=php)](https://packagist.org/packages/testo/bridge-double) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/testo/bridge-double.svg?style=flat-square&logo=packagist)](https://packagist.org/packages/testo/bridge-double) +[![License](https://img.shields.io/packagist/l/testo/bridge-double.svg?style=flat-square)](https://github.com/php-testo/testo/blob/1.x/LICENSE.md) +[![Total Downloads](https://img.shields.io/packagist/dt/testo/bridge-double.svg?style=flat-square)](https://packagist.org/packages/testo/bridge-double/stats) diff --git a/bridge/double/composer.json b/bridge/double/composer.json new file mode 100644 index 00000000..2f0b7493 --- /dev/null +++ b/bridge/double/composer.json @@ -0,0 +1,55 @@ +{ + "name": "testo/bridge-double", + "description": "Double bridge for the Testo testing framework.", + "license": "BSD-3-Clause", + "type": "library", + "keywords": [ + "testo", + "double", + "mock", + "stub", + "spy", + "testing" + ], + "authors": [ + { + "name": "Aleksei Gagarin (roxblnfk)", + "homepage": "https://github.com/roxblnfk" + } + ], + "funding": [ + { + "type": "boosty", + "url": "https://boosty.to/roxblnfk" + } + ], + "require": { + "php": ">=8.3", + "jasonmccreary/double": "^0.6.1", + "testo/testo": "0.10.39 - 1" + }, + "require-dev": { + "testo/assert": "^0.1.13", + "testo/bridge-revolt": "^0.1.1", + "testo/codecov": "^0.1.12", + "testo/fiber": "^0.1.2", + "testo/test": "^0.1.6" + }, + "autoload": { + "psr-4": { + "Testo\\Bridge\\Double\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\Bridge\\Double\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + } +} diff --git a/bridge/double/src/DoublePlugin.php b/bridge/double/src/DoublePlugin.php new file mode 100644 index 00000000..44fe0093 --- /dev/null +++ b/bridge/double/src/DoublePlugin.php @@ -0,0 +1,37 @@ +get(InterceptorCollector::class)->addInterceptor(new DoubleInterceptor()); + } +} diff --git a/bridge/double/src/Internal/DoubleInterceptor.php b/bridge/double/src/Internal/DoubleInterceptor.php new file mode 100644 index 00000000..e1633a7e --- /dev/null +++ b/bridge/double/src/Internal/DoubleInterceptor.php @@ -0,0 +1,144 @@ +run($info, $next); + } finally { + try { + $verified = Double::verifyAll(); + $verified > 0 and self::reportVerifiedChecks($verified); + } catch (\Throwable $e) { + # Record the unmet expectation on the test, then turn it into a normal failure — an + # exception escaping here would abort the pipeline (Status::Aborted) instead. Leave an + # already-failed result alone; a null $result means $next() threw — let it propagate. + $failure = self::reportFailedCheck($e); + $result?->status === Status::Passed and $result = $result + ->with(status: Status::Failed) + ->withFailure($failure ?? $e); + } + } + + return $result; + } + + /** + * Record the `$count` passed Double checks as one fulfilled assertion on the current test, so a test + * whose only checks are `expects()` / `received()` verifications is not flagged as making no assertions. + * + * No-op without the Assert plugin (the {@see \class_exists()} guard). Runs innermost, before the + * Assert plugin reads the history, so the record lands on the current test. + */ + private static function reportVerifiedChecks(int $count): void + { + if (!\class_exists(StaticState::class)) { + return; + } + + $state = StaticState::current(); + $state === null or $state->history[] = new ExpectationFulfilled( + \sprintf('%d Double %s verified', $count, $count === 1 ? 'check was' : 'checks were'), + '', + ); + } + + /** + * Record an unmet Double expectation as a failed assertion on the current test and return it, + * so the caller can use the same record as the test's failure. + * + * Returns null without the Assert plugin — the caller then falls back to the raw Double exception. + */ + private static function reportFailedCheck(\Throwable $e): ?ExpectationFailed + { + if (!\class_exists(StaticState::class)) { + return null; + } + + $failure = new ExpectationFailed( + expectation: 'the Double expectations are fulfilled', + context: '', + reason: $e->getMessage(), + details: '', + ); + + $state = StaticState::current(); + $state === null or $state->history[] = $failure; + + return $failure; + } + + /** + * Run the test, keeping this test's pending doubles bound to it across fiber suspensions. + * + * Double's pending doubles live in process-global state, so under concurrent (fiber-based) execution + * sibling tests would sweep each other's doubles into the wrong teardown. On every suspension we park + * this test's state with {@see Double::captureAutoVerifyScope()} and hand a fresh slate to the sibling; + * on resumption we reinstall it with {@see Double::restoreAutoVerifyScope()}. + * + * @param callable(TestInfo): TestResult $next + */ + private function run(TestInfo $info, callable $next): TestResult + { + if (\Fiber::getCurrent() === null) { + return $next($info); + } + + $fiber = new \Fiber(static fn(): TestResult => $next($info)); + $value = $fiber->start(); + while (!$fiber->isTerminated()) { + $scope = Double::captureAutoVerifyScope(); + try { + $resume = \Fiber::suspend($value); + } catch (\Throwable $e) { + Double::restoreAutoVerifyScope($scope); + $value = $fiber->throw($e); + continue; + } + + Double::restoreAutoVerifyScope($scope); + $value = $fiber->resume($resume); + } + + /** @var TestResult $result */ + $result = $fiber->getReturn(); + return $result; + } +} diff --git a/bridge/double/tests/Acceptance/DoubleBridgeTest.php b/bridge/double/tests/Acceptance/DoubleBridgeTest.php new file mode 100644 index 00000000..f4a22a92 --- /dev/null +++ b/bridge/double/tests/Acceptance/DoubleBridgeTest.php @@ -0,0 +1,55 @@ +expects('count')->returns(7); + + Assert::same($double->count(), 7); + } + + public function expectedCallCountIsVerifiedOnTeardown(): void + { + /** @var DoubleInterface&\Countable $double */ + $double = Double::for(\Countable::class); + $double->expects('count')->times(2)->returns(2); + + $double->count(); + $double->count(); + } + + public function spyRecordsCallsWithReceived(): void + { + /** @var DoubleInterface&\Countable $spy */ + $spy = Double::for(\Countable::class); + $spy->allows('count')->returns(3); + + Assert::same($spy->count(), 3); + + $spy->received('count')->times(1); + } +} diff --git a/bridge/double/tests/Feature/DoubleInterleaveAttributionTest.php b/bridge/double/tests/Feature/DoubleInterleaveAttributionTest.php new file mode 100644 index 00000000..522f3433 --- /dev/null +++ b/bridge/double/tests/Feature/DoubleInterleaveAttributionTest.php @@ -0,0 +1,75 @@ +status, Status::Passed); + Assert::same($second->status, Status::Passed); + } + + public function bodyAssertionsLandInEachTestsHistory(): void + { + // Each stub test makes two Assert::same() calls of its own; the bridge adds one record for the + // verified double. A transparent bridge leaves all three in the test's history. + $first = TestRunner::runTest([DoubleAssertConcurrencyScenarios::class, 'firstAssertsAroundItsDouble']); + $second = TestRunner::runTest([DoubleAssertConcurrencyScenarios::class, 'secondAssertsAroundItsDouble']); + + Assert::same(self::historyCount($first), 3, 'first test: 2 body asserts + 1 double verification'); + Assert::same(self::historyCount($second), 3, 'second test: 2 body asserts + 1 double verification'); + } + + public function expectExceptionSurvivesTheInterleave(): void + { + // Each stub test declares Expect::exception() up front and throws after its yield — with a + // transparent bridge both expectations are fulfilled and both tests pass. + $first = TestRunner::runTest([DoubleExpectConcurrencyScenarios::class, 'firstExpectsItsException']); + $second = TestRunner::runTest([DoubleExpectConcurrencyScenarios::class, 'secondExpectsItsException']); + + Assert::same($first->status, Status::Passed); + Assert::same($second->status, Status::Passed); + } + + private static function historyCount(TestResult $result): int + { + $state = $result->getAttribute(TestState::class); + + return $state instanceof TestState ? \count($state->history) : -1; + } +} diff --git a/bridge/double/tests/Feature/DoubleStatusTest.php b/bridge/double/tests/Feature/DoubleStatusTest.php new file mode 100644 index 00000000..6f94be8c --- /dev/null +++ b/bridge/double/tests/Feature/DoubleStatusTest.php @@ -0,0 +1,94 @@ +status, Status::Passed); + Assert::true(self::hasRecord($result, success: true)); + } + + public function unfulfilledExpectationFailsTheTest(): void + { + $result = TestRunner::runTest([DoubleScenarios::class, 'unfulfilledExpectation']); + Assert::same($result->status, Status::Failed); + Assert::true(self::hasRecord($result, success: false)); + } + + public function noDoublesNoAssertionsStaysRisky(): void + { + $result = TestRunner::runTest([DoubleScenarios::class, 'noDoublesNoAssertions']); + Assert::same($result->status, Status::Risky); + } + + public function receivedVerificationCountsAsAssertion(): void + { + $result = TestRunner::runTest([DoubleScenarios::class, 'receivedVerificationOnly']); + Assert::same($result->status, Status::Passed); + } + + public function doubleAndAssertCoexist(): void + { + $result = TestRunner::runTest([DoubleScenarios::class, 'doubleAndAssertMixed']); + Assert::same($result->status, Status::Passed); + } + + public function stateIsDrainedAfterAFailingTest(): void + { + // leavesUnmetExpectation fails on verifyAll(); seesCleanSlate runs right after it and would + // fail too if that unmet expectation had leaked into the global pending list. Both statuses + // being as expected proves the drain happens on the failure path, not only when a test passes. + $failed = TestRunner::runTest([DoubleResetScenarios::class, 'leavesUnmetExpectation']); + Assert::same($failed->status, Status::Failed); + + $next = TestRunner::runTest([DoubleResetScenarios::class, 'seesCleanSlate']); + Assert::same($next->status, Status::Passed); + } + + /** + * Whether the test's assertion history holds a record with the given success flag — i.e. the + * bridge reported the double verification (fulfilled or failed) to the Assert plugin. + */ + private static function hasRecord(TestResult $result, bool $success): bool + { + $state = $result->getAttribute(TestState::class); + if (!$state instanceof TestState) { + return false; + } + + foreach ($state->history as $record) { + if ($record->isSuccess() === $success) { + return true; + } + } + + return false; + } +} diff --git a/bridge/double/tests/Self/DoubleAndAssertCombinations.php b/bridge/double/tests/Self/DoubleAndAssertCombinations.php new file mode 100644 index 00000000..ea6ee54d --- /dev/null +++ b/bridge/double/tests/Self/DoubleAndAssertCombinations.php @@ -0,0 +1,87 @@ +expects('count')->returns(3); + + $double->count(); + } + + public function expectationThenAssert(): void + { + $double = Double::for(\Countable::class); + $double->expects('count')->returns(9); + + Assert::same($double->count(), 9); + Assert::instanceOf($double, \Countable::class); + } + + public function multipleDoublesAndAsserts(): void + { + $counter = Double::for(\Countable::class); + $counter->expects('count')->times(2)->returns(1); + $other = Double::for(\Countable::class); + $other->expects('count')->returns(4); + + Assert::same($counter->count(), 1); + $counter->count(); + Assert::same($other->count(), 4); + } + + public function spyWithAssert(): void + { + $spy = Double::for(\Countable::class); + $spy->allows('count')->returns(7); + + Assert::same($spy->count(), 7); + + $spy->received('count')->times(1); + } + + public function looseStubWithAssert(): void + { + $stub = Double::for(\Countable::class); + $stub->allows('count')->returns(0); + + Assert::same($stub->count(), 0); + } + + public function doubleThrowsWithExpectException(): void + { + $double = Double::for(\Countable::class); + $double->expects('count')->throws(new \RuntimeException('boom')); + + Expect::exception(\RuntimeException::class)->withMessageContaining('boom'); + $double->count(); + } +} diff --git a/bridge/double/tests/Self/DoubleUnderFibersTest.php b/bridge/double/tests/Self/DoubleUnderFibersTest.php new file mode 100644 index 00000000..2b007078 --- /dev/null +++ b/bridge/double/tests/Self/DoubleUnderFibersTest.php @@ -0,0 +1,53 @@ +expects('count')->returns(4); + + Assert::same($double->count(), 4); + } + + #[RunInRevolt] + public function doubleVerifiesAcrossARevoltAwait(): void + { + /** @var DoubleInterface&\Countable $double */ + $double = Double::for(\Countable::class); + $double->expects('count')->returns(7); + + $suspension = EventLoop::getSuspension(); + EventLoop::delay(0.001, static fn() => $suspension->resume()); + $suspension->suspend(); + + Assert::same($double->count(), 7); + } +} diff --git a/bridge/double/tests/Stub/DoubleAssertConcurrencyScenarios.php b/bridge/double/tests/Stub/DoubleAssertConcurrencyScenarios.php new file mode 100644 index 00000000..f1b39578 --- /dev/null +++ b/bridge/double/tests/Stub/DoubleAssertConcurrencyScenarios.php @@ -0,0 +1,50 @@ +expects('count')->returns(1); + + Assert::same(1, 1); + + \Fiber::suspend(); + + Assert::same($double->count(), 1); + } + + public function secondAssertsAroundItsDouble(): void + { + /** @var DoubleInterface&\Countable $double */ + $double = Double::for(\Countable::class); + $double->expects('count')->returns(2); + + Assert::same(2, 2); + + \Fiber::suspend(); + + Assert::same($double->count(), 2); + } +} diff --git a/bridge/double/tests/Stub/DoubleExpectConcurrencyScenarios.php b/bridge/double/tests/Stub/DoubleExpectConcurrencyScenarios.php new file mode 100644 index 00000000..7b350d68 --- /dev/null +++ b/bridge/double/tests/Stub/DoubleExpectConcurrencyScenarios.php @@ -0,0 +1,52 @@ +expects('count')->returns(1); + + \Fiber::suspend(); + + $double->count(); + throw new \DomainException('first'); + } + + public function secondExpectsItsException(): never + { + Expect::exception(\DomainException::class); + + /** @var DoubleInterface&\Countable $double */ + $double = Double::for(\Countable::class); + $double->expects('count')->returns(2); + + \Fiber::suspend(); + + $double->count(); + throw new \DomainException('second'); + } +} diff --git a/bridge/double/tests/Stub/DoubleResetScenarios.php b/bridge/double/tests/Stub/DoubleResetScenarios.php new file mode 100644 index 00000000..9a7332f3 --- /dev/null +++ b/bridge/double/tests/Stub/DoubleResetScenarios.php @@ -0,0 +1,35 @@ +expects('count'); + } + + #[Test] + public function seesCleanSlate(): void + { + // If the previous test's unmet expectation had leaked, verifyAll() would fail this test too. + // This double's own expectation is fulfilled, so a Passed status proves the slate was drained. + $double = Double::for(\Countable::class); + $double->expects('count')->returns(1); + + Assert::same($double->count(), 1); + } +} diff --git a/bridge/double/tests/Stub/DoubleScenarios.php b/bridge/double/tests/Stub/DoubleScenarios.php new file mode 100644 index 00000000..3a6f248d --- /dev/null +++ b/bridge/double/tests/Stub/DoubleScenarios.php @@ -0,0 +1,61 @@ +expects('count')->returns(1); + + $double->count(); + } + + #[Test] + public function unfulfilledExpectation(): void + { + /** @var DoubleInterface&\Countable $double */ + $double = Double::for(\Countable::class); + $double->expects('count'); + } + + #[Test] + public function noDoublesNoAssertions(): void {} + + #[Test] + public function receivedVerificationOnly(): void + { + /** @var DoubleInterface&\Countable $spy */ + $spy = Double::for(\Countable::class); + $spy->allows('count')->returns(0); + + $spy->count(); + + $spy->received('count')->times(1); + } + + #[Test] + public function doubleAndAssertMixed(): void + { + /** @var DoubleInterface&\Countable $double */ + $double = Double::for(\Countable::class); + $double->expects('count')->returns(5); + + Assert::same($double->count(), 5); + } +} diff --git a/bridge/double/tests/suites.php b/bridge/double/tests/suites.php new file mode 100644 index 00000000..adc51070 --- /dev/null +++ b/bridge/double/tests/suites.php @@ -0,0 +1,33 @@ + Date: Wed, 26 Aug 2026 00:06:30 +0400 Subject: [PATCH 2/3] feat(bridge-double): record Double checks in the assertion history Surface Double's verification activity in the Assert plugin's history so a test that uses doubles shows what was checked, next to its plain assertions. - Per double: one fulfilled record built from the captured auto-verify scope, summarising its recorded calls (label + method counts), so a double-only test is no longer flagged Risky and its report shows what was verified. - A Double check that throws in the test body (unused(), an unexpected call on a strict double, a never()/times() limit exceeded) is recorded as a failed assertion. The result is passed through untouched, so #[ExpectException] still decides the final status: caught still passes, with the failure visible in the history. Misuse errors (unknown/static/magic method, mode) are left as plain errors. Reads Double's introspection state through its @internal API; see jasonmccreary/double#13 for a proposed public event model. Bump the dependency to dev-master, which ships that state. Assisted-By: Claude Opus 4.8 (1M context) --- bridge/double/composer.json | 2 +- .../double/src/Internal/DoubleInterceptor.php | 155 ++++++++++++++++-- .../double/tests/Feature/DoubleStatusTest.php | 58 +++++++ .../Self/DoubleAndAssertCombinations.php | 47 ++++++ bridge/double/tests/Stub/DoubleScenarios.php | 11 ++ 5 files changed, 258 insertions(+), 15 deletions(-) diff --git a/bridge/double/composer.json b/bridge/double/composer.json index 2f0b7493..4d4d7544 100644 --- a/bridge/double/composer.json +++ b/bridge/double/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.3", - "jasonmccreary/double": "^0.6.1", + "jasonmccreary/double": "dev-master", "testo/testo": "0.10.39 - 1" }, "require-dev": { diff --git a/bridge/double/src/Internal/DoubleInterceptor.php b/bridge/double/src/Internal/DoubleInterceptor.php index e1633a7e..d19572da 100644 --- a/bridge/double/src/Internal/DoubleInterceptor.php +++ b/bridge/double/src/Internal/DoubleInterceptor.php @@ -4,7 +4,16 @@ namespace Testo\Bridge\Double\Internal; +use JMac\Testing\AutoVerifyScope; use JMac\Testing\Double; +use JMac\Testing\Engine\DoubleState; +use JMac\Testing\Exceptions\ExpectationCallLimitExceededException; +use JMac\Testing\Exceptions\ExpectationCallMismatchException; +use JMac\Testing\Exceptions\OutOfOrderCallException; +use JMac\Testing\Exceptions\UnexpectedCallException; +use JMac\Testing\Exceptions\UnsatisfiedExpectationException; +use JMac\Testing\Exceptions\UnsatisfiedReceivedAssertionException; +use JMac\Testing\Exceptions\UnusedAssertionException; use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Expectation\ExpectationFailed; use Testo\Assert\State\Expectation\ExpectationFulfilled; @@ -17,10 +26,17 @@ /** * Arms {@see Double::armAutoVerify()} before every test and runs {@see Double::verifyAll()} afterwards - * in a `finally` block: unmet `expects()` and deferred `received()` assertions fail the test. `verifyAll()` - * returns how many checks it performed, which the bridge reports to the Assert plugin so a double-only test - * still counts as making assertions. It always drains Double's global state, so an unmet expectation fails - * an otherwise-passing test while an already-failed result is left alone. + * in a `finally` block: unmet `expects()` and deferred `received()` assertions fail the test. Before + * verifying, the bridge snapshots the pending doubles and reports one fulfilled assertion per double + * (with its recorded calls) to the Assert plugin, so a double-only test still counts as making assertions + * and its report carries what was checked. It always drains Double's global state, so an unmet expectation + * fails an otherwise-passing test while an already-failed result is left alone. + * + * A Double check that throws inside the test body (a failed `received()`, `unused()` or an unexpected + * call on a strict double) is captured by the runner into the result's failure. The bridge records it in + * the assertion history so the double's diagnostic stays visible, then passes the result through untouched: + * whether the test ends up failed or passes because `#[ExpectException]` caught that throw is decided by + * the rest of the pipeline, not here. * * Runs innermost so the teardown fires as close as possible to the test function. * @@ -44,8 +60,13 @@ public function runTest(TestInfo $info, callable $next): TestResult $result = $this->run($info, $next); } finally { try { - $verified = Double::verifyAll(); - $verified > 0 and self::reportVerifiedChecks($verified); + # Snapshot the pending doubles before verifying so we can describe each in the test's + # assertion history; restore it verbatim so verifyAll() checks exactly the same set. + $scope = Double::captureAutoVerifyScope(); + $records = \class_exists(StaticState::class) ? self::describeVerified($scope) : []; + Double::restoreAutoVerifyScope($scope); + Double::verifyAll(); + $records === [] or self::pushHistory($records); } catch (\Throwable $e) { # Record the unmet expectation on the test, then turn it into a normal failure — an # exception escaping here would abort the pipeline (Status::Aborted) instead. Leave an @@ -57,27 +78,98 @@ public function runTest(TestInfo $info, callable $next): TestResult } } + # A Double check that failed in the test body arrives as the result's failure. Record it, leaving + # the result itself untouched so the pipeline still decides the final status (including a pass when + # #[ExpectException] catches that throw). + $result === null or self::reportBodyFailure($result); + return $result; } /** - * Record the `$count` passed Double checks as one fulfilled assertion on the current test, so a test - * whose only checks are `expects()` / `received()` verifications is not flagged as making no assertions. + * Every Double exception that represents a *check* failing, as opposed to a misuse error (calling an + * unknown or static method, misconfiguring a mode). Only these are recorded as a failed check. + */ + private const CHECK_FAILURES = [ + UnsatisfiedExpectationException::class, + UnsatisfiedReceivedAssertionException::class, + UnusedAssertionException::class, + UnexpectedCallException::class, + ExpectationCallLimitExceededException::class, + ExpectationCallMismatchException::class, + OutOfOrderCallException::class, + ]; + + /** + * Build one fulfilled-assertion record per pending double (summarising the calls it recorded) plus one + * for the deferred `received()` assertions, so a test whose only checks are Double verifications is not + * flagged as making no assertions and its report shows what was verified. + * + * Reads only from the captured snapshot, never throws, and touches no reflection — `received()` + * assertions expose no readable detail, so they collapse into a single count. + * + * @return list + */ + private static function describeVerified(AutoVerifyScope $scope): array + { + $records = []; + foreach ($scope->pending() as $state) { + $records[] = new ExpectationFulfilled(self::describeDouble($state), ''); + } + + $received = \count($scope->pendingReceived()); + $received > 0 and $records[] = new ExpectationFulfilled( + \sprintf('%d Double received %s verified', $received, $received === 1 ? 'assertion was' : 'assertions were'), + '', + ); + + return $records; + } + + /** + * A one-line summary of a single double: its label and the methods it recorded calls to, with counts. + */ + private static function describeDouble(DoubleState $state): string + { + $counts = []; + foreach ($state->calls() as $call) { + $counts[$call['method']] = ($counts[$call['method']] ?? 0) + 1; + } + + if ($counts === []) { + return \sprintf('Double `%s` verified with no recorded calls', $state->label()); + } + + $parts = []; + foreach ($counts as $method => $times) { + $parts[] = \sprintf('%s()×%d', $method, $times); + } + + return \sprintf('Double `%s` verified (%s)', $state->label(), \implode(', ', $parts)); + } + + /** + * Append the fulfilled records to the current test's assertion history. * * No-op without the Assert plugin (the {@see \class_exists()} guard). Runs innermost, before the - * Assert plugin reads the history, so the record lands on the current test. + * Assert plugin reads the history, so the records land on the current test. + * + * @param list $records */ - private static function reportVerifiedChecks(int $count): void + private static function pushHistory(array $records): void { if (!\class_exists(StaticState::class)) { return; } $state = StaticState::current(); - $state === null or $state->history[] = new ExpectationFulfilled( - \sprintf('%d Double %s verified', $count, $count === 1 ? 'check was' : 'checks were'), - '', - ); + if ($state === null) { + return; + } + + foreach ($records as $record) { + $state->history[] = $record; + } } /** @@ -105,6 +197,41 @@ private static function reportFailedCheck(\Throwable $e): ?ExpectationFailed return $failure; } + /** + * Record a Double check that failed inside the test body (surfaced as `$result->failure`) as a failed + * assertion on the current test. The result is not modified: the failure is already there, and whether + * it fails the test or is absorbed by `#[ExpectException]` is the pipeline's call. + * + * No-op unless the failure is a Double check failure (see {@see self::CHECK_FAILURES}) and the Assert + * plugin is present. + */ + private static function reportBodyFailure(TestResult $result): void + { + $failure = $result->failure; + if ($failure === null || !self::isCheckFailure($failure) || !\class_exists(StaticState::class)) { + return; + } + + $state = StaticState::current(); + $state === null or $state->history[] = new ExpectationFailed( + expectation: 'the Double checks passed', + context: '', + reason: $failure->getMessage(), + details: '', + ); + } + + private static function isCheckFailure(\Throwable $failure): bool + { + foreach (self::CHECK_FAILURES as $class) { + if ($failure instanceof $class) { + return true; + } + } + + return false; + } + /** * Run the test, keeping this test's pending doubles bound to it across fiber suspensions. * diff --git a/bridge/double/tests/Feature/DoubleStatusTest.php b/bridge/double/tests/Feature/DoubleStatusTest.php index 6f94be8c..71b63e86 100644 --- a/bridge/double/tests/Feature/DoubleStatusTest.php +++ b/bridge/double/tests/Feature/DoubleStatusTest.php @@ -54,12 +54,32 @@ public function receivedVerificationCountsAsAssertion(): void Assert::same($result->status, Status::Passed); } + public function verifiedRecordNamesTheDoubleAndItsCalls(): void + { + // The fulfilled record carries the double's label and a per-method call summary, not just a count. + $result = TestRunner::runTest([DoubleScenarios::class, 'fulfilledExpectationOnly']); + Assert::string(self::successExpectation($result)) + ->contains('Countable') + ->contains('count()×1'); + } + public function doubleAndAssertCoexist(): void { $result = TestRunner::runTest([DoubleScenarios::class, 'doubleAndAssertMixed']); Assert::same($result->status, Status::Passed); } + public function bodyCheckFailureIsRecordedButResultLeftAsIs(): void + { + // A Double check that throws in the body (here: unused() on a called spy) with no #[ExpectException] + // to catch it: the bridge records the failure in the history but does not touch the result, so it + // stays the Error the runner produced from the uncaught throw. + $result = TestRunner::runTest([DoubleScenarios::class, 'bodyCheckFailsUncaught']); + Assert::same($result->status, Status::Error); + Assert::true(self::hasRecord($result, success: false)); + Assert::string(self::failReason($result))->contains('expected no calls'); + } + public function stateIsDrainedAfterAFailingTest(): void { // leavesUnmetExpectation fails on verifyAll(); seesCleanSlate runs right after it and would @@ -91,4 +111,42 @@ private static function hasRecord(TestResult $result, bool $success): bool return false; } + + /** + * The expectation text of the test's first fulfilled (success) assertion record, or '' if none. + */ + private static function successExpectation(TestResult $result): string + { + $state = $result->getAttribute(TestState::class); + if (!$state instanceof TestState) { + return ''; + } + + foreach ($state->history as $record) { + if ($record->isSuccess()) { + return $record->getExpectation(); + } + } + + return ''; + } + + /** + * The fail reason of the test's first failed (unsuccessful) assertion record, or '' if none. + */ + private static function failReason(TestResult $result): string + { + $state = $result->getAttribute(TestState::class); + if (!$state instanceof TestState) { + return ''; + } + + foreach ($state->history as $record) { + if (!$record->isSuccess()) { + return $record->getFailReason(); + } + } + + return ''; + } } diff --git a/bridge/double/tests/Self/DoubleAndAssertCombinations.php b/bridge/double/tests/Self/DoubleAndAssertCombinations.php index ea6ee54d..b8c3b5f2 100644 --- a/bridge/double/tests/Self/DoubleAndAssertCombinations.php +++ b/bridge/double/tests/Self/DoubleAndAssertCombinations.php @@ -5,7 +5,11 @@ namespace Tests\Bridge\Double\Self; use JMac\Testing\Double; +use JMac\Testing\Exceptions\ExpectationCallLimitExceededException; +use JMac\Testing\Exceptions\UnexpectedCallException; +use JMac\Testing\Exceptions\UnusedAssertionException; use Testo\Assert; +use Testo\Assert\ExpectException; use Testo\Bridge\Double\DoublePlugin; use Testo\Bridge\Double\Internal\DoubleInterceptor; use Testo\Codecov\Covers; @@ -84,4 +88,47 @@ public function doubleThrowsWithExpectException(): void Expect::exception(\RuntimeException::class)->withMessageContaining('boom'); $double->count(); } + + #[ExpectException(UnusedAssertionException::class)] + public function unusedAssertionThrowsAndIsCaught(): void + { + $double = Double::for(\Countable::class); + $double->allows('count')->returns(0); + $double->count(); + + // A Double check that fails in the test body (here: unused() on a called double) throws like any + // other exception, so #[ExpectException] catches it. Unlike an unmet expects(), which surfaces only + // from the teardown verifyAll() and leaves nothing for the attribute to see. + $double->unused(); + } + + #[ExpectException(UnexpectedCallException::class)] + public function strictUnexpectedCallThrowsAndIsCaught(): void + { + // A strict double rejects any call it was not configured for, right at the call site. + $double = Double::for(\Countable::class)->strict(); + + $double->count(); + } + + #[ExpectException(ExpectationCallLimitExceededException::class)] + public function neverExpectationExceededThrowsAndIsCaught(): void + { + // never() allows zero calls; the first call breaks the limit at the call site. + $double = Double::for(\Countable::class); + $double->expects('count')->never(); + + $double->count(); + } + + #[ExpectException(ExpectationCallLimitExceededException::class)] + public function callCountExceededThrowsAndIsCaught(): void + { + // times(1) allows a single call; the second call exceeds the limit at the call site. + $double = Double::for(\Countable::class); + $double->expects('count')->times(1)->returns(0); + + $double->count(); + $double->count(); + } } diff --git a/bridge/double/tests/Stub/DoubleScenarios.php b/bridge/double/tests/Stub/DoubleScenarios.php index 3a6f248d..6ae585bc 100644 --- a/bridge/double/tests/Stub/DoubleScenarios.php +++ b/bridge/double/tests/Stub/DoubleScenarios.php @@ -58,4 +58,15 @@ public function doubleAndAssertMixed(): void Assert::same($double->count(), 5); } + + #[Test] + public function bodyCheckFailsUncaught(): void + { + /** @var DoubleInterface&\Countable $spy */ + $spy = Double::for(\Countable::class); + $spy->allows('count')->returns(0); + $spy->count(); + + $spy->unused(); + } } From d12d3d327afaf1f73d1215ec91a5c9a5fb9f8ce6 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 26 Aug 2026 20:35:10 +0400 Subject: [PATCH 3/3] refactor(bridge-double): record checks via Double's listener API Rewire the assertion-history recording from @internal introspection to Double's public listener API (jasonmccreary/double#13, shipped in #14). The interceptor now registers one process-wide Double::listen() recorder that mirrors each CheckEvent into the current test's history the moment the check resolves. This replaces the post-verifyAll() scope snapshot, the body-failure exception matching, and its hardcoded check-failure class list, and fixes all three limitations that drove the request: - Ordering: checks are recorded live, interleaved with the test's own assertions, instead of batched at teardown. - Completeness: immediate call-time failures (unused(), a strict unexpected call, a times()/never() limit exceeded) fire events too, so they no longer have to be reconstructed from the thrown exception. - No @internal: nothing reaches into AutoVerifyScope/DoubleState anymore. The listener only records; verifyAll() still runs and its throw still flips Passed to Failed, so #[ExpectException] keeps deciding the final status. The fiber trampoline is unchanged bar the pause/resume rename. CheckEvent carries no per-call breakdown, so records drop the count()xN detail. Assisted-By: Claude Opus 4.8 (1M context) --- .../double/src/Internal/DoubleInterceptor.php | 221 ++++-------------- .../double/tests/Feature/DoubleStatusTest.php | 68 +++++- bridge/double/tests/Stub/DoubleScenarios.php | 11 + 3 files changed, 116 insertions(+), 184 deletions(-) diff --git a/bridge/double/src/Internal/DoubleInterceptor.php b/bridge/double/src/Internal/DoubleInterceptor.php index d19572da..91d37511 100644 --- a/bridge/double/src/Internal/DoubleInterceptor.php +++ b/bridge/double/src/Internal/DoubleInterceptor.php @@ -4,16 +4,9 @@ namespace Testo\Bridge\Double\Internal; -use JMac\Testing\AutoVerifyScope; +use JMac\Testing\AutoVerifySnapshot; +use JMac\Testing\CheckEvent; use JMac\Testing\Double; -use JMac\Testing\Engine\DoubleState; -use JMac\Testing\Exceptions\ExpectationCallLimitExceededException; -use JMac\Testing\Exceptions\ExpectationCallMismatchException; -use JMac\Testing\Exceptions\OutOfOrderCallException; -use JMac\Testing\Exceptions\UnexpectedCallException; -use JMac\Testing\Exceptions\UnsatisfiedExpectationException; -use JMac\Testing\Exceptions\UnsatisfiedReceivedAssertionException; -use JMac\Testing\Exceptions\UnusedAssertionException; use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Expectation\ExpectationFailed; use Testo\Assert\State\Expectation\ExpectationFulfilled; @@ -25,18 +18,17 @@ use Testo\Pipeline\Middleware\TestRunInterceptor; /** - * Arms {@see Double::armAutoVerify()} before every test and runs {@see Double::verifyAll()} afterwards - * in a `finally` block: unmet `expects()` and deferred `received()` assertions fail the test. Before - * verifying, the bridge snapshots the pending doubles and reports one fulfilled assertion per double - * (with its recorded calls) to the Assert plugin, so a double-only test still counts as making assertions - * and its report carries what was checked. It always drains Double's global state, so an unmet expectation - * fails an otherwise-passing test while an already-failed result is left alone. + * Bridges Double's auto-verification into a Testo test. * - * A Double check that throws inside the test body (a failed `received()`, `unused()` or an unexpected - * call on a strict double) is captured by the runner into the result's failure. The bridge records it in - * the assertion history so the double's diagnostic stays visible, then passes the result through untouched: - * whether the test ends up failed or passes because `#[ExpectException]` caught that throw is decided by - * the rest of the pipeline, not here. + * Turns on {@see Double::enableAutoVerify()} before the test body and runs {@see Double::verifyAll()} + * afterwards in a `finally`: unmet `expects()` and deferred `received()` assertions are checked there, and + * a failure turns an otherwise-passing test into a failed one (an already-failed result is left alone). + * + * A {@see Double::listen()} listener mirrors every check into the Assert plugin's history the moment it + * resolves, pass or fail, immediate call-time failures included. So a double-only test still counts as + * making assertions, and the report shows what was checked in the order it happened. The listener only + * records; it never changes the result, so whether a body-thrown check failure fails the test or is + * absorbed by `#[ExpectException]` stays the rest of the pipeline's call. * * Runs innermost so the teardown fires as close as possible to the test function. * @@ -52,184 +44,69 @@ #[\Override] public function runTest(TestInfo $info, callable $next): TestResult { - # Arm before the test body runs so every double it creates is collected for verification. - Double::armAutoVerify(); + self::ensureListening(); + + # Enable before the test body runs so every double it creates is collected for verification. + Double::enableAutoVerify(); $result = null; try { $result = $this->run($info, $next); } finally { try { - # Snapshot the pending doubles before verifying so we can describe each in the test's - # assertion history; restore it verbatim so verifyAll() checks exactly the same set. - $scope = Double::captureAutoVerifyScope(); - $records = \class_exists(StaticState::class) ? self::describeVerified($scope) : []; - Double::restoreAutoVerifyScope($scope); Double::verifyAll(); - $records === [] or self::pushHistory($records); } catch (\Throwable $e) { - # Record the unmet expectation on the test, then turn it into a normal failure — an - # exception escaping here would abort the pipeline (Status::Aborted) instead. Leave an - # already-failed result alone; a null $result means $next() threw — let it propagate. - $failure = self::reportFailedCheck($e); + # The unmet expectation was already recorded by the listener. Turn it into a normal failure + # here — an exception escaping would abort the pipeline (Status::Aborted) instead. Leave an + # already-failed result alone; a null $result means $next() threw, let it propagate. $result?->status === Status::Passed and $result = $result ->with(status: Status::Failed) - ->withFailure($failure ?? $e); + ->withFailure($e); } } - # A Double check that failed in the test body arrives as the result's failure. Record it, leaving - # the result itself untouched so the pipeline still decides the final status (including a pass when - # #[ExpectException] catches that throw). - $result === null or self::reportBodyFailure($result); - return $result; } /** - * Every Double exception that represents a *check* failing, as opposed to a misuse error (calling an - * unknown or static method, misconfiguring a mode). Only these are recorded as a failed check. - */ - private const CHECK_FAILURES = [ - UnsatisfiedExpectationException::class, - UnsatisfiedReceivedAssertionException::class, - UnusedAssertionException::class, - UnexpectedCallException::class, - ExpectationCallLimitExceededException::class, - ExpectationCallMismatchException::class, - OutOfOrderCallException::class, - ]; - - /** - * Build one fulfilled-assertion record per pending double (summarising the calls it recorded) plus one - * for the deferred `received()` assertions, so a test whose only checks are Double verifications is not - * flagged as making no assertions and its report shows what was verified. - * - * Reads only from the captured snapshot, never throws, and touches no reflection — `received()` - * assertions expose no readable detail, so they collapse into a single count. - * - * @return list - */ - private static function describeVerified(AutoVerifyScope $scope): array - { - $records = []; - foreach ($scope->pending() as $state) { - $records[] = new ExpectationFulfilled(self::describeDouble($state), ''); - } - - $received = \count($scope->pendingReceived()); - $received > 0 and $records[] = new ExpectationFulfilled( - \sprintf('%d Double received %s verified', $received, $received === 1 ? 'assertion was' : 'assertions were'), - '', - ); - - return $records; - } - - /** - * A one-line summary of a single double: its label and the methods it recorded calls to, with counts. - */ - private static function describeDouble(DoubleState $state): string - { - $counts = []; - foreach ($state->calls() as $call) { - $counts[$call['method']] = ($counts[$call['method']] ?? 0) + 1; - } - - if ($counts === []) { - return \sprintf('Double `%s` verified with no recorded calls', $state->label()); - } - - $parts = []; - foreach ($counts as $method => $times) { - $parts[] = \sprintf('%s()×%d', $method, $times); - } - - return \sprintf('Double `%s` verified (%s)', $state->label(), \implode(', ', $parts)); - } - - /** - * Append the fulfilled records to the current test's assertion history. - * - * No-op without the Assert plugin (the {@see \class_exists()} guard). Runs innermost, before the - * Assert plugin reads the history, so the records land on the current test. - * - * @param list $records + * Register the check recorder with Double once per process. Double's listener registry is process-wide + * and long-lived by design, so registering per test would pile up duplicates. No-op without the Assert + * plugin: there is no history to write to, and {@see Double::verifyAll()} still fails tests on its own. */ - private static function pushHistory(array $records): void + private static function ensureListening(): void { - if (!\class_exists(StaticState::class)) { - return; - } - - $state = StaticState::current(); - if ($state === null) { + static $listening = false; + if ($listening || !\class_exists(StaticState::class)) { return; } - foreach ($records as $record) { - $state->history[] = $record; - } + $listening = true; + Double::listen(self::record(...)); } /** - * Record an unmet Double expectation as a failed assertion on the current test and return it, - * so the caller can use the same record as the test's failure. - * - * Returns null without the Assert plugin — the caller then falls back to the raw Double exception. + * Mirror one resolved Double check into the current test's assertion history: a fulfilled record when + * it passed, a failed one carrying the diagnostic when it did not. */ - private static function reportFailedCheck(\Throwable $e): ?ExpectationFailed + private static function record(CheckEvent $event): void { - if (!\class_exists(StaticState::class)) { - return null; - } - - $failure = new ExpectationFailed( - expectation: 'the Double expectations are fulfilled', - context: '', - reason: $e->getMessage(), - details: '', - ); - $state = StaticState::current(); - $state === null or $state->history[] = $failure; - - return $failure; - } - - /** - * Record a Double check that failed inside the test body (surfaced as `$result->failure`) as a failed - * assertion on the current test. The result is not modified: the failure is already there, and whether - * it fails the test or is absorbed by `#[ExpectException]` is the pipeline's call. - * - * No-op unless the failure is a Double check failure (see {@see self::CHECK_FAILURES}) and the Assert - * plugin is present. - */ - private static function reportBodyFailure(TestResult $result): void - { - $failure = $result->failure; - if ($failure === null || !self::isCheckFailure($failure) || !\class_exists(StaticState::class)) { + if ($state === null) { return; } - $state = StaticState::current(); - $state === null or $state->history[] = new ExpectationFailed( - expectation: 'the Double checks passed', - context: '', - reason: $failure->getMessage(), - details: '', - ); - } - - private static function isCheckFailure(\Throwable $failure): bool - { - foreach (self::CHECK_FAILURES as $class) { - if ($failure instanceof $class) { - return true; - } - } - - return false; + $subject = $event->method === null + ? \sprintf('Double `%s`', $event->label) + : \sprintf('Double `%s`->%s()', $event->label, $event->method); + + $state->history[] = $event->passed + ? new ExpectationFulfilled($subject . ' passed its check', '') + : new ExpectationFailed( + expectation: $subject . ' passed its check', + context: '', + reason: $event->failure?->getMessage() ?? '', + details: '', + ); } /** @@ -237,8 +114,8 @@ private static function isCheckFailure(\Throwable $failure): bool * * Double's pending doubles live in process-global state, so under concurrent (fiber-based) execution * sibling tests would sweep each other's doubles into the wrong teardown. On every suspension we park - * this test's state with {@see Double::captureAutoVerifyScope()} and hand a fresh slate to the sibling; - * on resumption we reinstall it with {@see Double::restoreAutoVerifyScope()}. + * this test's state with {@see Double::pauseAutoVerify()} and hand a fresh slate to the sibling; on + * resumption we reinstall it with {@see Double::resumeAutoVerify()}. * * @param callable(TestInfo): TestResult $next */ @@ -251,16 +128,16 @@ private function run(TestInfo $info, callable $next): TestResult $fiber = new \Fiber(static fn(): TestResult => $next($info)); $value = $fiber->start(); while (!$fiber->isTerminated()) { - $scope = Double::captureAutoVerifyScope(); + $snapshot = Double::pauseAutoVerify(); try { $resume = \Fiber::suspend($value); } catch (\Throwable $e) { - Double::restoreAutoVerifyScope($scope); + Double::resumeAutoVerify($snapshot); $value = $fiber->throw($e); continue; } - Double::restoreAutoVerifyScope($scope); + Double::resumeAutoVerify($snapshot); $value = $fiber->resume($resume); } diff --git a/bridge/double/tests/Feature/DoubleStatusTest.php b/bridge/double/tests/Feature/DoubleStatusTest.php index 71b63e86..feb20a16 100644 --- a/bridge/double/tests/Feature/DoubleStatusTest.php +++ b/bridge/double/tests/Feature/DoubleStatusTest.php @@ -54,13 +54,13 @@ public function receivedVerificationCountsAsAssertion(): void Assert::same($result->status, Status::Passed); } - public function verifiedRecordNamesTheDoubleAndItsCalls(): void + public function verifiedChecksNameTheDoubleAndMethod(): void { - // The fulfilled record carries the double's label and a per-method call summary, not just a count. - $result = TestRunner::runTest([DoubleScenarios::class, 'fulfilledExpectationOnly']); - Assert::string(self::successExpectation($result)) - ->contains('Countable') - ->contains('count()×1'); + // Each recorded check names its double, and a received() check names the method it verified. + $result = TestRunner::runTest([DoubleScenarios::class, 'receivedVerificationOnly']); + Assert::string(self::successExpectations($result)) + ->contains('Double `Countable`') + ->contains('count()'); } public function doubleAndAssertCoexist(): void @@ -69,6 +69,33 @@ public function doubleAndAssertCoexist(): void Assert::same($result->status, Status::Passed); } + public function checksAreRecordedInChronologicalOrder(): void + { + // The scenario asserts, runs a passing Double check, then asserts again. Because checks are recorded + // the moment they resolve (not batched at teardown), a plain assertion lands after the Double check + // in the history. + $result = TestRunner::runTest([DoubleScenarios::class, 'checkInterleavesWithAssertions']); + $order = self::orderedExpectations($result); + + $firstDouble = null; + foreach ($order as $i => $expectation) { + if (\str_contains($expectation, 'Double `')) { + $firstDouble = $i; + break; + } + } + Assert::true($firstDouble !== null, 'a Double check was recorded'); + + $assertionAfterDouble = false; + foreach ($order as $i => $expectation) { + if ($i > $firstDouble && !\str_contains($expectation, 'Double `')) { + $assertionAfterDouble = true; + break; + } + } + Assert::true($assertionAfterDouble, 'a plain assertion is recorded after a Double check'); + } + public function bodyCheckFailureIsRecordedButResultLeftAsIs(): void { // A Double check that throws in the body (here: unused() on a called spy) with no #[ExpectException] @@ -113,22 +140,39 @@ private static function hasRecord(TestResult $result, bool $success): bool } /** - * The expectation text of the test's first fulfilled (success) assertion record, or '' if none. + * The rendered text of every assertion record, in the order they were recorded. + * + * @return list */ - private static function successExpectation(TestResult $result): string + private static function orderedExpectations(TestResult $result): array + { + $state = $result->getAttribute(TestState::class); + if (!$state instanceof TestState) { + return []; + } + + return \array_map( + static fn(\Stringable $record): string => (string) $record, + $state->history, + ); + } + + /** + * The expectation texts of every fulfilled (success) assertion record, joined by newlines. + */ + private static function successExpectations(TestResult $result): string { $state = $result->getAttribute(TestState::class); if (!$state instanceof TestState) { return ''; } + $expectations = []; foreach ($state->history as $record) { - if ($record->isSuccess()) { - return $record->getExpectation(); - } + $record->isSuccess() and $expectations[] = (string) $record; } - return ''; + return \implode("\n", $expectations); } /** diff --git a/bridge/double/tests/Stub/DoubleScenarios.php b/bridge/double/tests/Stub/DoubleScenarios.php index 6ae585bc..1999a543 100644 --- a/bridge/double/tests/Stub/DoubleScenarios.php +++ b/bridge/double/tests/Stub/DoubleScenarios.php @@ -69,4 +69,15 @@ public function bodyCheckFailsUncaught(): void $spy->unused(); } + + #[Test] + public function checkInterleavesWithAssertions(): void + { + /** @var DoubleInterface&\Countable $spy */ + $spy = Double::for(\Countable::class); + + Assert::same(1, 1); + $spy->unused(); // passes immediately (never called), recorded here rather than at teardown + Assert::same(2, 2); + } }