From e4dbc055908eac729d5e4bc7df98e930ed49dede Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Thu, 2 Jul 2026 23:16:49 +0100 Subject: [PATCH 1/4] feat(bridge): add Mockery bridge (testo/bridge-mockery) Closes #41 Implements MockeryPlugin + MockeryInterceptor to call Mockery::close() in a try/finally after every test, ensuring mock expectations are always verified and the Mockery container is cleared between tests. --- bridge/mockery/CHANGELOG.md | 7 +++ bridge/mockery/composer.json | 41 ++++++++++++++++ .../src/Internal/MockeryInterceptor.php | 40 ++++++++++++++++ bridge/mockery/src/MockeryPlugin.php | 38 +++++++++++++++ .../tests/Acceptance/MockeryBridgeTest.php | 47 +++++++++++++++++++ bridge/mockery/tests/suites.php | 15 ++++++ composer.json | 4 ++ 7 files changed, 192 insertions(+) create mode 100644 bridge/mockery/CHANGELOG.md create mode 100644 bridge/mockery/composer.json create mode 100644 bridge/mockery/src/Internal/MockeryInterceptor.php create mode 100644 bridge/mockery/src/MockeryPlugin.php create mode 100644 bridge/mockery/tests/Acceptance/MockeryBridgeTest.php create mode 100644 bridge/mockery/tests/suites.php diff --git a/bridge/mockery/CHANGELOG.md b/bridge/mockery/CHANGELOG.md new file mode 100644 index 0000000..f9a1c1e --- /dev/null +++ b/bridge/mockery/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## [0.1.0] — unreleased + +### Features + +* Initial release: automatic `Mockery::close()` teardown via `MockeryPlugin` / `MockeryInterceptor`. diff --git a/bridge/mockery/composer.json b/bridge/mockery/composer.json new file mode 100644 index 0000000..6aa522d --- /dev/null +++ b/bridge/mockery/composer.json @@ -0,0 +1,41 @@ +{ + "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" + }, + "autoload": { + "psr-4": { + "Testo\\Bridge\\Mockery\\": "src/" + } + }, + "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 0000000..3f27baa --- /dev/null +++ b/bridge/mockery/src/Internal/MockeryInterceptor.php @@ -0,0 +1,40 @@ +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 0000000..4b427fb --- /dev/null +++ b/bridge/mockery/tests/Acceptance/MockeryBridgeTest.php @@ -0,0 +1,47 @@ +expects('count')->once()->andReturn(7); + + Assert::same($mock->count(), 7); + // MockeryPlugin calls Mockery::close() after this test, + // which verifies the expectation was met. + } + + public function spyRecordsCallsWithoutStrictExpectations(): void + { + /** @var MockInterface&\Countable $spy */ + $spy = Mockery::spy(\Countable::class); + + $spy->count(); + + $spy->shouldHaveReceived('count')->once(); + } + + public function mockContainerIsClearedBetweenTests(): void + { + // If close() had not been called after the previous test, its unfulfilled + // expectation would surface here as a late Mockery error. Reaching this + // assertion cleanly confirms the container was reset. + /** @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/suites.php b/bridge/mockery/tests/suites.php new file mode 100644 index 0000000..ab27ab4 --- /dev/null +++ b/bridge/mockery/tests/suites.php @@ -0,0 +1,15 @@ + Date: Fri, 3 Jul 2026 12:42:30 +0400 Subject: [PATCH 2/4] chore(bridge-mockery): wire release-please, mirror scaffolding, and tests - register the package in release-please (config, version.json seed, split-publish tag) - add mirror README and close-prs workflow to match the other bridges - reduce CHANGELOG to a stub so release-please manages it - wire the acceptance suite into testo.php and register MockeryPlugin on it - strengthen the acceptance test: #[Covers] + assert the container is reset, and drop the risky spy test by adding a real assertion - align the composer `suggest` wording with the infection entry Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/.release-please-config.json | 6 +++ .github/workflows/split-publish.yml | 1 + .../mockery/.github/workflows/close-prs.yml | 14 +++++ bridge/mockery/CHANGELOG.md | 6 --- bridge/mockery/README.md | 51 +++++++++++++++++++ .../tests/Acceptance/MockeryBridgeTest.php | 21 ++++++-- bridge/mockery/tests/suites.php | 3 ++ composer.json | 2 +- resources/version.json | 1 + testo.php | 2 + 10 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 bridge/mockery/.github/workflows/close-prs.yml create mode 100644 bridge/mockery/README.md diff --git a/.github/.release-please-config.json b/.github/.release-please-config.json index dfa56c0..d7b1ff3 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 b03d2fc..658df87 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 0000000..7640d59 --- /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 index f9a1c1e..825c32f 100644 --- a/bridge/mockery/CHANGELOG.md +++ b/bridge/mockery/CHANGELOG.md @@ -1,7 +1 @@ # Changelog - -## [0.1.0] — unreleased - -### Features - -* Initial release: automatic `Mockery::close()` teardown via `MockeryPlugin` / `MockeryInterceptor`. diff --git a/bridge/mockery/README.md b/bridge/mockery/README.md new file mode 100644 index 0000000..b6f9901 --- /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/tests/Acceptance/MockeryBridgeTest.php b/bridge/mockery/tests/Acceptance/MockeryBridgeTest.php index 4b427fb..4e871e9 100644 --- a/bridge/mockery/tests/Acceptance/MockeryBridgeTest.php +++ b/bridge/mockery/tests/Acceptance/MockeryBridgeTest.php @@ -7,9 +7,19 @@ use Mockery; use Mockery\MockInterface; use Testo\Assert; +use Testo\Bridge\Mockery\Internal\MockeryInterceptor; +use Testo\Bridge\Mockery\MockeryPlugin; +use Testo\Codecov\Covers; use Testo\Test; +/** + * Acceptance tests for {@see MockeryPlugin}. The suite registers the plugin + * (see `bridge/mockery/tests/suites.php`), so `\Mockery::close()` fires after + * every test. Assertions therefore depend on the plugin doing its job: + * expectations are verified on teardown and the container is reset between tests. + */ #[Test] +#[Covers(MockeryPlugin::class, MockeryInterceptor::class)] final class MockeryBridgeTest { public function mockCreatedAndExpectationFulfilled(): void @@ -27,17 +37,20 @@ public function spyRecordsCallsWithoutStrictExpectations(): void { /** @var MockInterface&\Countable $spy */ $spy = Mockery::spy(\Countable::class); + $spy->allows('count')->andReturn(3); - $spy->count(); + Assert::same($spy->count(), 3); $spy->shouldHaveReceived('count')->once(); } public function mockContainerIsClearedBetweenTests(): void { - // If close() had not been called after the previous test, its unfulfilled - // expectation would surface here as a late Mockery error. Reaching this - // assertion cleanly confirms the container was reset. + // The previous tests registered expectations on their mocks. If the plugin + // had not called Mockery::close() after each of them, those expectations + // would still live in the container now. A zero count proves it was reset. + Assert::same(Mockery::getContainer()->mockery_getExpectationCount(), 0); + /** @var MockInterface&\Stringable $mock */ $mock = Mockery::mock(\Stringable::class); $mock->allows('__toString')->andReturn('clean slate'); diff --git a/bridge/mockery/tests/suites.php b/bridge/mockery/tests/suites.php index ab27ab4..a564dbd 100644 --- a/bridge/mockery/tests/suites.php +++ b/bridge/mockery/tests/suites.php @@ -3,7 +3,9 @@ declare(strict_types=1); use Testo\Application\Config\FinderConfig; +use Testo\Application\Config\Plugin\SuitePlugins; use Testo\Application\Config\SuiteConfig; +use Testo\Bridge\Mockery\MockeryPlugin; return [ new SuiteConfig( @@ -11,5 +13,6 @@ location: new FinderConfig( include: [__DIR__ . '/Acceptance'], ), + plugins: SuitePlugins::with(new MockeryPlugin()), ), ]; diff --git a/composer.json b/composer.json index 9f1d33c..9ceae14 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,7 @@ }, "suggest": { "testo/bridge-infection": "Provides integration with Infection PHP mutation testing framework.", - "testo/bridge-mockery": "Provides automatic Mockery teardown (Mockery::close()) after every test.", + "testo/bridge-mockery": "Provides integration with the Mockery mocking framework.", "llm/skills": "Ship Testo's bundled AI-agent skills into your project's skills directory automatically." }, "minimum-stability": "dev", diff --git a/resources/version.json b/resources/version.json index 2f4ff53..761d9de 100644 --- a/resources/version.json +++ b/resources/version.json @@ -14,5 +14,6 @@ "plugin/facade": "0.1.1", "bridge/symfony-console": "0.1.8", "bridge/infection": "0.1.8", + "bridge/mockery": "0.1.0", "bridge/rector": "0.1.2" } diff --git a/testo.php b/testo.php index d382bae..52c3567 100644 --- a/testo.php +++ b/testo.php @@ -11,6 +11,7 @@ src: new FinderConfig( ['core', 'plugin', 'bridge'], [ + 'bridge/mockery/tests', 'bridge/rector/tests', 'bridge/symfony-console/tests', 'plugin/assert/tests', @@ -45,6 +46,7 @@ // ), // ), // ], + require 'bridge/mockery/tests/suites.php', require 'bridge/rector/tests/suites.php', require 'bridge/symfony-console/tests/suites.php', require 'plugin/assert/tests/suites.php', From c9a5310b09f0af56867eaba809c2d1d778f184e1 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 3 Jul 2026 14:00:29 +0400 Subject: [PATCH 3/4] feat(bridge-mockery): report verified and failed Mockery expectations as assertions - MockeryInterceptor records verified expectations (ExpectationFulfilled) and unmet ones (ExpectationFailed) on the current test, so a mock-only test is not Risky and an unmet expectation fails the test instead of aborting the pipeline - guard the process-global Mockery container across fiber suspensions with the same save/reset/restore dance as MessengerHub::scope - tune ExpectationsInterceptor order to ORDER_ASSERTIONS + 2000 so the Assert plugin reads the history after the bridge records into it - add Self / Feature / Stub tests covering mock+assert combinations and statuses - declare testo/assert, testo/codecov, testo/test as dev dependencies Co-Authored-By: Claude Opus 4.8 (1M context) --- bridge/mockery/composer.json | 10 ++ .../src/Internal/MockeryInterceptor.php | 117 ++++++++++++++++-- .../tests/Acceptance/MockeryBridgeTest.php | 24 ++-- .../tests/Feature/MockeryStatusTest.php | 80 ++++++++++++ .../tests/Self/MockAndAssertCombinations.php | 85 +++++++++++++ .../mockery/tests/Stub/MockeryScenarios.php | 59 +++++++++ bridge/mockery/tests/suites.php | 15 +++ .../Middleware/ExpectationsInterceptor.php | 2 +- 8 files changed, 374 insertions(+), 18 deletions(-) create mode 100644 bridge/mockery/tests/Feature/MockeryStatusTest.php create mode 100644 bridge/mockery/tests/Self/MockAndAssertCombinations.php create mode 100644 bridge/mockery/tests/Stub/MockeryScenarios.php diff --git a/bridge/mockery/composer.json b/bridge/mockery/composer.json index 6aa522d..673d7eb 100644 --- a/bridge/mockery/composer.json +++ b/bridge/mockery/composer.json @@ -26,11 +26,21 @@ "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": { diff --git a/bridge/mockery/src/Internal/MockeryInterceptor.php b/bridge/mockery/src/Internal/MockeryInterceptor.php index 3f27baa..ff2a0d0 100644 --- a/bridge/mockery/src/Internal/MockeryInterceptor.php +++ b/bridge/mockery/src/Internal/MockeryInterceptor.php @@ -5,25 +5,28 @@ namespace Testo\Bridge\Mockery\Internal; use Mockery; +use Testo\Assert\Internal\StaticState; +use Testo\Assert\State\Expectation\ExpectationFailed; +use Testo\Assert\State\Expectation\ExpectationFulfilled; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; +use Testo\Core\Value\Status; use Testo\Core\Value\TestType; use Testo\Pipeline\Attribute\InterceptorOptions; use Testo\Pipeline\Middleware\TestRunInterceptor; /** - * Calls {@see Mockery::close()} in a `finally` block after every test so that: - * - unfulfilled expectations are reported as test failures, and - * - the Mockery container is cleared between tests, preventing state leak. + * Calls {@see Mockery::close()} in a `finally` block after every test: unfulfilled expectations + * fail the test and the container is cleared between tests. Verified expectations are also reported + * to the Assert plugin as assertions, mirroring PHPUnit's `MockeryPHPUnitIntegration`. * - * Runs at {@see PHP_INT_MAX} order, placing it as the innermost interceptor - * so the teardown fires as close as possible to the actual test function. + * Runs innermost so the teardown fires as close as possible to the test function. * * @internal * @psalm-internal Testo\Bridge\Mockery */ #[InterceptorOptions( - order: PHP_INT_MAX, + order: InterceptorOptions::ORDER_CLOSE_TO_TEST, testType: TestType::Test, )] final readonly class MockeryInterceptor implements TestRunInterceptor @@ -31,10 +34,108 @@ #[\Override] public function runTest(TestInfo $info, callable $next): TestResult { + $result = null; try { - return $next($info); + $result = $this->run($info, $next); } finally { - Mockery::close(); + # 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/tests/Acceptance/MockeryBridgeTest.php b/bridge/mockery/tests/Acceptance/MockeryBridgeTest.php index 4e871e9..3a4e465 100644 --- a/bridge/mockery/tests/Acceptance/MockeryBridgeTest.php +++ b/bridge/mockery/tests/Acceptance/MockeryBridgeTest.php @@ -25,18 +25,26 @@ final class MockeryBridgeTest public function mockCreatedAndExpectationFulfilled(): void { /** @var MockInterface&\Countable $mock */ - $mock = Mockery::mock(\Countable::class); + $mock = \Mockery::mock(\Countable::class); $mock->expects('count')->once()->andReturn(7); Assert::same($mock->count(), 7); - // MockeryPlugin calls Mockery::close() after this test, - // which verifies the expectation was met. + } + + 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 = \Mockery::spy(\Countable::class); $spy->allows('count')->andReturn(3); Assert::same($spy->count(), 3); @@ -46,13 +54,11 @@ public function spyRecordsCallsWithoutStrictExpectations(): void public function mockContainerIsClearedBetweenTests(): void { - // The previous tests registered expectations on their mocks. If the plugin - // had not called Mockery::close() after each of them, those expectations - // would still live in the container now. A zero count proves it was reset. - Assert::same(Mockery::getContainer()->mockery_getExpectationCount(), 0); + // 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 = \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 0000000..2c52e36 --- /dev/null +++ b/bridge/mockery/tests/Feature/MockeryStatusTest.php @@ -0,0 +1,80 @@ +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); + } + + /** + * 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 0000000..820fcfe --- /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/MockeryScenarios.php b/bridge/mockery/tests/Stub/MockeryScenarios.php new file mode 100644 index 0000000..0f33637 --- /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 index a564dbd..e337d04 100644 --- a/bridge/mockery/tests/suites.php +++ b/bridge/mockery/tests/suites.php @@ -15,4 +15,19 @@ ), plugins: SuitePlugins::with(new MockeryPlugin()), ), + new SuiteConfig( + name: 'Bridge/Mockery/Self', + location: new FinderConfig( + include: [__DIR__ . '/Self'], + ), + plugins: SuitePlugins::with(new MockeryPlugin()), + ), + // The Feature suite drives Mockery only through the TestRunner harness (which loads + // MockeryPlugin itself via #[TestingSuite]), so it runs with the default plugin set. + new SuiteConfig( + name: 'Bridge/Mockery/Feature', + location: new FinderConfig( + include: [__DIR__ . '/Feature'], + ), + ), ]; diff --git a/plugin/assert/src/Internal/Middleware/ExpectationsInterceptor.php b/plugin/assert/src/Internal/Middleware/ExpectationsInterceptor.php index 4373572..b885586 100644 --- a/plugin/assert/src/Internal/Middleware/ExpectationsInterceptor.php +++ b/plugin/assert/src/Internal/Middleware/ExpectationsInterceptor.php @@ -20,7 +20,7 @@ * * @note Must be placed right before the test execution. */ -#[InterceptorOptions(order: 10_000, onConflict: ConflictPolicy::First)] +#[InterceptorOptions(order: InterceptorOptions::ORDER_ASSERTIONS + 2000, onConflict: ConflictPolicy::First)] final readonly class ExpectationsInterceptor implements TestRunInterceptor { /** From 2bd2773c4cbcde034b783f8efdbbb7343bd20a9f Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 3 Jul 2026 14:08:36 +0400 Subject: [PATCH 4/4] test(bridge-mockery): assert the container resets after a failing test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the failure path of the teardown: a stub that leaves an unmet expectation (Failed) is followed by one that checks the container is empty at its start (Passed). The latter only passes if close() cleared the container despite the failure — proving the interceptor both fails the right test and resets state for subsequent tests, not only when a test passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/Feature/MockeryStatusTest.php | 13 +++++++++ .../tests/Stub/MockeryResetScenarios.php | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 bridge/mockery/tests/Stub/MockeryResetScenarios.php diff --git a/bridge/mockery/tests/Feature/MockeryStatusTest.php b/bridge/mockery/tests/Feature/MockeryStatusTest.php index 2c52e36..8bb3f5d 100644 --- a/bridge/mockery/tests/Feature/MockeryStatusTest.php +++ b/bridge/mockery/tests/Feature/MockeryStatusTest.php @@ -14,6 +14,7 @@ use Testo\Test; use Testo\Testing\Attribute\TestingSuite; use Testo\Testing\Helper\TestRunner; +use Tests\Bridge\Mockery\Stub\MockeryResetScenarios; use Tests\Bridge\Mockery\Stub\MockeryScenarios; /** @@ -58,6 +59,18 @@ public function mockAndAssertCoexist(): void 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. diff --git a/bridge/mockery/tests/Stub/MockeryResetScenarios.php b/bridge/mockery/tests/Stub/MockeryResetScenarios.php new file mode 100644 index 0000000..5b8234e --- /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); + } +}