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..4d4d7544 --- /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": "dev-master", + "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..91d37511 --- /dev/null +++ b/bridge/double/src/Internal/DoubleInterceptor.php @@ -0,0 +1,148 @@ +run($info, $next); + } finally { + try { + Double::verifyAll(); + } catch (\Throwable $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($e); + } + } + + return $result; + } + + /** + * 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 ensureListening(): void + { + static $listening = false; + if ($listening || !\class_exists(StaticState::class)) { + return; + } + + $listening = true; + Double::listen(self::record(...)); + } + + /** + * 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 record(CheckEvent $event): void + { + $state = StaticState::current(); + if ($state === null) { + return; + } + + $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: '', + ); + } + + /** + * 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::pauseAutoVerify()} and hand a fresh slate to the sibling; on + * resumption we reinstall it with {@see Double::resumeAutoVerify()}. + * + * @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()) { + $snapshot = Double::pauseAutoVerify(); + try { + $resume = \Fiber::suspend($value); + } catch (\Throwable $e) { + Double::resumeAutoVerify($snapshot); + $value = $fiber->throw($e); + continue; + } + + Double::resumeAutoVerify($snapshot); + $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..feb20a16 --- /dev/null +++ b/bridge/double/tests/Feature/DoubleStatusTest.php @@ -0,0 +1,196 @@ +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 verifiedChecksNameTheDoubleAndMethod(): void + { + // 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 + { + $result = TestRunner::runTest([DoubleScenarios::class, 'doubleAndAssertMixed']); + 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] + // 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 + // 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; + } + + /** + * The rendered text of every assertion record, in the order they were recorded. + * + * @return list + */ + 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) { + $record->isSuccess() and $expectations[] = (string) $record; + } + + return \implode("\n", $expectations); + } + + /** + * 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 new file mode 100644 index 00000000..b8c3b5f2 --- /dev/null +++ b/bridge/double/tests/Self/DoubleAndAssertCombinations.php @@ -0,0 +1,134 @@ +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(); + } + + #[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/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..1999a543 --- /dev/null +++ b/bridge/double/tests/Stub/DoubleScenarios.php @@ -0,0 +1,83 @@ +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); + } + + #[Test] + public function bodyCheckFailsUncaught(): void + { + /** @var DoubleInterface&\Countable $spy */ + $spy = Double::for(\Countable::class); + $spy->allows('count')->returns(0); + $spy->count(); + + $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); + } +} 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 @@ +