Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Repeat.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@
{
/**
* @param int<1, max> $times Number of times to repeat the test.
* @param int<1, max> $failureThreshold Number of failures that should fail the repeated execution.
*/
public function __construct(
public int $times = 2,
public int $failureThreshold = 1,
) {
$times > 0 or throw new \InvalidArgumentException('Times must be greater than 0.');
$failureThreshold > 0 or throw new \InvalidArgumentException('Failure threshold must be greater than 0.');
}
}
29 changes: 27 additions & 2 deletions src/Repeat/Internal/RepeatInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Testo\Pipeline\Attribute\InterceptorOptions;
use Testo\Pipeline\Middleware\TestRunInterceptor;
use Testo\Pipeline\Policy\ConflictPolicy;
use Testo\Core\Value\Status;
use Testo\Repeat;

/**
Expand All @@ -29,10 +30,34 @@ public function __construct(
public function runTest(TestInfo $info, callable $next): TestResult
{
$times = $this->options->times;
$failureThreshold = $this->options->failureThreshold;
$failures = 0;
$result = null;
\assert($times > 0);
do {
\assert($failureThreshold > 0);

while ($times-- > 0) {
$result = $next($info);
} while (--$times > 0 && !$result->status->isFailure() && $result->status->isCompleted());
if (!$result->status->isCompleted()) {
return $result;
}

if (!$result->status->isFailure()) {
continue;
}

if (++$failures >= $failureThreshold) {
return $result;
}
}

\assert($result instanceof TestResult);

if ($failures > 0) {
return $result
->with(status: Status::Passed)
->withFailure(null);
}

return $result;
}
Expand Down
16 changes: 16 additions & 0 deletions tests/Repeat/Feature/RepeatFeatureTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ public function repeatOncePasses(): void
Assert::same($result->status, Status::Passed);
}

#[Test]
public function repeatWithToleratedFailuresPasses(): void
{
$result = TestRunner::runTest([RepeatPassingStub::class, 'repeatWithToleratedFailures']);

Assert::same($result->status, Status::Passed);
}

#[Test]
public function failsOnSecondIteration(): void
{
Expand All @@ -56,6 +64,14 @@ public function failsImmediately(): void
Assert::same($result->status, Status::Failed);
}

#[Test]
public function reachesFailureThresholdFails(): void
{
$result = TestRunner::runTest([RepeatFailingStub::class, 'reachesFailureThreshold']);

Assert::same($result->status, Status::Failed);
}

#[Test]
public function classLevelRepeatFirstTest(): void
{
Expand Down
16 changes: 16 additions & 0 deletions tests/Repeat/Stub/RepeatFailingStub.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
*/
final class RepeatFailingStub
{
private int $reachesFailureThresholdCounter = 0;

/**
* Passes first time, fails on second iteration.
*/
Expand All @@ -35,4 +37,18 @@ public function failsImmediately(): void
{
Assert::same(1, 2);
}

/**
* Fails twice and reaches the failure threshold.
*/
#[Test]
#[Repeat(times: 4, failureThreshold: 2)]
public function reachesFailureThreshold(): void
{
++$this->reachesFailureThresholdCounter;

if ($this->reachesFailureThresholdCounter % 2 === 0) {
Assert::fail('Simulated failure for threshold testing.');
}
}
}
16 changes: 16 additions & 0 deletions tests/Repeat/Stub/RepeatPassingStub.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
*/
final class RepeatPassingStub
{
private int $repeatWithToleratedFailuresCounter = 0;

/**
* Default repeat (2 times), all pass.
*/
Expand Down Expand Up @@ -42,4 +44,18 @@ public function repeatOnce(): void
{
Assert::true(true);
}

/**
* Fails occasionally but stays below failure threshold.
*/
#[Test]
#[Repeat(times: 4, failureThreshold: 3)]
public function repeatWithToleratedFailures(): void
{
++$this->repeatWithToleratedFailuresCounter;

if ($this->repeatWithToleratedFailuresCounter % 2 === 0) {
Assert::fail('Simulated failure for threshold testing.');
}
}
}
53 changes: 53 additions & 0 deletions tests/Repeat/Unit/RepeatInterceptorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,59 @@ public function stopsOnFailureMidway(): void
Assert::same($result->status, Status::Failed);
}

public function repeatsUntilFailureThresholdIsReached(): void
{
// Arrange
$interceptor = new RepeatInterceptor(new Repeat(times: 5, failureThreshold: 3));
$info = self::createTestInfo();
$callCount = 0;
$next = static function (TestInfo $info) use (&$callCount): TestResult {
$callCount++;
return match ($callCount) {
2, 4, 5 => new TestResult(
info: $info,
status: Status::Failed,
failure: new \RuntimeException('Simulated failure for threshold testing.'),
),
default => new TestResult(info: $info, status: Status::Passed),
};
};

// Act
$result = $interceptor->runTest($info, $next);

// Assert
Assert::same($callCount, 5);
Assert::same($result->status, Status::Failed);
}

public function failuresBelowThresholdPassAfterAllIterations(): void
{
// Arrange
$interceptor = new RepeatInterceptor(new Repeat(times: 4, failureThreshold: 3));
$info = self::createTestInfo();
$callCount = 0;
$next = static function (TestInfo $info) use (&$callCount): TestResult {
$callCount++;
return match ($callCount) {
2, 4 => new TestResult(
info: $info,
status: Status::Failed,
failure: new \RuntimeException('Simulated failure for threshold testing.'),
),
default => new TestResult(info: $info, status: Status::Passed),
};
};

// Act
$result = $interceptor->runTest($info, $next);

// Assert
Assert::same($callCount, 4);
Assert::same($result->status, Status::Passed);
Assert::null($result->failure);
}

/**
* Verifies repeat behavior per status: failure statuses stop the loop, others continue.
*/
Expand Down
32 changes: 32 additions & 0 deletions tests/Repeat/Unit/RepeatTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public function defaultTimesIsTwo(): void

// Assert
Assert::same($repeat->times, 2);
Assert::same($repeat->failureThreshold, 1);
}

public function customTimes(): void
Expand All @@ -39,6 +40,25 @@ public function timesOneIsValid(): void
Assert::same($repeat->times, 1);
}

public function customFailureThreshold(): void
{
// Act
$repeat = new Repeat(times: 5, failureThreshold: 3);

// Assert
Assert::same($repeat->failureThreshold, 3);
}

public function positionalConstructorArguments(): void
{
// Act
$repeat = new Repeat(10, 3);

// Assert
Assert::same($repeat->times, 10);
Assert::same($repeat->failureThreshold, 3);
}

#[ExpectException(\InvalidArgumentException::class)]
public function zeroTimesThrowsException(): void
{
Expand All @@ -50,4 +70,16 @@ public function negativeTimesThrowsException(): void
{
new Repeat(times: -1);
}

#[ExpectException(\InvalidArgumentException::class)]
public function zeroFailureThresholdThrowsException(): void
{
new Repeat(failureThreshold: 0);
}

#[ExpectException(\InvalidArgumentException::class)]
public function negativeFailureThresholdThrowsException(): void
{
new Repeat(failureThreshold: -1);
}
}