diff --git a/.github/.release-please-config.json b/.github/.release-please-config.json index dfa56c06..d7b1ff32 100644 --- a/.github/.release-please-config.json +++ b/.github/.release-please-config.json @@ -95,6 +95,12 @@ "include-component-in-tag": true, "changelog-path": "CHANGELOG.md" }, + "bridge/mockery": { + "package-name": "testo/bridge-mockery", + "component": "bridge-mockery", + "include-component-in-tag": true, + "changelog-path": "CHANGELOG.md" + }, "bridge/rector": { "package-name": "testo/bridge-rector", "component": "bridge-rector", diff --git a/.github/workflows/split-publish.yml b/.github/workflows/split-publish.yml index b03d2fcf..658df876 100644 --- a/.github/workflows/split-publish.yml +++ b/.github/workflows/split-publish.yml @@ -19,6 +19,7 @@ on: # yamllint disable-line rule:truthy - 'assert-[0-9]*' - 'bench-[0-9]*' - 'bridge-infection-[0-9]*' + - 'bridge-mockery-[0-9]*' - 'bridge-rector-[0-9]*' - 'bridge-symfony-console-[0-9]*' - 'codecov-[0-9]*' diff --git a/bridge/mockery/.github/workflows/close-prs.yml b/bridge/mockery/.github/workflows/close-prs.yml new file mode 100644 index 00000000..7640d59f --- /dev/null +++ b/bridge/mockery/.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/mockery/CHANGELOG.md b/bridge/mockery/CHANGELOG.md new file mode 100644 index 00000000..825c32f0 --- /dev/null +++ b/bridge/mockery/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/bridge/mockery/README.md b/bridge/mockery/README.md new file mode 100644 index 00000000..b6f9901b --- /dev/null +++ b/bridge/mockery/README.md @@ -0,0 +1,51 @@ +

+ TESTO +

+ +

Mockery 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/mockery/`. 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 + +Automatic [Mockery](https://github.com/mockery/mockery) teardown for Testo. Register `MockeryPlugin` and `\Mockery::close()` is called in a `finally` block after every test, so mock expectations are always verified and the Mockery container is cleared between tests — no per-test teardown boilerplate. + +```php +// testo.php +use Testo\Application\Config\ApplicationConfig; +use Testo\Application\Config\SuiteConfig; +use Testo\Bridge\Mockery\MockeryPlugin; + +return new ApplicationConfig( + plugins: [new MockeryPlugin()], + suites: [new SuiteConfig(name: 'Unit', location: ['tests/Unit'])], +); +``` + +## Install + +```bash +composer require --dev testo/bridge-mockery +``` + +[![PHP](https://img.shields.io/packagist/php-v/testo/bridge-mockery.svg?style=flat-square&logo=php)](https://packagist.org/packages/testo/bridge-mockery) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/testo/bridge-mockery.svg?style=flat-square&logo=packagist)](https://packagist.org/packages/testo/bridge-mockery) +[![License](https://img.shields.io/packagist/l/testo/bridge-mockery.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-mockery.svg?style=flat-square)](https://packagist.org/packages/testo/bridge-mockery/stats) diff --git a/bridge/mockery/composer.json b/bridge/mockery/composer.json new file mode 100644 index 00000000..673d7eb8 --- /dev/null +++ b/bridge/mockery/composer.json @@ -0,0 +1,51 @@ +{ + "name": "testo/bridge-mockery", + "description": "Mockery bridge for the Testo testing framework.", + "license": "BSD-3-Clause", + "type": "library", + "keywords": [ + "testo", + "mockery", + "mock", + "testing" + ], + "authors": [ + { + "name": "Aleksei Gagarin (roxblnfk)", + "homepage": "https://github.com/roxblnfk" + } + ], + "funding": [ + { + "type": "boosty", + "url": "https://boosty.to/roxblnfk" + } + ], + "require": { + "php": ">=8.2", + "mockery/mockery": "^1.6", + "testo/testo": "0.10.33 - 1" + }, + "require-dev": { + "testo/assert": "^0.1.10", + "testo/codecov": "^0.1.11", + "testo/test": "^0.1.6" + }, + "autoload": { + "psr-4": { + "Testo\\Bridge\\Mockery\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\Bridge\\Mockery\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + } +} diff --git a/bridge/mockery/src/Internal/MockeryInterceptor.php b/bridge/mockery/src/Internal/MockeryInterceptor.php new file mode 100644 index 00000000..ff2a0d04 --- /dev/null +++ b/bridge/mockery/src/Internal/MockeryInterceptor.php @@ -0,0 +1,141 @@ +run($info, $next); + } finally { + # close() clears the container, so read the count first. + $verified = \Mockery::getContainer()->mockery_getExpectationCount(); + try { + \Mockery::close(); + $verified > 0 and self::reportVerifiedExpectations($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::reportFailedExpectation($e); + $result?->status === Status::Passed and $result = $result + ->with(status: Status::Failed) + ->withFailure($failure ?? $e); + } + } + + return $result; + } + + /** + * Record the `$count` verified Mockery expectations as one fulfilled assertion on the current test. + * + * 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 reportVerifiedExpectations(int $count): void + { + if (!\class_exists(StaticState::class)) { + return; + } + + $state = StaticState::current(); + $state === null or $state->history[] = new ExpectationFulfilled( + \sprintf('%d Mockery %s fulfilled', $count, $count === 1 ? 'expectation was' : 'expectations were'), + '', + ); + } + + /** + * Record an unmet Mockery 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 (mirrors {@see \Testo\Assert\Internal\Expectation\NotLeaks}). + * + * Returns null without the Assert plugin — the caller then falls back to the raw Mockery exception. + */ + private static function reportFailedExpectation(\Throwable $e): ?ExpectationFailed + { + if (!\class_exists(StaticState::class)) { + return null; + } + + $failure = new ExpectationFailed( + expectation: 'the Mockery expectations are fulfilled', + context: '', + reason: $e->getMessage(), + details: '', + ); + + $state = StaticState::current(); + $state === null or $state->history[] = $failure; + + return $failure; + } + + /** + * Run the test, keeping the global Mockery container bound to it across fiber suspensions. + * + * The container is process-global static state, so under concurrent (fiber-based) execution + * sibling tests would clobber each other's mocks. On every suspension we save this test's + * container and reset the global one; on resumption we restore it — the same scope-guarding + * dance as {@see \Testo\Application\Internal\MessengerHub::scope()}. + * + * @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()) { + $container = \Mockery::getContainer(); + \Mockery::resetContainer(); + try { + $resume = \Fiber::suspend($value); + } catch (\Throwable $e) { + \Mockery::setContainer($container); + $value = $fiber->throw($e); + continue; + } + + \Mockery::setContainer($container); + $value = $fiber->resume($resume); + } + + /** @var TestResult $result */ + $result = $fiber->getReturn(); + return $result; + } +} diff --git a/bridge/mockery/src/MockeryPlugin.php b/bridge/mockery/src/MockeryPlugin.php new file mode 100644 index 00000000..0f3d20e3 --- /dev/null +++ b/bridge/mockery/src/MockeryPlugin.php @@ -0,0 +1,38 @@ +get(InterceptorCollector::class)->addInterceptor(new MockeryInterceptor()); + } +} diff --git a/bridge/mockery/tests/Acceptance/MockeryBridgeTest.php b/bridge/mockery/tests/Acceptance/MockeryBridgeTest.php new file mode 100644 index 00000000..3a4e465b --- /dev/null +++ b/bridge/mockery/tests/Acceptance/MockeryBridgeTest.php @@ -0,0 +1,66 @@ +expects('count')->once()->andReturn(7); + + Assert::same($mock->count(), 7); + } + + public function mockExpectationCountsAsAssertion(): void + { + /** @var MockInterface&\Countable $mock */ + $mock = \Mockery::mock(\Countable::class); + $mock->expects('count')->twice()->andReturn(2); + + $mock->count(); + $mock->count(); + } + + public function spyRecordsCallsWithoutStrictExpectations(): void + { + /** @var MockInterface&\Countable $spy */ + $spy = \Mockery::spy(\Countable::class); + $spy->allows('count')->andReturn(3); + + Assert::same($spy->count(), 3); + + $spy->shouldHaveReceived('count')->once(); + } + + public function mockContainerIsClearedBetweenTests(): void + { + // A non-zero count would mean the previous tests' expectations survived — i.e. close() never ran. + Assert::same(\Mockery::getContainer()->mockery_getExpectationCount(), 0); + + /** @var MockInterface&\Stringable $mock */ + $mock = \Mockery::mock(\Stringable::class); + $mock->allows('__toString')->andReturn('clean slate'); + + Assert::same((string) $mock, 'clean slate'); + } +} diff --git a/bridge/mockery/tests/Feature/MockeryStatusTest.php b/bridge/mockery/tests/Feature/MockeryStatusTest.php new file mode 100644 index 00000000..8bb3f5dc --- /dev/null +++ b/bridge/mockery/tests/Feature/MockeryStatusTest.php @@ -0,0 +1,93 @@ +status, Status::Passed); + Assert::true(self::hasRecord($result, success: true)); + } + + public function unfulfilledExpectationFailsTheTest(): void + { + $result = TestRunner::runTest([MockeryScenarios::class, 'unfulfilledExpectation']); + Assert::same($result->status, Status::Failed); + Assert::true(self::hasRecord($result, success: false)); + } + + public function noMocksNoAssertionsStaysRisky(): void + { + $result = TestRunner::runTest([MockeryScenarios::class, 'noMocksNoAssertions']); + Assert::same($result->status, Status::Risky); + } + + public function spyVerificationCountsAsAssertion(): void + { + $result = TestRunner::runTest([MockeryScenarios::class, 'spyVerificationOnly']); + Assert::same($result->status, Status::Passed); + } + + public function mockAndAssertCoexist(): void + { + $result = TestRunner::runTest([MockeryScenarios::class, 'mockAndAssertMixed']); + Assert::same($result->status, Status::Passed); + } + + public function stateIsResetAfterAFailingTest(): void + { + // leavesUnmetExpectation fails on close(); seesCleanContainer runs right after it and would + // fail too if that close() had not cleared the container. Both being reported as expected + // proves the reset happens on the failure path, not only when a test passes. + $failed = TestRunner::runTest([MockeryResetScenarios::class, 'leavesUnmetExpectation']); + Assert::same($failed->status, Status::Failed); + + $next = TestRunner::runTest([MockeryResetScenarios::class, 'seesCleanContainer']); + 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 mock 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/mockery/tests/Self/MockAndAssertCombinations.php b/bridge/mockery/tests/Self/MockAndAssertCombinations.php new file mode 100644 index 00000000..820fcfee --- /dev/null +++ b/bridge/mockery/tests/Self/MockAndAssertCombinations.php @@ -0,0 +1,85 @@ +expects('count')->once()->andReturn(3); + + $mock->count(); + } + + public function mockThenAssert(): void + { + $mock = \Mockery::mock(\Countable::class); + $mock->expects('count')->once()->andReturn(9); + + Assert::same($mock->count(), 9); + Assert::instanceOf($mock, \Countable::class); + } + + public function multipleMocksAndAsserts(): void + { + $counter = \Mockery::mock(\Countable::class); + $counter->expects('count')->twice()->andReturn(1); + $text = \Mockery::mock(\Stringable::class); + $text->expects('__toString')->once()->andReturn('x'); + + Assert::same($counter->count(), 1); + $counter->count(); + Assert::same((string) $text, 'x'); + } + + public function spyWithAssert(): void + { + $spy = \Mockery::spy(\Countable::class); + $spy->allows('count')->andReturn(7); + + Assert::same($spy->count(), 7); + + $spy->shouldHaveReceived('count')->once(); + } + + public function looseMockWithAssert(): void + { + $mock = \Mockery::mock(\Countable::class); + $mock->allows('count')->andReturn(0); + + Assert::same($mock->count(), 0); + } + + public function mockThrowsWithExpectException(): void + { + $mock = \Mockery::mock(\Countable::class); + $mock->expects('count')->once()->andThrow(new \RuntimeException('boom')); + + Expect::exception(\RuntimeException::class)->withMessageContaining('boom'); + $mock->count(); + } +} diff --git a/bridge/mockery/tests/Stub/MockeryResetScenarios.php b/bridge/mockery/tests/Stub/MockeryResetScenarios.php new file mode 100644 index 00000000..5b8234ee --- /dev/null +++ b/bridge/mockery/tests/Stub/MockeryResetScenarios.php @@ -0,0 +1,29 @@ +expects('count')->once(); + } + + #[Test] + public function seesCleanContainer(): void + { + Assert::same(\Mockery::getContainer()->mockery_getExpectationCount(), 0); + } +} diff --git a/bridge/mockery/tests/Stub/MockeryScenarios.php b/bridge/mockery/tests/Stub/MockeryScenarios.php new file mode 100644 index 00000000..0f336373 --- /dev/null +++ b/bridge/mockery/tests/Stub/MockeryScenarios.php @@ -0,0 +1,59 @@ +expects('count')->once()->andReturn(1); + + $mock->count(); + } + + #[Test] + public function unfulfilledExpectation(): void + { + /** @var MockInterface&\Countable $mock */ + $mock = \Mockery::mock(\Countable::class); + $mock->expects('count')->once(); + } + + #[Test] + public function noMocksNoAssertions(): void {} + + #[Test] + public function spyVerificationOnly(): void + { + /** @var MockInterface&\Countable $spy */ + $spy = \Mockery::spy(\Countable::class); + + $spy->count(); + + $spy->shouldHaveReceived('count')->once(); + } + + #[Test] + public function mockAndAssertMixed(): void + { + /** @var MockInterface&\Countable $mock */ + $mock = \Mockery::mock(\Countable::class); + $mock->expects('count')->once()->andReturn(5); + + Assert::same($mock->count(), 5); + } +} diff --git a/bridge/mockery/tests/suites.php b/bridge/mockery/tests/suites.php new file mode 100644 index 00000000..e337d04e --- /dev/null +++ b/bridge/mockery/tests/suites.php @@ -0,0 +1,33 @@ +