Skip to content
Draft
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
14 changes: 14 additions & 0 deletions bridge/double/.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
3 changes: 3 additions & 0 deletions bridge/double/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Changelog

## Changelog
62 changes: 62 additions & 0 deletions bridge/double/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<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">Double 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/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)
55 changes: 55 additions & 0 deletions bridge/double/composer.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
37 changes: 37 additions & 0 deletions bridge/double/src/DoublePlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Testo\Bridge\Double;

use Internal\Container\Container;
use Testo\Bridge\Double\Internal\DoubleInterceptor;
use Testo\Common\PluginConfigurator;
use Testo\Pipeline\InterceptorCollector;

/**
* Plugin that automatically verifies every {@see \JMac\Testing\Double} created during a test.
*
* Register it in your {@see \Testo\Application\ApplicationConfig} `$plugins` list:
*
* ```php
* // testo.php
* return new ApplicationConfig(
* plugins: [new DoublePlugin()],
* suites: [new SuiteConfig(name: 'Unit', location: ['tests/Unit'])],
* );
* ```
*
* Once registered, `Double::verifyAll()` runs after every test, so unmet `expects()` and
* `received()` assertions fail the test with no per-test `verify()` teardown boilerplate.
*
* @api
*/
final readonly class DoublePlugin implements PluginConfigurator
{
#[\Override]
public function configure(Container $container): void
{
$container->get(InterceptorCollector::class)->addInterceptor(new DoubleInterceptor());
}
}
148 changes: 148 additions & 0 deletions bridge/double/src/Internal/DoubleInterceptor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

declare(strict_types=1);

namespace Testo\Bridge\Double\Internal;

use JMac\Testing\AutoVerifySnapshot;
use JMac\Testing\CheckEvent;
use JMac\Testing\Double;
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;

