├── src ├── CallbackStub.php ├── UnhandledException.php ├── TestException.php └── AsyncTestCase.php ├── .php-cs-fixer.dist.php ├── test ├── InvalidAsyncTestCaseTest.php └── AsyncTestCaseTest.php ├── phpunit.xml.dist ├── composer.json ├── README.md ├── LICENSE ├── CONTRIBUTING.md └── .github └── workflows └── ci.yml /src/CallbackStub.php: -------------------------------------------------------------------------------- 1 | getFinder() 5 | ->in(__DIR__ . '/src') 6 | ->in(__DIR__ . '/test'); 7 | 8 | $config->setCacheFile(__DIR__ . '/.php_cs.cache'); 9 | 10 | return $config; 11 | -------------------------------------------------------------------------------- /src/UnhandledException.php: -------------------------------------------------------------------------------- 1 | getMessage() 13 | ), 0, $previous); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/TestException.php: -------------------------------------------------------------------------------- 1 | fail(...). 8 | * 9 | * try { 10 | * functionCallThatShouldThrow(); 11 | * $this->fail("Expected functionCallThatShouldThrow() to throw."); 12 | * } catch (TestException $e) { 13 | * $this->assertSame($expectedExceptionInstance, $e); 14 | * } 15 | */ 16 | class TestException extends \Exception 17 | { 18 | // nothing 19 | } 20 | -------------------------------------------------------------------------------- /test/InvalidAsyncTestCaseTest.php: -------------------------------------------------------------------------------- 1 | expectException(AssertionFailedError::class); 14 | $this->expectExceptionMessage('without calling the parent method'); 15 | } 16 | 17 | public function testMethod() 18 | { 19 | // Test will fail because setUp() did not call the parent method 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | test 18 | 19 | 20 | test 21 | 22 | 23 | 24 | 25 | src 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amphp/phpunit-util", 3 | "homepage": "https://amphp.org/phpunit-util", 4 | "description": "Helper package to ease testing with PHPUnit.", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Niklas Keller", 9 | "email": "me@kelunik.com" 10 | }, 11 | { 12 | "name": "Aaron Piotrowski", 13 | "email": "aaron@trowski.com" 14 | } 15 | ], 16 | "support": { 17 | "issues": "https://github.com/amphp/phpunit-util/issues" 18 | }, 19 | "require": { 20 | "php": ">=8.1", 21 | "amphp/amp": "^3", 22 | "phpunit/phpunit": "^9", 23 | "revolt/event-loop": "^1 || ^0.2" 24 | }, 25 | "require-dev": { 26 | "amphp/php-cs-fixer-config": "^2" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Amp\\PHPUnit\\": "src" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Amp\\PHPUnit\\": "test" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # amphp/phpunit-util 2 | 3 | AMPHP is a collection of event-driven libraries for PHP designed with fibers and concurrency in mind. 4 | `amphp/phpunit-util` is a small helper package to ease testing with PHPUnit. 5 | 6 | ![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square) 7 | 8 | ## Installation 9 | 10 | This package can be installed as a [Composer](https://getcomposer.org/) dependency. 11 | 12 | ```bash 13 | composer require --dev amphp/phpunit-util 14 | ``` 15 | 16 | The package requires PHP 8.1 or later. 17 | 18 | ## Usage 19 | 20 | ```php 21 | write('foobar'); 36 | 37 | $this->assertSame('foobar', ByteStream\buffer($socket)); 38 | } 39 | } 40 | ``` 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2022 amphp (Niklas Keller, Aaron Piotrowski, and contributors) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Submitting useful bug reports 2 | 3 | Please search existing issues first to make sure this is not a duplicate. 4 | Every issue report has a cost for the developers required to field it; be 5 | respectful of others' time and ensure your report isn't spurious prior to 6 | submission. Please adhere to [sound bug reporting principles](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html). 7 | 8 | ## Development ideology 9 | 10 | Truths which we believe to be self-evident: 11 | 12 | - **It's an asynchronous world.** Be wary of anything that undermines 13 | async principles. 14 | 15 | - **The answer is not more options.** If you feel compelled to expose 16 | new preferences to the user it's very possible you've made a wrong 17 | turn somewhere. 18 | 19 | - **There are no power users.** The idea that some users "understand" 20 | concepts better than others has proven to be, for the most part, false. 21 | If anything, "power users" are more dangerous than the rest, and we 22 | should avoid exposing dangerous functionality to them. 23 | 24 | ## Code style 25 | 26 | The amphp project adheres to the [PSR-2 style guide](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md). 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | tests: 9 | strategy: 10 | matrix: 11 | include: 12 | - operating-system: 'ubuntu-latest' 13 | php-version: '8.1' 14 | 15 | - operating-system: 'windows-latest' 16 | php-version: '8.1' 17 | job-description: 'on Windows' 18 | 19 | - operating-system: 'macos-latest' 20 | php-version: '8.1' 21 | job-description: 'on macOS' 22 | 23 | name: PHP ${{ matrix.php-version }} ${{ matrix.job-description }} 24 | 25 | runs-on: ${{ matrix.operating-system }} 26 | 27 | steps: 28 | - name: Set git to use LF 29 | run: | 30 | git config --global core.autocrlf false 31 | git config --global core.eol lf 32 | 33 | - name: Checkout code 34 | uses: actions/checkout@v2 35 | 36 | - name: Setup PHP 37 | uses: shivammathur/setup-php@v2 38 | with: 39 | php-version: ${{ matrix.php-version }} 40 | extensions: fiber-amphp/ext-fiber@master 41 | 42 | - name: Get Composer cache directory 43 | id: composer-cache 44 | run: echo "::set-output name=dir::$(composer config cache-dir)" 45 | 46 | - name: Cache dependencies 47 | uses: actions/cache@v2 48 | with: 49 | path: ${{ steps.composer-cache.outputs.dir }} 50 | key: composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }}-${{ matrix.composer-flags }} 51 | restore-keys: | 52 | composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }}- 53 | composer-${{ runner.os }}-${{ matrix.php-version }}- 54 | composer-${{ runner.os }}- 55 | composer- 56 | 57 | - name: Install dependencies 58 | uses: nick-invision/retry@v2 59 | with: 60 | timeout_minutes: 5 61 | max_attempts: 5 62 | retry_wait_seconds: 30 63 | command: | 64 | composer update --optimize-autoloader --no-interaction --no-progress ${{ matrix.composer-flags }} 65 | composer info -D 66 | 67 | - name: Run tests 68 | run: vendor/bin/phpunit ${{ matrix.phpunit-flags }} 69 | 70 | - name: Run style fixer 71 | env: 72 | PHP_CS_FIXER_IGNORE_ENV: 1 73 | run: vendor/bin/php-cs-fixer --diff --dry-run -v fix 74 | -------------------------------------------------------------------------------- /test/AsyncTestCaseTest.php: -------------------------------------------------------------------------------- 1 | getFuture()->await(); 21 | self::assertSame('foobar', $data); 22 | $returnDeferred->complete(); 23 | }); 24 | 25 | EventLoop::queue(function () use ($testDeferred): void { 26 | $testDeferred->complete('foobar'); 27 | }); 28 | 29 | return $returnDeferred->getFuture(); 30 | } 31 | 32 | public function testHandleNullReturn(): void 33 | { 34 | $testDeferred = new DeferredFuture; 35 | $testData = new \stdClass; 36 | $testData->val = 0; 37 | 38 | EventLoop::defer(function () use ($testData, $testDeferred) { 39 | $testData->val++; 40 | $testDeferred->complete(); 41 | }); 42 | 43 | $testDeferred->getFuture()->await(); 44 | 45 | self::assertSame(1, $testData->val); 46 | } 47 | 48 | public function testReturningFuture(): Future 49 | { 50 | $deferred = new DeferredFuture; 51 | 52 | EventLoop::delay(0.1, fn () => $deferred->complete('value')); 53 | 54 | $returnValue = $deferred->getFuture(); 55 | $this->expectNotToPerformAssertions(); 56 | 57 | return $returnValue; // Return value used by testReturnValueFromDependentTest 58 | } 59 | 60 | public function testExpectingAnExceptionThrown(): Future 61 | { 62 | $throwException = function (): void { 63 | delay(0.1); 64 | 65 | throw new TestException('threw the error'); 66 | }; 67 | 68 | $this->expectException(TestException::class); 69 | $this->expectExceptionMessage('threw the error'); 70 | 71 | return async($throwException); 72 | } 73 | 74 | public function testExpectingAnErrorThrown(): Future 75 | { 76 | $this->expectException(\Error::class); 77 | 78 | return async(function (): void { 79 | throw new \Error; 80 | }); 81 | } 82 | 83 | public function provideArguments(): array 84 | { 85 | return [ 86 | ['foo', 42, true], 87 | ]; 88 | } 89 | 90 | /** 91 | * @dataProvider provideArguments 92 | */ 93 | public function testArgumentSupport(string $foo, int $bar, bool $baz): void 94 | { 95 | self::assertSame('foo', $foo); 96 | self::assertSame(42, $bar); 97 | self::assertTrue($baz); 98 | } 99 | 100 | /** 101 | * @depends testReturningFuture 102 | */ 103 | public function testReturnValueFromDependentTest(string $value = null): void 104 | { 105 | self::assertSame('value', $value); 106 | } 107 | 108 | public function testSetTimeout(): void 109 | { 110 | $this->setTimeout(0.5); 111 | $this->expectNotToPerformAssertions(); 112 | 113 | delay(0.25); 114 | } 115 | 116 | public function testSetTimeoutReplace(): void 117 | { 118 | $this->setTimeout(0.5); 119 | $this->setTimeout(1); 120 | 121 | delay(0.75); 122 | 123 | $this->expectNotToPerformAssertions(); 124 | } 125 | 126 | public function testSetTimeoutWithFuture(): Future 127 | { 128 | $this->setTimeout(0.1); 129 | 130 | $this->expectException(AssertionFailedError::class); 131 | $this->expectExceptionMessage('Expected test to complete before 0.100s time limit'); 132 | 133 | $deferred = new DeferredFuture; 134 | 135 | EventLoop::delay(0.2, fn () => $deferred->complete()); 136 | 137 | return $deferred->getFuture(); 138 | } 139 | 140 | public function testSetTimeoutWithAwait(): void 141 | { 142 | $this->setTimeout(0.1); 143 | 144 | $this->expectException(AssertionFailedError::class); 145 | $this->expectExceptionMessage('Expected test to complete before 0.100s time limit'); 146 | 147 | delay(0.2); 148 | } 149 | 150 | public function testSetMinimumRunTime(): void 151 | { 152 | $this->setMinimumRuntime(1); 153 | 154 | $this->expectException(AssertionFailedError::class); 155 | $this->expectExceptionMessageMatches("/Expected test to take at least 1.000s but instead took 0.\d{3}s/"); 156 | 157 | delay(0.5); 158 | } 159 | 160 | public function testCreateCallback(): void 161 | { 162 | $mock = $this->createCallback(1, fn (int $value) => $value + 1, [1]); 163 | self::assertSame(2, $mock(1)); 164 | } 165 | 166 | public function testThrowToEventLoop(): void 167 | { 168 | $this->setTimeout(0.1); 169 | 170 | EventLoop::queue(static fn () => throw new TestException('message')); 171 | 172 | $this->expectException(UnhandledException::class); 173 | $pattern = "/(.+) thrown to event loop error handler: (.*)/"; 174 | $this->expectExceptionMessageMatches($pattern); 175 | 176 | (new DeferredFuture)->getFuture()->await(); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/AsyncTestCase.php: -------------------------------------------------------------------------------- 1 | realTestName = $name; 37 | } 38 | 39 | protected function setUp(): void 40 | { 41 | $this->setUpInvoked = true; 42 | $this->deferredFuture = new DeferredFuture(); 43 | 44 | EventLoop::setErrorHandler(function (\Throwable $exception): void { 45 | if ($this->deferredFuture->isComplete()) { 46 | return; 47 | } 48 | 49 | $this->deferredFuture->error(new UnhandledException($exception)); 50 | }); 51 | } 52 | 53 | /** @internal */ 54 | final protected function runAsyncTest(mixed ...$args): mixed 55 | { 56 | if (!$this->setUpInvoked) { 57 | self::fail(\sprintf( 58 | '%s::setUp() overrides %s::setUp() without calling the parent method', 59 | \str_replace("\0", '@', static::class), // replace NUL-byte in anonymous class name 60 | self::class 61 | )); 62 | } 63 | 64 | parent::setName($this->realTestName); 65 | 66 | $start = now(); 67 | 68 | try { 69 | [, $returnValue] = Future\await([ 70 | $this->deferredFuture->getFuture(), 71 | async(function () use ($args): mixed { 72 | try { 73 | $result = ([$this, $this->realTestName])(...$args); 74 | if ($result instanceof Future) { 75 | $result = $result->await(); 76 | } 77 | 78 | // Force an extra tick of the event loop to ensure any uncaught exceptions are 79 | // forwarded to the event loop handler before the test ends. 80 | $deferred = new DeferredFuture(); 81 | EventLoop::defer(static fn () => $deferred->complete()); 82 | $deferred->getFuture()->await(); 83 | 84 | return $result; 85 | } finally { 86 | if (!$this->deferredFuture->isComplete()) { 87 | $this->deferredFuture->complete(); 88 | } 89 | } 90 | }), 91 | ]); 92 | } finally { 93 | if (isset($this->timeoutId)) { 94 | EventLoop::cancel($this->timeoutId); 95 | } 96 | 97 | \gc_collect_cycles(); // Throw from as many destructors as possible. 98 | } 99 | 100 | $end = now(); 101 | 102 | if ($this->minimumRuntime > 0) { 103 | $actualRuntime = \round($end - $start, self::RUNTIME_PRECISION); 104 | $msg = 'Expected test to take at least %0.3fs but instead took %0.3fs'; 105 | self::assertGreaterThanOrEqual( 106 | $this->minimumRuntime, 107 | $actualRuntime, 108 | \sprintf($msg, $this->minimumRuntime, $actualRuntime) 109 | ); 110 | } 111 | 112 | return $returnValue; 113 | } 114 | 115 | final protected function runTest(): mixed 116 | { 117 | parent::setName('runAsyncTest'); 118 | return parent::runTest(); 119 | } 120 | 121 | /** 122 | * Fails the test if it does not run for at least the given amount of time. 123 | * 124 | * @param float $seconds Required runtime in seconds. 125 | */ 126 | final protected function setMinimumRuntime(float $seconds): void 127 | { 128 | if ($seconds <= 0) { 129 | throw new \Error('Minimum runtime must be greater than 0, got ' . $seconds); 130 | } 131 | 132 | $this->minimumRuntime = \round($seconds, self::RUNTIME_PRECISION); 133 | } 134 | 135 | /** 136 | * Fails the test (and stops the event loop) after the given timeout. 137 | * 138 | * @param float $seconds Timeout in seconds. 139 | */ 140 | final protected function setTimeout(float $seconds): void 141 | { 142 | if (isset($this->timeoutId)) { 143 | EventLoop::cancel($this->timeoutId); 144 | } 145 | 146 | $this->timeoutId = EventLoop::delay($seconds, function () use ($seconds): void { 147 | EventLoop::setErrorHandler(null); 148 | 149 | $additionalInfo = ''; 150 | 151 | $driver = EventLoop::getDriver(); 152 | if ($driver instanceof TracingDriver) { 153 | $additionalInfo .= "\r\n\r\n" . $driver->dump(); 154 | } else { 155 | $additionalInfo .= "\r\n\r\nSet REVOLT_DEBUG_TRACE_WATCHERS=true as environment variable to trace watchers keeping the loop running."; 156 | } 157 | 158 | if ($this->deferredFuture->isComplete()) { 159 | return; 160 | } 161 | 162 | try { 163 | $this->fail(\sprintf( 164 | 'Expected test to complete before %0.3fs time limit%s', 165 | $seconds, 166 | $additionalInfo 167 | )); 168 | } catch (AssertionFailedError $e) { 169 | $this->deferredFuture->error($e); 170 | } 171 | }); 172 | 173 | EventLoop::unreference($this->timeoutId); 174 | } 175 | 176 | /** 177 | * @param int $invocationCount Number of times the callback must be invoked or the test will fail. 178 | * @param callable|null $returnCallback Callable providing a return value for the callback. 179 | * @param array $expectArgs Arguments expected to be passed to the callback. 180 | */ 181 | final protected function createCallback( 182 | int $invocationCount, 183 | ?callable $returnCallback = null, 184 | array $expectArgs = [], 185 | ): \Closure { 186 | $mock = $this->createMock(CallbackStub::class); 187 | $invocationMocker = $mock->expects(self::exactly($invocationCount)) 188 | ->method('__invoke'); 189 | 190 | if ($returnCallback) { 191 | $invocationMocker->willReturnCallback($returnCallback); 192 | } 193 | 194 | if ($expectArgs) { 195 | $invocationMocker->with(...$expectArgs); 196 | } 197 | 198 | return \Closure::fromCallable($mock); 199 | } 200 | } 201 | --------------------------------------------------------------------------------