-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
#[Retry]
attribute to support retrying flaky test
#6182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
<?php declare(strict_types=1); | ||
/* | ||
* This file is part of PHPUnit. | ||
* | ||
* (c) Sebastian Bergmann <sebastian@phpunit.de> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
namespace PHPUnit\Framework\Attributes; | ||
|
||
use Attribute; | ||
|
||
/** | ||
* @immutable | ||
* | ||
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit | ||
*/ | ||
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] | ||
final readonly class Retry | ||
{ | ||
private int $maxRetries; | ||
private int $delay; | ||
|
||
/** | ||
* @var ?non-empty-string | ||
*/ | ||
private ?string $retryOn; | ||
|
||
/** | ||
* @param ?non-empty-string $retryOn | ||
*/ | ||
public function __construct(int $maxRetries, ?int $delay = 0, ?string $retryOn = null) | ||
{ | ||
$this->maxRetries = $maxRetries; | ||
$this->delay = $delay; | ||
$this->retryOn = $retryOn; | ||
} | ||
|
||
public function maxRetries(): int | ||
{ | ||
return $this->maxRetries; | ||
} | ||
|
||
public function delay(): int | ||
{ | ||
return $this->delay; | ||
} | ||
|
||
/** | ||
* @return ?non-empty-string | ||
*/ | ||
public function retryOn(): ?string | ||
{ | ||
return $this->retryOn; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -46,6 +46,7 @@ | |
use function restore_exception_handler; | ||
use function set_error_handler; | ||
use function set_exception_handler; | ||
use function sleep; | ||
use function sprintf; | ||
use function str_contains; | ||
use function stream_get_contents; | ||
|
@@ -79,6 +80,7 @@ | |
use PHPUnit\Metadata\Api\HookMethods; | ||
use PHPUnit\Metadata\Api\Requirements; | ||
use PHPUnit\Metadata\Parser\Registry as MetadataRegistry; | ||
use PHPUnit\Metadata\Retry; | ||
use PHPUnit\Metadata\WithEnvironmentVariable; | ||
use PHPUnit\Runner\BackedUpEnvironmentVariable; | ||
use PHPUnit\Runner\DeprecationCollector\Facade as DeprecationCollector; | ||
|
@@ -1248,7 +1250,7 @@ protected function onNotSuccessfulTest(Throwable $t): never | |
* @throws ExpectationFailedException | ||
* @throws Throwable | ||
*/ | ||
private function runTest(): mixed | ||
private function runTest(int $attempt = 0): mixed | ||
{ | ||
$testArguments = array_merge($this->data, array_values($this->dependencyInput)); | ||
|
||
|
@@ -1276,6 +1278,12 @@ private function runTest(): mixed | |
} | ||
|
||
if (!$this->shouldExceptionExpectationsBeVerified($exception)) { | ||
$metadata = $this->getRetryMetadata($exception, $attempt); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only had a very cursory look at the proposed changes yet, but this caught my eye: how are tests retried that use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for pointing that out! Just to clarify how this works: in PHPUnit, if an exception is thrown after calling For example: #[Retry(2)]
public function testFoo(): void
{
throw new Exception;
$this->expectException(Exception::class);
} This will be retried, since the exception occurs before the expected exception is declared. Whereas: #[Retry(2)]
public function testFoo(): void
{
$this->expectException(Exception::class);
throw new Exception;
} This will not be retried, because the exception is expected and treated as a failure if something goes wrong, not an unexpected error. So i believe (maybe I'm wrong) that the the retry behavior aligns with how PHPUnit distinguishes between errors and failures. Thanks again for your comment There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That said, I'm not entirely sure if this approach fully aligns with your expectations or the overall goals of PHPUnit. If there's anything I'm not comfortable with or could be improved, I'd greatly appreciate your guidance. I'd be happy to review the logic and make adjustments to better fit PHPUnit's design and intent. |
||
|
||
if (null !== $metadata) { | ||
return $this->retryTest($metadata, $attempt); | ||
} | ||
|
||
throw $exception; | ||
} | ||
|
||
|
@@ -2255,6 +2263,28 @@ private function handleExceptionFromInvokedCountMockObjectRule(Throwable $t): vo | |
} | ||
} | ||
|
||
private function getRetryMetadata(Throwable $th, int $attempt): ?Retry | ||
{ | ||
foreach (MetadataRegistry::parser()->forMethod($this::class, $this->name())->isRetry() as $metadata) { | ||
assert($metadata instanceof Retry); | ||
|
||
if ($metadata->maxRetries() > $attempt && (null === $metadata->retryOn() || $th instanceof ($metadata->retryOn()))) { | ||
return $metadata; | ||
} | ||
} | ||
|
||
return null; | ||
} | ||
|
||
private function retryTest(Retry $metadata, int $attempt): mixed | ||
{ | ||
if ($metadata->delay() > 0) { | ||
sleep($metadata->delay()); | ||
} | ||
|
||
return $this->runTest(++$attempt); | ||
} | ||
|
||
/** | ||
* Creates a test stub for the specified interface or class. | ||
* | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
<?php declare(strict_types=1); | ||
/* | ||
* This file is part of PHPUnit. | ||
* | ||
* (c) Sebastian Bergmann <sebastian@phpunit.de> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
namespace PHPUnit\Metadata; | ||
|
||
/** | ||
* @immutable | ||
* | ||
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit | ||
*/ | ||
final readonly class Retry extends Metadata | ||
{ | ||
private int $maxRetries; | ||
private int $delay; | ||
|
||
/** | ||
* @var ?non-empty-string | ||
*/ | ||
private ?string $retryOn; | ||
|
||
/** | ||
* @param ?non-empty-string $retryOn | ||
*/ | ||
public function __construct(int $level, int $maxRetries, int $delay, ?string $retryOn) | ||
{ | ||
parent::__construct($level); | ||
|
||
$this->maxRetries = $maxRetries; | ||
$this->delay = $delay; | ||
$this->retryOn = $retryOn; | ||
} | ||
|
||
public function isRetry(): bool | ||
{ | ||
return true; | ||
} | ||
|
||
public function maxRetries(): int | ||
{ | ||
return $this->maxRetries; | ||
} | ||
|
||
public function delay(): int | ||
{ | ||
return $this->delay; | ||
} | ||
|
||
/** | ||
* @return ?non-empty-string | ||
*/ | ||
public function retryOn(): ?string | ||
{ | ||
return $this->retryOn; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
<?php declare(strict_types=1); | ||
/* | ||
* This file is part of PHPUnit. | ||
* | ||
* (c) Sebastian Bergmann <sebastian@phpunit.de> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
namespace PHPUnit\TestFixture\Metadata\Attribute; | ||
|
||
use LogicException; | ||
use PHPUnit\Framework\Attributes\Retry; | ||
use PHPUnit\Framework\TestCase; | ||
|
||
final class RetryTest extends TestCase | ||
{ | ||
#[Retry(1)] | ||
#[Retry(2, 1)] | ||
#[Retry(3, 0, LogicException::class)] | ||
public function testOne(): void | ||
{ | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
<?php declare(strict_types=1); | ||
/* | ||
* This file is part of PHPUnit. | ||
* | ||
* (c) Sebastian Bergmann <sebastian@phpunit.de> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
namespace PHPUnit\Framework\Attributes; | ||
|
||
use DateTime; | ||
use Exception; | ||
use LogicException; | ||
use PHPUnit\Framework\TestCase; | ||
|
||
final class RetryTest extends TestCase | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Currently, it seems pre/post test hooks are not called. But that can be an issue. I would like to have this tested by E2E tests to show the behaviour and also combined with other features like process isolation. |
||
{ | ||
private static int $retryNumber = 0; | ||
private static ?DateTime $start = null; | ||
|
||
protected function setUp(): void | ||
{ | ||
self::$retryNumber = 0; | ||
self::$start = new DateTime; | ||
} | ||
|
||
#[Retry(3)] | ||
public function testRetriesUntilMaxAttemptsThenSucceeds(): void | ||
{ | ||
if (self::$retryNumber < 3) { | ||
self::$retryNumber++; | ||
|
||
throw new Exception; | ||
} | ||
|
||
$this->assertSame(3, self::$retryNumber); | ||
} | ||
|
||
#[Retry(1)] | ||
public function testSingleRetryThenThrowsExpectedException(): void | ||
{ | ||
if (self::$retryNumber < 1) { | ||
self::$retryNumber++; | ||
|
||
throw new Exception; | ||
} | ||
|
||
$this->expectException(Exception::class); | ||
$this->expectExceptionMessage('test exception two'); | ||
$this->assertSame(1, self::$retryNumber); | ||
|
||
throw new Exception('test exception two'); | ||
} | ||
|
||
#[Retry(2, 0, LogicException::class)] | ||
public function testRetryWithUnmatchedExceptionTypeFailsImmediately(): void | ||
{ | ||
$this->expectException(Exception::class); | ||
$this->expectExceptionMessage('test exception'); | ||
$this->assertSame(0, self::$retryNumber); | ||
self::$retryNumber++; | ||
|
||
throw new Exception('test exception'); | ||
} | ||
|
||
#[Retry(2, 0, LogicException::class)] | ||
#[Retry(2)] | ||
public function testMultipleRetryAttributesFallBackToDefaultRetry(): void | ||
{ | ||
if (self::$retryNumber < 2) { | ||
self::$retryNumber++; | ||
|
||
throw new Exception; | ||
} | ||
|
||
$this->assertSame(2, self::$retryNumber); | ||
} | ||
|
||
#[Retry(5, 0, LogicException::class)] | ||
public function testRetriesUntilLogicExceptionStopsThrowing(): void | ||
{ | ||
if (self::$retryNumber < 5) { | ||
self::$retryNumber++; | ||
|
||
throw new LogicException; | ||
} | ||
|
||
$this->assertSame(5, self::$retryNumber); | ||
} | ||
|
||
#[Retry(1, 2)] | ||
public function testRetryDelaysExecutionBySpecifiedSeconds(): void | ||
{ | ||
$end = new DateTime; | ||
|
||
if (self::$retryNumber < 1) { | ||
self::$retryNumber++; | ||
|
||
throw new Exception; | ||
} | ||
|
||
$diffInSeconds = $end->getTimestamp() - self::$start->getTimestamp(); | ||
|
||
$this->assertSame(1, self::$retryNumber); | ||
$this->assertSame(2, $diffInSeconds); | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would expect this to be of type
float
to support delays like0.5
(500 ms).And usually this is very bad retry strategy. Exponential delay is much better. Maybe there should be no native delayed retry support as it can slowdown testing massively.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right, but instead of passing a
float
, maybe it's a better idea to replacesleep
withusleep
. As for the performance issue, I think the solution is simply not to pass that parameter. Still, I believe the delay is a good idea since it's optional.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In SI units definitely, ie. seconds.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe using milliseconds instead of seconds is a better and more flexible option, especially when it comes to controlling timeouts and delays more precisely. This approach is also more commonly adopted, for example, both Symfony's HTTP client and Guzzle use milliseconds for time-related settings.
Additionally, we could consider adding a multiplier option (similar to Symfony) to allow increasing the delay between each retry. Personally, I prefer Guzzle's approach of using a callable for this logic. However, I understand that passing closures directly in PHP attributes isn't natively supported,
Happy to hear your thoughts on this!
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Milliseconds are not base SI units.
Still, I am thinking if a delay is wanted to be supported natively.
I think there can be an elegant option - the "try number" can be accessible from
setUp
method and the use can wait/sleep the way he needs by any code.