Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/.release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/split-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]*'
Expand Down
14 changes: 14 additions & 0 deletions bridge/mockery/.github/workflows/close-prs.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions bridge/mockery/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
51 changes: 51 additions & 0 deletions bridge/mockery/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<p align="center">
<a href="https://github.com/php-testo/testo"><img alt="TESTO"
src="https://github.com/php-testo/.github/blob/1.x/resources/logo-full.svg?raw=true"
style="width: 2in; display: block"
/></a>
</p>

<p align="center">Mockery bridge</p>

<div align="center">

[![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)

</div>

<br />

> [!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)
51 changes: 51 additions & 0 deletions bridge/mockery/composer.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
141 changes: 141 additions & 0 deletions bridge/mockery/src/Internal/MockeryInterceptor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

declare(strict_types=1);

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: 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 innermost so the teardown fires as close as possible to the test function.
*
* @internal
* @psalm-internal Testo\Bridge\Mockery
*/
#[InterceptorOptions(
order: InterceptorOptions::ORDER_CLOSE_TO_TEST,
testType: TestType::Test,
)]
final readonly class MockeryInterceptor implements TestRunInterceptor
{
#[\Override]
public function runTest(TestInfo $info, callable $next): TestResult
{
$result = null;
try {
$result = $this->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;
}
}
38 changes: 38 additions & 0 deletions bridge/mockery/src/MockeryPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Testo\Bridge\Mockery;

use Internal\Container\Container;
use Testo\Bridge\Mockery\Internal\MockeryInterceptor;
use Testo\Common\PluginConfigurator;
use Testo\Pipeline\InterceptorCollector;

/**
* Plugin that automatically closes the Mockery container after every test.
*
* Register it in your {@see \Testo\Application\ApplicationConfig} `$plugins` list:
*
* ```php
* // testo.php
* return new ApplicationConfig(
* plugins: [new MockeryPlugin()],
* suites: [new SuiteConfig(name: 'Unit', location: ['tests/Unit'])],
* );
* ```
*
* Once registered, `\Mockery::close()` is called automatically in a `finally`
* block after each test, so you never need to add teardown boilerplate and mock
* expectations are always verified.
*
* @api
*/
final readonly class MockeryPlugin implements PluginConfigurator
{
#[\Override]
public function configure(Container $container): void
{
$container->get(InterceptorCollector::class)->addInterceptor(new MockeryInterceptor());
}
}
66 changes: 66 additions & 0 deletions bridge/mockery/tests/Acceptance/MockeryBridgeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace Tests\Bridge\Mockery\Acceptance;

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
{
/** @var MockInterface&\Countable $mock */
$mock = \Mockery::mock(\Countable::class);
$mock->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');
}
}
Loading
Loading