/**
* Bridges Double's auto-verification into a Testo test.
*
* Turns on {@see Double::enableAutoVerify()} before the test body and runs {@see Double::verifyAll()}
* afterwards in a `finally`: unmet `expects()` and deferred `received()` assertions are checked there, and
* a failure turns an otherwise-passing test into a failed one (an already-failed result is left alone).
*
* A {@see Double::listen()} listener mirrors every check into the Assert plugin's history the moment it
* resolves, pass or fail, immediate call-time failures included. So a double-only test still counts as
* making assertions, and the report shows what was checked in the order it happened. The listener only
* records; it never changes the result, so whether a body-thrown check failure fails the test or is
* absorbed by `#[ExpectException]` stays the rest of the pipeline's call.
*
* Runs innermost so the teardown fires as close as possible to the test function.
*
* @internal
* @psalm-internal Testo\Bridge\Double
*/
#[InterceptorOptions(
order: InterceptorOptions::ORDER_CLOSE_TO_TEST,
testType: TestType::Test,
)]
final readonly class DoubleInterceptor implements TestRunInterceptor
{
#[\Override]
public function runTest(TestInfo $info, callable $next): TestResult
{
self::ensureListening();

# Enable before the test body runs so every double it creates is collected for verification.
Double::enableAutoVerify();

$result = null;
try {

Check warning on line 53 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "UnwrapFinally": @@ @@ Double::enableAutoVerify(); $result = null; + $result = $this->run($info, $next); try { - $result = $this->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); - } + 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;

Check warning on line 53 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "UnwrapFinally": @@ @@ Double::enableAutoVerify(); $result = null; + $result = $this->run($info, $next); try { - $result = $this->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); - } + 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;
$result = $this->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

Check warning on line 62 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "NullSafePropertyCall": @@ @@ # 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 + $result->status === Status::Passed and $result = $result ->with(status: Status::Failed) ->withFailure($e); }

Check warning on line 62 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "NullSafePropertyCall": @@ @@ # 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 + $result->status === Status::Passed and $result = $result ->with(status: Status::Failed) ->withFailure($e); }
->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', '')

Check warning on line 103 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ : \sprintf('Double `%s`->%s()', $event->label, $event->method); $state->history[] = $event->passed - ? new ExpectationFulfilled($subject . ' passed its check', '') + ? new ExpectationFulfilled($subject, '') : new ExpectationFailed( expectation: $subject . ' passed its check', context: '',

Check warning on line 103 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "Concat": @@ @@ : \sprintf('Double `%s`->%s()', $event->label, $event->method); $state->history[] = $event->passed - ? new ExpectationFulfilled($subject . ' passed its check', '') + ? new ExpectationFulfilled(' passed its check' . $subject, '') : new ExpectationFailed( expectation: $subject . ' passed its check', context: '',

Check warning on line 103 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ : \sprintf('Double `%s`->%s()', $event->label, $event->method); $state->history[] = $event->passed - ? new ExpectationFulfilled($subject . ' passed its check', '') + ? new ExpectationFulfilled($subject, '') : new ExpectationFailed( expectation: $subject . ' passed its check', context: '',

Check warning on line 103 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "Concat": @@ @@ : \sprintf('Double `%s`->%s()', $event->label, $event->method); $state->history[] = $event->passed - ? new ExpectationFulfilled($subject . ' passed its check', '') + ? new ExpectationFulfilled(' passed its check' . $subject, '') : new ExpectationFailed( expectation: $subject . ' passed its check', context: '',
: new ExpectationFailed(
expectation: $subject . ' passed its check',

Check warning on line 105 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ $state->history[] = $event->passed ? new ExpectationFulfilled($subject . ' passed its check', '') : new ExpectationFailed( - expectation: $subject . ' passed its check', + expectation: $subject, context: '', reason: $event->failure?->getMessage() ?? '', details: '',

Check warning on line 105 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ $state->history[] = $event->passed ? new ExpectationFulfilled($subject . ' passed its check', '') : new ExpectationFailed( - expectation: $subject . ' passed its check', + expectation: ' passed its check', context: '', reason: $event->failure?->getMessage() ?? '', details: '',

Check warning on line 105 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "Concat": @@ @@ $state->history[] = $event->passed ? new ExpectationFulfilled($subject . ' passed its check', '') : new ExpectationFailed( - expectation: $subject . ' passed its check', + expectation: ' passed its check' . $subject, context: '', reason: $event->failure?->getMessage() ?? '', details: '',

Check warning on line 105 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ $state->history[] = $event->passed ? new ExpectationFulfilled($subject . ' passed its check', '') : new ExpectationFailed( - expectation: $subject . ' passed its check', + expectation: $subject, context: '', reason: $event->failure?->getMessage() ?? '', details: '',

Check warning on line 105 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ $state->history[] = $event->passed ? new ExpectationFulfilled($subject . ' passed its check', '') : new ExpectationFailed( - expectation: $subject . ' passed its check', + expectation: ' passed its check', context: '', reason: $event->failure?->getMessage() ?? '', details: '',

Check warning on line 105 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "Concat": @@ @@ $state->history[] = $event->passed ? new ExpectationFulfilled($subject . ' passed its check', '') : new ExpectationFailed( - expectation: $subject . ' passed its check', + expectation: ' passed its check' . $subject, context: '', reason: $event->failure?->getMessage() ?? '', details: '',
context: '',
reason: $event->failure?->getMessage() ?? '',

Check warning on line 107 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "NullSafeMethodCall": @@ @@ : new ExpectationFailed( expectation: $subject . ' passed its check', context: '', - reason: $event->failure?->getMessage() ?? '', + reason: $event->failure->getMessage() ?? '', details: '', ); }

Check warning on line 107 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "NullSafeMethodCall": @@ @@ : new ExpectationFailed( expectation: $subject . ' passed its check', context: '', - reason: $event->failure?->getMessage() ?? '', + reason: $event->failure->getMessage() ?? '', details: '', ); }
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);

Check warning on line 125 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ private function run(TestInfo $info, callable $next): TestResult { if (\Fiber::getCurrent() === null) { - return $next($info); + } $fiber = new \Fiber(static fn(): TestResult => $next($info));

Check warning on line 125 in bridge/double/src/Internal/DoubleInterceptor.php

View workflow job for this annotation

GitHub Actions / Infection PHP8.4

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ private function run(TestInfo $info, callable $next): TestResult { if (\Fiber::getCurrent() === null) { - return $next($info); + } $fiber = new \Fiber(static fn(): TestResult => $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;
}
}
55 changes: 55 additions & 0 deletions bridge/double/tests/Acceptance/DoubleBridgeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace Tests\Bridge\Double\Acceptance;

use JMac\Testing\Double;
use JMac\Testing\DoubleInterface;
use Testo\Assert;
use Testo\Bridge\Double\DoublePlugin;
use Testo\Bridge\Double\Internal\DoubleInterceptor;
use Testo\Codecov\Covers;
use Testo\Test;

/**
* Acceptance tests for {@see DoublePlugin}. The suite registers the plugin
* (see `bridge/double/tests/suites.php`), so `Double::verifyAll()` fires after
* every test. Assertions therefore depend on the plugin doing its job:
* expectations are verified on teardown with no per-test `verify()` call.
*/
#[Test]
#[Covers(DoublePlugin::class)]
#[Covers(DoubleInterceptor::class)]
final class DoubleBridgeTest
{
public function doubleCreatedAndExpectationFulfilled(): void
{
/** @var DoubleInterface&\Countable $double */
$double = Double::for(\Countable::class);
$double->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);
}
}
Loading
Loading