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 @@
+
+
+
+
+Mockery bridge
+
+
+
+[](https://php-testo.github.io)
+[](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
+```
+
+[](https://packagist.org/packages/testo/bridge-mockery)
+[](https://packagist.org/packages/testo/bridge-mockery)
+[](https://github.com/php-testo/testo/blob/1.x/LICENSE.md)
+[](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 @@
+