├── .github └── workflows │ └── code_analysis.yaml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpstan.neon ├── phpunit.xml.dist ├── src └── Asynchronicity │ ├── PHPUnit │ ├── Asynchronicity.php │ └── Eventually.php │ └── Polling │ ├── Clock.php │ ├── IncorrectUsage.php │ ├── Interrupted.php │ ├── Poller.php │ ├── SystemClock.php │ └── Timeout.php └── tests └── Asynchronicity ├── PHPUnit ├── EventuallyTest.php ├── FileHasBeenCreated.php └── IntegrationTest.php └── Polling ├── PollerTest.php ├── SystemClockTest.php └── TimeoutTest.php /.github/workflows/code_analysis.yaml: -------------------------------------------------------------------------------- 1 | name: Code Analysis 2 | 3 | on: 4 | pull_request: null 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | code_analysis: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | php: 15 | - "8.1" 16 | - "8.2" 17 | - "8.3" 18 | actions: 19 | - 20 | name: "PHPStan" 21 | run: vendor/bin/phpstan 22 | 23 | - 24 | name: "PHPUnit" 25 | run: vendor/bin/phpunit 26 | 27 | name: PHP${{ matrix.php }}:${{ matrix.actions.name }} 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | # see https://github.com/shivammathur/setup-php 34 | - uses: shivammathur/setup-php@v2 35 | with: 36 | php-version: ${{ matrix.php }}) 37 | coverage: none 38 | 39 | # composer install cache - https://github.com/ramsey/composer-install 40 | - uses: "ramsey/composer-install@v2" 41 | 42 | - run: ${{ matrix.actions.run }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | vendor/ 3 | .phpunit.result.cache 4 | .phpunit.cache 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Matthias Noback 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Asynchronicity 2 | 3 | Using this library you can make a test wait for certain conditions, e.g. to test the output of another process. 4 | 5 | See my [blog post on the subject](https://matthiasnoback.nl/2014/03/test-symfony2-commands-using-the-process-component-and-asynchronous-assertions/) for an explanation of the concepts and some code samples. Please note that this article covers version 1 of the library. 6 | 7 | # Usage 8 | 9 | ## With PHPUnit 10 | 11 | ```php 12 | use Asynchronicity\PHPUnit\Asynchronicity; 13 | use PHPUnit\Framework\Assert; 14 | use PHPUnit\Framework\TestCase; 15 | 16 | final class ProcessTest extends TestCase 17 | { 18 | use Asynchronicity; 19 | 20 | /** 21 | * @test 22 | */ 23 | public function it_creates_a_pid_file(): void 24 | { 25 | // start the asynchronous process that will eventually create a PID file... 26 | 27 | self::assertEventually( 28 | function (): void { 29 | Assert::assertFileExists(__DIR__ . '/pid'); 30 | } 31 | ); 32 | } 33 | } 34 | ``` 35 | 36 | ## With Behat 37 | 38 | Within a Behat `FeatureContext` you could use it for example that a page eventually contains some text: 39 | 40 | ```php 41 | use Asynchronicity\PHPUnit\Asynchronicity; 42 | use Behat\MinkExtension\Context\MinkContext; 43 | use PHPUnit\Framework\Assert; 44 | 45 | final class FeatureContext extends MinkContext 46 | { 47 | use Asynchronicity; 48 | 49 | /** 50 | * @Then the stock level has been updated to :expectedStockLevel 51 | */ 52 | public function thenTheFileHasBeenCreated(string $expectedStockLevel): void 53 | { 54 | self::assertEventually(function () use ($expectedStockLevel): void { 55 | $this->visit('/stock-levels'); 56 | 57 | $actualStockLevel = $this->getSession()->getPage())->find('css', '.stock-level')->getText(); 58 | 59 | Assert::assertEquals($expectedStockLevel, $actualStockLevel); 60 | }); 61 | } 62 | } 63 | ``` 64 | 65 | ## Comments and suggestions 66 | 67 | - You can use `$this` inside these callables. 68 | - You can add `use ($...)` to pass in extra data. 69 | - You can throw any type of exception inside the callable to indicate that what you're looking for is not yet the case. 70 | - Often it's convenient to just use the usual assertion methods (PHPUnit or otherwise) inside the callable. They will often provide the right amount of detail in their error messages too. 71 | - `assertEventually()` supports extra arguments for setting the timeout and wait time in milliseconds. 72 | - You can use any callable as the first argument to `assertEventually()`, including objects with an `__invoke()` method or something like `[$object, 'methodName']`. 73 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matthiasnoback/phpunit-asynchronicity", 3 | "description": "Library for asserting things that happen asynchronously with PHPUnit", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "PHPUnit", 8 | "asynchronicity", 9 | "assertion" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "Matthias Noback", 14 | "email": "matthiasnoback@gmail.com", 15 | "homepage": "https://matthiasnoback.nl" 16 | } 17 | ], 18 | "autoload": { 19 | "psr-4": { 20 | "Asynchronicity\\": "src/Asynchronicity/" 21 | } 22 | }, 23 | "autoload-dev": { 24 | "psr-4": { 25 | "Asynchronicity\\": "tests/Asynchronicity/" 26 | } 27 | }, 28 | "require": { 29 | "php": "^8.1" 30 | }, 31 | "require-dev": { 32 | "phpstan/phpstan": "^1.10", 33 | "phpunit/phpunit": "^10.0 || ^11.0", 34 | "ext-pcntl": "*" 35 | } 36 | } -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - src 5 | - tests 6 | ignoreErrors: [] 7 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ./tests 7 | 8 | 9 | 10 | 11 | ./ 12 | 13 | 14 | ./tests 15 | ./vendor 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Asynchronicity/PHPUnit/Asynchronicity.php: -------------------------------------------------------------------------------- 1 | timeoutMilliseconds = $timeoutMilliseconds; 21 | $this->waitMilliseconds = $waitMilliseconds; 22 | } 23 | 24 | /** 25 | * @throws Interrupted 26 | */ 27 | public function evaluate(mixed $probe, string $description = '', bool $returnResult = false): ?bool 28 | { 29 | if (!is_callable($probe)) { 30 | throw new IncorrectUsage(); 31 | } 32 | 33 | try { 34 | $poller = new Poller(); 35 | $poller->poll( 36 | $probe, 37 | new Timeout(new SystemClock(), $this->waitMilliseconds, $this->timeoutMilliseconds) 38 | ); 39 | } catch (Interrupted $exception) { 40 | if ($returnResult) { 41 | return false; 42 | } 43 | 44 | throw $exception; 45 | } 46 | 47 | return true; 48 | } 49 | 50 | protected function failureDescription(mixed $other): string 51 | { 52 | return 'the given probe was satisfied within the provided timeout'; 53 | } 54 | 55 | public function toString(): string 56 | { 57 | return 'Eventually'; 58 | } 59 | } -------------------------------------------------------------------------------- /src/Asynchronicity/Polling/Clock.php: -------------------------------------------------------------------------------- 1 | start(); 20 | $lastException = null; 21 | 22 | while (true) { 23 | try { 24 | $returnValue = $probe(); 25 | 26 | if ($returnValue !== null) { 27 | throw IncorrectUsage::theProbeShouldNotReturnAnything(); 28 | } 29 | 30 | // the probe was successful, so we can return now 31 | return; 32 | } catch (IncorrectUsage $exception) { 33 | throw $exception; 34 | } catch (Exception $exception) { 35 | // the probe was unsuccessful, we remember the last exception 36 | $lastException = $exception; 37 | } 38 | 39 | if ($timeout->hasTimedOut()) { 40 | throw new Interrupted( 41 | 'A timeout has occurred. Last exception: ' . $lastException->getMessage(), 42 | 0, 43 | $lastException 44 | ); 45 | } 46 | 47 | // we wait before trying again 48 | $timeout->wait(); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Asynchronicity/Polling/SystemClock.php: -------------------------------------------------------------------------------- 1 | clock = $clock; 46 | $this->wait = static::millisecondsToMicroseconds($wait); 47 | $this->timeout = static::millisecondsToMicroseconds($timeout); 48 | } 49 | 50 | public function start(): void 51 | { 52 | $this->timeoutAt = $this->clock->getMicrotime() + $this->timeout; 53 | } 54 | 55 | private static function millisecondsToMicroseconds(int $milliseconds): int 56 | { 57 | return 1000 * $milliseconds; 58 | } 59 | 60 | public function hasTimedOut(): bool 61 | { 62 | if ($this->timeoutAt === null) { 63 | throw new LogicException('You need to call start() first'); 64 | } 65 | 66 | $now = $this->clock->getMicrotime(); 67 | 68 | return $now >= $this->timeoutAt; 69 | } 70 | 71 | public function wait(): void 72 | { 73 | $this->clock->sleep($this->wait); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/Asynchronicity/PHPUnit/EventuallyTest.php: -------------------------------------------------------------------------------- 1 | constraint = new Eventually(100, 50); 23 | } 24 | 25 | /** 26 | * @test 27 | */ 28 | public function it_fails_when_a_timeout_occurs(): void 29 | { 30 | $this->probeAlwaysFails(); 31 | 32 | $this->assertFalse($this->constraint->evaluate($this->probe, '', true)); 33 | } 34 | 35 | /** 36 | * @test 37 | */ 38 | public function it_succeeds_when_a_timeout_has_not_occurred_and_the_probe_is_satisfied(): void 39 | { 40 | $this->probeIsSatisfied(); 41 | 42 | $this->assertTrue($this->constraint->evaluate($this->probe, '', true)); 43 | } 44 | 45 | /** 46 | * @test 47 | */ 48 | public function its_failure_message_contains_the_word_timeout(): void 49 | { 50 | $this->probeAlwaysFails(); 51 | 52 | try { 53 | $this->assertFalse($this->constraint->evaluate($this->probe)); 54 | $this->fail('Expected the constraint to fail'); 55 | } catch (Interrupted $exception) { 56 | $this->assertStringContainsString('timeout', $exception->getMessage()); 57 | } 58 | } 59 | 60 | /** 61 | * @test 62 | */ 63 | public function when_rendering_the_error_message_it_does_not_try_to_export_the_probe_itself_and_crash(): void 64 | { 65 | $this->probeAlwaysFails(); 66 | $constraint = new Eventually(10, 10); 67 | 68 | $this->expectException(Interrupted::class); 69 | $this->expectExceptionMessage('A timeout has occurred'); 70 | 71 | self::assertThat($this->probe, $constraint); 72 | } 73 | 74 | /** 75 | * @test 76 | */ 77 | public function it_fails_if_the_user_returns_false_from_the_probe(): void 78 | { 79 | $this->probeReturnsFalse(); 80 | 81 | $constraint = new Eventually(10, 10); 82 | 83 | $this->expectException(IncorrectUsage::class); 84 | $this->expectExceptionMessage('should not return anything'); 85 | 86 | self::assertThat($this->probe, $constraint); 87 | } 88 | 89 | /** 90 | * @test 91 | */ 92 | public function it_accepts_a_closure_as_probe(): void 93 | { 94 | $constraint = new Eventually(); 95 | 96 | $this->assertTrue($constraint->evaluate(function (): void { 97 | return; 98 | })); 99 | } 100 | 101 | /** 102 | * @test 103 | */ 104 | public function it_throws_an_exception_if_the_probe_is_not_callable(): void 105 | { 106 | $constraint = new Eventually(); 107 | 108 | $this->expectException(IncorrectUsage::class); 109 | $constraint->evaluate('foobar'); 110 | } 111 | 112 | /** 113 | * @test 114 | */ 115 | public function it_is_stringable(): void 116 | { 117 | $constraint = new Eventually(); 118 | 119 | $this->assertSame('Eventually', $constraint->toString()); 120 | } 121 | 122 | private function probeAlwaysFails(): void 123 | { 124 | $this->probe = function (): void { 125 | Assert::assertTrue(false, 'I am never satisfied'); 126 | }; 127 | } 128 | 129 | private function probeIsSatisfied(): void 130 | { 131 | $this->probe = function (): void { 132 | // I am always satisfied, so I don't throw an exception 133 | Assert::assertTrue(true); 134 | }; 135 | } 136 | 137 | private function probeReturnsFalse(): void 138 | { 139 | $this->probe = function (): bool { 140 | // Incorrect usage; the probe shouldn't return anything. 141 | return false; 142 | }; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /tests/Asynchronicity/PHPUnit/FileHasBeenCreated.php: -------------------------------------------------------------------------------- 1 | path = $path; 15 | } 16 | 17 | public function __invoke(): void 18 | { 19 | Assert::assertFileExists($this->path); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Asynchronicity/PHPUnit/IntegrationTest.php: -------------------------------------------------------------------------------- 1 | fail('Could not create child process'); 30 | } elseif ($pid > 0) { 31 | // the child process has been created 32 | self::assertEventually(new FileHasBeenCreated($file), $timeoutMilliseconds, $waitMilliseconds); 33 | unlink($file); 34 | pcntl_wait($status); 35 | } else { 36 | // we are the child process 37 | file_put_contents($file, 'test'); 38 | exit; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Asynchronicity/Polling/PollerTest.php: -------------------------------------------------------------------------------- 1 | clock = $this->createMock(Clock::class); 37 | $this->timeout = new Timeout($this->clock, $this->waitTimeInMilliseconds, 5000); 38 | $this->poller = new Poller(); 39 | } 40 | 41 | /** 42 | * @test 43 | */ 44 | public function it_asks_the_probe_if_it_is_satisfied_with_a_sample_and_waits_if_necessary_until_a_timeout_occurs(): void 45 | { 46 | $this->clock->expects($this->any()) 47 | ->method('getMicrotime') 48 | ->willReturn( 49 | 0, // start time 50 | 1 * 1000000, // 1 second, which is well within the configured timeout of 5000 51 | 10 * 1000000 // 10 seconds have passed, which is beyond the configured timeout of 5000 52 | ); 53 | $this->clock->expects($this->once()) 54 | ->method('sleep') 55 | ->with($this->waitTimeInMilliseconds * 1000); 56 | 57 | $this->probeIsNeverSatisfied(); 58 | 59 | $this->expectException(Interrupted::class); 60 | 61 | $this->poller->poll($this->probe, $this->timeout); 62 | } 63 | 64 | /** 65 | * @test 66 | */ 67 | public function it_is_not_interrupted_if_no_timeout_occurs_and_the_probe_was_satisfied(): void 68 | { 69 | $this->clock->expects($this->any()) 70 | ->method('getMicrotime') 71 | ->willReturn( 72 | 0, // start time: 0 73 | 1 * 1000000 // next time: 1 second, which is well within the configured timeout of 5000 74 | ); 75 | $this->clock->expects($this->once()) 76 | ->method('sleep') 77 | ->with($this->waitTimeInMilliseconds * 1000); 78 | 79 | $this->probeIsSatisfiedAtSecondRun(); 80 | 81 | $this->poller->poll($this->probe, $this->timeout); 82 | 83 | // just getting here makes the test successful 84 | $this->addToAssertionCount(1); 85 | } 86 | 87 | private function probeIsNeverSatisfied(): void 88 | { 89 | $this->probe = function () { 90 | Assert::assertTrue(false, 'I am never satisfied'); 91 | }; 92 | } 93 | 94 | private function probeIsSatisfiedAtSecondRun(): void 95 | { 96 | $isSatisfied = [false, true]; 97 | 98 | $this->probe = function () use (&$isSatisfied) { 99 | $expectedToBeSatisfied = current($isSatisfied); 100 | next($isSatisfied); 101 | 102 | Assert::assertTrue($expectedToBeSatisfied); 103 | }; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/Asynchronicity/Polling/SystemClockTest.php: -------------------------------------------------------------------------------- 1 | getMicrotime(); 19 | 20 | $difference = $actualMicrotime - $expectedMicrotime; 21 | $this->assertTrue($difference < 100); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Asynchronicity/Polling/TimeoutTest.php: -------------------------------------------------------------------------------- 1 | clock = $this->createMock(Clock::class); 34 | $this->waitMilliseconds = 10; 35 | $this->timeoutMilliseconds = 50; 36 | $this->timeout = new Timeout($this->clock, $this->waitMilliseconds, $this->timeoutMilliseconds); 37 | } 38 | 39 | /** 40 | * @test 41 | */ 42 | public function it_times_out_after_a_given_amount_of_milliseconds(): void 43 | { 44 | $initialMicrotime = 1000000000; 45 | 46 | $this->clockReturnsMicrotimes( 47 | array( 48 | $initialMicrotime, 49 | // halfway before the timeout should occur 50 | $initialMicrotime + (($this->timeoutMilliseconds * 1000) / 2), 51 | // after the timeout should have occurred 52 | $initialMicrotime + 2 * ($this->timeoutMilliseconds * 1000) 53 | ) 54 | ); 55 | 56 | $this->timeout->start(); 57 | 58 | // first time: we are halfway 59 | $this->assertFalse($this->timeout->hasTimedOut()); 60 | 61 | // second time: a timeout has occurred 62 | $this->assertTrue($this->timeout->hasTimedOut()); 63 | } 64 | 65 | /** 66 | * @test 67 | */ 68 | public function it_waits_for_the_given_amount_of_milliseconds(): void 69 | { 70 | $startTime = 1000000 * microtime(true); 71 | $this->timeout->wait(); 72 | $endTime = 1000000 * microtime(true); 73 | 74 | $difference = $endTime - $startTime; 75 | $this->assertTrue($difference < 50000); 76 | } 77 | 78 | /** 79 | * @test 80 | */ 81 | public function it_fails_when_start_is_not_called_first(): void 82 | { 83 | $this->expectException(\LogicException::class); 84 | $this->expectExceptionMessage('start()'); 85 | 86 | $this->timeout->hasTimedOut(); 87 | } 88 | 89 | /** 90 | * @param int[] $microtimes 91 | */ 92 | private function clockReturnsMicrotimes(array $microtimes): void 93 | { 94 | $this->clock 95 | ->expects($this->exactly(count($microtimes))) 96 | ->method('getMicrotime') 97 | ->willReturnOnConsecutiveCalls(...$microtimes); 98 | } 99 | } 100 | --------------------------------------------------------------------------------