diff --git a/TUnit.Core/Attributes/TestMetadata/NotDiscoverableAttribute.cs b/TUnit.Core/Attributes/TestMetadata/NotDiscoverableAttribute.cs new file mode 100644 index 0000000000..0ad78f372a --- /dev/null +++ b/TUnit.Core/Attributes/TestMetadata/NotDiscoverableAttribute.cs @@ -0,0 +1,100 @@ +using TUnit.Core.Interfaces; + +namespace TUnit.Core; + +/// +/// Specifies that a test method, test class, or assembly should be hidden from test discovery/explorer. +/// +/// +/// +/// When applied to a test method, class, or assembly, the NotDiscoverableAttribute prevents the test(s) +/// from appearing in test explorers and IDE test runners, while still allowing them to execute normally +/// when run via filters or direct invocation. +/// +/// +/// This is useful for infrastructure tests, internal helpers, or tests that should only be run +/// as dependencies of other tests. +/// +/// +/// +/// +/// // Simple usage - hide test from explorer +/// [Test] +/// [NotDiscoverable] +/// public void InfrastructureSetupTest() +/// { +/// // This test will not appear in test explorer but can still be executed +/// } +/// +/// // With reason for documentation +/// [Test] +/// [NotDiscoverable("Internal fixture helper - not meant to be run directly")] +/// public void SharedFixtureSetup() +/// { +/// // Hidden from discovery +/// } +/// +/// // Conditional hiding via inheritance +/// public class NotDiscoverableOnCIAttribute : NotDiscoverableAttribute +/// { +/// public NotDiscoverableOnCIAttribute() : base("Hidden on CI") { } +/// +/// public override Task<bool> ShouldHide(TestRegisteredContext context) +/// { +/// return Task.FromResult(Environment.GetEnvironmentVariable("CI") == "true"); +/// } +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = false, Inherited = true)] +public class NotDiscoverableAttribute : TUnitAttribute, ITestRegisteredEventReceiver +{ + /// + /// Gets the reason why this test is hidden from discovery. + /// + public string? Reason { get; } + + /// + /// Initializes a new instance of the class. + /// + public NotDiscoverableAttribute() + { + } + + /// + /// Initializes a new instance of the class with a reason. + /// + /// The reason why this test is hidden from discovery. + public NotDiscoverableAttribute(string reason) + { + Reason = reason; + } + + /// + public int Order => int.MinValue; + + /// + public async ValueTask OnTestRegistered(TestRegisteredContext context) + { + if (await ShouldHide(context)) + { + context.TestContext.IsNotDiscoverable = true; + } + } + + /// + /// Determines whether the test should be hidden from discovery. + /// + /// The test context containing information about the test being registered. + /// + /// A task that represents the asynchronous operation. + /// The task result is true if the test should be hidden; otherwise, false. + /// + /// + /// Can be overridden in derived classes to implement conditional hiding logic + /// based on specific conditions or criteria. + /// + /// The default implementation always returns true, meaning the test will always be hidden. + /// + public virtual Task ShouldHide(TestRegisteredContext context) => Task.FromResult(true); +} diff --git a/TUnit.Core/Interfaces/ITestExecution.cs b/TUnit.Core/Interfaces/ITestExecution.cs index 16a3bd6d50..efab81b9c0 100644 --- a/TUnit.Core/Interfaces/ITestExecution.cs +++ b/TUnit.Core/Interfaces/ITestExecution.cs @@ -107,6 +107,13 @@ public interface ITestExecution /// bool ReportResult { get; set; } + /// + /// Gets or sets whether the test should be hidden from test discovery/explorer. + /// Defaults to false. Set to true to hide the test from discovery notifications + /// while still allowing it to execute when run directly. + /// + bool IsNotDiscoverable { get; set; } + /// /// Links an external cancellation token to this test's execution token. /// Useful for coordinating cancellation across multiple operations or tests. diff --git a/TUnit.Core/TestContext.Execution.cs b/TUnit.Core/TestContext.Execution.cs index 1ac299776c..2a45e0848d 100644 --- a/TUnit.Core/TestContext.Execution.cs +++ b/TUnit.Core/TestContext.Execution.cs @@ -22,6 +22,7 @@ public partial class TestContext internal Func>? RetryFunc { get; set; } internal IHookExecutor? CustomHookExecutor { get; set; } internal bool ReportResult { get; set; } = true; + internal bool IsNotDiscoverable { get; set; } // Explicit interface implementations for ITestExecution TestPhase ITestExecution.Phase => Phase; @@ -62,6 +63,11 @@ bool ITestExecution.ReportResult get => ReportResult; set => ReportResult = value; } + bool ITestExecution.IsNotDiscoverable + { + get => IsNotDiscoverable; + set => IsNotDiscoverable = value; + } void ITestExecution.OverrideResult(TestState state, string reason) => OverrideResult(state, reason); void ITestExecution.AddLinkedCancellationToken(CancellationToken cancellationToken) => AddLinkedCancellationToken(cancellationToken); diff --git a/TUnit.Engine/TUnitMessageBus.cs b/TUnit.Engine/TUnitMessageBus.cs index 00a6212010..0eecd07bfd 100644 --- a/TUnit.Engine/TUnitMessageBus.cs +++ b/TUnit.Engine/TUnitMessageBus.cs @@ -23,6 +23,11 @@ internal class TUnitMessageBus(IExtension extension, ICommandLineOptions command public async ValueTask Discovered(TestContext testContext) { + if (testContext.IsNotDiscoverable) + { + return; + } + await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage( sessionUid: _sessionSessionUid, testNode: testContext.ToTestNode(DiscoveredTestNodeStateProperty.CachedInstance) 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 23efc08b5c..693efbbaa0 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 @@ -1004,6 +1004,16 @@ namespace public override int GetHashCode() { } protected virtual bool PrintMembers(.StringBuilder stringBuilder) { } } + [(.Assembly | .Class | .Method, AllowMultiple=false, Inherited=true)] + public class NotDiscoverableAttribute : .TUnitAttribute, ., . + { + public NotDiscoverableAttribute() { } + public NotDiscoverableAttribute(string reason) { } + public int Order { get; } + public string? Reason { get; } + public . OnTestRegistered(.TestRegisteredContext context) { } + public virtual . ShouldHide(.TestRegisteredContext context) { } + } [(.Assembly | .Class | .Method)] public class NotInParallelAttribute : .SingleTUnitAttribute, .IScopedAttribute, ., . { @@ -2412,6 +2422,7 @@ namespace .Interfaces .CancellationToken CancellationToken { get; } int CurrentRetryAttempt { get; } .? CustomHookExecutor { get; set; } + bool IsNotDiscoverable { get; set; } .TestPhase Phase { get; } bool ReportResult { get; set; } .TestResult? Result { get; } 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 83d0d32987..3a5b603267 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 @@ -1004,6 +1004,16 @@ namespace public override int GetHashCode() { } protected virtual bool PrintMembers(.StringBuilder stringBuilder) { } } + [(.Assembly | .Class | .Method, AllowMultiple=false, Inherited=true)] + public class NotDiscoverableAttribute : .TUnitAttribute, ., . + { + public NotDiscoverableAttribute() { } + public NotDiscoverableAttribute(string reason) { } + public int Order { get; } + public string? Reason { get; } + public . OnTestRegistered(.TestRegisteredContext context) { } + public virtual . ShouldHide(.TestRegisteredContext context) { } + } [(.Assembly | .Class | .Method)] public class NotInParallelAttribute : .SingleTUnitAttribute, .IScopedAttribute, ., . { @@ -2412,6 +2422,7 @@ namespace .Interfaces .CancellationToken CancellationToken { get; } int CurrentRetryAttempt { get; } .? CustomHookExecutor { get; set; } + bool IsNotDiscoverable { get; set; } .TestPhase Phase { get; } bool ReportResult { get; set; } .TestResult? Result { get; } 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 36ed6038ef..529f3101a9 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 @@ -1004,6 +1004,16 @@ namespace public override int GetHashCode() { } protected virtual bool PrintMembers(.StringBuilder stringBuilder) { } } + [(.Assembly | .Class | .Method, AllowMultiple=false, Inherited=true)] + public class NotDiscoverableAttribute : .TUnitAttribute, ., . + { + public NotDiscoverableAttribute() { } + public NotDiscoverableAttribute(string reason) { } + public int Order { get; } + public string? Reason { get; } + public . OnTestRegistered(.TestRegisteredContext context) { } + public virtual . ShouldHide(.TestRegisteredContext context) { } + } [(.Assembly | .Class | .Method)] public class NotInParallelAttribute : .SingleTUnitAttribute, .IScopedAttribute, ., . { @@ -2412,6 +2422,7 @@ namespace .Interfaces .CancellationToken CancellationToken { get; } int CurrentRetryAttempt { get; } .? CustomHookExecutor { get; set; } + bool IsNotDiscoverable { get; set; } .TestPhase Phase { get; } bool ReportResult { get; set; } .TestResult? Result { get; } 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 387faefc64..de5560aeb5 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 @@ -967,6 +967,16 @@ namespace public override int GetHashCode() { } protected virtual bool PrintMembers(.StringBuilder stringBuilder) { } } + [(.Assembly | .Class | .Method, AllowMultiple=false, Inherited=true)] + public class NotDiscoverableAttribute : .TUnitAttribute, ., . + { + public NotDiscoverableAttribute() { } + public NotDiscoverableAttribute(string reason) { } + public int Order { get; } + public string? Reason { get; } + public . OnTestRegistered(.TestRegisteredContext context) { } + public virtual . ShouldHide(.TestRegisteredContext context) { } + } [(.Assembly | .Class | .Method)] public class NotInParallelAttribute : .SingleTUnitAttribute, .IScopedAttribute, ., . { @@ -2342,6 +2352,7 @@ namespace .Interfaces .CancellationToken CancellationToken { get; } int CurrentRetryAttempt { get; } .? CustomHookExecutor { get; set; } + bool IsNotDiscoverable { get; set; } .TestPhase Phase { get; } bool ReportResult { get; set; } .TestResult? Result { get; } diff --git a/TUnit.TestProject/NotDiscoverableTests.cs b/TUnit.TestProject/NotDiscoverableTests.cs new file mode 100644 index 0000000000..86dde5bd74 --- /dev/null +++ b/TUnit.TestProject/NotDiscoverableTests.cs @@ -0,0 +1,61 @@ +namespace TUnit.TestProject; + +public class NotDiscoverableTests +{ + [Test] + [NotDiscoverable] + public void Test_WithNotDiscoverable_ShouldNotAppearInDiscovery() + { + // This test should execute but not appear in test explorer + } + + [Test] + [NotDiscoverable("Infrastructure test")] + public void Test_WithNotDiscoverableAndReason_ShouldNotAppearInDiscovery() + { + // This test should execute but not appear in test explorer + } + + [Test] + public void Test_WithoutNotDiscoverable_ShouldAppearInDiscovery() + { + // This test should appear normally in test explorer + } +} + +[NotDiscoverable] +public class NotDiscoverableClassTests +{ + [Test] + public void Test_InNotDiscoverableClass_ShouldNotAppearInDiscovery() + { + // All tests in this class should be hidden from discovery + } + + [Test] + public void AnotherTest_InNotDiscoverableClass_ShouldNotAppearInDiscovery() + { + // All tests in this class should be hidden from discovery + } +} + +public class ConditionalNotDiscoverableAttribute : NotDiscoverableAttribute +{ + public ConditionalNotDiscoverableAttribute() : base("Conditionally hidden") { } + + public override Task ShouldHide(TestRegisteredContext context) + { + // Only hide if environment variable is set + return Task.FromResult(Environment.GetEnvironmentVariable("HIDE_TEST") == "true"); + } +} + +public class ConditionalNotDiscoverableTests +{ + [Test] + [ConditionalNotDiscoverable] + public void Test_WithConditionalNotDiscoverable_HidesBasedOnCondition() + { + // This test is hidden only when HIDE_TEST=true + } +}