diff --git a/AspNetCore.Analyzer.props b/AspNetCore.Analyzer.props new file mode 100644 index 0000000000..5036184620 --- /dev/null +++ b/AspNetCore.Analyzer.props @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props index 3a10242347..1691a1d4eb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -33,6 +33,13 @@ false true + + netstandard2.0 + TUnit.AspNetCore.Analyzers + TUnit.AspNetCore.Analyzers + false + true + $([System.DateTime]::Now.ToString("yyyy")) diff --git a/Directory.Packages.props b/Directory.Packages.props index ecc225a1ff..67ded5f410 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -86,6 +86,7 @@ + diff --git a/TUnit.AspNetCore.Analyzers.Roslyn414/TUnit.AspNetCore.Analyzers.Roslyn414.csproj b/TUnit.AspNetCore.Analyzers.Roslyn414/TUnit.AspNetCore.Analyzers.Roslyn414.csproj new file mode 100644 index 0000000000..a37eda453b --- /dev/null +++ b/TUnit.AspNetCore.Analyzers.Roslyn414/TUnit.AspNetCore.Analyzers.Roslyn414.csproj @@ -0,0 +1,9 @@ + + + + 4.14 + + + + + diff --git a/TUnit.AspNetCore.Analyzers.Roslyn44/TUnit.AspNetCore.Analyzers.Roslyn44.csproj b/TUnit.AspNetCore.Analyzers.Roslyn44/TUnit.AspNetCore.Analyzers.Roslyn44.csproj new file mode 100644 index 0000000000..5df2a50bec --- /dev/null +++ b/TUnit.AspNetCore.Analyzers.Roslyn44/TUnit.AspNetCore.Analyzers.Roslyn44.csproj @@ -0,0 +1,9 @@ + + + + 4.4 + + + + + diff --git a/TUnit.AspNetCore.Analyzers.Roslyn47/TUnit.AspNetCore.Analyzers.Roslyn47.csproj b/TUnit.AspNetCore.Analyzers.Roslyn47/TUnit.AspNetCore.Analyzers.Roslyn47.csproj new file mode 100644 index 0000000000..5694a2f514 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers.Roslyn47/TUnit.AspNetCore.Analyzers.Roslyn47.csproj @@ -0,0 +1,9 @@ + + + + 4.7 + + + + + diff --git a/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj b/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj new file mode 100644 index 0000000000..e7bcc196d9 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj @@ -0,0 +1,22 @@ + + + + + + net8.0;net9.0;net10.0 + + + + + + + + + + + + + + + + diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs new file mode 100644 index 0000000000..1c085261f9 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs @@ -0,0 +1,59 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers; + +public static partial class CSharpAnalyzerVerifier + where TAnalyzer : DiagnosticAnalyzer, new() +{ + public class Test : CSharpAnalyzerTest + { + public Test() + { + ReferenceAssemblies.AddAssemblies(ReferenceAssemblies.Net.Net60.Assemblies); + SolutionTransforms.Add((solution, projectId) => + { + var project = solution.GetProject(projectId); + + if (project is null) + { + return solution; + } + + var compilationOptions = project.CompilationOptions; + + if (compilationOptions is null) + { + return solution; + } + + if (compilationOptions is CSharpCompilationOptions cSharpCompilationOptions) + { + compilationOptions = + cSharpCompilationOptions.WithNullableContextOptions(NullableContextOptions.Enable); + } + + if (project.ParseOptions is not CSharpParseOptions parseOptions) + { + return solution; + } + + compilationOptions = compilationOptions + .WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions + .SetItems(CSharpVerifierHelper.NullableWarnings) + // Suppress analyzer release tracking warnings - we're testing TUnit analyzers, not release tracking + .SetItem("RS2007", ReportDiagnostic.Suppress) + .SetItem("RS2008", ReportDiagnostic.Suppress)); + + solution = solution.WithProjectCompilationOptions(projectId, compilationOptions) + .WithProjectParseOptions(projectId, parseOptions + .WithLanguageVersion(LanguageVersion.Preview)); + + return solution; + }); + } + } +} diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs new file mode 100644 index 0000000000..08543e0cd5 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers; + +public static partial class CSharpAnalyzerVerifier + where TAnalyzer : DiagnosticAnalyzer, new() +{ + /// + public static DiagnosticResult Diagnostic() + => CSharpAnalyzerVerifier.Diagnostic(); + + /// + public static DiagnosticResult Diagnostic(string diagnosticId) + => CSharpAnalyzerVerifier.Diagnostic(diagnosticId); + + /// + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) + => CSharpAnalyzerVerifier.Diagnostic(descriptor); + + /// + public static Task VerifyAnalyzerAsync([StringSyntax("c#")] string source, params DiagnosticResult[] expected) + { + return VerifyAnalyzerAsync(source, _ => { }, expected); + } + + /// + public static async Task VerifyAnalyzerAsync([StringSyntax("c#")] string source, Action configureTest, params DiagnosticResult[] expected) + { + var test = new Test + { + TestCode = source, + ReferenceAssemblies = ReferenceAssemblies.Net.Net90, + TestState = + { + AdditionalReferences = + { + typeof(TUnit.Core.TUnitAttribute).Assembly.Location, + }, + }, + }; + + test.ExpectedDiagnostics.AddRange(expected); + + configureTest(test); + + await test.RunAsync(CancellationToken.None); + } +} diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpVerifierHelper.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpVerifierHelper.cs new file mode 100644 index 0000000000..e34302c084 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpVerifierHelper.cs @@ -0,0 +1,32 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers; + +internal static class CSharpVerifierHelper +{ + /// + /// By default, the compiler reports diagnostics for nullable reference types at + /// , and the analyzer test framework defaults to only validating + /// diagnostics at . This map contains all compiler diagnostic IDs + /// related to nullability mapped to , which is then used to enable all + /// of these warnings for default validation during analyzer and code fix tests. + /// + internal static ImmutableDictionary NullableWarnings { get; } = GetNullableWarningsFromCompiler(); + + private static ImmutableDictionary GetNullableWarningsFromCompiler() + { + string[] args = ["/warnaserror:nullable", "-p:LangVersion=preview"]; + var commandLineArguments = CSharpCommandLineParser.Default.Parse(args, baseDirectory: Environment.CurrentDirectory, sdkDirectory: Environment.CurrentDirectory); + var nullableWarnings = commandLineArguments.CompilationOptions.SpecificDiagnosticOptions; + + // Workaround for https://github.com/dotnet/roslyn/issues/41610 + nullableWarnings = nullableWarnings + .SetItem("CS8632", ReportDiagnostic.Error) + .SetItem("CS8669", ReportDiagnostic.Error) + .SetItem("CS8652", ReportDiagnostic.Suppress); + + return nullableWarnings; + } +} diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs new file mode 100644 index 0000000000..848dd250a3 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs @@ -0,0 +1,101 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers; + +/// +/// A custom verifier that normalizes line endings to LF before comparison to support cross-platform testing. +/// This prevents tests from failing due to differences between Windows (CRLF) and Unix (LF) line endings. +/// By normalizing to LF (the universal standard), tests pass consistently on all platforms. +/// +public class LineEndingNormalizingVerifier : IVerifier +{ + private readonly DefaultVerifier _defaultVerifier = new(); + + public void Empty(string collectionName, IEnumerable collection) + { + _defaultVerifier.Empty(collectionName, collection); + } + + public void Equal(T expected, T actual, string? message = null) + { + // Normalize line endings for string comparisons + if (expected is string expectedString && actual is string actualString) + { + var normalizedExpected = NormalizeLineEndings(expectedString); + var normalizedActual = NormalizeLineEndings(actualString); + _defaultVerifier.Equal(normalizedExpected, normalizedActual, message); + } + else + { + _defaultVerifier.Equal(expected, actual, message); + } + } + + public void True(bool assert, string? message = null) + { + _defaultVerifier.True(assert, message); + } + + public void False(bool assert, string? message = null) + { + _defaultVerifier.False(assert, message); + } + + [DoesNotReturn] + public void Fail(string? message = null) + { + _defaultVerifier.Fail(message); + } + + public void LanguageIsSupported(string language) + { + _defaultVerifier.LanguageIsSupported(language); + } + + public void NotEmpty(string collectionName, IEnumerable collection) + { + _defaultVerifier.NotEmpty(collectionName, collection); + } + + public void SequenceEqual(IEnumerable expected, IEnumerable actual, IEqualityComparer? equalityComparer = null, string? message = null) + { + // Normalize line endings for string sequence comparisons + if (typeof(T) == typeof(string)) + { + var normalizedExpected = expected.Cast().Select(NormalizeLineEndings).Cast(); + var normalizedActual = actual.Cast().Select(NormalizeLineEndings).Cast(); + _defaultVerifier.SequenceEqual(normalizedExpected, normalizedActual, equalityComparer, message); + } + else + { + _defaultVerifier.SequenceEqual(expected, actual, equalityComparer, message); + } + } + + public IVerifier PushContext(string context) + { + // Create a new verifier that wraps the result of PushContext on the default verifier + return new LineEndingNormalizingVerifierWithContext(_defaultVerifier.PushContext(context)); + } + + private static string NormalizeLineEndings(string value) + { + // Normalize all line endings to LF (Unix) for cross-platform consistent comparison + // LF is the universal standard and prevents Windows/Linux test mismatches + return value.Replace("\r\n", "\n"); + } + + /// + /// Internal helper class to wrap a verifier with context + /// + private class LineEndingNormalizingVerifierWithContext : LineEndingNormalizingVerifier + { + private readonly IVerifier _wrappedVerifier; + + public LineEndingNormalizingVerifierWithContext(IVerifier wrappedVerifier) + { + _wrappedVerifier = wrappedVerifier; + } + } +} diff --git a/TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryAccessAnalyzerTests.cs b/TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryAccessAnalyzerTests.cs new file mode 100644 index 0000000000..e2e6407c68 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryAccessAnalyzerTests.cs @@ -0,0 +1,547 @@ +using Verifier = TUnit.AspNetCore.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; + +namespace TUnit.AspNetCore.Analyzers.Tests; + +public class WebApplicationFactoryAccessAnalyzerTests +{ + private const string WebApplicationTestStub = """ + namespace TUnit.AspNetCore + { + public abstract class WebApplicationTest + { + public int UniqueId { get; } + } + + public abstract class WebApplicationTest : WebApplicationTest + where TFactory : class, new() + where TEntryPoint : class + { + public TFactory GlobalFactory { get; set; } = null!; + public object Factory { get; } = null!; + public System.IServiceProvider Services { get; } = null!; + public object? HttpCapture { get; } + + protected virtual System.Threading.Tasks.Task SetupAsync() => System.Threading.Tasks.Task.CompletedTask; + } + } + """; + + private const string WebApplicationFactoryStub = """ + namespace Microsoft.AspNetCore.Mvc.Testing + { + public class WebApplicationFactory where TEntryPoint : class + { + public System.IServiceProvider Services { get; } = null!; + public object Server { get; } = null!; + public object CreateClient() => new object(); + public object CreateDefaultClient() => new object(); + } + } + + namespace TUnit.AspNetCore + { + public class TestWebApplicationFactory : Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory + where TEntryPoint : class + { + } + } + """; + + [Test] + public async Task No_Error_When_Accessing_Factory_In_Test_Method() + { + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + {{WebApplicationTestStub}} + + public class MyFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + [Test] + public void MyTest() + { + var factory = Factory; + var services = Services; + } + } + """ + ); + } + + [Test] + public async Task Error_When_Accessing_Factory_In_Constructor() + { + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + {{WebApplicationTestStub}} + + public class MyFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + public MyTests() + { + var factory = {|#0:Factory|}; + } + + [Test] + public void MyTest() + { + } + } + """, + Verifier.Diagnostic(Rules.FactoryAccessedTooEarly) + .WithLocation(0) + .WithArguments("Factory", "constructor") + ); + } + + [Test] + public async Task Error_When_Accessing_Services_In_Constructor() + { + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + {{WebApplicationTestStub}} + + public class MyFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + public MyTests() + { + var services = {|#0:Services|}; + } + + [Test] + public void MyTest() + { + } + } + """, + Verifier.Diagnostic(Rules.FactoryAccessedTooEarly) + .WithLocation(0) + .WithArguments("Services", "constructor") + ); + } + + [Test] + public async Task Error_When_Accessing_Factory_In_SetupAsync() + { + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + using System.Threading.Tasks; + {{WebApplicationTestStub}} + + public class MyFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + protected override Task SetupAsync() + { + var factory = {|#0:Factory|}; + return Task.CompletedTask; + } + + [Test] + public void MyTest() + { + } + } + """, + Verifier.Diagnostic(Rules.FactoryAccessedTooEarly) + .WithLocation(0) + .WithArguments("Factory", "SetupAsync") + ); + } + + [Test] + public async Task Error_When_Accessing_HttpCapture_In_SetupAsync() + { + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + using System.Threading.Tasks; + {{WebApplicationTestStub}} + + public class MyFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + protected override Task SetupAsync() + { + var capture = {|#0:HttpCapture|}; + return Task.CompletedTask; + } + + [Test] + public void MyTest() + { + } + } + """, + Verifier.Diagnostic(Rules.FactoryAccessedTooEarly) + .WithLocation(0) + .WithArguments("HttpCapture", "SetupAsync") + ); + } + + [Test] + public async Task Error_When_Accessing_GlobalFactory_In_Constructor() + { + // GlobalFactory is NOT available in constructor - it's injected via property injection after construction + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + {{WebApplicationTestStub}} + + public class MyFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + public MyTests() + { + var factory = {|#0:GlobalFactory|}; + } + + [Test] + public void MyTest() + { + } + } + """, + Verifier.Diagnostic(Rules.FactoryAccessedTooEarly) + .WithLocation(0) + .WithArguments("GlobalFactory", "constructor") + ); + } + + [Test] + public async Task No_Error_When_Accessing_GlobalFactory_In_SetupAsync() + { + // GlobalFactory IS available in SetupAsync - it's injected before SetupAsync runs + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + using System.Threading.Tasks; + {{WebApplicationTestStub}} + + public class MyFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + protected override Task SetupAsync() + { + var factory = GlobalFactory; + return Task.CompletedTask; + } + + [Test] + public void MyTest() + { + } + } + """ + ); + } + + [Test] + public async Task No_Error_When_Accessing_UniqueId_In_SetupAsync() + { + // UniqueId IS available in SetupAsync + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + using System.Threading.Tasks; + {{WebApplicationTestStub}} + + public class MyFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + protected override Task SetupAsync() + { + var id = UniqueId; + return Task.CompletedTask; + } + + [Test] + public void MyTest() + { + } + } + """ + ); + } + + [Test] + public async Task No_Error_For_Unrelated_Factory_Property() + { + // A property named Factory on an unrelated class should not trigger the analyzer + await Verifier + .VerifyAnalyzerAsync( + """ + using TUnit.Core; + + public class SomeClass + { + public object Factory { get; } = null!; + } + + public class MyTests + { + private SomeClass _someClass = new(); + + public MyTests() + { + var factory = _someClass.Factory; + } + + [Test] + public void MyTest() + { + } + } + """ + ); + } + + [Test] + public async Task Error_When_Accessing_GlobalFactory_Services() + { + // GlobalFactory.Services should never be accessed - use Factory.Services instead + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + {{WebApplicationFactoryStub}} + {{WebApplicationTestStub}} + + public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + [Test] + public void MyTest() + { + var services = {|#0:GlobalFactory.Services|}; + } + } + """, + Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess) + .WithLocation(0) + .WithArguments("Services") + ); + } + + [Test] + public async Task Error_When_Accessing_GlobalFactory_Server() + { + // GlobalFactory.Server should never be accessed - use Factory.Server instead + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + {{WebApplicationFactoryStub}} + {{WebApplicationTestStub}} + + public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + [Test] + public void MyTest() + { + var server = {|#0:GlobalFactory.Server|}; + } + } + """, + Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess) + .WithLocation(0) + .WithArguments("Server") + ); + } + + [Test] + public async Task Error_When_Calling_GlobalFactory_CreateClient() + { + // GlobalFactory.CreateClient() should never be called - use Factory.CreateClient() instead + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + {{WebApplicationFactoryStub}} + {{WebApplicationTestStub}} + + public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + [Test] + public void MyTest() + { + var client = {|#0:GlobalFactory.CreateClient()|}; + } + } + """, + Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess) + .WithLocation(0) + .WithArguments("CreateClient") + ); + } + + [Test] + public async Task Error_When_Calling_GlobalFactory_CreateDefaultClient() + { + // GlobalFactory.CreateDefaultClient() should never be called - use Factory.CreateDefaultClient() instead + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + {{WebApplicationFactoryStub}} + {{WebApplicationTestStub}} + + public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + [Test] + public void MyTest() + { + var client = {|#0:GlobalFactory.CreateDefaultClient()|}; + } + } + """, + Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess) + .WithLocation(0) + .WithArguments("CreateDefaultClient") + ); + } + + [Test] + public async Task No_Error_When_Accessing_Factory_Services_In_Test() + { + // Factory.Services is the correct way to access services + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + {{WebApplicationFactoryStub}} + {{WebApplicationTestStub}} + + public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { } + public class Program { } + + public class MyTests : TUnit.AspNetCore.WebApplicationTest + { + [Test] + public void MyTest() + { + var services = Services; + } + } + """ + ); + } + + [Test] + public async Task Error_When_Accessing_Factory_In_Constructor_With_Deep_Inheritance() + { + // Analyzer should detect WebApplicationTest even through multiple levels of inheritance + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + {{WebApplicationTestStub}} + + public class MyFactory { } + public class Program { } + + public abstract class BaseTestClass : TUnit.AspNetCore.WebApplicationTest + { + } + + public abstract class MiddleTestClass : BaseTestClass + { + } + + public class MyTests : MiddleTestClass + { + public MyTests() + { + var factory = {|#0:Factory|}; + } + + [Test] + public void MyTest() + { + } + } + """, + Verifier.Diagnostic(Rules.FactoryAccessedTooEarly) + .WithLocation(0) + .WithArguments("Factory", "constructor") + ); + } + + [Test] + public async Task Error_When_Accessing_GlobalFactory_Services_With_Deep_Inheritance() + { + // Analyzer should detect GlobalFactory.Services access even through multiple levels of inheritance + await Verifier + .VerifyAnalyzerAsync( + $$""" + using TUnit.Core; + {{WebApplicationFactoryStub}} + {{WebApplicationTestStub}} + + public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { } + public class Program { } + + public abstract class BaseTestClass : TUnit.AspNetCore.WebApplicationTest + { + } + + public abstract class MiddleTestClass : BaseTestClass + { + } + + public class MyTests : MiddleTestClass + { + [Test] + public void MyTest() + { + var services = {|#0:GlobalFactory.Services|}; + } + } + """, + Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess) + .WithLocation(0) + .WithArguments("Services") + ); + } +} diff --git a/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Shipped.md b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000000..39071b5a25 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,6 @@ +## Release 1.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- diff --git a/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000000..e69fa429b4 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,11 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +TUnit0062 | Usage | Error | Factory property accessed before initialization in WebApplicationTest +TUnit0063 | Usage | Error | GlobalFactory member access breaks test isolation + +### Removed Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- diff --git a/TUnit.AspNetCore.Analyzers/ConcurrentDiagnosticAnalyzer.cs b/TUnit.AspNetCore.Analyzers/ConcurrentDiagnosticAnalyzer.cs new file mode 100644 index 0000000000..0cef679d93 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers/ConcurrentDiagnosticAnalyzer.cs @@ -0,0 +1,16 @@ +using Microsoft.CodeAnalysis.Diagnostics; + +namespace TUnit.AspNetCore.Analyzers; + +public abstract class ConcurrentDiagnosticAnalyzer : DiagnosticAnalyzer +{ + public sealed override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + InitializeInternal(context); + } + + protected abstract void InitializeInternal(AnalysisContext context); +} diff --git a/TUnit.AspNetCore.Analyzers/Resources.Designer.cs b/TUnit.AspNetCore.Analyzers/Resources.Designer.cs new file mode 100644 index 0000000000..de3968cd70 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers/Resources.Designer.cs @@ -0,0 +1,112 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace TUnit.AspNetCore.Analyzers { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("TUnit.AspNetCore.Analyzers.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The Factory, Services, and HttpCapture properties are not available in constructors or SetupAsync... + /// + internal static string TUnit0062Description { + get { + return ResourceManager.GetString("TUnit0062Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' cannot be accessed in {1}. It is not initialized until after SetupAsync completes. + /// + internal static string TUnit0062MessageFormat { + get { + return ResourceManager.GetString("TUnit0062MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Factory property accessed before initialization. + /// + internal static string TUnit0062Title { + get { + return ResourceManager.GetString("TUnit0062Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Do not access Services, Server, or CreateClient on GlobalFactory directly... + /// + internal static string TUnit0063Description { + get { + return ResourceManager.GetString("TUnit0063Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Do not access '{0}' on GlobalFactory. Use 'Factory.{0}' instead to ensure test isolation. + /// + internal static string TUnit0063MessageFormat { + get { + return ResourceManager.GetString("TUnit0063MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to GlobalFactory member access breaks test isolation. + /// + internal static string TUnit0063Title { + get { + return ResourceManager.GetString("TUnit0063Title", resourceCulture); + } + } + } +} diff --git a/TUnit.AspNetCore.Analyzers/Resources.resx b/TUnit.AspNetCore.Analyzers/Resources.resx new file mode 100644 index 0000000000..f749a357ac --- /dev/null +++ b/TUnit.AspNetCore.Analyzers/Resources.resx @@ -0,0 +1,39 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The Factory, Services, and HttpCapture properties are not available in constructors or SetupAsync. These properties are initialized in the [Before(HookType.Test)] hook which runs after SetupAsync. Access these properties only in test methods or [Before(HookType.Test)]/[After(HookType.Test)] hooks. + + + '{0}' cannot be accessed in {1}. It is not initialized until after SetupAsync completes. + + + Factory property accessed before initialization + + + Do not access Services, Server, or CreateClient on GlobalFactory directly. GlobalFactory is a shared template - use the Factory property instead, which provides an isolated instance for each test. + + + Do not access '{0}' on GlobalFactory. Use 'Factory.{0}' instead to ensure test isolation. + + + GlobalFactory member access breaks test isolation + + diff --git a/TUnit.AspNetCore.Analyzers/Rules.cs b/TUnit.AspNetCore.Analyzers/Rules.cs new file mode 100644 index 0000000000..aeccd1bc44 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers/Rules.cs @@ -0,0 +1,30 @@ +using Microsoft.CodeAnalysis; + +namespace TUnit.AspNetCore.Analyzers; + +public static class Rules +{ + private const string UsageCategory = "Usage"; + + public static readonly DiagnosticDescriptor FactoryAccessedTooEarly = + CreateDescriptor("TUnit0062", UsageCategory, DiagnosticSeverity.Error); + + public static readonly DiagnosticDescriptor GlobalFactoryMemberAccess = + CreateDescriptor("TUnit0063", UsageCategory, DiagnosticSeverity.Error); + + private static DiagnosticDescriptor CreateDescriptor(string diagnosticId, string category, DiagnosticSeverity severity) + { + return new DiagnosticDescriptor( + id: diagnosticId, + title: new LocalizableResourceString(diagnosticId + "Title", + Resources.ResourceManager, typeof(Resources)), + messageFormat: new LocalizableResourceString(diagnosticId + "MessageFormat", Resources.ResourceManager, + typeof(Resources)), + category: category, + defaultSeverity: severity, + isEnabledByDefault: true, + description: new LocalizableResourceString(diagnosticId + "Description", Resources.ResourceManager, + typeof(Resources)) + ); + } +} diff --git a/TUnit.AspNetCore.Analyzers/TUnit.AspNetCore.Analyzers.csproj b/TUnit.AspNetCore.Analyzers/TUnit.AspNetCore.Analyzers.csproj new file mode 100644 index 0000000000..ba541a0f23 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers/TUnit.AspNetCore.Analyzers.csproj @@ -0,0 +1,47 @@ + + + + + + netstandard2.0 + enable + latest + true + true + false + true + TUnit.AspNetCore.Analyzers + TUnit.AspNetCore.Analyzers + RS2003 + false + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + True + True + Resources.resx + + + + + + + + diff --git a/TUnit.AspNetCore.Analyzers/WebApplicationFactoryAccessAnalyzer.cs b/TUnit.AspNetCore.Analyzers/WebApplicationFactoryAccessAnalyzer.cs new file mode 100644 index 0000000000..ab7ce672c4 --- /dev/null +++ b/TUnit.AspNetCore.Analyzers/WebApplicationFactoryAccessAnalyzer.cs @@ -0,0 +1,203 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace TUnit.AspNetCore.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class WebApplicationFactoryAccessAnalyzer : ConcurrentDiagnosticAnalyzer +{ + // Properties not available in constructors OR SetupAsync (initialized in Before hook) + private static readonly ImmutableHashSet RestrictedInConstructorAndSetup = ImmutableHashSet.Create( + "Factory", + "Services", + "HttpCapture" + ); + + // Properties not available in constructors only (available after property injection, before SetupAsync) + private static readonly ImmutableHashSet RestrictedInConstructorOnly = ImmutableHashSet.Create( + "GlobalFactory" + ); + + // Members that should never be accessed on GlobalFactory (breaks test isolation) + private static readonly ImmutableHashSet RestrictedGlobalFactoryMembers = ImmutableHashSet.Create( + "Services", + "Server", + "CreateClient", + "CreateDefaultClient" + ); + + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Rules.FactoryAccessedTooEarly, Rules.GlobalFactoryMemberAccess); + + protected override void InitializeInternal(AnalysisContext context) + { + context.RegisterOperationAction(AnalyzePropertyReference, OperationKind.PropertyReference); + context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation); + } + + private void AnalyzePropertyReference(OperationAnalysisContext context) + { + if (context.Operation is not IPropertyReferenceOperation propertyReference) + { + return; + } + + var propertyName = propertyReference.Property.Name; + + // Check for GlobalFactory.Services or GlobalFactory.Server access + if (RestrictedGlobalFactoryMembers.Contains(propertyName) && + IsGlobalFactoryAccess(propertyReference.Instance)) + { + context.ReportDiagnostic(Diagnostic.Create( + Rules.GlobalFactoryMemberAccess, + context.Operation.Syntax.GetLocation(), + propertyName)); + return; + } + + var isRestrictedInBoth = RestrictedInConstructorAndSetup.Contains(propertyName); + var isRestrictedInConstructorOnly = RestrictedInConstructorOnly.Contains(propertyName); + + if (!isRestrictedInBoth && !isRestrictedInConstructorOnly) + { + return; + } + + // Check if this property belongs to WebApplicationTest or a derived type + var containingType = propertyReference.Property.ContainingType; + if (!IsWebApplicationTestType(containingType)) + { + return; + } + + // Check if we're in a constructor or SetupAsync method + var containingMethod = GetContainingMethod(context.Operation); + if (containingMethod == null) + { + return; + } + + string? contextName = null; + + if (containingMethod.MethodKind == MethodKind.Constructor) + { + // All restricted properties are invalid in constructor + contextName = "constructor"; + } + else if (containingMethod.Name == "SetupAsync" && containingMethod.IsOverride) + { + // Only Factory/Services/HttpCapture are invalid in SetupAsync + // GlobalFactory IS available in SetupAsync + if (isRestrictedInBoth) + { + contextName = "SetupAsync"; + } + } + + if (contextName != null) + { + context.ReportDiagnostic(Diagnostic.Create( + Rules.FactoryAccessedTooEarly, + context.Operation.Syntax.GetLocation(), + propertyName, + contextName)); + } + } + + private void AnalyzeInvocation(OperationAnalysisContext context) + { + if (context.Operation is not IInvocationOperation invocation) + { + return; + } + + var methodName = invocation.TargetMethod.Name; + + // Check for GlobalFactory.CreateClient() access + if (RestrictedGlobalFactoryMembers.Contains(methodName) && + IsGlobalFactoryAccess(invocation.Instance)) + { + context.ReportDiagnostic(Diagnostic.Create( + Rules.GlobalFactoryMemberAccess, + context.Operation.Syntax.GetLocation(), + methodName)); + } + } + + private static bool IsGlobalFactoryAccess(IOperation? instance) + { + if (instance is not IPropertyReferenceOperation propertyRef) + { + return false; + } + + // Check if accessing GlobalFactory property on a WebApplicationTest type + if (propertyRef.Property.Name != "GlobalFactory") + { + return false; + } + + return IsWebApplicationTestType(propertyRef.Property.ContainingType); + } + + private static bool IsWebApplicationTestType(INamedTypeSymbol? type) + { + while (type != null) + { + var typeName = type.Name; + var namespaceName = type.ContainingNamespace?.ToDisplayString(); + + // Check for WebApplicationTest or WebApplicationTest + if (typeName == "WebApplicationTest" && namespaceName == "TUnit.AspNetCore") + { + return true; + } + + // Also check the generic version + if (type.OriginalDefinition?.Name == "WebApplicationTest" && + type.OriginalDefinition.ContainingNamespace?.ToDisplayString() == "TUnit.AspNetCore") + { + return true; + } + + type = type.BaseType; + } + + return false; + } + + private static IMethodSymbol? GetContainingMethod(IOperation operation) + { + var current = operation; + while (current != null) + { + if (current is IMethodBodyOperation or IBlockOperation) + { + // Get the semantic model to find the containing method + var syntax = current.Syntax; + while (syntax != null) + { + if (syntax is MethodDeclarationSyntax or ConstructorDeclarationSyntax) + { + var semanticModel = operation.SemanticModel; + if (semanticModel != null) + { + var symbol = semanticModel.GetDeclaredSymbol(syntax); + if (symbol is IMethodSymbol methodSymbol) + { + return methodSymbol; + } + } + } + syntax = syntax.Parent; + } + } + current = current.Parent; + } + + return null; + } +} diff --git a/TUnit.AspNetCore/TUnit.AspNetCore.csproj b/TUnit.AspNetCore/TUnit.AspNetCore.csproj index d2f9702d99..03e41e5ad3 100644 --- a/TUnit.AspNetCore/TUnit.AspNetCore.csproj +++ b/TUnit.AspNetCore/TUnit.AspNetCore.csproj @@ -27,9 +27,26 @@ + + + + + + + + + + diff --git a/TUnit.AspNetCore/TestWebApplicationFactory.cs b/TUnit.AspNetCore/TestWebApplicationFactory.cs index 39ce8380f0..f489c846ea 100644 --- a/TUnit.AspNetCore/TestWebApplicationFactory.cs +++ b/TUnit.AspNetCore/TestWebApplicationFactory.cs @@ -27,8 +27,13 @@ public WebApplicationFactory GetIsolatedFactory( configureWebHostBuilder?.Invoke(builder); // Then apply standard configuration - builder.ConfigureTestServices(configureServices) - .ConfigureAppConfiguration(configureConfiguration); + builder + .ConfigureAppConfiguration(configureConfiguration) + .ConfigureTestServices(services => + { + configureServices(services); + services.AddSingleton(testContext); + }); if (options.EnableHttpExchangeCapture) { diff --git a/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj b/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj index 7b3f975755..e22918675d 100644 --- a/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj +++ b/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj @@ -1,17 +1,19 @@ - + net10.0 + + - + @@ -19,7 +21,7 @@ - + diff --git a/TUnit.Pipeline/Modules/RunAspNetCoreAnalyzersTestsModule.cs b/TUnit.Pipeline/Modules/RunAspNetCoreAnalyzersTestsModule.cs new file mode 100644 index 0000000000..921b8b8a51 --- /dev/null +++ b/TUnit.Pipeline/Modules/RunAspNetCoreAnalyzersTestsModule.cs @@ -0,0 +1,34 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Enums; +using ModularPipelines.Extensions; +using ModularPipelines.Git.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace TUnit.Pipeline.Modules; + +[NotInParallel("DotNetTests")] +public class RunAspNetCoreAnalyzersTestsModule : Module +{ + protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken) + { + var project = context.Git().RootDirectory.FindFile(x => x.Name == "TUnit.AspNetCore.Analyzers.Tests.csproj").AssertExists(); + + return await context.DotNet().Test(new DotNetTestOptions + { + WorkingDirectory = project.Folder!, + NoBuild = true, + Configuration = Configuration.Release, + Framework = "net9.0", + Arguments = ["--", "--hangdump", "--hangdump-filename", "hangdump.aspnetcore-analyzers-tests.dmp", "--hangdump-timeout", "5m"], + EnvironmentVariables = new Dictionary + { + ["DISABLE_GITHUB_REPORTER"] = "true", + }, + CommandLogging = CommandLogging.Input | CommandLogging.Error | CommandLogging.Duration | CommandLogging.ExitCode + }, cancellationToken); + } +} diff --git a/TUnit.Pipeline/Modules/TestAspNetCoreNugetPackageModule.cs b/TUnit.Pipeline/Modules/TestAspNetCoreNugetPackageModule.cs new file mode 100644 index 0000000000..ff6acab658 --- /dev/null +++ b/TUnit.Pipeline/Modules/TestAspNetCoreNugetPackageModule.cs @@ -0,0 +1,51 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Extensions; +using ModularPipelines.Git.Extensions; +using ModularPipelines.Models; +using Polly.Retry; +using TUnit.Pipeline.Modules.Abstract; + +namespace TUnit.Pipeline.Modules; + +[DependsOn] +[DependsOn] +public class TestAspNetCoreNugetPackageModule : TestBaseModule +{ + protected override AsyncRetryPolicy?> RetryPolicy + => CreateRetryPolicy(3); + + // ASP.NET Core only supports .NET Core frameworks, not .NET Framework + protected override IEnumerable TestableFrameworks + { + get + { + yield return "net10.0"; + yield return "net9.0"; + yield return "net8.0"; + } + } + + protected override async Task GetTestOptions(IPipelineContext context, string framework, + CancellationToken cancellationToken) + { + var version = await GetModule(); + + var project = context.Git() + .RootDirectory + .AssertExists() + .FindFile(x => x.Name == "TUnit.AspNetCore.NugetTester.csproj") + .AssertExists(); + + return new DotNetRunOptions + { + WorkingDirectory = project.Folder!, + Framework = framework, + Properties = + [ + new KeyValue("TUnitVersion", version.Value!.SemVer!) + ] + }; + } +} diff --git a/TUnit.sln b/TUnit.sln index 12b327bdf5..2bb8c46d11 100644 --- a/TUnit.sln +++ b/TUnit.sln @@ -141,6 +141,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Profile", "TUnit.Prof EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore", "TUnit.AspNetCore\TUnit.AspNetCore.csproj", "{A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.Analyzers.Roslyn44", "TUnit.AspNetCore.Analyzers.Roslyn44\TUnit.AspNetCore.Analyzers.Roslyn44.csproj", "{9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.Analyzers.Roslyn47", "TUnit.AspNetCore.Analyzers.Roslyn47\TUnit.AspNetCore.Analyzers.Roslyn47.csproj", "{6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.Analyzers.Roslyn414", "TUnit.AspNetCore.Analyzers.Roslyn414\TUnit.AspNetCore.Analyzers.Roslyn414.csproj", "{D5C70ADD-B960-4E6C-836C-6041938D04BE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.Analyzers.Tests", "TUnit.AspNetCore.Analyzers.Tests\TUnit.AspNetCore.Analyzers.Tests.csproj", "{9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.Analyzers", "TUnit.AspNetCore.Analyzers\TUnit.AspNetCore.Analyzers.csproj", "{6134813B-F928-443F-A629-F6726A1112F9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -799,6 +809,66 @@ Global {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Release|x64.Build.0 = Release|Any CPU {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Release|x86.ActiveCfg = Release|Any CPU {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Release|x86.Build.0 = Release|Any CPU + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Debug|x64.ActiveCfg = Debug|Any CPU + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Debug|x64.Build.0 = Debug|Any CPU + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Debug|x86.ActiveCfg = Debug|Any CPU + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Debug|x86.Build.0 = Debug|Any CPU + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Release|Any CPU.Build.0 = Release|Any CPU + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Release|x64.ActiveCfg = Release|Any CPU + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Release|x64.Build.0 = Release|Any CPU + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Release|x86.ActiveCfg = Release|Any CPU + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Release|x86.Build.0 = Release|Any CPU + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Debug|x64.ActiveCfg = Debug|Any CPU + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Debug|x64.Build.0 = Debug|Any CPU + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Debug|x86.ActiveCfg = Debug|Any CPU + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Debug|x86.Build.0 = Debug|Any CPU + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Release|Any CPU.Build.0 = Release|Any CPU + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Release|x64.ActiveCfg = Release|Any CPU + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Release|x64.Build.0 = Release|Any CPU + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Release|x86.ActiveCfg = Release|Any CPU + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Release|x86.Build.0 = Release|Any CPU + {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Debug|x64.ActiveCfg = Debug|Any CPU + {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Debug|x64.Build.0 = Debug|Any CPU + {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Debug|x86.ActiveCfg = Debug|Any CPU + {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Debug|x86.Build.0 = Debug|Any CPU + {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Release|Any CPU.Build.0 = Release|Any CPU + {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Release|x64.ActiveCfg = Release|Any CPU + {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Release|x64.Build.0 = Release|Any CPU + {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Release|x86.ActiveCfg = Release|Any CPU + {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Release|x86.Build.0 = Release|Any CPU + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Debug|x64.ActiveCfg = Debug|Any CPU + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Debug|x64.Build.0 = Debug|Any CPU + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Debug|x86.ActiveCfg = Debug|Any CPU + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Debug|x86.Build.0 = Debug|Any CPU + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Release|Any CPU.Build.0 = Release|Any CPU + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Release|x64.ActiveCfg = Release|Any CPU + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Release|x64.Build.0 = Release|Any CPU + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Release|x86.ActiveCfg = Release|Any CPU + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Release|x86.Build.0 = Release|Any CPU + {6134813B-F928-443F-A629-F6726A1112F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6134813B-F928-443F-A629-F6726A1112F9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6134813B-F928-443F-A629-F6726A1112F9}.Debug|x64.ActiveCfg = Debug|Any CPU + {6134813B-F928-443F-A629-F6726A1112F9}.Debug|x64.Build.0 = Debug|Any CPU + {6134813B-F928-443F-A629-F6726A1112F9}.Debug|x86.ActiveCfg = Debug|Any CPU + {6134813B-F928-443F-A629-F6726A1112F9}.Debug|x86.Build.0 = Debug|Any CPU + {6134813B-F928-443F-A629-F6726A1112F9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6134813B-F928-443F-A629-F6726A1112F9}.Release|Any CPU.Build.0 = Release|Any CPU + {6134813B-F928-443F-A629-F6726A1112F9}.Release|x64.ActiveCfg = Release|Any CPU + {6134813B-F928-443F-A629-F6726A1112F9}.Release|x64.Build.0 = Release|Any CPU + {6134813B-F928-443F-A629-F6726A1112F9}.Release|x86.ActiveCfg = Release|Any CPU + {6134813B-F928-443F-A629-F6726A1112F9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -860,6 +930,11 @@ Global {A7B8C9D0-1234-4567-8901-23456789ABCD} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850} {FEB5F3F3-A7DD-4DE0-A9C7-B9AD489E9C52} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850} {A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75} = {1B56B580-4D59-4E83-9F80-467D58DADAC1} + {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1} = {503DA9FA-045D-4910-8AF6-905E6048B1F1} + {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4} = {503DA9FA-045D-4910-8AF6-905E6048B1F1} + {D5C70ADD-B960-4E6C-836C-6041938D04BE} = {503DA9FA-045D-4910-8AF6-905E6048B1F1} + {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850} + {6134813B-F928-443F-A629-F6726A1112F9} = {503DA9FA-045D-4910-8AF6-905E6048B1F1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {109D285A-36B3-4503-BCDF-8E26FB0E2C5B} diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.WebApp/Program.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.WebApp/Program.cs new file mode 100644 index 0000000000..17ce53228e --- /dev/null +++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.WebApp/Program.cs @@ -0,0 +1,63 @@ +var builder = WebApplication.CreateBuilder(args); + +// Register services that can be replaced in tests +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// Basic ping endpoint +app.MapGet("/ping", () => "pong"); + +// Greeting endpoint using injected service +app.MapGet("/greet/{name}", (string name, IGreetingService greetingService) => + greetingService.GetGreeting(name)); + +// Time endpoint using injected service +app.MapGet("/time", (ITimeService timeService) => + new { CurrentTime = timeService.GetCurrentTime() }); + +// Configuration endpoint for testing config overrides +app.MapGet("/config/message", (IConfiguration config) => + config["TestMessage"] ?? "default message"); + +// Echo endpoint for testing request/response capture +app.MapPost("/echo", async (HttpRequest request) => +{ + using var reader = new StreamReader(request.Body); + var body = await reader.ReadToEndAsync(); + return Results.Ok(new { Echo = body, Received = DateTime.UtcNow }); +}); + +// Status endpoint that returns headers for testing +app.MapGet("/status", (HttpContext context) => +{ + context.Response.Headers.Append("X-Custom-Header", "test-value"); + return Results.Ok(new { Status = "healthy" }); +}); + +app.Run(); + +// Service interfaces and implementations +public interface IGreetingService +{ + string GetGreeting(string name); +} + +public class DefaultGreetingService : IGreetingService +{ + public string GetGreeting(string name) => $"Hello, {name}!"; +} + +public interface ITimeService +{ + DateTime GetCurrentTime(); +} + +public class SystemTimeService : ITimeService +{ + public DateTime GetCurrentTime() => DateTime.UtcNow; +} + +// Required for WebApplicationFactory +public partial class Program; diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.WebApp/TUnit.AspNetCore.NugetTester.WebApp.csproj b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.WebApp/TUnit.AspNetCore.NugetTester.WebApp.csproj new file mode 100644 index 0000000000..ff70c90a2d --- /dev/null +++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.WebApp/TUnit.AspNetCore.NugetTester.WebApp.csproj @@ -0,0 +1,10 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + false + + + diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.sln b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.sln new file mode 100644 index 0000000000..2e8965641f --- /dev/null +++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.NugetTester.WebApp", "TUnit.AspNetCore.NugetTester.WebApp\TUnit.AspNetCore.NugetTester.WebApp.csproj", "{72905889-262E-4555-A47B-F3C2E8768BA0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.NugetTester", "TUnit.AspNetCore.NugetTester\TUnit.AspNetCore.NugetTester.csproj", "{D9FA8977-E954-436E-81C8-38DCE64B1A7D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {72905889-262E-4555-A47B-F3C2E8768BA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72905889-262E-4555-A47B-F3C2E8768BA0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72905889-262E-4555-A47B-F3C2E8768BA0}.Debug|x64.ActiveCfg = Debug|Any CPU + {72905889-262E-4555-A47B-F3C2E8768BA0}.Debug|x64.Build.0 = Debug|Any CPU + {72905889-262E-4555-A47B-F3C2E8768BA0}.Debug|x86.ActiveCfg = Debug|Any CPU + {72905889-262E-4555-A47B-F3C2E8768BA0}.Debug|x86.Build.0 = Debug|Any CPU + {72905889-262E-4555-A47B-F3C2E8768BA0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72905889-262E-4555-A47B-F3C2E8768BA0}.Release|Any CPU.Build.0 = Release|Any CPU + {72905889-262E-4555-A47B-F3C2E8768BA0}.Release|x64.ActiveCfg = Release|Any CPU + {72905889-262E-4555-A47B-F3C2E8768BA0}.Release|x64.Build.0 = Release|Any CPU + {72905889-262E-4555-A47B-F3C2E8768BA0}.Release|x86.ActiveCfg = Release|Any CPU + {72905889-262E-4555-A47B-F3C2E8768BA0}.Release|x86.Build.0 = Release|Any CPU + {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Debug|x64.ActiveCfg = Debug|Any CPU + {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Debug|x64.Build.0 = Debug|Any CPU + {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Debug|x86.ActiveCfg = Debug|Any CPU + {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Debug|x86.Build.0 = Debug|Any CPU + {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Release|Any CPU.Build.0 = Release|Any CPU + {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Release|x64.ActiveCfg = Release|Any CPU + {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Release|x64.Build.0 = Release|Any CPU + {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Release|x86.ActiveCfg = Release|Any CPU + {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/GlobalUsings.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/GlobalUsings.cs new file mode 100644 index 0000000000..4ee3a4af7b --- /dev/null +++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using System.Collections.Concurrent; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Configuration; +global using TUnit.Core; +global using TUnit.AspNetCore.Extensions; diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/HooksAndLifecycleTests.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/HooksAndLifecycleTests.cs new file mode 100644 index 0000000000..69d24dd51a --- /dev/null +++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/HooksAndLifecycleTests.cs @@ -0,0 +1,131 @@ +namespace TUnit.AspNetCore.NugetTester; + +/// +/// Tests verifying that TUnit hooks and WebApplicationTest lifecycle work correctly together. +/// These tests prove that source generation and hooks function properly when TUnit.AspNetCore +/// is consumed as a NuGet package. +/// +public class HooksAndLifecycleTests : TestsBase +{ + private bool _beforeTestHookRan; + private bool _setupAsyncRan; + private string? _isolatedName; + + protected override async Task SetupAsync() + { + _setupAsyncRan = true; + _isolatedName = GetIsolatedName("test-resource"); + await Task.CompletedTask; + } + + [Before(HookType.Test)] + public void BeforeTestHook() + { + _beforeTestHookRan = true; + Console.WriteLine($"[BeforeTest] Starting test: {TestContext.Current?.Metadata?.DisplayName}"); + } + + [After(HookType.Test)] + public void AfterTestHook() + { + // This hook runs after each test, proving After hooks work with WebApplicationTest + Console.WriteLine($"[AfterTest] Finished test: {TestContext.Current?.Metadata?.DisplayName}"); + } + + [Test] + public async Task Factory_IsNotNull_WhenTestRuns() + { + // Verifies that the Factory is properly initialized before test execution + await Assert.That(Factory).IsNotNull(); + } + + [Test] + public async Task Factory_CreateClient_Works() + { + // Verifies that we can create an HttpClient from the factory + var client = Factory.CreateClient(); + await Assert.That(client).IsNotNull(); + } + + [Test] + public async Task Services_AreAccessible_FromFactory() + { + // Verifies that Services property exposes the service provider + await Assert.That(Services).IsNotNull(); + + var greetingService = Services.GetService(); + await Assert.That(greetingService).IsNotNull(); + } + + [Test] + public async Task GlobalFactory_IsShared_AcrossTests() + { + // Verifies that GlobalFactory is accessible + await Assert.That(GlobalFactory).IsNotNull(); + } + + [Test] + public async Task SetupAsync_RanBeforeTest() + { + // Verifies that SetupAsync was called before the test + await Assert.That(_setupAsyncRan).IsTrue(); + } + + [Test] + public async Task BeforeTestHook_RanBeforeTest() + { + // Verifies that the [Before(Test)] hook ran before this test + await Assert.That(_beforeTestHookRan).IsTrue(); + } + + [Test] + public async Task GetIsolatedName_ReturnsUniqueValue() + { + // Verifies that GetIsolatedName produces a unique isolation name + await Assert.That(_isolatedName).IsNotNull(); + await Assert.That(_isolatedName).Contains("test-resource"); + await Assert.That(_isolatedName).Contains("Test_"); + } + + [Test] + public async Task UniqueId_IsPositive() + { + // Verifies that UniqueId is assigned and positive + await Assert.That(UniqueId).IsPositive(); + } + + [Test] + public async Task GetIsolatedPrefix_ReturnsFormattedPrefix() + { + // Verifies that GetIsolatedPrefix produces a formatted prefix + var prefix = GetIsolatedPrefix("_"); + await Assert.That(prefix).StartsWith("test_"); + await Assert.That(prefix).Contains("_"); + } + + [Test] + public async Task Ping_Endpoint_ReturnsExpectedResponse() + { + // Basic integration test verifying the factory and web app work together + var client = Factory.CreateClient(); + var response = await client.GetAsync("/ping"); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + var content = await response.Content.ReadAsStringAsync(); + await Assert.That(content).IsEqualTo("pong"); + } + + [Test] + [Arguments("Alice")] + [Arguments("Bob")] + [Arguments("Charlie")] + public async Task DataDrivenTest_WithArguments_Works(string name) + { + // Verifies that data-driven tests work with WebApplicationTest + var client = Factory.CreateClient(); + var response = await client.GetAsync($"/greet/{name}"); + + var content = await response.Content.ReadAsStringAsync(); + await Assert.That(content).Contains(name); + } +} diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/HttpExchangeCaptureTests.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/HttpExchangeCaptureTests.cs new file mode 100644 index 0000000000..67ac41ffdc --- /dev/null +++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/HttpExchangeCaptureTests.cs @@ -0,0 +1,184 @@ +using System.Net; +using System.Text; +using TUnit.AspNetCore; +using TUnit.AspNetCore.Interception; + +namespace TUnit.AspNetCore.NugetTester; + +/// +/// Tests verifying that HTTP exchange capture works correctly when TUnit.AspNetCore +/// is consumed as a NuGet package. +/// +public class HttpExchangeCaptureTests : TestsBase +{ + protected override void ConfigureTestOptions(WebApplicationTestOptions options) + { + // Enable HTTP exchange capture for these tests + options.EnableHttpExchangeCapture = true; + } + + /// + /// Gets the HttpExchangeCapture from DI services. + /// Note: Use this instead of the HttpCapture property until bug is fixed. + /// + private HttpExchangeCapture Capture => Services.GetRequiredService(); + + [Test] + public async Task HttpCapture_IsAvailableInServices_WhenEnabled() + { + // Verifies that HttpExchangeCapture is registered when enabled in options + var capture = Services.GetService(); + await Assert.That(capture).IsNotNull(); + } + + [Test] + public async Task HttpCapture_CapturesGetRequest() + { + // Verifies that GET requests are captured + var client = Factory.CreateClient(); + await client.GetAsync("/ping"); + + await Assert.That(Capture.Count).IsEqualTo(1); + await Assert.That(Capture.Last!.Request.Method).IsEqualTo("GET"); + await Assert.That(Capture.Last.Request.Path).IsEqualTo("/ping"); + } + + [Test] + public async Task HttpCapture_CapturesResponseStatusCode() + { + // Verifies that response status codes are captured + var client = Factory.CreateClient(); + await client.GetAsync("/ping"); + + await Assert.That(Capture.Last!.Response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(Capture.Last.Response.StatusCodeValue).IsEqualTo(200); + } + + [Test] + public async Task HttpCapture_CapturesResponseBody() + { + // Verifies that response body is captured + var client = Factory.CreateClient(); + await client.GetAsync("/ping"); + + await Assert.That(Capture.Last!.Response.Body).IsEqualTo("pong"); + } + + [Test] + public async Task HttpCapture_CapturesPostRequestBody() + { + // Verifies that POST request body is captured + var client = Factory.CreateClient(); + var content = new StringContent("test payload", Encoding.UTF8, "text/plain"); + await client.PostAsync("/echo", content); + + await Assert.That(Capture.Last!.Request.Method).IsEqualTo("POST"); + await Assert.That(Capture.Last.Request.Body).Contains("test payload"); + } + + [Test] + public async Task HttpCapture_CapturesResponseHeaders() + { + // Verifies that response headers are captured + var client = Factory.CreateClient(); + await client.GetAsync("/status"); + + await Assert.That(Capture.Last!.Response.Headers) + .ContainsKey("X-Custom-Header"); + } + + [Test] + public async Task HttpCapture_CapturesMultipleExchanges() + { + // Verifies that multiple exchanges are captured in order + var client = Factory.CreateClient(); + await client.GetAsync("/ping"); + await client.GetAsync("/status"); + await client.GetAsync("/greet/World"); + + await Assert.That(Capture.Count).IsEqualTo(3); + await Assert.That(Capture.First!.Request.Path).IsEqualTo("/ping"); + await Assert.That(Capture.Last!.Request.Path).IsEqualTo("/greet/World"); + } + + [Test] + public async Task HttpCapture_ForMethod_FiltersCorrectly() + { + // Verifies that filtering by HTTP method works + var client = Factory.CreateClient(); + await client.GetAsync("/ping"); + await client.PostAsync("/echo", new StringContent("test")); + + var getExchanges = Capture.ForMethod("GET"); + var postExchanges = Capture.ForMethod("POST"); + + await Assert.That(getExchanges.Count()).IsEqualTo(1); + await Assert.That(postExchanges.Count()).IsEqualTo(1); + } + + [Test] + public async Task HttpCapture_ForPath_FiltersCorrectly() + { + // Verifies that filtering by path works + var client = Factory.CreateClient(); + await client.GetAsync("/ping"); + await client.GetAsync("/status"); + + var pingExchanges = Capture.ForPath("/ping"); + + await Assert.That(pingExchanges.Count()).IsEqualTo(1); + await Assert.That(pingExchanges.First()!.Request.Path).IsEqualTo("/ping"); + } + + [Test] + public async Task HttpCapture_ForPathStartingWith_FiltersCorrectly() + { + // Verifies that filtering by path prefix works + var client = Factory.CreateClient(); + await client.GetAsync("/greet/Alice"); + await client.GetAsync("/greet/Bob"); + await client.GetAsync("/ping"); + + var greetExchanges = Capture.ForPathStartingWith("/greet"); + + await Assert.That(greetExchanges.Count()).IsEqualTo(2); + } + + [Test] + public async Task HttpCapture_Clear_RemovesAllExchanges() + { + // Verifies that Clear() works correctly + var client = Factory.CreateClient(); + await client.GetAsync("/ping"); + await client.GetAsync("/status"); + + await Assert.That(Capture.Count).IsEqualTo(2); + + Capture.Clear(); + + await Assert.That(Capture.Count).IsEqualTo(0); + } + + [Test] + public async Task HttpCapture_TracksDuration() + { + // Verifies that request duration is tracked + var client = Factory.CreateClient(); + await client.GetAsync("/ping"); + + await Assert.That(Capture.Last!.Duration).IsGreaterThanOrEqualTo(TimeSpan.Zero); + } + + [Test] + public async Task HttpCapture_TracksTimestamp() + { + // Verifies that timestamp is tracked + var before = DateTime.UtcNow; + var client = Factory.CreateClient(); + await client.GetAsync("/ping"); + var after = DateTime.UtcNow; + + await Assert.That(Capture.Last!.Timestamp).IsGreaterThanOrEqualTo(before); + await Assert.That(Capture.Last.Timestamp).IsLessThanOrEqualTo(after); + } +} diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/ServiceOverrideTests.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/ServiceOverrideTests.cs new file mode 100644 index 0000000000..560aaac12c --- /dev/null +++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/ServiceOverrideTests.cs @@ -0,0 +1,195 @@ +using Microsoft.Extensions.Configuration; +using TUnit.AspNetCore; + +namespace TUnit.AspNetCore.NugetTester; + +/// +/// Tests verifying that service and configuration overrides work correctly when +/// TUnit.AspNetCore is consumed as a NuGet package. +/// +public class ServiceOverrideTests : TestsBase +{ + private const string TestConfigMessage = "Custom test message from configuration"; + + protected override void ConfigureTestServices(IServiceCollection services) + { + // Replace the greeting service with a test double + services.ReplaceService(new TestGreetingService()); + + // Replace the time service with a fixed time for deterministic testing + services.ReplaceService(new FixedTimeService(new DateTime(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc))); + } + + protected override void ConfigureTestConfiguration(IConfigurationBuilder config) + { + // Add test-specific configuration + config.AddInMemoryCollection(new Dictionary + { + { "TestMessage", TestConfigMessage } + }); + } + + [Test] + public async Task ServiceReplacement_UsesTestDouble() + { + // Verifies that the replaced IGreetingService is used + var client = Factory.CreateClient(); + var response = await client.GetAsync("/greet/World"); + var content = await response.Content.ReadAsStringAsync(); + + // The test double should return a different format + await Assert.That(content).IsEqualTo("Test greeting for: World"); + } + + [Test] + public async Task ServiceReplacement_FixedTimeService_ReturnsDeterministicTime() + { + // Verifies that the replaced ITimeService returns the fixed time + var timeService = Services.GetRequiredService(); + var time = timeService.GetCurrentTime(); + + await Assert.That(time).IsEqualTo(new DateTime(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc)); + } + + [Test] + public async Task ConfigurationOverride_AppliesTestConfiguration() + { + // Verifies that configuration overrides are applied + var client = Factory.CreateClient(); + var response = await client.GetAsync("/config/message"); + var content = await response.Content.ReadAsStringAsync(); + + await Assert.That(content).IsEqualTo(TestConfigMessage); + } + + [Test] + public async Task ConfigurationOverride_AccessibleViaServices() + { + // Verifies that configuration is accessible via IConfiguration + var config = Services.GetRequiredService(); + var message = config["TestMessage"]; + + await Assert.That(message).IsEqualTo(TestConfigMessage); + } + + /// + /// Test double for IGreetingService that returns a different format. + /// + private class TestGreetingService : IGreetingService + { + public string GetGreeting(string name) => $"Test greeting for: {name}"; + } + + /// + /// Test double for ITimeService that returns a fixed time. + /// + private class FixedTimeService : ITimeService + { + private readonly DateTime _fixedTime; + + public FixedTimeService(DateTime fixedTime) + { + _fixedTime = fixedTime; + } + + public DateTime GetCurrentTime() => _fixedTime; + } +} + +/// +/// Tests verifying that SetupAsync can perform async operations before factory creation, +/// and those results can be used in synchronous configuration methods. +/// +public class AsyncSetupTests : TestsBase +{ + private string? _asyncResult; + + protected override async Task SetupAsync() + { + // Simulate async setup work (e.g., container health check, database creation) + await Task.Delay(10); + _asyncResult = $"async-result-{UniqueId}"; + } + + protected override void ConfigureTestConfiguration(IConfigurationBuilder config) + { + // Use the result from SetupAsync in the synchronous configuration + config.AddInMemoryCollection(new Dictionary + { + { "AsyncResult", _asyncResult } + }); + } + + [Test] + public async Task SetupAsync_CompletesBeforeConfigureTestConfiguration() + { + // Verifies that SetupAsync runs before ConfigureTestConfiguration + await Assert.That(_asyncResult).IsNotNull(); + await Assert.That(_asyncResult).StartsWith("async-result-"); + } + + [Test] + public async Task AsyncResultFromSetup_AvailableInConfiguration() + { + // Verifies that async setup results are available in configuration + var config = Services.GetRequiredService(); + var asyncResult = config["AsyncResult"]; + + await Assert.That(asyncResult).IsNotNull(); + await Assert.That(asyncResult).IsEqualTo(_asyncResult); + } +} + +/// +/// Tests verifying that test isolation works correctly - each test gets its own +/// factory and services don't leak between tests. +/// +public class IsolationTests : TestsBase +{ + private static readonly ConcurrentDictionary SeenUniqueIds = new(); + + [Test] + [Repeat(3)] + public async Task UniqueId_IsDifferentForEachTestInstance() + { + // Record the unique ID for this test instance + var wasAdded = SeenUniqueIds.TryAdd(UniqueId.ToString(), 1); + + // Each test should have a unique ID not seen before + await Assert.That(wasAdded).IsTrue(); + } + + [Test] + public async Task GetIsolatedName_IncludesUniqueId() + { + // Verifies that isolated names include the unique ID + var name1 = GetIsolatedName("resource"); + var name2 = GetIsolatedName("resource"); + + // Same test instance should get the same isolated name for same base + await Assert.That(name1).IsEqualTo(name2); + await Assert.That(name1).Contains(UniqueId.ToString()); + } +} + +/// +/// Tests verifying that logging integration works correctly. +/// +public class LoggingIntegrationTests : TestsBase +{ + [Test] + public async Task TestContext_IsAccessible_DuringTest() + { + // Verifies that TestContext is accessible during test execution + await Assert.That(TestContext.Current).IsNotNull(); + await Assert.That(TestContext.Current!.Metadata?.DisplayName).IsNotNull(); + } + + [Test] + public async Task LoggerFactory_IsAvailable_InServices() + { + // Verifies that logging services are available + var loggerFactory = Services.GetService(); + await Assert.That(loggerFactory).IsNotNull(); + } +} diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.csproj b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.csproj new file mode 100644 index 0000000000..0752ac3005 --- /dev/null +++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.csproj @@ -0,0 +1,26 @@ + + + + net8.0;net9.0;net10.0 + latest + true + enable + exe + enable + true + $(NoWarn);NU1504 + + + + + + $(TUnitVersion) + + + + + + + + + diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TestWebAppFactory.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TestWebAppFactory.cs new file mode 100644 index 0000000000..38ca78b8c2 --- /dev/null +++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TestWebAppFactory.cs @@ -0,0 +1,30 @@ +using TUnit.AspNetCore; +using TUnit.Core.Interfaces; + +namespace TUnit.AspNetCore.NugetTester; + +/// +/// Factory for integration tests. Extends TestWebApplicationFactory to support +/// per-test isolation via the WebApplicationTest pattern. +/// +public class TestWebAppFactory : TestWebApplicationFactory, IAsyncInitializer +{ + private int _configuredWebHostCalled; + + /// + /// Tracks how many times ConfiguredWebHost was called, useful for verifying factory behavior. + /// + public int ConfiguredWebHostCalled => _configuredWebHostCalled; + + public Task InitializeAsync() + { + // Eagerly start the server to catch configuration errors early + _ = Server; + return Task.CompletedTask; + } + + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + Interlocked.Increment(ref _configuredWebHostCalled); + } +} diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TestsBase.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TestsBase.cs new file mode 100644 index 0000000000..4252c2f609 --- /dev/null +++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TestsBase.cs @@ -0,0 +1,10 @@ +using TUnit.AspNetCore; + +namespace TUnit.AspNetCore.NugetTester; + +/// +/// Base class for ASP.NET Core integration tests using the WebApplicationTest pattern. +/// +public abstract class TestsBase : WebApplicationTest +{ +}