-
-
Notifications
You must be signed in to change notification settings - Fork 109
feat: TUnit.AspNetCore #4123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: TUnit.AspNetCore #4123
Conversation
…pdate attribute usage
PR Review: TUnit.AspNetCoreExcellent work! This is a well-designed addition to TUnit with strong architecture and API design. I found one critical issue and several minor improvements: Critical IssueClassDataSourceConstructorAnalyzer.cs:113-145 - The HasAccessibleParameterlessConstructor method doesn't detect implicitly-defined parameterless constructors. Classes without explicit constructors have an implicit public parameterless constructor, but the current code only checks explicit constructors in InstanceConstructors and returns false. Fix: Add logic to return true when constructors.Length == 0 or all constructors have parameters, unless the type is abstract or static. Moderate IssueHttpExchangeCaptureMiddleware.cs:32-57 - Potential stream leak if _next(context) throws before line 56. The responseBodyStream is never copied back to originalBodyStream on exception. Need to ensure stream restoration in finally block even on exception. Minor Issues
Strengths
VerdictApprove with changes. Fix the critical analyzer bug before merge. Great work overall - the architecture is solid! |
Pull Request Review: TUnit.AspNetCoreSummaryThis PR introduces TUnit.AspNetCore, a comprehensive ASP.NET Core testing library, along with important improvements to the analyzer infrastructure and core framework. The implementation is well-designed and follows TUnit's architectural principles. ✅ Strengths1. Excellent Architecture & Design
2. HTTP Exchange CaptureThe middleware-based HTTP capture system is well-implemented:
3. Strong Analyzer FoundationThe new
4. Code Quality
🔍 Issues & RecommendationsCritical Issues
|
| Rule | Status | Notes |
|---|---|---|
| Rule 1: Dual-Mode | Need explicit verification for property injection changes | |
| Rule 2: Snapshots | ✅ Pass | Public API snapshots updated correctly |
| Rule 3: No VSTest | ✅ Pass | Uses Microsoft.Testing.Platform |
| Rule 4: Performance | Missing ArrayPool usage in hot path |
|
| Rule 5: AOT/Trimming | ✅ Pass | Proper [DynamicallyAccessedMembers] annotations |
| Code Style | ✅ Pass | Modern C# 12 syntax, collection expressions, file-scoped namespaces |
📋 Pre-Commit Checklist Status
- ✅ All tests pass (assumed - CI will verify)
- ✅ Source generator changes: snapshots accepted
- ✅ Public API changes: snapshots accepted
⚠️ Dual-mode feature: Verification needed- ❓ Performance-critical: Profiling needed (HTTP middleware is hot path)
- ✅ Touching reflection: AOT annotations present
- ✅ Code follows style guide
- ✅ No breaking changes
🎉 Final Verdict
This is high-quality work that adds significant value to TUnit. The ASP.NET Core integration is well-architected, the analyzer improves DX, and the examples demonstrate real-world patterns.
Recommendation: Approve with minor changes
Required Changes:
- ✅ Add
ArrayPool<byte>toHttpExchangeCaptureMiddleware.ReadBodyAsync(performance critical) - ✅ Verify dual-mode parity for nested property injection (add test or confirm existing coverage)
Suggested Improvements:
- Add
MaxCapturedExchangeslimit to prevent memory leaks - Document analyzer limitations for protected constructors
- Consider adding performance benchmarks for HTTP capture overhead
💡 Kudos
Special recognition for:
- Clean separation of concerns: Logging, service replacement, HTTP capture are independent
- Developer experience: Extension methods like
ReplaceService<T>make tests readable - Documentation: XML comments with examples throughout
- Test examples: The Todo/Redis/Kafka examples serve as excellent documentation
Great work! 🚀
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces TUnit.AspNetCore, a new library that provides ASP.NET Core integration testing helpers for TUnit. The implementation includes base classes for isolated per-test web application factories, correlated logging, and HTTP request/response capture middleware.
Key Changes
- New TUnit.AspNetCore library with
WebApplicationTest<TFactory, TEntryPoint>base class enabling per-test server isolation via delegating factories - Logging integration (
TUnitLogger,TUnitLoggerProvider) to correlate ASP.NET logs back to test context - HTTP capture middleware to intercept and inspect requests/responses for test assertions
- Core framework enhancements to support open generic types in source generation and improved property injection with nested object initialization
Reviewed changes
Copilot reviewed 62 out of 62 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| TUnit.AspNetCore/WebApplicationTest.cs | Core base class providing per-test factory isolation with lifecycle hooks |
| TUnit.AspNetCore/TestWebApplicationFactory.cs | Factory wrapper enabling delegated configuration per test |
| TUnit.AspNetCore/Logging/*.cs | Logging provider and logger to capture ASP.NET logs in test output |
| TUnit.AspNetCore/Interception/*.cs | HTTP exchange capture middleware and storage for request/response inspection |
| TUnit.AspNetCore/Extensions/*.cs | Extension methods for service replacement and logging configuration |
| TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs | Refactored to support parameterless constructor with type inference |
| TUnit.Engine/Services/StaticPropertyHandler.cs | Enhanced to initialize nested objects depth-first before parents |
| TUnit.Core.SourceGenerator/*.cs | Improved handling of open generic types and array constant formatting |
| TUnit.Analyzers/ClassDataSourceConstructorAnalyzer.cs | New analyzer ensuring ClassDataSource types have accessible constructors |
| TUnit.Example.Asp.Net.TestProject/*.cs | Comprehensive example tests demonstrating isolation patterns |
| TUnit.Example.Asp.Net/*.cs | Example ASP.NET app with PostgreSQL, Redis, and Kafka dependencies |
| // Use ArrayPool for better performance with large bodies | ||
| var buffer = new byte[Math.Min(maxSize, 81920)]; // 80KB chunks |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment on line 123 states "Use ArrayPool for better performance with large bodies", but the code doesn't actually use ArrayPool. It allocates a byte array directly with new byte[].
To actually use ArrayPool as the comment suggests, the code should be:
var buffer = ArrayPool<byte>.Shared.Rent(Math.Min(maxSize, 81920));
try
{
// ... existing code ...
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}Either update the code to use ArrayPool (following TUnit's performance guidelines), or remove the misleading comment.
|
|
||
|
|
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Empty lines at 21-22 serve no purpose and should be removed to follow clean code practices.
| private readonly string _categoryName; | ||
| private readonly TestContext _context; | ||
| private readonly LogLevel _minLogLevel; | ||
|
|
||
| internal TUnitLogger(string categoryName, TestContext context, LogLevel minLogLevel) | ||
| { | ||
| _categoryName = categoryName; |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The _categoryName field is stored but never used in the Log method. Category names are typically included in log output for context. Consider either using it in the log format (e.g., Console.WriteLine($"[{logLevel}] {_categoryName}: {message}");) or removing the field if it's intentionally unused.
| TestContext.Current = _context; | ||
|
|
||
| var message = formatter(state, exception); | ||
|
|
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The exception parameter is retrieved from the formatter but not included in the log output. When exceptions are logged, they should be displayed. Consider appending exception details to the message when exception is not null, for example:
var message = formatter(state, exception);
if (exception != null)
{
message = $"{message}\n{exception}";
}| if (exception is not null) | |
| { | |
| message = $"{message}{Environment.NewLine}{exception}"; | |
| } |
|
|
||
| public static readonly DiagnosticDescriptor PropertyRequiredNotSet = | ||
| CreateDescriptor("TUnit0043", UsageCategory, DiagnosticSeverity.Error); | ||
| CreateDescriptor("TUnit0043", UsageCategory, DiagnosticSeverity.Info); |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the comment on line 15, the description references checking if a property "must use required keyword", but the severity has been downgraded from Error to Info. The comment should be updated to accurately describe this is now a suggestion rather than a requirement, or the rule description should be updated to match the new severity level.
| CreateDescriptor("TUnit0043", UsageCategory, DiagnosticSeverity.Info); | |
| CreateDescriptor("TUnit0043", UsageCategory, DiagnosticSeverity.Error); |
| 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 = []; | ||
| } |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
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.
| foreach (var constructor in type.InstanceConstructors) | ||
| { | ||
| if (constructor.Parameters.Length == 0) | ||
| { | ||
| // Check if the constructor is accessible | ||
| if (constructor.DeclaredAccessibility == Accessibility.Public || | ||
| constructor.DeclaredAccessibility == Accessibility.Internal || | ||
| constructor.DeclaredAccessibility == Accessibility.ProtectedOrInternal) | ||
| { | ||
| return true; | ||
| } | ||
| } | ||
| } |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.
|
|
||
| internal WebApplicationTest() | ||
| { | ||
| UniqueId = Interlocked.Increment(ref _idCounter); |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Write to static field from instance method, property, or constructor.
| // Check for an explicit parameterless constructor | ||
| foreach (var constructor in type.InstanceConstructors) | ||
| { | ||
| if (constructor.Parameters.Length == 0) | ||
| { | ||
| // Check if the constructor is accessible | ||
| if (constructor.DeclaredAccessibility == Accessibility.Public || | ||
| constructor.DeclaredAccessibility == Accessibility.Internal || | ||
| constructor.DeclaredAccessibility == Accessibility.ProtectedOrInternal) | ||
| { | ||
| return true; | ||
| } |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 'if' statements can be combined.
| // Check for an explicit parameterless constructor | |
| foreach (var constructor in type.InstanceConstructors) | |
| { | |
| if (constructor.Parameters.Length == 0) | |
| { | |
| // Check if the constructor is accessible | |
| if (constructor.DeclaredAccessibility == Accessibility.Public || | |
| constructor.DeclaredAccessibility == Accessibility.Internal || | |
| constructor.DeclaredAccessibility == Accessibility.ProtectedOrInternal) | |
| { | |
| return true; | |
| } | |
| // Check for an explicit accessible parameterless constructor | |
| foreach (var constructor in type.InstanceConstructors) | |
| { | |
| if (constructor.Parameters.Length == 0 && | |
| (constructor.DeclaredAccessibility == Accessibility.Public || | |
| constructor.DeclaredAccessibility == Accessibility.Internal || | |
| constructor.DeclaredAccessibility == Accessibility.ProtectedOrInternal)) | |
| { | |
| return true; |
| 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}"; | ||
| } |
Copilot
AI
Dec 21, 2025
There was a problem hiding this comment.
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.
| 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}"; |
Pull Request Review: TUnit.AspNetCoreThis PR introduces a well-designed ASP.NET Core testing helper library for TUnit. Overall, this is excellent work with strong architecture, comprehensive tests, and good documentation. Overall Assessment: 9/10This is high-quality work that follows TUnit conventions well and provides valuable functionality. The architecture is sound, the code is clean, and the examples are helpful. ✅ StrengthsArchitecture & Design
Code Quality
Testing
See next comment for detailed issues and recommendations... |
Critical and Major IssuesCRITICAL: TestContext.Current Mutation (TUnitAspNetLogger.cs:43)The logger mutates global static TestContext.Current which creates race conditions in parallel tests. Multiple threads will overwrite each others context causing logs to be attributed to wrong tests. Recommendation: Use AsyncLocal if available or document threading implications. The logger already has _context injected so this mutation may not be necessary. MAJOR: Dual-Mode Implementation Verification NeededPer CLAUDE.md Rule 1, ALL changes must work identically in both source-generated and reflection modes. Action Required: Verify the new Shared parameter on ClassDataSourceAttribute works identically in TUnit.Core.SourceGenerator and TUnit.Engine. MAJOR: Potential SQL Injection (TodoTestBase.cs:74-82)While tableName is controlled, using string interpolation for SQL CREATE TABLE commands is risky. Add validation to ensure table names are safe, especially since this is example code that may be copied. MAJOR: Middleware Stream Restoration (HttpExchangeCaptureMiddleware.cs:40-51)The stream restoration in finally block could fail in extreme edge cases. Consider documenting this is acceptable for test middleware or restructure for guaranteed restoration. |
Minor Issues and SuggestionsField Keyword Usage (WebApplicationTest.cs:244)Uses C# 13 field keyword for auto-property backing field. Ensure all target frameworks and TUnit users support C# 13. Missing CancellationToken SupportSeveral async methods lack CancellationToken parameters:
While not critical for tests, best practice would add them. Magic Number in Buffer SizeExtract 81920 to named constant like DefaultBufferSize = 80 * 1024 at HttpExchangeCaptureMiddleware.cs:129 Performance Suggestions
Security ConcernsHeader capture could expose sensitive data (Authorization headers, cookies). Consider adding filtering options for sensitive headers. Test Coverage Gaps
|
Recommendations SummaryMust Fix Before Merge
Should Fix Before or Soon After Merge
Nice to Have
Code Style Compliance (CLAUDE.md)✅ Follows Guidelines:
Minor deviations:
Overall, this is excellent work! The main concerns are thread safety in logging and ensuring dual-mode parity. With those addressed, this will be a great addition to TUnit. 🎉 |
Code Review: TUnit.AspNetCoreGreat work on this new ASP.NET Core integration library! This is a valuable addition that will significantly improve the testing experience for ASP.NET Core applications. ✅ Strengths1. Excellent Architecture
2. Well-Designed API
3. Performance Conscious
4. Good Code Quality
|
🚨 Critical Issues1. Missing Unit Tests for TUnit.AspNetCore Library
|
|
ASP.NET helpers for TUnit
WebApplicationTest - Base class for ASP.NET tests, which helps created isolated test servers for each test.
TUnitLogger - Correlates server logs back to a TUnit test's context
HttpExchangeCapture - Registers a middleware that captures requests and responses so your tests can inspect and assert on them