Skip to content

Commit b9e9327

Browse files
authored
Merge pull request #6 from WyriHaximus/enforce-timeouts
Enforce timeouts
2 parents 2057515 + 05d73b5 commit b9e9327

9 files changed

+142
-25
lines changed

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,43 @@ final class SomeTest extends TestCase
4343
}
4444
```
4545

46+
## Timeouts
47+
48+
This package supports marking a test failed once a timeout has been reached. Note that this doesn't stop anything
49+
running in the fiber the rest runs in or cleans up the loop as we cannot kill the running fiber once it starts. An
50+
exception is thrown in the scope between the test and PHPUnit that handles running the test in a fiber. And this is out
51+
of control of the test.
52+
53+
```php
54+
<?php
55+
56+
declare(strict_types=1);
57+
58+
use PHPUnit\Framework\TestCase;
59+
use React\Promise\Promise;
60+
use WyriHaximus\React\PHPUnit\RunTestsInFibersTrait;
61+
use WyriHaximus\React\PHPUnit\TimeOut;
62+
63+
use function React\Async\await;
64+
65+
#[TimeOut(30)]
66+
final class SomeTest extends TestCase
67+
{
68+
use RunTestsInFibersTrait;
69+
70+
/**
71+
* @test
72+
*/
73+
#[TimeOut(0.1)]
74+
public function happyFlow()
75+
{
76+
self::assertTrue(await(new Promise(static function (callable $resolve): void {
77+
$resolve(true);
78+
})));
79+
}
80+
}
81+
```
82+
4683

4784
# License
4885

etc/qa/composer-require-checker.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"static", "self", "parent",
55
"array", "string", "int", "float", "bool", "iterable", "callable", "void", "object", "mixed",
66
"futurePromise", "WyriHaximus\\Constants\\ComposerAutoloader\\LOCATION",
7-
"WyriHaximus\\Constants\\Boolean\\FALSE_", "WyriHaximus\\Constants\\Boolean\\TRUE_"
7+
"React\\Promise\\Timer\\sleep"
88
],
99
"php-core-extensions" : [
1010
"Core",

infection.json.dist

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,18 @@
1212
"perMutator": "./var/infection-per-mutator.md"
1313
},
1414
"mutators": {
15-
"@default": true
15+
"@default": true,
16+
"PublicVisibility": {
17+
"ignore": [
18+
"WyriHaximus\\React\\PHPUnit\\RunTestsInFibersTrait::setName::25"
19+
]
20+
},
21+
"MethodCallRemoval": {
22+
"ignore": [
23+
"WyriHaximus\\React\\PHPUnit\\RunTestsInFibersTrait::setName::28",
24+
"WyriHaximus\\React\\PHPUnit\\RunTestsInFibersTrait::runAsyncTest::46"
25+
]
26+
}
1627
},
1728
"phpUnit": {
1829
"configDir": "./etc/qa/"

src/RunTestsInFibersTrait.php

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,21 @@
44

55
namespace WyriHaximus\React\PHPUnit;
66

7-
use React\EventLoop\Loop;
7+
use React\Promise\PromiseInterface;
88
use ReflectionClass;
99

1010
use function assert;
1111
use function is_string;
1212
use function React\Async\async;
1313
use function React\Async\await;
14+
use function React\Promise\race;
15+
use function React\Promise\reject;
16+
use function React\Promise\Timer\sleep;
1417

1518
trait RunTestsInFibersTrait
1619
{
20+
private const DEFAULT_TIMEOUT_SECONDS = 30;
21+
1722
private string|null $realTestName = null;
1823

1924
/** @codeCoverageIgnore Invoked before code coverage data is being collected. */
@@ -42,15 +47,15 @@ final protected function runAsyncTest(mixed ...$args): mixed
4247

4348
assert(is_string($this->realTestName));
4449

45-
$timeout = 30;
50+
$timeout = self::DEFAULT_TIMEOUT_SECONDS;
4651
$reflectionClass = new ReflectionClass($this::class);
4752
foreach ($reflectionClass->getAttributes() as $classAttribute) {
4853
$classTimeout = $classAttribute->newInstance();
49-
if (! ($classTimeout instanceof TimeOut)) {
54+
if (! ($classTimeout instanceof TimeOutInterface)) {
5055
continue;
5156
}
5257

53-
$timeout = $classTimeout->timeout;
58+
$timeout = $classTimeout->timeout();
5459
}
5560

5661
/**
@@ -59,26 +64,23 @@ final protected function runAsyncTest(mixed ...$args): mixed
5964
*/
6065
foreach ($reflectionClass->getMethod($this->realTestName)->getAttributes() as $methodAttribute) {
6166
$methodTimeout = $methodAttribute->newInstance();
62-
if (! ($methodTimeout instanceof TimeOut)) {
67+
if (! ($methodTimeout instanceof TimeOutInterface)) {
6368
continue;
6469
}
6570

66-
$timeout = $methodTimeout->timeout;
71+
$timeout = $methodTimeout->timeout();
6772
}
6873

69-
$timeout = Loop::addTimer($timeout, static fn () => Loop::stop());
70-
71-
try {
72-
/**
73-
* @psalm-suppress MixedArgument
74-
* @psalm-suppress UndefinedInterfaceMethod
75-
*/
76-
return await(async(
74+
/**
75+
* @psalm-suppress MixedArgument
76+
* @psalm-suppress UndefinedInterfaceMethod
77+
*/
78+
return await(race([
79+
async(
7780
fn (): mixed => ([$this, $this->realTestName])(...$args), /** @phpstan-ignore-line */
78-
)());
79-
} finally {
80-
Loop::cancelTimer($timeout);
81-
}
81+
)(),
82+
sleep($timeout)->then(static fn (): PromiseInterface => reject(new TimedOut('Test timed out after ' . $timeout . ' second(s)'))),
83+
]));
8284
}
8385

8486
final protected function runTest(): mixed

src/TimeOut.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77
use Attribute;
88

99
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
10-
final readonly class TimeOut
10+
final readonly class TimeOut implements TimeOutInterface
1111
{
1212
public function __construct(
13-
public int|float $timeout,
13+
private int|float $timeout,
1414
) {
1515
}
16+
17+
public function timeout(): int|float
18+
{
19+
return $this->timeout;
20+
}
1621
}

src/TimeOutInterface.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WyriHaximus\React\PHPUnit;
6+
7+
interface TimeOutInterface
8+
{
9+
public function timeout(): int|float;
10+
}

src/TimedOut.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WyriHaximus\React\PHPUnit;
6+
7+
use RuntimeException;
8+
9+
/**
10+
* Ignore to allow Runtime Exception extension
11+
*
12+
* @phpstan-ignore-next-line
13+
*/
14+
final class TimedOut extends RuntimeException
15+
{
16+
}

tests/RunTestsInFibersTraitTest.php

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,22 @@
77
use PHPUnit\Framework\TestCase;
88
use React\EventLoop\Loop;
99
use WyriHaximus\React\PHPUnit\RunTestsInFibersTrait;
10+
use WyriHaximus\React\PHPUnit\TimedOut;
1011
use WyriHaximus\React\PHPUnit\TimeOut;
1112

1213
use function React\Async\async;
1314
use function React\Async\await;
1415
use function React\Promise\Timer\sleep;
1516

16-
#[TimeOut(1)]
17+
#[SomeAttribute]
18+
#[TimeOut(0.5)]
19+
#[SomeAttribute]
1720
final class RunTestsInFibersTraitTest extends TestCase
1821
{
1922
use RunTestsInFibersTrait;
2023

21-
#[TimeOut(0.1)]
22-
public function testAllTestsAreRanInAFiber(): void
24+
/** @test */
25+
public function allTestsAreRanInAFiber(): void
2326
{
2427
self::expectOutputString('ab');
2528

@@ -31,4 +34,25 @@ public function testAllTestsAreRanInAFiber(): void
3134

3235
echo 'b';
3336
}
37+
38+
/** @test */
39+
#[SomeAttribute]
40+
#[TimeOut(0.1)]
41+
#[SomeAttribute]
42+
public function methodLevelTimeout(): void
43+
{
44+
self::expectException(TimedOut::class);
45+
self::expectExceptionMessage('Test timed out after 0.1 second(s)');
46+
47+
await(sleep(0.2));
48+
}
49+
50+
/** @test */
51+
public function classLevelTimeout(): void
52+
{
53+
self::expectException(TimedOut::class);
54+
self::expectExceptionMessage('Test timed out after 0.5 second(s)');
55+
56+
await(sleep(0.6));
57+
}
3458
}

tests/SomeAttribute.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WyriHaximus\Tests\React\PHPUnit;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
10+
final readonly class SomeAttribute
11+
{
12+
}

0 commit comments

Comments
 (0)