diff --git a/TUnit.Core/Attributes/TestMetadata/SkipAttribute.cs b/TUnit.Core/Attributes/TestMetadata/SkipAttribute.cs index 8bc727bdd5..327ce59b1e 100644 --- a/TUnit.Core/Attributes/TestMetadata/SkipAttribute.cs +++ b/TUnit.Core/Attributes/TestMetadata/SkipAttribute.cs @@ -54,9 +54,7 @@ public async ValueTask OnTestRegistered(TestRegisteredContext context) { if (await ShouldSkip(context)) { - // Store skip reason directly on TestContext - context.TestContext.SkipReason = Reason; - context.TestContext.Metadata.TestDetails.ClassInstance = SkippedTestInstance.Instance; + context.SetSkipped(GetSkipReason(context)); } } @@ -75,4 +73,35 @@ public async ValueTask OnTestRegistered(TestRegisteredContext context) /// The default implementation always returns true, meaning the test will always be skipped. /// public virtual Task ShouldSkip(TestRegisteredContext context) => Task.FromResult(true); + + /// + /// Gets the skip reason for the test. + /// + /// The test context containing information about the test being registered. + /// The reason why the test should be skipped. + /// + /// Can be overridden in derived classes to provide dynamic skip reasons based on runtime information. + /// This allows including contextual information (e.g., device names, environment variables) in skip messages. + /// + /// The default implementation returns the property value. + /// + /// + /// + /// public class SkipOnDeviceAttribute : SkipAttribute + /// { + /// private readonly string _deviceName; + /// + /// public SkipOnDeviceAttribute(string deviceName) : base("Device-specific skip") + /// { + /// _deviceName = deviceName; + /// } + /// + /// protected override string GetSkipReason(TestRegisteredContext context) + /// { + /// return $"Test '{context.TestName}' is not supported on device '{_deviceName}'"; + /// } + /// } + /// + /// + protected virtual string GetSkipReason(TestRegisteredContext context) => Reason; } diff --git a/TUnit.Core/Contexts/TestRegisteredContext.cs b/TUnit.Core/Contexts/TestRegisteredContext.cs index 4924981de0..53cfac1063 100644 --- a/TUnit.Core/Contexts/TestRegisteredContext.cs +++ b/TUnit.Core/Contexts/TestRegisteredContext.cs @@ -51,4 +51,15 @@ public void SetParallelLimiter(IParallelLimit parallelLimit) { TestContext.Parallelism.SetLimiter(parallelLimit); } + + /// + /// Marks the test as skipped with the specified reason. + /// This can only be called during the test registration phase. + /// + /// The reason why the test is being skipped + public void SetSkipped(string reason) + { + TestContext.SkipReason = reason; + TestContext.Metadata.TestDetails.ClassInstance = SkippedTestInstance.Instance; + } } diff --git a/TUnit.Engine.Tests/DynamicSkipReasonTests.cs b/TUnit.Engine.Tests/DynamicSkipReasonTests.cs new file mode 100644 index 0000000000..f057a1b8c9 --- /dev/null +++ b/TUnit.Engine.Tests/DynamicSkipReasonTests.cs @@ -0,0 +1,49 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +public class DynamicSkipReasonTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task TestSkippedViaSetSkippedMethod_ShouldContainDynamicDeviceName() + { + await RunTestsWithFilter( + "/*/*/DynamicSkipReasonTests/TestSkippedViaSetSkippedMethod", + [ + result => result.ResultSummary.Outcome.ShouldBe("Failed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(0), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(1) + ]); + } + + [Test] + public async Task TestSkippedViaGetSkipReasonOverride_ShouldContainDynamicDeviceName() + { + await RunTestsWithFilter( + "/*/*/DynamicSkipReasonTests/TestSkippedViaGetSkipReasonOverride", + [ + result => result.ResultSummary.Outcome.ShouldBe("Failed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(0), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(1) + ]); + } + + [Test] + public async Task TestNotSkippedWhenConditionFalse_ShouldPass() + { + await RunTestsWithFilter( + "/*/*/DynamicSkipReasonTests/TestNotSkippedWhenConditionFalse", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0) + ]); + } +} diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 89f8aef8ae..2ebbe8d853 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1179,6 +1179,7 @@ namespace public SkipAttribute(string reason) { } public int Order { get; } public string Reason { get; } + protected virtual string GetSkipReason(.TestRegisteredContext context) { } public . OnTestRegistered(.TestRegisteredContext context) { } public virtual . ShouldSkip(.TestRegisteredContext context) { } } @@ -1451,6 +1452,7 @@ namespace public string TestName { get; } public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } + public void SetSkipped(string reason) { } public void SetTestExecutor(. executor) { } } public class TestResult : <.TestResult> diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 3b5e11f8a7..d0234eb43f 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1179,6 +1179,7 @@ namespace public SkipAttribute(string reason) { } public int Order { get; } public string Reason { get; } + protected virtual string GetSkipReason(.TestRegisteredContext context) { } public . OnTestRegistered(.TestRegisteredContext context) { } public virtual . ShouldSkip(.TestRegisteredContext context) { } } @@ -1451,6 +1452,7 @@ namespace public string TestName { get; } public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } + public void SetSkipped(string reason) { } public void SetTestExecutor(. executor) { } } public class TestResult : <.TestResult> diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index f18ebe2c0f..2cc3deb828 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1179,6 +1179,7 @@ namespace public SkipAttribute(string reason) { } public int Order { get; } public string Reason { get; } + protected virtual string GetSkipReason(.TestRegisteredContext context) { } public . OnTestRegistered(.TestRegisteredContext context) { } public virtual . ShouldSkip(.TestRegisteredContext context) { } } @@ -1451,6 +1452,7 @@ namespace public string TestName { get; } public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } + public void SetSkipped(string reason) { } public void SetTestExecutor(. executor) { } } public class TestResult : <.TestResult> diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index e7c1406e71..a5f8e35f71 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1138,6 +1138,7 @@ namespace public SkipAttribute(string reason) { } public int Order { get; } public string Reason { get; } + protected virtual string GetSkipReason(.TestRegisteredContext context) { } public . OnTestRegistered(.TestRegisteredContext context) { } public virtual . ShouldSkip(.TestRegisteredContext context) { } } @@ -1404,6 +1405,7 @@ namespace public string TestName { get; } public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } + public void SetSkipped(string reason) { } public void SetTestExecutor(. executor) { } } public class TestResult : <.TestResult> diff --git a/TUnit.TestProject/DynamicSkipReasonTests.cs b/TUnit.TestProject/DynamicSkipReasonTests.cs new file mode 100644 index 0000000000..d78da5deb6 --- /dev/null +++ b/TUnit.TestProject/DynamicSkipReasonTests.cs @@ -0,0 +1,93 @@ +namespace TUnit.TestProject; + +using TUnit.Core.Interfaces; + +public class DynamicSkipReasonTests +{ + [Test] + [CustomSkipViaSetSkipped("TestDevice123")] + public void TestSkippedViaSetSkippedMethod() + { + throw new Exception("This test should have been skipped!"); + } + + [Test] + [CustomSkipViaGetSkipReason("CustomDevice456")] + public void TestSkippedViaGetSkipReasonOverride() + { + throw new Exception("This test should have been skipped!"); + } + + [Test] + [ConditionalSkipAttribute("AllowedDevice")] + public void TestNotSkippedWhenConditionFalse() + { + } +} + +/// +/// Custom attribute that uses TestRegisteredContext.SetSkipped() to skip tests dynamically +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class CustomSkipViaSetSkippedAttribute : Attribute, ITestRegisteredEventReceiver +{ + private readonly string _deviceName; + + public CustomSkipViaSetSkippedAttribute(string deviceName) + { + _deviceName = deviceName; + } + + public int Order => int.MinValue; + + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + context.SetSkipped($"Test '{context.TestName}' is not supported on device '{_deviceName}'"); + return default; + } +} + +/// +/// Custom SkipAttribute that overrides GetSkipReason() to provide dynamic skip reasons +/// +public class CustomSkipViaGetSkipReasonAttribute : SkipAttribute +{ + private readonly string _deviceName; + + public CustomSkipViaGetSkipReasonAttribute(string deviceName) + : base("Device-specific skip") + { + _deviceName = deviceName; + } + + protected override string GetSkipReason(TestRegisteredContext context) + { + return $"Test '{context.TestName}' skipped for device '{_deviceName}' via GetSkipReason override"; + } +} + +/// +/// Conditional skip attribute that only skips if device name is not in allowed list +/// +public class ConditionalSkipAttribute : SkipAttribute +{ + private readonly string _deviceName; + private static readonly string[] AllowedDevices = ["AllowedDevice", "AnotherAllowedDevice"]; + + public ConditionalSkipAttribute(string deviceName) + : base("Device not allowed") + { + _deviceName = deviceName; + } + + public override Task ShouldSkip(TestRegisteredContext context) + { + bool shouldSkip = !AllowedDevices.Contains(_deviceName); + return Task.FromResult(shouldSkip); + } + + protected override string GetSkipReason(TestRegisteredContext context) + { + return $"Test '{context.TestName}' skipped because device '{_deviceName}' is not in allowed list"; + } +}