Skip to content

Commit 50f1dd4

Browse files
authored
feat: add NotDiscoverableAttribute to hide tests from discovery (#4154)
* feat: add IsNotDiscoverable property to TestContext * feat: add NotDiscoverableAttribute * feat: skip discovery notification for NotDiscoverable tests * test: add integration tests for NotDiscoverableAttribute * test: add conditional NotDiscoverable test * chore: update public API snapshots for NotDiscoverableAttribute
1 parent c91b8e2 commit 50f1dd4

9 files changed

Lines changed: 223 additions & 0 deletions
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using TUnit.Core.Interfaces;
2+
3+
namespace TUnit.Core;
4+
5+
/// <summary>
6+
/// Specifies that a test method, test class, or assembly should be hidden from test discovery/explorer.
7+
/// </summary>
8+
/// <remarks>
9+
/// <para>
10+
/// When applied to a test method, class, or assembly, the NotDiscoverableAttribute prevents the test(s)
11+
/// from appearing in test explorers and IDE test runners, while still allowing them to execute normally
12+
/// when run via filters or direct invocation.
13+
/// </para>
14+
/// <para>
15+
/// This is useful for infrastructure tests, internal helpers, or tests that should only be run
16+
/// as dependencies of other tests.
17+
/// </para>
18+
/// </remarks>
19+
/// <example>
20+
/// <code>
21+
/// // Simple usage - hide test from explorer
22+
/// [Test]
23+
/// [NotDiscoverable]
24+
/// public void InfrastructureSetupTest()
25+
/// {
26+
/// // This test will not appear in test explorer but can still be executed
27+
/// }
28+
///
29+
/// // With reason for documentation
30+
/// [Test]
31+
/// [NotDiscoverable("Internal fixture helper - not meant to be run directly")]
32+
/// public void SharedFixtureSetup()
33+
/// {
34+
/// // Hidden from discovery
35+
/// }
36+
///
37+
/// // Conditional hiding via inheritance
38+
/// public class NotDiscoverableOnCIAttribute : NotDiscoverableAttribute
39+
/// {
40+
/// public NotDiscoverableOnCIAttribute() : base("Hidden on CI") { }
41+
///
42+
/// public override Task&lt;bool&gt; ShouldHide(TestRegisteredContext context)
43+
/// {
44+
/// return Task.FromResult(Environment.GetEnvironmentVariable("CI") == "true");
45+
/// }
46+
/// }
47+
/// </code>
48+
/// </example>
49+
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = false, Inherited = true)]
50+
public class NotDiscoverableAttribute : TUnitAttribute, ITestRegisteredEventReceiver
51+
{
52+
/// <summary>
53+
/// Gets the reason why this test is hidden from discovery.
54+
/// </summary>
55+
public string? Reason { get; }
56+
57+
/// <summary>
58+
/// Initializes a new instance of the <see cref="NotDiscoverableAttribute"/> class.
59+
/// </summary>
60+
public NotDiscoverableAttribute()
61+
{
62+
}
63+
64+
/// <summary>
65+
/// Initializes a new instance of the <see cref="NotDiscoverableAttribute"/> class with a reason.
66+
/// </summary>
67+
/// <param name="reason">The reason why this test is hidden from discovery.</param>
68+
public NotDiscoverableAttribute(string reason)
69+
{
70+
Reason = reason;
71+
}
72+
73+
/// <inheritdoc />
74+
public int Order => int.MinValue;
75+
76+
/// <inheritdoc />
77+
public async ValueTask OnTestRegistered(TestRegisteredContext context)
78+
{
79+
if (await ShouldHide(context))
80+
{
81+
context.TestContext.IsNotDiscoverable = true;
82+
}
83+
}
84+
85+
/// <summary>
86+
/// Determines whether the test should be hidden from discovery.
87+
/// </summary>
88+
/// <param name="context">The test context containing information about the test being registered.</param>
89+
/// <returns>
90+
/// A task that represents the asynchronous operation.
91+
/// The task result is true if the test should be hidden; otherwise, false.
92+
/// </returns>
93+
/// <remarks>
94+
/// Can be overridden in derived classes to implement conditional hiding logic
95+
/// based on specific conditions or criteria.
96+
///
97+
/// The default implementation always returns true, meaning the test will always be hidden.
98+
/// </remarks>
99+
public virtual Task<bool> ShouldHide(TestRegisteredContext context) => Task.FromResult(true);
100+
}

TUnit.Core/Interfaces/ITestExecution.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ public interface ITestExecution
107107
/// </summary>
108108
bool ReportResult { get; set; }
109109

110+
/// <summary>
111+
/// Gets or sets whether the test should be hidden from test discovery/explorer.
112+
/// Defaults to false. Set to true to hide the test from discovery notifications
113+
/// while still allowing it to execute when run directly.
114+
/// </summary>
115+
bool IsNotDiscoverable { get; set; }
116+
110117
/// <summary>
111118
/// Links an external cancellation token to this test's execution token.
112119
/// Useful for coordinating cancellation across multiple operations or tests.

TUnit.Core/TestContext.Execution.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public partial class TestContext
2222
internal Func<TestContext, Exception, int, Task<bool>>? RetryFunc { get; set; }
2323
internal IHookExecutor? CustomHookExecutor { get; set; }
2424
internal bool ReportResult { get; set; } = true;
25+
internal bool IsNotDiscoverable { get; set; }
2526

2627
// Explicit interface implementations for ITestExecution
2728
TestPhase ITestExecution.Phase => Phase;
@@ -62,6 +63,11 @@ bool ITestExecution.ReportResult
6263
get => ReportResult;
6364
set => ReportResult = value;
6465
}
66+
bool ITestExecution.IsNotDiscoverable
67+
{
68+
get => IsNotDiscoverable;
69+
set => IsNotDiscoverable = value;
70+
}
6571

6672
void ITestExecution.OverrideResult(TestState state, string reason) => OverrideResult(state, reason);
6773
void ITestExecution.AddLinkedCancellationToken(CancellationToken cancellationToken) => AddLinkedCancellationToken(cancellationToken);

TUnit.Engine/TUnitMessageBus.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ internal class TUnitMessageBus(IExtension extension, ICommandLineOptions command
2323

2424
public async ValueTask Discovered(TestContext testContext)
2525
{
26+
if (testContext.IsNotDiscoverable)
27+
{
28+
return;
29+
}
30+
2631
await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(
2732
sessionUid: _sessionSessionUid,
2833
testNode: testContext.ToTestNode(DiscoveredTestNodeStateProperty.CachedInstance)

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,16 @@ namespace
10041004
public override int GetHashCode() { }
10051005
protected virtual bool PrintMembers(.StringBuilder stringBuilder) { }
10061006
}
1007+
[(.Assembly | .Class | .Method, AllowMultiple=false, Inherited=true)]
1008+
public class NotDiscoverableAttribute : .TUnitAttribute, ., .
1009+
{
1010+
public NotDiscoverableAttribute() { }
1011+
public NotDiscoverableAttribute(string reason) { }
1012+
public int Order { get; }
1013+
public string? Reason { get; }
1014+
public . OnTestRegistered(.TestRegisteredContext context) { }
1015+
public virtual .<bool> ShouldHide(.TestRegisteredContext context) { }
1016+
}
10071017
[(.Assembly | .Class | .Method)]
10081018
public class NotInParallelAttribute : .SingleTUnitAttribute, .IScopedAttribute, ., .
10091019
{
@@ -2412,6 +2422,7 @@ namespace .Interfaces
24122422
.CancellationToken CancellationToken { get; }
24132423
int CurrentRetryAttempt { get; }
24142424
.? CustomHookExecutor { get; set; }
2425+
bool IsNotDiscoverable { get; set; }
24152426
.TestPhase Phase { get; }
24162427
bool ReportResult { get; set; }
24172428
.TestResult? Result { get; }

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,16 @@ namespace
10041004
public override int GetHashCode() { }
10051005
protected virtual bool PrintMembers(.StringBuilder stringBuilder) { }
10061006
}
1007+
[(.Assembly | .Class | .Method, AllowMultiple=false, Inherited=true)]
1008+
public class NotDiscoverableAttribute : .TUnitAttribute, ., .
1009+
{
1010+
public NotDiscoverableAttribute() { }
1011+
public NotDiscoverableAttribute(string reason) { }
1012+
public int Order { get; }
1013+
public string? Reason { get; }
1014+
public . OnTestRegistered(.TestRegisteredContext context) { }
1015+
public virtual .<bool> ShouldHide(.TestRegisteredContext context) { }
1016+
}
10071017
[(.Assembly | .Class | .Method)]
10081018
public class NotInParallelAttribute : .SingleTUnitAttribute, .IScopedAttribute, ., .
10091019
{
@@ -2412,6 +2422,7 @@ namespace .Interfaces
24122422
.CancellationToken CancellationToken { get; }
24132423
int CurrentRetryAttempt { get; }
24142424
.? CustomHookExecutor { get; set; }
2425+
bool IsNotDiscoverable { get; set; }
24152426
.TestPhase Phase { get; }
24162427
bool ReportResult { get; set; }
24172428
.TestResult? Result { get; }

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,16 @@ namespace
10041004
public override int GetHashCode() { }
10051005
protected virtual bool PrintMembers(.StringBuilder stringBuilder) { }
10061006
}
1007+
[(.Assembly | .Class | .Method, AllowMultiple=false, Inherited=true)]
1008+
public class NotDiscoverableAttribute : .TUnitAttribute, ., .
1009+
{
1010+
public NotDiscoverableAttribute() { }
1011+
public NotDiscoverableAttribute(string reason) { }
1012+
public int Order { get; }
1013+
public string? Reason { get; }
1014+
public . OnTestRegistered(.TestRegisteredContext context) { }
1015+
public virtual .<bool> ShouldHide(.TestRegisteredContext context) { }
1016+
}
10071017
[(.Assembly | .Class | .Method)]
10081018
public class NotInParallelAttribute : .SingleTUnitAttribute, .IScopedAttribute, ., .
10091019
{
@@ -2412,6 +2422,7 @@ namespace .Interfaces
24122422
.CancellationToken CancellationToken { get; }
24132423
int CurrentRetryAttempt { get; }
24142424
.? CustomHookExecutor { get; set; }
2425+
bool IsNotDiscoverable { get; set; }
24152426
.TestPhase Phase { get; }
24162427
bool ReportResult { get; set; }
24172428
.TestResult? Result { get; }

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,16 @@ namespace
967967
public override int GetHashCode() { }
968968
protected virtual bool PrintMembers(.StringBuilder stringBuilder) { }
969969
}
970+
[(.Assembly | .Class | .Method, AllowMultiple=false, Inherited=true)]
971+
public class NotDiscoverableAttribute : .TUnitAttribute, ., .
972+
{
973+
public NotDiscoverableAttribute() { }
974+
public NotDiscoverableAttribute(string reason) { }
975+
public int Order { get; }
976+
public string? Reason { get; }
977+
public . OnTestRegistered(.TestRegisteredContext context) { }
978+
public virtual .<bool> ShouldHide(.TestRegisteredContext context) { }
979+
}
970980
[(.Assembly | .Class | .Method)]
971981
public class NotInParallelAttribute : .SingleTUnitAttribute, .IScopedAttribute, ., .
972982
{
@@ -2342,6 +2352,7 @@ namespace .Interfaces
23422352
.CancellationToken CancellationToken { get; }
23432353
int CurrentRetryAttempt { get; }
23442354
.? CustomHookExecutor { get; set; }
2355+
bool IsNotDiscoverable { get; set; }
23452356
.TestPhase Phase { get; }
23462357
bool ReportResult { get; set; }
23472358
.TestResult? Result { get; }
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
namespace TUnit.TestProject;
2+
3+
public class NotDiscoverableTests
4+
{
5+
[Test]
6+
[NotDiscoverable]
7+
public void Test_WithNotDiscoverable_ShouldNotAppearInDiscovery()
8+
{
9+
// This test should execute but not appear in test explorer
10+
}
11+
12+
[Test]
13+
[NotDiscoverable("Infrastructure test")]
14+
public void Test_WithNotDiscoverableAndReason_ShouldNotAppearInDiscovery()
15+
{
16+
// This test should execute but not appear in test explorer
17+
}
18+
19+
[Test]
20+
public void Test_WithoutNotDiscoverable_ShouldAppearInDiscovery()
21+
{
22+
// This test should appear normally in test explorer
23+
}
24+
}
25+
26+
[NotDiscoverable]
27+
public class NotDiscoverableClassTests
28+
{
29+
[Test]
30+
public void Test_InNotDiscoverableClass_ShouldNotAppearInDiscovery()
31+
{
32+
// All tests in this class should be hidden from discovery
33+
}
34+
35+
[Test]
36+
public void AnotherTest_InNotDiscoverableClass_ShouldNotAppearInDiscovery()
37+
{
38+
// All tests in this class should be hidden from discovery
39+
}
40+
}
41+
42+
public class ConditionalNotDiscoverableAttribute : NotDiscoverableAttribute
43+
{
44+
public ConditionalNotDiscoverableAttribute() : base("Conditionally hidden") { }
45+
46+
public override Task<bool> ShouldHide(TestRegisteredContext context)
47+
{
48+
// Only hide if environment variable is set
49+
return Task.FromResult(Environment.GetEnvironmentVariable("HIDE_TEST") == "true");
50+
}
51+
}
52+
53+
public class ConditionalNotDiscoverableTests
54+
{
55+
[Test]
56+
[ConditionalNotDiscoverable]
57+
public void Test_WithConditionalNotDiscoverable_HidesBasedOnCondition()
58+
{
59+
// This test is hidden only when HIDE_TEST=true
60+
}
61+
}

0 commit comments

Comments
 (0)