Skip to content
Merged
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
100 changes: 100 additions & 0 deletions TUnit.Core/Attributes/TestMetadata/NotDiscoverableAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using TUnit.Core.Interfaces;

namespace TUnit.Core;

/// <summary>
/// Specifies that a test method, test class, or assembly should be hidden from test discovery/explorer.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// This is useful for infrastructure tests, internal helpers, or tests that should only be run
/// as dependencies of other tests.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // 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&lt;bool&gt; ShouldHide(TestRegisteredContext context)
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTML entity '<' should be '<' in the code documentation example.

Suggested change
/// public override Task&lt;bool&gt; ShouldHide(TestRegisteredContext context)
/// public override Task<bool> ShouldHide(TestRegisteredContext context)

Copilot uses AI. Check for mistakes.
/// {
/// return Task.FromResult(Environment.GetEnvironmentVariable("CI") == "true");
/// }
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = false, Inherited = true)]
public class NotDiscoverableAttribute : TUnitAttribute, ITestRegisteredEventReceiver
{
/// <summary>
/// Gets the reason why this test is hidden from discovery.
/// </summary>
public string? Reason { get; }

/// <summary>
/// Initializes a new instance of the <see cref="NotDiscoverableAttribute"/> class.
/// </summary>
public NotDiscoverableAttribute()
{
}

/// <summary>
/// Initializes a new instance of the <see cref="NotDiscoverableAttribute"/> class with a reason.
/// </summary>
/// <param name="reason">The reason why this test is hidden from discovery.</param>
public NotDiscoverableAttribute(string reason)
{
Reason = reason;
}

/// <inheritdoc />
public int Order => int.MinValue;

/// <inheritdoc />
public async ValueTask OnTestRegistered(TestRegisteredContext context)
{
if (await ShouldHide(context))
{
context.TestContext.IsNotDiscoverable = true;
}
}

/// <summary>
/// Determines whether the test should be hidden from discovery.
/// </summary>
/// <param name="context">The test context containing information about the test being registered.</param>
/// <returns>
/// A task that represents the asynchronous operation.
/// The task result is true if the test should be hidden; otherwise, false.
/// </returns>
/// <remarks>
/// 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.
/// </remarks>
public virtual Task<bool> ShouldHide(TestRegisteredContext context) => Task.FromResult(true);
}
7 changes: 7 additions & 0 deletions TUnit.Core/Interfaces/ITestExecution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ public interface ITestExecution
/// </summary>
bool ReportResult { get; set; }

/// <summary>
/// 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.
/// </summary>
bool IsNotDiscoverable { get; set; }

/// <summary>
/// Links an external cancellation token to this test's execution token.
/// Useful for coordinating cancellation across multiple operations or tests.
Expand Down
6 changes: 6 additions & 0 deletions TUnit.Core/TestContext.Execution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public partial class TestContext
internal Func<TestContext, Exception, int, Task<bool>>? 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;
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions TUnit.Engine/TUnitMessageBus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 .<bool> ShouldHide(.TestRegisteredContext context) { }
}
[(.Assembly | .Class | .Method)]
public class NotInParallelAttribute : .SingleTUnitAttribute, .IScopedAttribute, ., .
{
Expand Down Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 .<bool> ShouldHide(.TestRegisteredContext context) { }
}
[(.Assembly | .Class | .Method)]
public class NotInParallelAttribute : .SingleTUnitAttribute, .IScopedAttribute, ., .
{
Expand Down Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 .<bool> ShouldHide(.TestRegisteredContext context) { }
}
[(.Assembly | .Class | .Method)]
public class NotInParallelAttribute : .SingleTUnitAttribute, .IScopedAttribute, ., .
{
Expand Down Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 .<bool> ShouldHide(.TestRegisteredContext context) { }
}
[(.Assembly | .Class | .Method)]
public class NotInParallelAttribute : .SingleTUnitAttribute, .IScopedAttribute, ., .
{
Expand Down Expand Up @@ -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; }
Expand Down
61 changes: 61 additions & 0 deletions TUnit.TestProject/NotDiscoverableTests.cs
Original file line number Diff line number Diff line change
@@ -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<bool> 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
}
}
Loading