diff --git a/test/UnitTests/TestFramework.UnitTests/Attributes/RetryAttributeTests.cs b/test/UnitTests/TestFramework.UnitTests/Attributes/RetryAttributeTests.cs new file mode 100644 index 0000000000..f1706ed0cc --- /dev/null +++ b/test/UnitTests/TestFramework.UnitTests/Attributes/RetryAttributeTests.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#pragma warning disable MSTESTEXP // Experimental API + +using AwesomeAssertions; + +using TestFramework.ForTestingMSTest; + +namespace Microsoft.VisualStudio.TestPlatform.TestFramework.UnitTests.Attributes; + +/// +/// Tests for constructor validation and retry execution logic. +/// +public class RetryAttributeTests : TestContainer +{ + public void Constructor_WhenMaxRetryAttemptsIsZero_ThrowsArgumentOutOfRangeException() + { + Action act = static () => _ = new RetryAttribute(0); + act.Should().Throw() + .WithParameterName("maxRetryAttempts"); + } + + public void Constructor_WhenMaxRetryAttemptsIsNegative_ThrowsArgumentOutOfRangeException() + { + Action act = static () => _ = new RetryAttribute(-1); + act.Should().Throw() + .WithParameterName("maxRetryAttempts"); + } + + public void Constructor_WhenMaxRetryAttemptsIsOne_SetsMaxRetryAttempts() + { + var attribute = new RetryAttribute(1); + attribute.MaxRetryAttempts.Should().Be(1); + } + + public void Constructor_WhenMaxRetryAttemptsIsPositive_SetsMaxRetryAttempts() + { + var attribute = new RetryAttribute(5); + attribute.MaxRetryAttempts.Should().Be(5); + } + + public void BackoffType_DefaultsToConstant() + { + var attribute = new RetryAttribute(2); + attribute.BackoffType.Should().Be(DelayBackoffType.Constant); + } + + public void BackoffType_WhenSetToInvalidValue_ThrowsArgumentOutOfRangeException() + { + var attribute = new RetryAttribute(2); + Action act = () => attribute.BackoffType = (DelayBackoffType)99; + act.Should().Throw() + .WithParameterName("value"); + } + + public void BackoffType_WhenSetToExponential_Succeeds() + { + RetryAttribute attribute = new(2) { BackoffType = DelayBackoffType.Exponential }; + attribute.BackoffType.Should().Be(DelayBackoffType.Exponential); + } + + public void MillisecondsDelayBetweenRetries_DefaultsToZero() + { + var attribute = new RetryAttribute(2); + attribute.MillisecondsDelayBetweenRetries.Should().Be(0); + } + + public async Task ExecuteAsync_WhenTestPassesOnFirstRetry_StopsRetryingEarly() + { + var attribute = new RetryAttribute(maxRetryAttempts: 5); + int callCount = 0; + + var passResult = new TestResult { Outcome = UnitTestOutcome.Passed }; + var failResult = new TestResult { Outcome = UnitTestOutcome.Failed }; + + TestResult[] firstRunResults = [failResult]; + var context = new RetryContext( + () => + { + callCount++; + return Task.FromResult(new[] { passResult }); + }, + firstRunResults); + + RetryResult result = await attribute.ExecuteAsync(context); + + callCount.Should().Be(1); + result.TryGetLast().Should().ContainSingle() + .Which.Outcome.Should().Be(UnitTestOutcome.Passed); + } + + public async Task ExecuteAsync_WhenAllRetriesFail_ExecutesExactlyMaxRetryAttemptsTimes() + { + var attribute = new RetryAttribute(maxRetryAttempts: 3); + int callCount = 0; + + var failResult = new TestResult { Outcome = UnitTestOutcome.Failed }; + TestResult[] firstRunResults = [failResult]; + + var context = new RetryContext( + () => + { + callCount++; + return Task.FromResult(new[] { new TestResult { Outcome = UnitTestOutcome.Failed } }); + }, + firstRunResults); + + await attribute.ExecuteAsync(context); + + callCount.Should().Be(3); + } + + public async Task ExecuteAsync_WhenAllRetriesFail_ResultContainsLastAttemptOutcome() + { + var attribute = new RetryAttribute(maxRetryAttempts: 2); + int callCount = 0; + + TestResult[] firstRunResults = [new TestResult { Outcome = UnitTestOutcome.Failed }]; + var context = new RetryContext( + () => + { + callCount++; + UnitTestOutcome outcome = callCount == 2 ? UnitTestOutcome.Timeout : UnitTestOutcome.Failed; + return Task.FromResult(new[] { new TestResult { Outcome = outcome } }); + }, + firstRunResults); + + RetryResult result = await attribute.ExecuteAsync(context); + + result.TryGetLast().Should().ContainSingle() + .Which.Outcome.Should().Be(UnitTestOutcome.Timeout); + } + + public async Task ExecuteAsync_WhenTestPassesAfterSeveralFailures_StopsAtFirstSuccess() + { + var attribute = new RetryAttribute(maxRetryAttempts: 5); + int callCount = 0; + + TestResult[] firstRunResults = [new TestResult { Outcome = UnitTestOutcome.Failed }]; + var context = new RetryContext( + () => + { + callCount++; + UnitTestOutcome outcome = callCount < 3 ? UnitTestOutcome.Failed : UnitTestOutcome.Passed; + return Task.FromResult(new[] { new TestResult { Outcome = outcome } }); + }, + firstRunResults); + + RetryResult result = await attribute.ExecuteAsync(context); + + callCount.Should().Be(3); + result.TryGetLast().Should().ContainSingle() + .Which.Outcome.Should().Be(UnitTestOutcome.Passed); + } + + public async Task ExecuteAsync_WhenResultIsInconclusive_StopsRetrying() + { + var attribute = new RetryAttribute(maxRetryAttempts: 5); + int callCount = 0; + + TestResult[] firstRunResults = [new TestResult { Outcome = UnitTestOutcome.Failed }]; + var context = new RetryContext( + () => + { + callCount++; + return Task.FromResult(new[] { new TestResult { Outcome = UnitTestOutcome.Inconclusive } }); + }, + firstRunResults); + + await attribute.ExecuteAsync(context); + + callCount.Should().Be(1); + } +}