-
-
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?
#[Retry]
attribute to support retrying flaky test
#6182
Conversation
This PR could also offer a complementary approach to the discussion in #5718: Bring back --repeat CLI option. While Thanks again, looking forward to your feedback! 😊 |
#[Retry]
attribute to support retrying flaky test
@@ -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 comment
The 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 expectException()
, for example?
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.
Thanks for pointing that out!
Just to clarify how this works: in PHPUnit, if an exception is thrown after calling expectException()
, it's treated as a failure, not an error and failures are not retried. On the other hand, if an exception is thrown before expectException()
is set, PHPUnit treats it as an error, which will trigger a retry.
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
I really appreciate the feedback 😄 .
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.
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.
src/Framework/TestCase.php
Outdated
return null; | ||
} | ||
|
||
$metadatas = MetadataRegistry::parser()->forMethod($this::class, $this->name())->isRetry()->getIterator(); |
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.
The manual call to getIterator()
is superfluous as MetadataCollection
implements IteratorAggregate
.
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.
Thank you for the comment 😄
good catch. I agree with you, the manual call to getIterator()
is unnecessary given that MetadataCollection
implements IteratorAggregate
. I'll make that change.
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.
Thanks again for your feedback! I think everything's in order now. If you need me to tweak anything else, please let me know.
2a68f67
to
3343e8e
Compare
3343e8e
to
1ff940d
Compare
1bb9097
to
5af6639
Compare
Hey @santysisi Thanks for this PR! Any chance that you add the CLI option along with the attribute? |
I do not see how a CLI option would be relevant in the context of what this PR is about. |
I think that the functionality you propose here is useful. However, the current implementation can "only" be considered an early proof of concept in my opinion. Retried tests are not run on a fresh instance of the test case object, the before-test, after-test, etc. template methods are not run for retried tests, etc. Furthermore, no events are emitted to record the fact that a test was retried. At least as far as I can see. While I do think this would be a nice addition, I will not work on a "real" implementation myself. I am open to a more complete PR, of course. |
Hi @sebastianbergmann , thanks for your comment 😄. I really appreciate it! I'd like to give it another try, now taking the new requirements into account. Please let me know if I’m on the right track:
Does that align with what you had in mind? Also, just to confirm, do you agree with the behavior described here |
final readonly class Retry | ||
{ | ||
private int $maxRetries; | ||
private int $delay; |
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 like 0.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 replace sleep
with usleep
. 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!
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.
Hi @sebastianbergmann, I hope you're doing well 🙂 |
I am sorry that I have not gotten around to look into this yet. |
use LogicException; | ||
use PHPUnit\Framework\TestCase; | ||
|
||
final class RetryTest extends TestCase |
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.
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.
✨ Add Retry Attribute for Retrying Flaky Tests
This PR introduces a new Retry attribute that can be applied to individual test methods in PHPUnit. Its goal is to help deal with flaky or unstable tests that occasionally fail due to non-deterministic reasons (e.g. network issues, race conditions, external dependencies).
🔧 How it works
The Retry attribute allows a test to be automatically retried a specified number of times before being marked as failed. You can also control the delay between retries, and optionally restrict retries to specific exception types.
🧪 Usage
Here’s how you can use it:
You can also specify a delay (in seconds) between retries:
And restrict retries to certain exception types only:
#[Retry(4, retryOn: TimeoutException::class)]
All parameters:
✅ Example
📈 Benefits
🔄 Prior art
Other popular testing frameworks already provide similar functionality: