Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat: TUnit.AspNetCore
  • Loading branch information
thomhurst committed Dec 21, 2025
commit 2d1efb54259b847b9845a852d0a995df7103baad
11 changes: 6 additions & 5 deletions TUnit.AspNetCore/WebApplicationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,16 @@ public abstract class WebApplicationTest<TFactory, TEntryPoint> : WebApplication
where TFactory : TestWebApplicationFactory<TEntryPoint>, new()
where TEntryPoint : class
{
private static readonly TFactory _globalFactory = new();
[ClassDataSource(Shared = [SharedType.PerTestSession])]
public TFactory GlobalFactory { get; set; } = null!;

private WebApplicationFactory<TEntryPoint>? _factory;

/// <summary>
/// Gets the per-test delegating factory. This factory is isolated to the current test.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if accessed before test setup.</exception>
public WebApplicationFactory<TEntryPoint> Factory => _factory
?? throw new InvalidOperationException(
public WebApplicationFactory<TEntryPoint> Factory => _factory ?? throw new InvalidOperationException(
"Factory is not initialized. Ensure the test has started and the BeforeTest hook has run. " +
"Do not access Factory during test discovery or in data source methods.");

Expand All @@ -110,7 +111,7 @@ public async Task InitializeFactoryAsync(TestContext testContext)
await SetupAsync();

// Then create factory with sync configuration (required by ASP.NET Core hosting)
_factory = _globalFactory.GetIsolatedFactory(
_factory = GlobalFactory.GetIsolatedFactory(
testContext,
Options,
ConfigureTestServices,
Expand Down Expand Up @@ -241,5 +242,5 @@ protected virtual void ConfigureWebHostBuilder(IWebHostBuilder builder)
/// }
/// </code>
/// </example>
public HttpExchangeCapture? HttpCapture => Services.GetService<HttpExchangeCapture>();
public HttpExchangeCapture? HttpCapture => field ??= new();
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

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

The HttpCapture property uses a field-backed property with C# 13 syntax (field ??= new()), but this creates a new instance every time it's accessed if HttpExchangeCapture is not enabled in Options. This is inconsistent with the documentation which states it "Returns null if HTTP exchange capture is not enabled."

The current implementation will always return a non-null instance, but it won't be wired into the middleware unless EnableHttpExchangeCapture is true. This could be confusing for users who check if HttpCapture is null to determine if capture is enabled.

Consider either:

  1. Returning null when capture is not enabled: public HttpExchangeCapture? HttpCapture => Options.EnableHttpExchangeCapture ? (field ??= new()) : null;
  2. Updating the documentation to match the current behavior (always returns an instance, but only captures when enabled)
Suggested change
public HttpExchangeCapture? HttpCapture => field ??= new();
public HttpExchangeCapture? HttpCapture =>
Options.EnableHttpExchangeCapture ? (field ??= new()) : null;

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
return null;
}

// Skip open generic types - we can't generate code for types with unbound type parameters
// The initialization will happen in the consuming assembly that provides concrete type arguments
if (typeSymbol.IsGenericType && typeSymbol.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter))
{
return null;
}

// Check if this type has any static properties with data source attributes
var hasStaticPropertiesWithDataSources = GetStaticPropertyDataSources(typeSymbol).Any();

return hasStaticPropertiesWithDataSources ? typeSymbol : null;
}

Expand Down
8 changes: 6 additions & 2 deletions TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,14 @@ public static string GloballyQualified(this ISymbol typeSymbol)
if (typeSymbol is INamedTypeSymbol { IsGenericType: true } namedTypeSymbol)
{
// Check if this is an unbound generic type or has type parameter arguments
// Use multiple detection methods for robustness across Roslyn versions
var hasTypeParameters = namedTypeSymbol.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter);
var hasTypeParameterSymbols = namedTypeSymbol.TypeArguments.OfType<ITypeParameterSymbol>().Any();
var isUnboundGeneric = namedTypeSymbol.IsUnboundGenericType;

if (hasTypeParameters || isUnboundGeneric)
// Also detect generic type definitions by checking if type equals its OriginalDefinition
var isGenericTypeDefinition = SymbolEqualityComparer.Default.Equals(namedTypeSymbol, namedTypeSymbol.OriginalDefinition);

if (hasTypeParameters || hasTypeParameterSymbols || isUnboundGeneric || isGenericTypeDefinition)
{
// Special case for System.Nullable<> - Roslyn displays it as "T?" even for open generic
if (namedTypeSymbol.SpecialType == SpecialType.System_Nullable_T ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@
var inheritedProperties = typeSymbol.GetMembersIncludingBase()
.OfType<IPropertySymbol>()
.Where(CanSetProperty)
.Where(p => p.ContainingType != typeSymbol)

Check warning on line 261 in TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (pl-PL)

Użyj elementu „SymbolEqualityComparer” podczas porównywania symboli

Check warning on line 261 in TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (fr-FR)

Utiliser ’SymbolEqualityComparer’ lors de la comparaison de symboles

Check warning on line 261 in TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Use 'SymbolEqualityComparer' when comparing symbols
.ToList();

foreach (var property in directProperties)
Expand Down Expand Up @@ -605,11 +605,26 @@
TypedConstantKind.Primitive => constant.Value?.ToString() ?? "null",
TypedConstantKind.Enum => FormatEnumConstant(constant),
TypedConstantKind.Type => FormatTypeConstant(constant),
TypedConstantKind.Array => $"new object[] {{ {string.Join(", ", constant.Values.Select(FormatTypedConstant))} }}",
TypedConstantKind.Array => FormatArrayConstant(constant),
_ => constant.Value?.ToString() ?? "null"
};
}

private static string FormatArrayConstant(TypedConstant constant)
{
// Get the element type from the array type (e.g., SharedType[] -> SharedType)
if (constant.Type is IArrayTypeSymbol arrayType)
{
var elementTypeName = arrayType.ElementType.ToDisplayString();
var elements = string.Join(", ", constant.Values.Select(FormatTypedConstant));
return $"new {elementTypeName}[] {{ {elements} }}";
}

// Fallback to object[] if type information is not available
var fallbackElements = string.Join(", ", constant.Values.Select(FormatTypedConstant));
return $"new object[] {{ {fallbackElements} }}";
}

private static string FormatEnumConstant(TypedConstant constant)
{
if (constant is { Type: not null, Value: not null })
Expand Down
19 changes: 15 additions & 4 deletions TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -426,12 +426,23 @@ private static string GetPropertyAccessor(INamedTypeSymbol namedTypeSymbol, IPro
// For generic types with unresolved type parameters, we can't cast to the open generic type
// We need to use dynamic or reflection
var hasUnresolvedTypeParameters = namedTypeSymbol.IsGenericType &&
namedTypeSymbol.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter);
(namedTypeSymbol.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter) ||
namedTypeSymbol.TypeArguments.OfType<ITypeParameterSymbol>().Any() ||
SymbolEqualityComparer.Default.Equals(namedTypeSymbol, namedTypeSymbol.OriginalDefinition));

if (hasUnresolvedTypeParameters && !property.IsStatic)
if (hasUnresolvedTypeParameters)
{
// Use dynamic to avoid invalid cast to open generic type
return $"o => ((dynamic)o).{property.Name}";
if (property.IsStatic)
{
// Can't access static members on an unbound generic type like WebApplicationTest<,>
// Use reflection to get the value at runtime
return $"_ => typeof({namedTypeSymbol.GloballyQualified()}).GetProperty(\"{property.Name}\")?.GetValue(null)";
}
else
{
// Use dynamic to avoid invalid cast to open generic type
return $"o => ((dynamic)o).{property.Name}";
}
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

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

Both branches of this 'if' statement return - consider using '?' to express intent better.

Suggested change
if (property.IsStatic)
{
// Can't access static members on an unbound generic type like WebApplicationTest<,>
// Use reflection to get the value at runtime
return $"_ => typeof({namedTypeSymbol.GloballyQualified()}).GetProperty(\"{property.Name}\")?.GetValue(null)";
}
else
{
// Use dynamic to avoid invalid cast to open generic type
return $"o => ((dynamic)o).{property.Name}";
}
return property.IsStatic
// Can't access static members on an unbound generic type like WebApplicationTest<,>
// Use reflection to get the value at runtime
? $"_ => typeof({namedTypeSymbol.GloballyQualified()}).GetProperty(\"{property.Name}\")?.GetValue(null)"
// Use dynamic to avoid invalid cast to open generic type
: $"o => ((dynamic)o).{property.Name}";

Copilot uses AI. Check for mistakes.
}

var safeTypeName = namedTypeSymbol.GloballyQualified();
Expand Down
64 changes: 44 additions & 20 deletions TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,15 @@
namespace TUnit.Core;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)]
public sealed class ClassDataSourceAttribute<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T>
: DataSourceGeneratorAttribute<T>
public sealed class ClassDataSourceAttribute : UntypedDataSourceGeneratorAttribute
{
public SharedType Shared { get; set; } = SharedType.None;
public string Key { get; set; } = string.Empty;
public Type ClassType => typeof(T);
private Type[] _types;

protected override IEnumerable<Func<T>> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata)
public ClassDataSourceAttribute()
{
var testClassType = TestClassTypeHelper.GetTestClassType(dataGeneratorMetadata);
yield return () => ClassDataSources.Get(dataGeneratorMetadata.TestSessionId)
.Get<T>(Shared, testClassType, Key, dataGeneratorMetadata);
_types = [];
}
Comment on lines +9 to 14
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

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

The field _types is initialized in the parameterless constructor but can also be set in the params constructor. This means _types could be assigned twice if someone uses the parameterless constructor path. Additionally, in the GenerateDataSources method at line 86, there's a check for _types.Length == 0, but this will never be true if the params constructor was used (it would be empty array [], not null). The logic appears to work, but the dual initialization is confusing.

Consider making _types readonly and initializing it only once, or making the intent clearer with comments explaining why both constructors set it.

Copilot uses AI. Check for mistakes.


public IEnumerable<SharedType> GetSharedTypes() => [Shared];

public IEnumerable<string> GetKeys() => string.IsNullOrEmpty(Key) ? [] : [Key];
}

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)]
public sealed class ClassDataSourceAttribute : UntypedDataSourceGeneratorAttribute
{
private readonly Type[] _types;

[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Non-params constructor calls params one with proper annotations.")]
public ClassDataSourceAttribute(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)]
Expand Down Expand Up @@ -99,6 +83,25 @@ public ClassDataSourceAttribute(params Type[] types)
{
yield return () =>
{
if (_types.Length == 0)
{
_types = dataGeneratorMetadata.MembersToGenerate.Select(x =>
{
if (x is ParameterMetadata parameterMetadata)
{
return parameterMetadata.Type;
}

if (x is PropertyMetadata propertyMetadata)
{
return propertyMetadata.Type;
}

throw new ArgumentOutOfRangeException(nameof(dataGeneratorMetadata),
"Member to generate must be either a parameter or a property.");
}).ToArray();
}

var items = new object?[_types.Length];

for (var i = 0; i < _types.Length; i++)
Expand All @@ -117,3 +120,24 @@ public ClassDataSourceAttribute(params Type[] types)
public IEnumerable<string> GetKeys() => Keys;

}

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)]
public sealed class ClassDataSourceAttribute<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T>
: DataSourceGeneratorAttribute<T>
{
public SharedType Shared { get; set; } = SharedType.None;
public string Key { get; set; } = string.Empty;
public Type ClassType => typeof(T);

protected override IEnumerable<Func<T>> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata)
{
var testClassType = TestClassTypeHelper.GetTestClassType(dataGeneratorMetadata);
yield return () => ClassDataSources.Get(dataGeneratorMetadata.TestSessionId)
.Get<T>(Shared, testClassType, Key, dataGeneratorMetadata);
}


public IEnumerable<SharedType> GetSharedTypes() => [Shared];

public IEnumerable<string> GetKeys() => string.IsNullOrEmpty(Key) ? [] : [Key];
}
2 changes: 1 addition & 1 deletion TUnit.Engine/Framework/TUnitServiceProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ public TUnitServiceProvider(IExtension extension,
Logger,
ParallelLimitLockProvider));

var staticPropertyHandler = Register(new StaticPropertyHandler(Logger, objectTracker, trackableObjectGraphProvider, disposer));
var staticPropertyHandler = Register(new StaticPropertyHandler(Logger, objectTracker, trackableObjectGraphProvider, disposer, lazyPropertyInjector, objectGraphDiscoveryService));

var dynamicTestQueue = Register<IDynamicTestQueue>(new DynamicTestQueue(MessageBus));

Expand Down
57 changes: 56 additions & 1 deletion TUnit.Engine/Services/StaticPropertyHandler.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using TUnit.Core;
using TUnit.Core.Helpers;
using TUnit.Core.StaticProperties;
Expand All @@ -15,17 +16,24 @@ internal sealed class StaticPropertyHandler
private readonly ObjectTracker _objectTracker;
private readonly TrackableObjectGraphProvider _trackableObjectGraphProvider;
private readonly Disposer _disposer;
private readonly Lazy<PropertyInjector> _propertyInjector;
private readonly ObjectGraphDiscoveryService _objectGraphDiscoveryService;
private readonly ConcurrentDictionary<string, object?> _sessionObjectBag = new();
private bool _initialized;

public StaticPropertyHandler(TUnitFrameworkLogger logger,
ObjectTracker objectTracker,
TrackableObjectGraphProvider trackableObjectGraphProvider,
Disposer disposer)
Disposer disposer,
Lazy<PropertyInjector> propertyInjector,
ObjectGraphDiscoveryService objectGraphDiscoveryService)
{
_logger = logger;
_objectTracker = objectTracker;
_trackableObjectGraphProvider = trackableObjectGraphProvider;
_disposer = disposer;
_propertyInjector = propertyInjector;
_objectGraphDiscoveryService = objectGraphDiscoveryService;
}

/// <summary>
Expand All @@ -50,6 +58,20 @@ public async Task InitializeStaticPropertiesAsync(CancellationToken cancellation

if (value != null)
{
// Inject instance properties on the value before initialization
// This handles cases where the static property's type has instance properties
// with data source attributes (e.g., [ClassDataSource] with Shared = PerTestSession)
await _propertyInjector.Value.InjectPropertiesAsync(
value,
_sessionObjectBag,
methodMetadata: null,
new TestContextEvents());

// Initialize nested objects depth-first BEFORE initializing the main value
// This ensures containers (IAsyncInitializer) are started before the factory
// that depends on them calls methods like GetConnectionString()
await InitializeNestedObjectsAsync(value, cancellationToken);

// Initialize the value (IAsyncInitializer, etc.)
await ObjectInitializer.InitializeAsync(value, cancellationToken);

Expand Down Expand Up @@ -97,4 +119,37 @@ public async Task DisposeStaticPropertiesAsync(List<Exception> cleanupExceptions
throw new AggregateException("Errors occurred while disposing static properties", cleanupExceptions);
}
}

/// <summary>
/// Initializes nested objects depth-first (deepest first) before the parent object.
/// This ensures that containers/resources are fully initialized before the parent
/// object (like a WebApplicationFactory) tries to use them.
/// </summary>
private async Task InitializeNestedObjectsAsync(object rootObject, CancellationToken cancellationToken)
{
var graph = _objectGraphDiscoveryService.DiscoverNestedObjectGraph(rootObject, cancellationToken);

// Initialize from deepest to shallowest (skip depth 0 which is the root itself)
foreach (var depth in graph.GetDepthsDescending())
{
if (depth == 0)
{
continue; // Root handled separately by caller
}

var objectsAtDepth = graph.GetObjectsAtDepth(depth);

// Pre-allocate task list without LINQ Select
var tasks = new List<Task>();
foreach (var obj in objectsAtDepth)
{
tasks.Add(ObjectInitializer.InitializeAsync(obj, cancellationToken).AsTask());
}

if (tasks.Count > 0)
{
await Task.WhenAll(tasks);
}
}
}
}
1 change: 0 additions & 1 deletion TUnit.Example.Asp.Net.TestProject/RedisTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ namespace TUnit.Example.Asp.Net.TestProject;
/// - <see cref="ConfigureTestConfiguration"/> is auto-additive (no need to call base)
/// - <see cref="GetIsolatedPrefix"/> provides consistent isolation naming
/// </remarks>
[SuppressMessage("Usage", "TUnit0043:Property must use `required` keyword")]
public abstract class RedisTestBase : TestsBase
{
[ClassDataSource<InMemoryRedis>(Shared = SharedType.PerTestSession)]
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Pipeline/Modules/RunAspNetTestsModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class RunAspNetTestsModule : Module<CommandResult>
Configuration = Configuration.Release,
Framework = "net10.0",
WorkingDirectory = project.Folder!,
Arguments = ["--ignore-exit-code", "8", "--hangdump", "--hangdump-filename", "hangdump.aspnet-tests.dmp", "--hangdump-timeout", "5m"],
Arguments = ["--hangdump", "--hangdump-filename", "hangdump.aspnet-tests.dmp", "--hangdump-timeout", "5m"],
EnvironmentVariables = new Dictionary<string, string?>
{
["DISABLE_GITHUB_REPORTER"] = "true",
Expand Down
Loading