diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..41fb5ab87b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + open-pull-requests-limit: 20 + schedule: + interval: "daily" + groups: + test-dependencies: + patterns: + - NUnit* + - "*Test*" + modularpipelines-dependencies: + patterns: + - "*ModularPipeline*" + ignore: + - dependency-name: "Microsoft.Extensions.*" + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000000..f2eecf3162 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,39 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + inputs: + publish-packages: + description: Publish packages? + type: boolean + required: true + +jobs: + modularpipeline: + environment: ${{ github.ref == 'refs/heads/main' && 'Production' || 'Pull Requests' }} + runs-on: windows-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + persist-credentials: false + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + - name: Run Pipeline + run: dotnet run -c Release + working-directory: "TUnit.Pipeline" + env: + DOTNET_ENVIRONMENT: ${{ github.ref == 'refs/heads/main' && 'Production' || 'Development' }} + NuGet__ApiKey: ${{ secrets.NUGET__APIKEY }} + PULL_REQUEST_BRANCH: ${{ github.event.pull_request.head.ref }} + PUBLISH_PACKAGES: ${{ github.event.inputs.publish-packages }} diff --git a/.idea/.idea.TUnit/.idea/.gitignore b/.idea/.idea.TUnit/.idea/.gitignore new file mode 100644 index 0000000000..0be7a56a1e --- /dev/null +++ b/.idea/.idea.TUnit/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/projectSettingsUpdater.xml +/modules.xml +/contentModel.xml +/.idea.TUnit.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.TUnit/.idea/encodings.xml b/.idea/.idea.TUnit/.idea/encodings.xml new file mode 100644 index 0000000000..df87cf951f --- /dev/null +++ b/.idea/.idea.TUnit/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.TUnit/.idea/indexLayout.xml b/.idea/.idea.TUnit/.idea/indexLayout.xml new file mode 100644 index 0000000000..7b08163ceb --- /dev/null +++ b/.idea/.idea.TUnit/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.TUnit/.idea/vcs.xml b/.idea/.idea.TUnit/.idea/vcs.xml new file mode 100644 index 0000000000..35eb1ddfbb --- /dev/null +++ b/.idea/.idea.TUnit/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000000..85f8db039f --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,16 @@ + + + + <_Parameter1>TUnit.Core + + + <_Parameter1>TUnit.Engine + + + <_Parameter1>TUnit + + + <_Parameter1>TUnit.TestAdapter + + + \ No newline at end of file diff --git a/README.md b/README.md index a32891afa2..4e6bda8c71 100644 --- a/README.md +++ b/README.md @@ -1 +1,62 @@ -# TUnit \ No newline at end of file +# TUnit +T(est)Unit! + +```csharp + [Test] + public async Task Test1() + { + var value = "Hello world!"; + + await Assert.That(value) + .Is.Not.Null + .And.Is.EqualTo("hello world!", StringComparison.InvariantCultureIgnoreCase) + .And.Has.Count().EqualTo(12) + .And.Does.StartWith("H"); + } +``` + +## Motivations +There are only three main testing frameworks in the .NET world - xUnit, NUnit and MSTest. +More frameworks means more options, and more options motivates more features or improvements. + +These testing frameworks are amazing, but I've had some issues with them. You might not have had any of these, but these are my experiences: + +### xUnit +There is no way to tap into information about a test in a generic way. +For example, I've had some Playwright tests run before, and I want them to save a screenshot or video ONLY when the test fails. +If the test passes, I don't have anything to investigate, and it'll use up unnecessary storage, and it'll probably slow my test suite down if I had hundreds or thousands of tests all trying to save screenshots. + +However, if I'm in a Dispose method which is called when the test ends, then there's no way for me to know if my test succeeded or failed. I'd have to do some really clunky workaround involving try catch and setting a boolean or exception to a class field and checking that. And to do that for every test was just not ideal. + +#### Assertions +I have stumbled across assertions so many times where the arguments are the wrong way round. +This can result in really confusing error messages. +```csharp +var one = 2; +Assert.Equal(1, one) +Assert.Equal(one, 1) +``` + +### NUnit + +#### Assertions +I absolutely love the newer assertion syntax in NUnit. The `Assert.That(something, Is.Something)`. I think it's really clear to read, it's clear what is being asserted, and it's clear what you're trying to achieve. + +However, there is a lack of type checking on assertions. (Yes, there are analyzer packages to help with this, but this still isn't strict type checking.) + +`Assert.That("1", Throws.Exception);` + +This assertion makes no sense, because we're passing in a string. This can never throw an exception because it isn't a delegate that can be executed. But it's still perfectly valid code that will compile. + +As does this: +`Assert.That(1, Does.Contain("Foo!"));` + +With TUnit assertions, I wanted to make these impossible to compile. So type constraints are built into the assertions themselves. There should be no way for a non-delegate to be able to do a `Throws` assertion, or for an `int` assertion to check for `string` conditions. + +So in TUnit, this will compile: + +`await Assert.That(() => 1).Throws.Nothing;` + +This won't: + +`await Assert.That(1).Throws.Nothing;` \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers.Sample/Examples.cs b/TUnit.Analyzers/TUnit.Analyzers.Sample/Examples.cs new file mode 100644 index 0000000000..5a79910110 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Sample/Examples.cs @@ -0,0 +1,24 @@ +// ReSharper disable UnusedType.Global +// ReSharper disable UnusedMember.Global + +using System.Threading.Tasks; +using TUnit.Assertions; + +namespace TUnit.Analyzers.Sample; + +// If you don't see warnings, build the Analyzers Project. + +public class Examples +{ + public class CommonClass // Try to apply quick fix using the IDE. + { + } + + public async Task ToStars() + { + await Assert.That("1").Is.EqualTo("2"); + var spaceship = new Spaceship(); + spaceship.SetSpeed(300000000); // Invalid value, it should be highlighted. + spaceship.SetSpeed(42); + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers.Sample/Spaceship.cs b/TUnit.Analyzers/TUnit.Analyzers.Sample/Spaceship.cs new file mode 100644 index 0000000000..432f4431ba --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Sample/Spaceship.cs @@ -0,0 +1,12 @@ +using System; + +namespace TUnit.Analyzers.Sample; + +public class Spaceship +{ + public void SetSpeed(long speed) + { + if (speed > 299_792_458) + throw new ArgumentOutOfRangeException(nameof(speed)); + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers.Sample/TUnit.Analyzers.Sample.csproj b/TUnit.Analyzers/TUnit.Analyzers.Sample/TUnit.Analyzers.Sample.csproj new file mode 100644 index 0000000000..0937190a22 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Sample/TUnit.Analyzers.Sample.csproj @@ -0,0 +1,18 @@ + + + + net7.0 + enable + + + + + + + + + + + + diff --git a/TUnit.Analyzers/TUnit.Analyzers.Tests/AwaitAssertionAnalyzerTests.cs b/TUnit.Analyzers/TUnit.Analyzers.Tests/AwaitAssertionAnalyzerTests.cs new file mode 100644 index 0000000000..4f5efe475f --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Tests/AwaitAssertionAnalyzerTests.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using NUnit.Framework; +using Verifier = TUnit.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; + +namespace TUnit.Analyzers.Tests; + +public class AwaitAssertionAnalyzerTests +{ + [Test] + public async Task ClassWithMyCompanyTitle_AlertDiagnostic() + { + const string text = """ + using System.Threading.Tasks; + using TUnit.Assertions; + using TUnit.Core; + + public class MyClass + { + + public async Task MyTest() + { + var one = 1; + {|#0:Assert.That(one).Is.EqualTo(1);|} + } + + } + """; + + var expected = Verifier.Diagnostic().WithLocation(0); + + await Verifier.VerifyAnalyzerAsync(text, expected).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers.Tests/Net.cs b/TUnit.Analyzers/TUnit.Analyzers.Tests/Net.cs new file mode 100644 index 0000000000..fc95ec3cc2 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Tests/Net.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Immutable; +using System.IO; +using Microsoft.CodeAnalysis.Testing; + +namespace TUnit.Analyzers.Tests; + +internal static class Net +{ + private static readonly Lazy LazyNet60 = new(() => + new ReferenceAssemblies( + "net6.0", + new PackageIdentity( + "Microsoft.NETCore.App.Ref", + "6.0.21"), + Path.Combine("ref", "net6.0")) + .AddPackages(ImmutableArray.Create(new PackageIdentity("Microsoft.Extensions.Logging", "6.0.0"))) + ); + + public static ReferenceAssemblies Net60 => LazyNet60.Value; + + private static readonly Lazy LazyNet70 = new(() => + new ReferenceAssemblies( + "net7.0", + new PackageIdentity( + "Microsoft.NETCore.App.Ref", + "7.0.15"), + Path.Combine("ref", "net7.0")) + .AddPackages(ImmutableArray.Create(new PackageIdentity("Microsoft.Extensions.Logging", "7.0.0"))) + ); + + private static readonly Lazy LazyNet80 = new(() => + new ReferenceAssemblies( + "net8.0", + new PackageIdentity( + "Microsoft.NETCore.App.Ref", + "8.0.1"), + Path.Combine("ref", "net8.0")) + .AddPackages(ImmutableArray.Create(new PackageIdentity("Microsoft.Extensions.Logging", "8.0.0"))) + ); + + public static ReferenceAssemblies Net70 => LazyNet70.Value; + public static ReferenceAssemblies Net80 => LazyNet80.Value; +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers.Tests/SampleCodeFixProviderTests.cs b/TUnit.Analyzers/TUnit.Analyzers.Tests/SampleCodeFixProviderTests.cs new file mode 100644 index 0000000000..1eebd3b369 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Tests/SampleCodeFixProviderTests.cs @@ -0,0 +1,31 @@ +// using System.Threading.Tasks; +// using Xunit; +// using Verifier = +// Microsoft.CodeAnalysis.CSharp.Testing.XUnit.CodeFixVerifier; +// +// namespace TUnit.Analyzers.Tests; +// +// public class SampleCodeFixProviderTests +// { +// [Fact] +// public async Task ClassWithMyCompanyTitle_ReplaceWithCommonKeyword() +// { +// const string text = @" +// public class MyCompanyClass +// { +// } +// "; +// +// const string newText = @" +// public class CommonClass +// { +// } +// "; +// +// var expected = Verifier.Diagnostic() +// .WithLocation(2, 14) +// .WithArguments("MyCompanyClass"); +// await Verifier.VerifyCodeFixAsync(text, expected, newText).ConfigureAwait(false); +// } +// } \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj b/TUnit.Analyzers/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj new file mode 100644 index 0000000000..87da5d872f --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj @@ -0,0 +1,29 @@ + + + + net7.0 + enable + + false + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs new file mode 100644 index 0000000000..8c52e6fc73 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs @@ -0,0 +1,47 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing.Verifiers; + +namespace TUnit.Analyzers.Tests.Verifiers; + +public static partial class CSharpAnalyzerVerifier + where TAnalyzer : DiagnosticAnalyzer, new() +{ + public class Test : CSharpAnalyzerTest + { + public Test() + { + 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; + } + + var parseOptions = project.ParseOptions as CSharpParseOptions; + + if (parseOptions is null) + { + return solution; + } + + compilationOptions = compilationOptions.WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions.SetItems(CSharpVerifierHelper.NullableWarnings)); + + solution = solution.WithProjectCompilationOptions(projectId, compilationOptions) + .WithProjectParseOptions(projectId, parseOptions.WithLanguageVersion(LanguageVersion.Preview)); + + return solution; + }); + } + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs new file mode 100644 index 0000000000..51fa0c503b --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs @@ -0,0 +1,48 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; +using TUnit.Assertions; +using TUnit.Core; + +namespace TUnit.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 async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + var test = new Test + { + TestCode = source, + ReferenceAssemblies = Net.Net80, + TestState = + { + AdditionalReferences = + { + typeof(TUnitAttribute).Assembly.Location, + typeof(AssertionBuilder<>).Assembly.Location, + }, + }, + }; + + test.ExpectedDiagnostics.AddRange(expected); + await test.RunAsync(CancellationToken.None); + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs new file mode 100644 index 0000000000..4074d1ac9e --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs @@ -0,0 +1,49 @@ +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing.Verifiers; + +namespace TUnit.Analyzers.Tests.Verifiers; + +public static partial class CSharpCodeFixVerifier + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() +{ + public class Test : CSharpCodeFixTest + { + public Test() + { + 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; + } + + var parseOptions = project.ParseOptions as CSharpParseOptions; + + if (parseOptions is null) + { + return solution; + } + + compilationOptions = compilationOptions.WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions.SetItems(CSharpVerifierHelper.NullableWarnings)); + + solution = solution.WithProjectCompilationOptions(projectId, compilationOptions) + .WithProjectParseOptions(projectId, parseOptions.WithLanguageVersion(LanguageVersion.Preview)); + + return solution; + }); + } + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs new file mode 100644 index 0000000000..2000ecec69 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs @@ -0,0 +1,80 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; +using TUnit.Assertions; +using TUnit.Core; + +namespace TUnit.Analyzers.Tests.Verifiers; + +public static partial class CSharpCodeFixVerifier + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() +{ + /// + public static DiagnosticResult Diagnostic() + => CSharpCodeFixVerifier.Diagnostic(); + + /// + public static DiagnosticResult Diagnostic(string diagnosticId) + => CSharpCodeFixVerifier.Diagnostic(diagnosticId); + + /// + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) + => CSharpCodeFixVerifier.Diagnostic(descriptor); + + /// + public static async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + var test = new Test + { + TestCode = source, + CodeActionValidationMode = CodeActionValidationMode.SemanticStructure, + ReferenceAssemblies = Net.Net80, + TestState = + { + AdditionalReferences = + { + typeof(TUnitAttribute).Assembly.Location, + typeof(AssertionBuilder<>).Assembly.Location, + }, + }, + }; + + test.ExpectedDiagnostics.AddRange(expected); + await test.RunAsync(CancellationToken.None); + } + + /// + public static async Task VerifyCodeFixAsync(string source, string fixedSource) + => await VerifyCodeFixAsync(source, DiagnosticResult.EmptyDiagnosticResults, fixedSource); + + /// + public static async Task VerifyCodeFixAsync(string source, DiagnosticResult expected, string fixedSource) + => await VerifyCodeFixAsync(source, new[] { expected }, fixedSource); + + /// + public static async Task VerifyCodeFixAsync(string source, DiagnosticResult[] expected, string fixedSource) + { + var test = new Test + { + TestCode = source, + FixedCode = fixedSource, + ReferenceAssemblies = Net.Net80, + TestState = + { + AdditionalReferences = + { + typeof(TUnitAttribute).Assembly.Location, + typeof(AssertionBuilder<>).Assembly.Location, + }, }, + }; + + test.ExpectedDiagnostics.AddRange(expected); + await test.RunAsync(CancellationToken.None); + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpCodeRefactoringVerifier.cs b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpCodeRefactoringVerifier.cs new file mode 100644 index 0000000000..7fa594b0af --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpCodeRefactoringVerifier.cs @@ -0,0 +1,47 @@ +using Microsoft.CodeAnalysis.CodeRefactorings; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; + +namespace TUnit.Analyzers.Tests.Verifiers; + +public static partial class CSharpCodeRefactoringVerifier + where TCodeRefactoring : CodeRefactoringProvider, new() +{ + public class Test : CSharpCodeRefactoringTest + { + public Test() + { + 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; + } + + var parseOptions = project.ParseOptions as CSharpParseOptions; + + if (parseOptions is null) + { + return solution; + } + + compilationOptions = compilationOptions.WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions.SetItems(CSharpVerifierHelper.NullableWarnings)); + + solution = solution.WithProjectCompilationOptions(projectId, compilationOptions) + .WithProjectParseOptions(projectId, parseOptions.WithLanguageVersion(LanguageVersion.Preview)); + + return solution; + }); + } + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpCodeRefactoringVerifier`1.cs b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpCodeRefactoringVerifier`1.cs new file mode 100644 index 0000000000..1bbdb42268 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpCodeRefactoringVerifier`1.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CodeRefactorings; +using Microsoft.CodeAnalysis.Testing; + +namespace TUnit.Analyzers.Tests.Verifiers; + +public static partial class CSharpCodeRefactoringVerifier + where TCodeRefactoring : CodeRefactoringProvider, new() +{ + /// + public static async Task VerifyRefactoringAsync(string source, string fixedSource) + { + await VerifyRefactoringAsync(source, DiagnosticResult.EmptyDiagnosticResults, fixedSource); + } + + /// + public static async Task VerifyRefactoringAsync(string source, DiagnosticResult expected, string fixedSource) + { + await VerifyRefactoringAsync(source, new[] { expected }, fixedSource); + } + + /// + public static async Task VerifyRefactoringAsync(string source, DiagnosticResult[] expected, string fixedSource) + { + var test = new Test + { + TestCode = source, + FixedCode = fixedSource, + }; + + test.ExpectedDiagnostics.AddRange(expected); + await test.RunAsync(CancellationToken.None); + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpVerifierHelper.cs b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpVerifierHelper.cs new file mode 100644 index 0000000000..79517165e5 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers.Tests/Verifiers/CSharpVerifierHelper.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace TUnit.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; + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers/AnalyzerReleases.Shipped.md b/TUnit.Analyzers/TUnit.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000000..b49126e4dc --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,8 @@ +## Release 1.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------------------------------------------------ +TUnit0001 | Usage | Warning | Don't mix 'Or' & 'And' operators in assertions. +TUnit0002 | Usage | Error | Assert statements must be awaited. diff --git a/TUnit.Analyzers/TUnit.Analyzers/AnalyzerReleases.Unshipped.md b/TUnit.Analyzers/TUnit.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000000..062067f471 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,4 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers/AwaitAssertionAnalyzer.cs b/TUnit.Analyzers/TUnit.Analyzers/AwaitAssertionAnalyzer.cs new file mode 100644 index 0000000000..78cc8332c3 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers/AwaitAssertionAnalyzer.cs @@ -0,0 +1,109 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using TUnit.Analyzers.Extensions; + +namespace TUnit.Analyzers; + +/// +/// A sample analyzer that reports the company name being used in class declarations. +/// Traverses through the Syntax Tree and checks the name (identifier) of each class node. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class AwaitAssertionAnalyzer : DiagnosticAnalyzer +{ + // Preferred format of DiagnosticId is Your Prefix + Number, e.g. CA1234. + public const string DiagnosticId = "TUnit0002"; + + // Feel free to use raw strings if you don't need localization. + private static readonly LocalizableString Title = new LocalizableResourceString(DiagnosticId + "Title", + Resources.ResourceManager, typeof(Resources)); + + // The message that will be displayed to the user. + private static readonly LocalizableString MessageFormat = + new LocalizableResourceString(DiagnosticId + "MessageFormat", Resources.ResourceManager, + typeof(Resources)); + + private static readonly LocalizableString Description = + new LocalizableResourceString(DiagnosticId + "Description", Resources.ResourceManager, + typeof(Resources)); + + // The category of the diagnostic (Design, Naming etc.). + private const string Category = "Usage"; + + private static readonly DiagnosticDescriptor Rule = new(DiagnosticId, Title, MessageFormat, Category, + DiagnosticSeverity.Error, isEnabledByDefault: true, description: Description); + + // Keep in mind: you have to list your rules here. + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + // You must call this method to avoid analyzing generated code. + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + // You must call this method to enable the Concurrent Execution. + context.EnableConcurrentExecution(); + + // Subscribe to the Syntax Node with the appropriate 'SyntaxKind' (ClassDeclaration) action. + // To figure out which Syntax Nodes you should choose, consider installing the Roslyn syntax tree viewer plugin Rossynt: https://plugins.jetbrains.com/plugin/16902-rossynt/ + context.RegisterSyntaxNodeAction(AnalyzeSyntax, SyntaxKind.SimpleMemberAccessExpression); + + // Check other 'context.Register...' methods that might be helpful for your purposes. + } + + /// + /// Executed for each Syntax Node with 'SyntaxKind' is 'ClassDeclaration'. + /// + /// Operation context. + private void AnalyzeSyntax(SyntaxNodeAnalysisContext context) + { + // The Roslyn architecture is based on inheritance. + // To get the required metadata, we should match the 'Node' object to the particular type: 'ClassDeclarationSyntax'. + if (context.Node is not MemberAccessExpressionSyntax memberAccessExpressionSyntax) + { + return; + } + + if (memberAccessExpressionSyntax.ToString() != "Assert.That") + { + return; + } + + var symbol = context.SemanticModel.GetSymbolInfo(memberAccessExpressionSyntax); + + if (symbol.Symbol is not IMethodSymbol methodSymbol) + { + return; + } + + if (methodSymbol.ToDisplayString(DisplayFormats.FullyQualifiedNonGeneric) != + "global::TUnit.Assertions.Assert.That") + { + return; + } + + var expressionStatementParent = memberAccessExpressionSyntax.GetAncestorSyntaxOfType(); + + if (expressionStatementParent is null) + { + return; + } + + if (expressionStatementParent.ChildNodes().Any(x => x is AwaitExpressionSyntax)) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create( + new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Error, + true, Description), + expressionStatementParent.GetLocation()) + ); + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers/AwaitAssertionCodeFixProvider.cs b/TUnit.Analyzers/TUnit.Analyzers/AwaitAssertionCodeFixProvider.cs new file mode 100644 index 0000000000..121e2d3abc --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers/AwaitAssertionCodeFixProvider.cs @@ -0,0 +1,74 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; + +namespace TUnit.Analyzers; + +/// +/// A sample code fix provider that renames classes with the company name in their definition. +/// All code fixes must be linked to specific analyzers. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AwaitAssertionCodeFixProvider)), Shared] +public class AwaitAssertionCodeFixProvider : CodeFixProvider +{ + // Specify the diagnostic IDs of analyzers that are expected to be linked. + public sealed override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(AwaitAssertionAnalyzer.DiagnosticId); + + // If you don't need the 'fix all' behaviour, return null. + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + // We link only one diagnostic and assume there is only one diagnostic in the context. + var diagnostic = context.Diagnostics.Single(); + + // 'SourceSpan' of 'Location' is the highlighted area. We're going to use this area to find the 'SyntaxNode' to rename. + var diagnosticSpan = diagnostic.Location.SourceSpan; + + // Get the root of Syntax Tree that contains the highlighted diagnostic. + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + // Find SyntaxNode corresponding to the diagnostic. + var diagnosticNode = root?.FindNode(diagnosticSpan); + + // To get the required metadata, we should match the Node to the specific type: 'ClassDeclarationSyntax'. + if (diagnosticNode is not ExpressionStatementSyntax expressionStatementSyntax) + return; + + // Register a code action that will invoke the fix. + context.RegisterCodeFix( + CodeAction.Create( + title: Resources.TUnit0002CodeFixTitle, + createChangedDocument: c => AwaitAssertionAsync(context.Document, expressionStatementSyntax, c), + equivalenceKey: nameof(Resources.TUnit0002CodeFixTitle)), + diagnostic); + } + + /// + /// Executed on the quick fix action raised by the user. + /// + /// Affected source file. + /// Highlighted class declaration Syntax Node. + /// Any fix is cancellable by the user, so we should support the cancellation token. + /// Clone of the solution with updates: renamed class. + private async Task AwaitAssertionAsync(Document document, + ExpressionStatementSyntax expressionStatementSyntax, CancellationToken cancellationToken) + { + var editor = await DocumentEditor.CreateAsync(document, cancellationToken); + + var awaitExpressionSyntax = SyntaxFactory.AwaitExpression(expressionStatementSyntax.Expression); + + editor.ReplaceNode(expressionStatementSyntax.Expression, awaitExpressionSyntax); + + return editor.GetChangedDocument(); + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers/DisplayFormats.cs b/TUnit.Analyzers/TUnit.Analyzers/DisplayFormats.cs new file mode 100644 index 0000000000..abab77fa08 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers/DisplayFormats.cs @@ -0,0 +1,19 @@ +using Microsoft.CodeAnalysis; + +namespace TUnit.Analyzers; + +public class DisplayFormats +{ + public static SymbolDisplayFormat FullyQualifiedNonGeneric => new( + SymbolDisplayGlobalNamespaceStyle.Included, + SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + SymbolDisplayGenericsOptions.None, + SymbolDisplayMemberOptions.IncludeContainingType, + SymbolDisplayDelegateStyle.NameAndSignature, + SymbolDisplayExtensionMethodStyle.Default, + SymbolDisplayParameterOptions.IncludeType, + SymbolDisplayPropertyStyle.NameOnly, + SymbolDisplayLocalOptions.IncludeType, + SymbolDisplayKindOptions.None + ); +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers/Extensions/SyntaxExtensions.cs b/TUnit.Analyzers/TUnit.Analyzers/Extensions/SyntaxExtensions.cs new file mode 100644 index 0000000000..e4caed1e33 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers/Extensions/SyntaxExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.CodeAnalysis; + +namespace TUnit.Analyzers.Extensions; + +public static class SyntaxExtensions +{ + public static TOutput? GetAncestorSyntaxOfType(this SyntaxNode input) + where TOutput : SyntaxNode + { + var parent = input.Parent; + + while (parent != null && parent is not TOutput) + { + parent = parent.Parent; + } + + return parent as TOutput; + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers/MixAndOrOperatorsAnalyzer.cs b/TUnit.Analyzers/TUnit.Analyzers/MixAndOrOperatorsAnalyzer.cs new file mode 100644 index 0000000000..0a75fb6475 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers/MixAndOrOperatorsAnalyzer.cs @@ -0,0 +1,94 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using TUnit.Analyzers.Extensions; + +namespace TUnit.Analyzers; + +/// +/// A sample analyzer that reports the company name being used in class declarations. +/// Traverses through the Syntax Tree and checks the name (identifier) of each class node. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class MixAndOrOperatorsAnalyzer : DiagnosticAnalyzer +{ + // Preferred format of DiagnosticId is Your Prefix + Number, e.g. CA1234. + public const string DiagnosticId = "TUnit0001"; + + // Feel free to use raw strings if you don't need localization. + private static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.TUnit0001Title), + Resources.ResourceManager, typeof(Resources)); + + // The message that will be displayed to the user. + private static readonly LocalizableString MessageFormat = + new LocalizableResourceString(nameof(Resources.TUnit0001MessageFormat), Resources.ResourceManager, + typeof(Resources)); + + private static readonly LocalizableString Description = + new LocalizableResourceString(nameof(Resources.TUnit0001Description), Resources.ResourceManager, + typeof(Resources)); + + private const string Category = "Usage"; + + private static readonly DiagnosticDescriptor Rule = new(DiagnosticId, Title, MessageFormat, Category, + DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description); + + // Keep in mind: you have to list your rules here. + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + // You must call this method to avoid analyzing generated code. + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + // You must call this method to enable the Concurrent Execution. + context.EnableConcurrentExecution(); + + // Subscribe to the Syntax Node with the appropriate 'SyntaxKind' (ClassDeclaration) action. + // To figure out which Syntax Nodes you should choose, consider installing the Roslyn syntax tree viewer plugin Rossynt: https://plugins.jetbrains.com/plugin/16902-rossynt/ + context.RegisterSyntaxNodeAction(AnalyzeSyntax, SyntaxKind.SimpleMemberAccessExpression); + } + + private static void AnalyzeSyntax(SyntaxNodeAnalysisContext context) + { + // The Roslyn architecture is based on inheritance. + // To get the required metadata, we should match the 'Node' object to the particular type: 'ClassDeclarationSyntax'. + if (context.Node is not MemberAccessExpressionSyntax memberAccessExpressionSyntax) + return; + + if (memberAccessExpressionSyntax.Name.Identifier.Value is not "And" and not "Or") + { + return; + } + + if (memberAccessExpressionSyntax.GetAncestorSyntaxOfType() is not { } invocationExpressionSyntax) + { + return; + } + + var symbolInfo = context.SemanticModel.GetSymbolInfo(invocationExpressionSyntax); + + var fullyQualifiedSymbolInformation = symbolInfo.Symbol?.ToDisplayString(DisplayFormats.FullyQualifiedNonGeneric) + ?? string.Empty; + + if (!fullyQualifiedSymbolInformation.StartsWith("global::TUnit.Assertions")) + { + return; + } + + var fullInvocationStatement = invocationExpressionSyntax.ToFullString(); + if(fullInvocationStatement.Contains(".And.") + && fullInvocationStatement.Contains(".Or.")) + { + context.ReportDiagnostic( + Diagnostic.Create( + new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, + true, Description), + invocationExpressionSyntax.GetLocation()) + ); + } + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers/Properties/launchSettings.json b/TUnit.Analyzers/TUnit.Analyzers/Properties/launchSettings.json new file mode 100644 index 0000000000..5ff5a737e1 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "DebugRoslynAnalyzers": { + "commandName": "DebugRoslynComponent", + "targetProject": "../TUnit.Analyzers.Sample/TUnit.Analyzers.Sample.csproj" + } + } +} \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers/Readme.md b/TUnit.Analyzers/TUnit.Analyzers/Readme.md new file mode 100644 index 0000000000..7053db9f47 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers/Readme.md @@ -0,0 +1,29 @@ +# Roslyn Analyzers Sample + +A set of three sample projects that includes Roslyn analyzers with code fix providers. Enjoy this template to learn from and modify analyzers for your own needs. + +## Content +### TUnit.Analyzers +A .NET Standard project with implementations of sample analyzers and code fix providers. +**You must build this project to see the results (warnings) in the IDE.** + +- [SampleSemanticAnalyzer.cs](SampleSemanticAnalyzer.cs): An analyzer that reports invalid values used for the `speed` parameter of the `SetSpeed` function. +- [AwaitAssertionAnalyzer.cs](AwaitAssertionAnalyzer.cs): An analyzer that reports the company name used in class definitions. +- [SampleCodeFixProvider.cs](SampleCodeFixProvider.cs): A code fix that renames classes with company name in their definition. The fix is linked to [AwaitAssertionAnalyzer.cs](AwaitAssertionAnalyzer.cs). + +### TUnit.Analyzers.Sample +A project that references the sample analyzers. Note the parameters of `ProjectReference` in [TUnit.Analyzers.Sample.csproj](../TUnit.Analyzers.Sample/TUnit.Analyzers.Sample.csproj), they make sure that the project is referenced as a set of analyzers. + +### TUnit.Analyzers.Tests +Unit tests for the sample analyzers and code fix provider. The easiest way to develop language-related features is to start with unit tests. + +## How To? +### How to debug? +- Use the [launchSettings.json](Properties/launchSettings.json) profile. +- Debug tests. + +### How can I determine which syntax nodes I should expect? +Consider installing the Roslyn syntax tree viewer plugin [Rossynt](https://plugins.jetbrains.com/plugin/16902-rossynt/). + +### Learn more about wiring analyzers +The complete set of information is available at [roslyn github repo wiki](https://github.com/dotnet/roslyn/blob/main/docs/wiki/README.md). \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers/Resources.Designer.cs b/TUnit.Analyzers/TUnit.Analyzers/Resources.Designer.cs new file mode 100644 index 0000000000..e760496b65 --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers/Resources.Designer.cs @@ -0,0 +1,134 @@ +//------------------------------------------------------------------------------ +// +// 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.Analyzers { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [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.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 Don't mix 'Or' & 'And' operators in assertions. + /// + internal static string TUnit0001CodeFixTitle { + get { + return ResourceManager.GetString("TUnit0001CodeFixTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Don't mix 'Or' & 'And' operators in assertions.. + /// + internal static string TUnit0001Description { + get { + return ResourceManager.GetString("TUnit0001Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Don't mix 'Or' & 'And' operators in assertions. + /// + internal static string TUnit0001MessageFormat { + get { + return ResourceManager.GetString("TUnit0001MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Don't mix 'Or' & 'And' operators in assertions. + /// + internal static string TUnit0001Title { + get { + return ResourceManager.GetString("TUnit0001Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Assert statements must be awaited. + /// + internal static string TUnit0002CodeFixTitle { + get { + return ResourceManager.GetString("TUnit0002CodeFixTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Assert statements must be awaited.. + /// + internal static string TUnit0002Description { + get { + return ResourceManager.GetString("TUnit0002Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Assert statements must be awaited. + /// + internal static string TUnit0002MessageFormat { + get { + return ResourceManager.GetString("TUnit0002MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Assert statements must be awaited. + /// + internal static string TUnit0002Title { + get { + return ResourceManager.GetString("TUnit0002Title", resourceCulture); + } + } + } +} diff --git a/TUnit.Analyzers/TUnit.Analyzers/Resources.resx b/TUnit.Analyzers/TUnit.Analyzers/Resources.resx new file mode 100644 index 0000000000..97b894095d --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers/Resources.resx @@ -0,0 +1,45 @@ + + + + + + + + + + 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 + + + Don't mix 'Or' & 'And' operators in assertions.An optional longer localizable description of the diagnostic. + + + Don't mix 'Or' & 'And' operators in assertionsThe format-able message the diagnostic displays. + + + Don't mix 'Or' & 'And' operators in assertionsThe title of the diagnostic. + + + Don't mix 'Or' & 'And' operators in assertionsThe title of the code fix. + + + Assert statements must be awaited.An optional longer localizable description of the diagnostic. + + + Assert statements must be awaitedThe format-able message the diagnostic displays. + + + Assert statements must be awaitedThe title of the diagnostic. + + + Assert statements must be awaitedThe title of the code fix. + + \ No newline at end of file diff --git a/TUnit.Analyzers/TUnit.Analyzers/TUnit.Analyzers.csproj b/TUnit.Analyzers/TUnit.Analyzers/TUnit.Analyzers.csproj new file mode 100644 index 0000000000..7b71b51bbc --- /dev/null +++ b/TUnit.Analyzers/TUnit.Analyzers/TUnit.Analyzers.csproj @@ -0,0 +1,40 @@ + + + + netstandard2.0 + false + enable + latest + + true + true + + TUnit.Analyzers + TUnit.Analyzers + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + True + True + Resources.resx + + + + diff --git a/TUnit.Assertions.UnitTests/GlobalUsings.cs b/TUnit.Assertions.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000000..5e3c1307f3 --- /dev/null +++ b/TUnit.Assertions.UnitTests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using NUnit.Framework; +global using Assert = TUnit.Assertions.Assert; \ No newline at end of file diff --git a/TUnit.Assertions.UnitTests/TUnit.Assertions.UnitTests.csproj b/TUnit.Assertions.UnitTests/TUnit.Assertions.UnitTests.csproj new file mode 100644 index 0000000000..a4962c80df --- /dev/null +++ b/TUnit.Assertions.UnitTests/TUnit.Assertions.UnitTests.csproj @@ -0,0 +1,28 @@ + + + + net7.0 + enable + enable + + false + true + latest + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/TUnit.Assertions.UnitTests/ZeroAssertionTests.cs b/TUnit.Assertions.UnitTests/ZeroAssertionTests.cs new file mode 100644 index 0000000000..8d16eb5c28 --- /dev/null +++ b/TUnit.Assertions.UnitTests/ZeroAssertionTests.cs @@ -0,0 +1,47 @@ +namespace TUnit.Assertions.UnitTests; + + +public class ZeroAssertionTests +{ + [Test] + public void Int() + { + int zero = 0; + Assert.That(zero); + } + + [Test] + public void Long() + { + long zero = 0; + Assert.That(zero); + } + + [Test] + public void Short() + { + short zero = 0; + Assert.That(zero); + } + + [Test] + public void Int_Bad() + { + int zero = 1; + Assert.That(zero); + } + + [Test] + public void Long_Bad() + { + long zero = 1; + Assert.That(zero); + } + + [Test] + public void Short_Bad() + { + short zero = 1; + Assert.That(zero); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/Assert.cs b/TUnit.Assertions/Assert.cs new file mode 100644 index 0000000000..5a30db7d41 --- /dev/null +++ b/TUnit.Assertions/Assert.cs @@ -0,0 +1,29 @@ +namespace TUnit.Assertions; + +public static class Assert +{ + public static ValueAssertionBuilder That(TActual value) + { + return new ValueAssertionBuilder(value); + } + + public static DelegateAssertionBuilder That(Action value) + { + return new DelegateAssertionBuilder(value); + } + + public static DelegateAssertionBuilder That(Func value) + { + return new DelegateAssertionBuilder(value); + } + + public static AsyncDelegateAssertionBuilder That(Func value) + { + return new AsyncDelegateAssertionBuilder(value); + } + + public static AsyncDelegateAssertionBuilder That(Func> value) + { + return new AsyncDelegateAssertionBuilder(value!); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/AssertCondition.cs b/TUnit.Assertions/AssertConditions/AssertCondition.cs new file mode 100644 index 0000000000..306918ce6b --- /dev/null +++ b/TUnit.Assertions/AssertConditions/AssertCondition.cs @@ -0,0 +1,15 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions; + +public abstract class AssertCondition : BaseAssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + internal TExpected? ExpectedValue { get; } + + internal AssertCondition(AssertionBuilder assertionBuilder, TExpected? expected) : base(assertionBuilder) + { + ExpectedValue = expected; + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/BaseAssertCondition.cs b/TUnit.Assertions/AssertConditions/BaseAssertCondition.cs new file mode 100644 index 0000000000..91db5f4569 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/BaseAssertCondition.cs @@ -0,0 +1,77 @@ +using System.Runtime.CompilerServices; +using TUnit.Assertions.AssertConditions.Operators; +using TUnit.Assertions.Exceptions; + +namespace TUnit.Assertions.AssertConditions; + +public abstract class BaseAssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected internal AssertionBuilder AssertionBuilder { get; } + + internal BaseAssertCondition(AssertionBuilder assertionBuilder) + { + AssertionBuilder = assertionBuilder; + + And = TAnd.Create(this); + Or = TOr.Create(this); + } + + public TaskAwaiter GetAwaiter() + { + return AssertAsync().GetAwaiter(); + } + + private async Task AssertAsync() + { + var assertionData = await AssertionBuilder.GetAssertionData(); + + AssertAndThrow(assertionData.Result, assertionData.Exception); + } + + protected TActual? ActualValue { get; private set; } + protected Exception? Exception { get; private set; } + + + protected internal virtual string Message => MessageFactory?.Invoke(ActualValue, Exception) ?? DefaultMessage; + + private Func? MessageFactory { get; set; } + + public BaseAssertCondition WithMessage(Func messageFactory) + { + MessageFactory = messageFactory; + return this; + } + + protected abstract string DefaultMessage { get; } + + private void AssertAndThrow(TActual? actual, Exception? exception) + { + if (!Assert(actual, exception)) + { + throw new AssertionException(Message); + } + } + + internal bool Assert(TActual? actualValue, Exception? exception) + { + ActualValue = actualValue; + Exception = exception; + return IsInverted ? !Passes(actualValue, exception) : Passes(actualValue, exception); + } + + protected internal abstract bool Passes(TActual? actualValue, Exception? exception); + + public TAnd And { get; } + public TOr Or { get; } + + internal BaseAssertCondition Invert(Func messageFactory) + { + WithMessage(messageFactory); + IsInverted = true; + return this; + } + + protected bool IsInverted { get; set; } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Collections/EnumerableContainsAssertCondition.cs b/TUnit.Assertions/AssertConditions/Collections/EnumerableContainsAssertCondition.cs new file mode 100644 index 0000000000..e49a6d28df --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Collections/EnumerableContainsAssertCondition.cs @@ -0,0 +1,25 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Collections; + +public class EnumerableContainsAssertCondition : AssertCondition + where TActual : IEnumerable + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public EnumerableContainsAssertCondition(AssertionBuilder assertionBuilder, TInner expected) : base(assertionBuilder, expected) + { + } + + protected override string DefaultMessage => $"{ExpectedValue} was not found in the collection"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + if (actualValue is null) + { + return false; + } + + return actualValue.Contains(ExpectedValue); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Collections/EnumerableCountEqualToAssertCondition.cs b/TUnit.Assertions/AssertConditions/Collections/EnumerableCountEqualToAssertCondition.cs new file mode 100644 index 0000000000..834de5d168 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Collections/EnumerableCountEqualToAssertCondition.cs @@ -0,0 +1,38 @@ +using System.Collections; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Collections; + +public class EnumerableCountEqualToAssertCondition : AssertCondition + where TActual : IEnumerable + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public EnumerableCountEqualToAssertCondition(AssertionBuilder assertionBuilder, int expected) : base(assertionBuilder, expected) + { + } + + protected override string DefaultMessage => $"Length is {GetCount(ActualValue)} instead of {ExpectedValue}"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + return GetCount(actualValue) == ExpectedValue; + } + + private int GetCount(TActual? actualValue) + { + ArgumentNullException.ThrowIfNull(actualValue); + + if (actualValue is ICollection collection) + { + return collection.Count; + } + + if (actualValue is TInner[] array) + { + return array.Length; + } + + return actualValue.Count(); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Collections/EnumerableEquivalentToAssertCondition.cs b/TUnit.Assertions/AssertConditions/Collections/EnumerableEquivalentToAssertCondition.cs new file mode 100644 index 0000000000..902d1ee30f --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Collections/EnumerableEquivalentToAssertCondition.cs @@ -0,0 +1,25 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Collections; + +public class EnumerableEquivalentToAssertCondition : AssertCondition, TAnd, TOr> + where TActual : IEnumerable + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public EnumerableEquivalentToAssertCondition(AssertionBuilder assertionBuilder, IEnumerable expected) : base(assertionBuilder, expected) + { + } + + protected override string DefaultMessage => "The two Enumerables were not equivalent"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + if (actualValue is null && ExpectedValue is null) + { + return true; + } + + return actualValue?.SequenceEqual(ExpectedValue!) ?? false; + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Collections/PropertyOrMethodAccessor.cs b/TUnit.Assertions/AssertConditions/Collections/PropertyOrMethodAccessor.cs new file mode 100644 index 0000000000..219cbc3afa --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Collections/PropertyOrMethodAccessor.cs @@ -0,0 +1,10 @@ +namespace TUnit.Assertions.AssertConditions.Collections; + +public class PropertyOrMethodAccessor +{ + public PropertyOrMethodAccessor() + { + } + + +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/ConnectorType.cs b/TUnit.Assertions/AssertConditions/ConnectorType.cs new file mode 100644 index 0000000000..048182652a --- /dev/null +++ b/TUnit.Assertions/AssertConditions/ConnectorType.cs @@ -0,0 +1,8 @@ +namespace TUnit.Assertions.AssertConditions; + +public enum ConnectorType +{ + None, + And, + Or +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Connectors/AssertConditionAnd.cs b/TUnit.Assertions/AssertConditions/Connectors/AssertConditionAnd.cs new file mode 100644 index 0000000000..787e1a12f8 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Connectors/AssertConditionAnd.cs @@ -0,0 +1,47 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Connectors; + +public sealed class AssertConditionAnd : BaseAssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + private readonly BaseAssertCondition _condition1; + private readonly BaseAssertCondition _condition2; + + public AssertConditionAnd(BaseAssertCondition condition1, BaseAssertCondition condition2) : base(condition1.AssertionBuilder) + { + ArgumentNullException.ThrowIfNull(condition1); + ArgumentNullException.ThrowIfNull(condition2); + + _condition1 = condition1; + _condition2 = condition2; + } + + protected internal override string Message + { + get + { + var messages = new List(2); + + if (!_condition1.Assert(ActualValue, Exception)) + { + messages.Add(_condition1.Message); + } + + if (!_condition2.Assert(ActualValue, Exception)) + { + messages.Add(_condition2.Message); + } + + return string.Join($"{Environment.NewLine} ", messages); + } + } + + protected override string DefaultMessage => string.Empty; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + return _condition1.Assert(actualValue, exception) && _condition2.Assert(actualValue, exception); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Connectors/AssertConditionOr.cs b/TUnit.Assertions/AssertConditions/Connectors/AssertConditionOr.cs new file mode 100644 index 0000000000..ed39d5a1be --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Connectors/AssertConditionOr.cs @@ -0,0 +1,28 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Connectors; + +public sealed class AssertConditionOr : BaseAssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + private readonly BaseAssertCondition _condition1; + private readonly BaseAssertCondition _condition2; + + public AssertConditionOr(BaseAssertCondition condition1, BaseAssertCondition condition2) : base(condition1.AssertionBuilder) + { + ArgumentNullException.ThrowIfNull(condition1); + ArgumentNullException.ThrowIfNull(condition2); + + _condition1 = condition1; + _condition2 = condition2; + } + + protected internal override string Message => $"{_condition1.Message} & {_condition2.Message}"; + protected override string DefaultMessage => string.Empty; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + return _condition1.Assert(actualValue, exception) || _condition2.Assert(actualValue, exception); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/DelegateAssertCondition.cs b/TUnit.Assertions/AssertConditions/DelegateAssertCondition.cs new file mode 100644 index 0000000000..f6367842f1 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/DelegateAssertCondition.cs @@ -0,0 +1,26 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions; + +public class DelegateAssertCondition : AssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + private readonly Func _condition; + + public DelegateAssertCondition(AssertionBuilder assertionBuilder, + TExpected? expected, + Func condition, + Func messageFactory) : base(assertionBuilder, expected) + { + _condition = condition; + WithMessage(messageFactory); + } + + protected override string DefaultMessage => string.Empty; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + return _condition(actualValue, ExpectedValue, exception); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/FailureLocation.cs b/TUnit.Assertions/AssertConditions/FailureLocation.cs new file mode 100644 index 0000000000..b9bd39ac5b --- /dev/null +++ b/TUnit.Assertions/AssertConditions/FailureLocation.cs @@ -0,0 +1,8 @@ +namespace TUnit.Assertions.AssertConditions; + +public record FailureLocation +{ + public long Position { get; } + public object? ExpectedValue { get; } + public object? ActualValue { get; } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Generic/EqualsAssertCondition.cs b/TUnit.Assertions/AssertConditions/Generic/EqualsAssertCondition.cs new file mode 100644 index 0000000000..b4417ad2c6 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Generic/EqualsAssertCondition.cs @@ -0,0 +1,16 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Generic; + +public class EqualsAssertCondition(AssertionBuilder assertionBuilder, TActual expected) + : AssertCondition(assertionBuilder, expected) + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected override string DefaultMessage => $"Expected {ExpectedValue} but received {ActualValue}"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + return Equals(actualValue, ExpectedValue); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Generic/Property.cs b/TUnit.Assertions/AssertConditions/Generic/Property.cs new file mode 100644 index 0000000000..26ede218e2 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Generic/Property.cs @@ -0,0 +1,50 @@ +using TUnit.Assertions.AssertConditions.Connectors; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Generic; + +public class Property : Property + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public Property(string name, ConnectorType connectorType, BaseAssertCondition otherAssertConditions) + : base(name, connectorType, otherAssertConditions) + { + } + + public Property(AssertionBuilder assertionBuilder, string name) : base(assertionBuilder, name) + { + } +} + +public class Property(AssertionBuilder assertionBuilder, string name) + where TAnd : And, IAnd + where TOr : Or, IOr +{ + private readonly ConnectorType? _connectorType; + private readonly BaseAssertCondition? _otherAssertConditions; + + public Property(string name, ConnectorType connectorType, BaseAssertCondition otherAssertConditions) + : this(otherAssertConditions.AssertionBuilder, name) + { + _connectorType = connectorType; + _otherAssertConditions = otherAssertConditions; + } + + public BaseAssertCondition EqualTo(TExpected expected) + { + var assertCondition = new PropertyEqualsAssertCondition(assertionBuilder, name, expected); + + if (_connectorType is ConnectorType.And) + { + return new AssertConditionAnd(_otherAssertConditions!, assertCondition); + } + + if (_connectorType is ConnectorType.Or) + { + return new AssertConditionOr(_otherAssertConditions!, assertCondition); + } + + return assertCondition; + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Generic/PropertyEqualsAssertCondition.cs b/TUnit.Assertions/AssertConditions/Generic/PropertyEqualsAssertCondition.cs new file mode 100644 index 0000000000..adfc149da4 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Generic/PropertyEqualsAssertCondition.cs @@ -0,0 +1,32 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Generic; + +public class PropertyEqualsAssertCondition(AssertionBuilder assertionBuilder, string propertyName, TExpected expected) + : AssertCondition(assertionBuilder, expected) + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected override string DefaultMessage => $"Expected {ExpectedValue} but received {ActualValue}"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + var propertyValue = GetPropertyValue(actualValue); + + WithMessage((_, _) => $"Expected {ExpectedValue} but received {propertyValue}"); + + return Equals(propertyValue, ExpectedValue); + } + + private object? GetPropertyValue(object? actualValue) + { + ArgumentNullException.ThrowIfNull(actualValue); + + if (actualValue.GetType().GetProperty(propertyName) is null) + { + throw new ArgumentException($"No {propertyName} property or method was found on {actualValue.GetType().Name}"); + } + + return actualValue.GetPropertyValue(propertyName); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Generic/PropertyOrMethod.cs b/TUnit.Assertions/AssertConditions/Generic/PropertyOrMethod.cs new file mode 100644 index 0000000000..ee29fb216e --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Generic/PropertyOrMethod.cs @@ -0,0 +1,50 @@ +using TUnit.Assertions.AssertConditions.Connectors; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Generic; + +public class PropertyOrMethod : Property + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public PropertyOrMethod(string name, ConnectorType connectorType, BaseAssertCondition otherAssertConditions) + : base(name, connectorType, otherAssertConditions) + { + } + + public PropertyOrMethod(AssertionBuilder assertionBuilder, string name) : base(assertionBuilder, name) + { + } +} + +public class PropertyOrMethod(AssertionBuilder assertionBuilder, string name) + where TAnd : And, IAnd + where TOr : Or, IOr +{ + private readonly ConnectorType? _connectorType; + private readonly BaseAssertCondition? _otherAssertConditions; + + public PropertyOrMethod(string name, ConnectorType connectorType, BaseAssertCondition otherAssertConditions) + : this(otherAssertConditions.AssertionBuilder, name) + { + _connectorType = connectorType; + _otherAssertConditions = otherAssertConditions; + } + + public BaseAssertCondition EqualTo(TExpected expected) + { + var assertCondition = new PropertyEqualsAssertCondition(assertionBuilder, name, expected); + + if (_connectorType is ConnectorType.And) + { + return new AssertConditionAnd(_otherAssertConditions!, assertCondition); + } + + if (_connectorType is ConnectorType.Or) + { + return new AssertConditionOr(_otherAssertConditions!, assertCondition); + } + + return assertCondition; + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Generic/PropertyOrMethodEqualsAssertCondition.cs b/TUnit.Assertions/AssertConditions/Generic/PropertyOrMethodEqualsAssertCondition.cs new file mode 100644 index 0000000000..5432bd2f5d --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Generic/PropertyOrMethodEqualsAssertCondition.cs @@ -0,0 +1,33 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Generic; + +public class PropertyOrMethodEqualsAssertCondition(AssertionBuilder assertionBuilder, string propertyName, TExpected expected) + : AssertCondition(assertionBuilder, expected) + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected override string DefaultMessage => $"Expected {ExpectedValue} but received {ActualValue}"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + var propertyValue = GetPropertyValue(actualValue); + + WithMessage((_, _) => $"Expected {ExpectedValue} but received {propertyValue}"); + + return Equals(propertyValue, ExpectedValue); + } + + private object? GetPropertyValue(object? actualValue) + { + ArgumentNullException.ThrowIfNull(actualValue); + + if (actualValue.GetType().GetProperty(propertyName) is null + && actualValue.GetType().GetProperty(propertyName) is null) + { + throw new ArgumentException($"No {propertyName} property or method was found on {actualValue.GetType().Name}"); + } + + return actualValue.GetPropertyValue(propertyName) ?? actualValue.GetMethodReturnValue(propertyName); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Generic/SameReferenceAssertCondition.cs b/TUnit.Assertions/AssertConditions/Generic/SameReferenceAssertCondition.cs new file mode 100644 index 0000000000..25f537ef8c --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Generic/SameReferenceAssertCondition.cs @@ -0,0 +1,20 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Generic; + +public class SameReferenceAssertCondition : AssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + + public SameReferenceAssertCondition(AssertionBuilder assertionBuilder, TExpected expected) : base(assertionBuilder, expected) + { + } + + protected override string DefaultMessage => "The two objects are different references."; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + return ReferenceEquals(actualValue, ExpectedValue); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Generic/TypeOfAssertCondition.cs b/TUnit.Assertions/AssertConditions/Generic/TypeOfAssertCondition.cs new file mode 100644 index 0000000000..9f6c668547 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Generic/TypeOfAssertCondition.cs @@ -0,0 +1,16 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Generic; + +public class TypeOfAssertCondition(AssertionBuilder assertionBuilder) + : AssertCondition(assertionBuilder, default) + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected override string DefaultMessage => $"{ActualValue} is {ActualValue?.GetType().Name ?? "null"} instead of {typeof(TExpected).Name}"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + return actualValue?.GetType() == typeof(TExpected); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/NullAssertCondition.cs b/TUnit.Assertions/AssertConditions/NullAssertCondition.cs new file mode 100644 index 0000000000..2dbc472563 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/NullAssertCondition.cs @@ -0,0 +1,18 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions; + +public class NullAssertCondition : AssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public NullAssertCondition(AssertionBuilder assertionBuilder) : base(assertionBuilder, default) + { + } + + protected override string DefaultMessage => $"{ActualValue} is not null"; + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + return actualValue is null; + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/And.cs b/TUnit.Assertions/AssertConditions/Operators/And.cs new file mode 100644 index 0000000000..b31fb3d0d7 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/And.cs @@ -0,0 +1,13 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +public abstract class And + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected readonly BaseAssertCondition OtherAssertCondition; + + public And(BaseAssertCondition otherAssertCondition) + { + OtherAssertCondition = otherAssertCondition; + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/AssertionType.cs b/TUnit.Assertions/AssertConditions/Operators/AssertionType.cs new file mode 100644 index 0000000000..6aab074378 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/AssertionType.cs @@ -0,0 +1,8 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +[Flags] +public enum AssertionType +{ + Value = 1, + Delegate = 2 +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/DelegateAnd.cs b/TUnit.Assertions/AssertConditions/Operators/DelegateAnd.cs new file mode 100644 index 0000000000..a029569ad3 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/DelegateAnd.cs @@ -0,0 +1,18 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +public class DelegateAnd + : And, DelegateOr>, + IDelegateAssertions, DelegateOr>, + IAnd, TActual, DelegateAnd, DelegateOr> +{ + public DelegateAnd(BaseAssertCondition, DelegateOr> otherAssertCondition) : base(otherAssertCondition) + { + } + + public Throws, DelegateOr> Throws => new(OtherAssertCondition.AssertionBuilder, ConnectorType.And, OtherAssertCondition); + + public static DelegateAnd Create(BaseAssertCondition, DelegateOr> otherAssertCondition) + { + return new DelegateAnd(otherAssertCondition)!; + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/DelegateOr.cs b/TUnit.Assertions/AssertConditions/Operators/DelegateOr.cs new file mode 100644 index 0000000000..6d602d240d --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/DelegateOr.cs @@ -0,0 +1,18 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +public class DelegateOr + : Or, DelegateOr>, + IDelegateAssertions, DelegateOr>, + IOr, TActual, DelegateAnd, DelegateOr> +{ + public DelegateOr(BaseAssertCondition, DelegateOr> otherAssertCondition) : base(otherAssertCondition) + { + } + + public Throws, DelegateOr> Throws => new(OtherAssertCondition.AssertionBuilder, ConnectorType.Or, OtherAssertCondition); + + public static DelegateOr Create(BaseAssertCondition, DelegateOr> otherAssertCondition) + { + return new DelegateOr(otherAssertCondition); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/IAnd.cs b/TUnit.Assertions/AssertConditions/Operators/IAnd.cs new file mode 100644 index 0000000000..e4b51262ea --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/IAnd.cs @@ -0,0 +1,8 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +public interface IAnd + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public static abstract TSelf Create(BaseAssertCondition otherAssertCondition); +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/IDelegateAssertions.cs b/TUnit.Assertions/AssertConditions/Operators/IDelegateAssertions.cs new file mode 100644 index 0000000000..1c911f2ca8 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/IDelegateAssertions.cs @@ -0,0 +1,8 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +internal interface IDelegateAssertions + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public Throws Throws { get; } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/IOr.cs b/TUnit.Assertions/AssertConditions/Operators/IOr.cs new file mode 100644 index 0000000000..5147bbd9b7 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/IOr.cs @@ -0,0 +1,8 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +public interface IOr + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public static abstract TSelf Create(BaseAssertCondition otherAssertCondition); +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/IValueAssertions.cs b/TUnit.Assertions/AssertConditions/Operators/IValueAssertions.cs new file mode 100644 index 0000000000..8af1506c12 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/IValueAssertions.cs @@ -0,0 +1,10 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +internal interface IValueAssertions + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public Is Is { get; } + public Does Does { get; } + public Has Has { get; } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/Or.cs b/TUnit.Assertions/AssertConditions/Operators/Or.cs new file mode 100644 index 0000000000..0b32e7d3d5 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/Or.cs @@ -0,0 +1,13 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +public class Or + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected readonly BaseAssertCondition OtherAssertCondition; + + public Or(BaseAssertCondition otherAssertCondition) + { + OtherAssertCondition = otherAssertCondition; + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/ValueAnd.cs b/TUnit.Assertions/AssertConditions/Operators/ValueAnd.cs new file mode 100644 index 0000000000..b4c421a368 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/ValueAnd.cs @@ -0,0 +1,19 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +public class ValueAnd + : And, ValueOr>, IValueAssertions, ValueOr>, + IAnd, TActual, ValueAnd, ValueOr> +{ + public ValueAnd(BaseAssertCondition, ValueOr> otherAssertCondition) : base(otherAssertCondition) + { + } + + public Is, ValueOr> Is => new(OtherAssertCondition.AssertionBuilder, ConnectorType.And, OtherAssertCondition); + public Has, ValueOr> Has => new(OtherAssertCondition.AssertionBuilder, ConnectorType.And, OtherAssertCondition); + public Does, ValueOr> Does => new(OtherAssertCondition.AssertionBuilder, ConnectorType.And, OtherAssertCondition); + + public static ValueAnd Create(BaseAssertCondition, ValueOr> otherAssertCondition) + { + return new ValueAnd(otherAssertCondition); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/ValueDelegateAnd.cs b/TUnit.Assertions/AssertConditions/Operators/ValueDelegateAnd.cs new file mode 100644 index 0000000000..db41191f7e --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/ValueDelegateAnd.cs @@ -0,0 +1,22 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +public class ValueDelegateAnd + : And, ValueDelegateOr>, + IDelegateAssertions, ValueDelegateOr>, + IAnd, TActual, ValueDelegateAnd, ValueDelegateOr> +{ + public ValueDelegateAnd(BaseAssertCondition, ValueDelegateOr> otherAssertCondition) : base(otherAssertCondition) + { + } + + public Is, ValueDelegateOr> Is => new(OtherAssertCondition.AssertionBuilder, ConnectorType.And, OtherAssertCondition); + public Has, ValueDelegateOr> Has => new(OtherAssertCondition.AssertionBuilder, ConnectorType.And, OtherAssertCondition); + public Does, ValueDelegateOr> Does => new(OtherAssertCondition.AssertionBuilder, ConnectorType.And, OtherAssertCondition); + + public Throws, ValueDelegateOr> Throws => new(OtherAssertCondition.AssertionBuilder, ConnectorType.And, OtherAssertCondition); + + public static ValueDelegateAnd Create(BaseAssertCondition, ValueDelegateOr> otherAssertCondition) + { + return new ValueDelegateAnd(otherAssertCondition); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/ValueDelegateOr.cs b/TUnit.Assertions/AssertConditions/Operators/ValueDelegateOr.cs new file mode 100644 index 0000000000..69b87ab75a --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/ValueDelegateOr.cs @@ -0,0 +1,23 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +public class ValueDelegateOr + : Or, ValueDelegateOr>, + IValueAssertions, ValueDelegateOr>, + IDelegateAssertions, ValueDelegateOr>, + IOr, TActual, ValueDelegateAnd, ValueDelegateOr> +{ + public ValueDelegateOr(BaseAssertCondition, ValueDelegateOr> otherAssertCondition) : base(otherAssertCondition) + { + } + + public Is, ValueDelegateOr> Is => new(OtherAssertCondition.AssertionBuilder, ConnectorType.Or, OtherAssertCondition); + public Has, ValueDelegateOr> Has => new(OtherAssertCondition.AssertionBuilder, ConnectorType.Or, OtherAssertCondition); + public Does, ValueDelegateOr> Does => new(OtherAssertCondition.AssertionBuilder, ConnectorType.Or, OtherAssertCondition); + + public Throws, ValueDelegateOr> Throws => new(OtherAssertCondition.AssertionBuilder, ConnectorType.Or, OtherAssertCondition); + + public static ValueDelegateOr Create(BaseAssertCondition, ValueDelegateOr> otherAssertCondition) + { + return new ValueDelegateOr(otherAssertCondition); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Operators/ValueOr.cs b/TUnit.Assertions/AssertConditions/Operators/ValueOr.cs new file mode 100644 index 0000000000..fc2156fdcf --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Operators/ValueOr.cs @@ -0,0 +1,21 @@ +namespace TUnit.Assertions.AssertConditions.Operators; + +public class ValueOr + : Or, ValueOr>, + IValueAssertions, ValueOr>, + IOr, TActual, ValueAnd, ValueOr> + +{ + public ValueOr(BaseAssertCondition, ValueOr> otherAssertCondition) : base(otherAssertCondition) + { + } + + public Is, ValueOr> Is => new(OtherAssertCondition.AssertionBuilder, ConnectorType.Or, OtherAssertCondition); + public Has, ValueOr> Has => new(OtherAssertCondition.AssertionBuilder, ConnectorType.Or, OtherAssertCondition); + public Does, ValueOr> Does => new(OtherAssertCondition.AssertionBuilder, ConnectorType.Or, OtherAssertCondition); + + public static ValueOr Create(BaseAssertCondition, ValueOr> otherAssertCondition) + { + return new ValueOr(otherAssertCondition); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/String/StringContainsAssertCondition.cs b/TUnit.Assertions/AssertConditions/String/StringContainsAssertCondition.cs new file mode 100644 index 0000000000..edd5ed5a67 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/String/StringContainsAssertCondition.cs @@ -0,0 +1,27 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.String; + +public class StringContainsAssertCondition : AssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + private readonly StringComparison _stringComparison; + + public StringContainsAssertCondition(AssertionBuilder assertionBuilder, string expected, StringComparison stringComparison) : base(assertionBuilder, expected) + { + _stringComparison = stringComparison; + } + + protected internal override bool Passes(string? actualValue, Exception? exception) + { + ArgumentNullException.ThrowIfNull(actualValue); + ArgumentNullException.ThrowIfNull(ExpectedValue); + + return actualValue.Contains(ExpectedValue, _stringComparison); + } + + protected override string DefaultMessage => $""" + Expected "{ExpectedValue}" but received "{ActualValue}" + """; +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/String/StringEqualsAssertCondition.cs b/TUnit.Assertions/AssertConditions/String/StringEqualsAssertCondition.cs new file mode 100644 index 0000000000..04303aec15 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/String/StringEqualsAssertCondition.cs @@ -0,0 +1,24 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.String; + +public class StringEqualsAssertCondition : AssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + private readonly StringComparison _stringComparison; + + public StringEqualsAssertCondition(AssertionBuilder assertionBuilder, string expected, StringComparison stringComparison) : base(assertionBuilder, expected) + { + _stringComparison = stringComparison; + } + + protected internal override bool Passes(string? actualValue, Exception? exception) + { + return string.Equals(actualValue, ExpectedValue, _stringComparison); + } + + protected override string DefaultMessage => $""" + Expected "{ExpectedValue}" but received "{ActualValue}" + """; +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Throws/ThrowsAnythingAssertCondition.cs b/TUnit.Assertions/AssertConditions/Throws/ThrowsAnythingAssertCondition.cs new file mode 100644 index 0000000000..23aeb0af9f --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Throws/ThrowsAnythingAssertCondition.cs @@ -0,0 +1,19 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Throws; + +public class ThrowsAnythingAssertCondition : AssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public ThrowsAnythingAssertCondition(AssertionBuilder assertionBuilder) : base(assertionBuilder, default) + { + } + + protected override string DefaultMessage => "Nothing was thrown"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + return exception != null; + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Throws/ThrowsExactTypeOfAssertCondition.cs b/TUnit.Assertions/AssertConditions/Throws/ThrowsExactTypeOfAssertCondition.cs new file mode 100644 index 0000000000..b9475b4b91 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Throws/ThrowsExactTypeOfAssertCondition.cs @@ -0,0 +1,21 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Throws; + +public class ThrowsExactTypeOfAssertCondition : AssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public ThrowsExactTypeOfAssertCondition(AssertionBuilder assertionBuilder) : base(assertionBuilder, default) + { + } + + protected override string DefaultMessage => $"A {Exception?.GetType().Name} was thrown instead of {typeof(TExpected).Name}"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + ArgumentNullException.ThrowIfNull(exception); + + return exception.GetType() == typeof(TExpected); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Throws/ThrowsNothingAssertCondition.cs b/TUnit.Assertions/AssertConditions/Throws/ThrowsNothingAssertCondition.cs new file mode 100644 index 0000000000..17426e6f8d --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Throws/ThrowsNothingAssertCondition.cs @@ -0,0 +1,19 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Throws; + +public class ThrowsNothingAssertCondition : AssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public ThrowsNothingAssertCondition(AssertionBuilder assertionBuilder) : base(assertionBuilder, default) + { + } + + protected override string DefaultMessage => $"A {Exception?.GetType().Name} was thrown"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + return exception is null; + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Throws/ThrowsSubClassOfAssertCondition.cs b/TUnit.Assertions/AssertConditions/Throws/ThrowsSubClassOfAssertCondition.cs new file mode 100644 index 0000000000..a81be7ac00 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Throws/ThrowsSubClassOfAssertCondition.cs @@ -0,0 +1,21 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Throws; + +public class ThrowsSubClassOfAssertCondition : AssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + public ThrowsSubClassOfAssertCondition(AssertionBuilder assertionBuilder) : base(assertionBuilder, default) + { + } + + protected override string DefaultMessage => $"A {Exception?.GetType().Name} was thrown instead of subclass of {typeof(TExpected).Name}"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + ArgumentNullException.ThrowIfNull(exception); + + return exception.GetType().IsSubclassOf(typeof(TExpected)); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Throws/ThrowsWithMessageContainingAssertCondition.cs b/TUnit.Assertions/AssertConditions/Throws/ThrowsWithMessageContainingAssertCondition.cs new file mode 100644 index 0000000000..6277fda299 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Throws/ThrowsWithMessageContainingAssertCondition.cs @@ -0,0 +1,25 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Throws; + +public class ThrowsWithMessageContainingAssertCondition : AssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + private readonly StringComparison _stringComparison; + + public ThrowsWithMessageContainingAssertCondition(AssertionBuilder assertionBuilder, string expected, StringComparison stringComparison) : base(assertionBuilder, expected) + { + _stringComparison = stringComparison; + } + + protected override string DefaultMessage => $"Message '{Exception?.Message}' did not contain '{ExpectedValue}'"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(ExpectedValue); + + return exception.Message.Contains(ExpectedValue, _stringComparison); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Throws/ThrowsWithMessageEqualToAssertCondition.cs b/TUnit.Assertions/AssertConditions/Throws/ThrowsWithMessageEqualToAssertCondition.cs new file mode 100644 index 0000000000..fcece123c5 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Throws/ThrowsWithMessageEqualToAssertCondition.cs @@ -0,0 +1,24 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Throws; + +public class ThrowsWithMessageEqualToAssertCondition : AssertCondition + where TAnd : And, IAnd + where TOr : Or, IOr +{ + private readonly StringComparison _stringComparison; + + public ThrowsWithMessageEqualToAssertCondition(AssertionBuilder assertionBuilder, string expected, StringComparison stringComparison) : base(assertionBuilder, expected) + { + _stringComparison = stringComparison; + } + + protected override string DefaultMessage => $"Message was {Exception?.Message} instead of {ExpectedValue}"; + + protected internal override bool Passes(TActual? actualValue, Exception? exception) + { + ArgumentNullException.ThrowIfNull(exception); + + return string.Equals(exception.Message, ExpectedValue, _stringComparison); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertConditions/Throws/WithMessage.cs b/TUnit.Assertions/AssertConditions/Throws/WithMessage.cs new file mode 100644 index 0000000000..e7aa987439 --- /dev/null +++ b/TUnit.Assertions/AssertConditions/Throws/WithMessage.cs @@ -0,0 +1,35 @@ +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions.AssertConditions.Throws; + +public class WithMessage : Connector + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected AssertionBuilder AssertionBuilder { get; } + + public WithMessage(AssertionBuilder assertionBuilder, ConnectorType connectorType, BaseAssertCondition? otherAssertCondition) : base(connectorType, otherAssertCondition) + { + AssertionBuilder = assertionBuilder; + } + + public BaseAssertCondition EqualTo(string expected) + { + return EqualTo(expected, StringComparison.Ordinal); + } + + public BaseAssertCondition EqualTo(string expected, StringComparison stringComparison) + { + return Wrap(new ThrowsWithMessageEqualToAssertCondition(AssertionBuilder, expected, stringComparison)); + } + + public BaseAssertCondition Containing(string expected) + { + return Containing(expected, StringComparison.Ordinal); + } + + public BaseAssertCondition Containing(string expected, StringComparison stringComparison) + { + return Wrap(new ThrowsWithMessageContainingAssertCondition(AssertionBuilder, expected, stringComparison)); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertionBuilder.cs b/TUnit.Assertions/AssertionBuilder.cs new file mode 100644 index 0000000000..e22001cb8f --- /dev/null +++ b/TUnit.Assertions/AssertionBuilder.cs @@ -0,0 +1,6 @@ +namespace TUnit.Assertions; + +public abstract class AssertionBuilder +{ + protected internal abstract Task> GetAssertionData(); +} \ No newline at end of file diff --git a/TUnit.Assertions/AssertionData.cs b/TUnit.Assertions/AssertionData.cs new file mode 100644 index 0000000000..877c00b1ad --- /dev/null +++ b/TUnit.Assertions/AssertionData.cs @@ -0,0 +1,7 @@ +namespace TUnit.Assertions; + +public record AssertionData(T? Result, Exception? Exception) +{ + public static implicit operator AssertionData((T?, Exception?) tuple) => + new(tuple.Item1, tuple.Item2); +} \ No newline at end of file diff --git a/TUnit.Assertions/AsyncDelegateAssertionBuilder.cs b/TUnit.Assertions/AsyncDelegateAssertionBuilder.cs new file mode 100644 index 0000000000..06b9350e33 --- /dev/null +++ b/TUnit.Assertions/AsyncDelegateAssertionBuilder.cs @@ -0,0 +1,45 @@ +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions; + +public class AsyncDelegateAssertionBuilder : AssertionBuilder +{ + private readonly Func> _function; + + public Does, DelegateOr> Does => new(this, ConnectorType.None, null); + public Is, DelegateOr> Is => new(this, ConnectorType.None, null); + public Has, DelegateOr> Has => new(this, ConnectorType.None, null); + public Throws, DelegateOr> Throws => new(this, ConnectorType.None, null); + + internal AsyncDelegateAssertionBuilder(Func> function) + { + _function = function; + } + + protected internal override async Task> GetAssertionData() + { + var assertionData = await _function.InvokeAndGetExceptionAsync(); + + return assertionData!; + } +} + +public class AsyncDelegateAssertionBuilder : AssertionBuilder +{ + private readonly Func _function; + + public Throws, DelegateOr> Throws => new(this, ConnectorType.None, null); + + internal AsyncDelegateAssertionBuilder(Func function) + { + _function = function; + } + + protected internal override async Task> GetAssertionData() + { + var exception = await _function.InvokeAndGetExceptionAsync(); + + return new AssertionData(null, exception); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/DelegateAssertionBuilder.cs b/TUnit.Assertions/DelegateAssertionBuilder.cs new file mode 100644 index 0000000000..0b799fed90 --- /dev/null +++ b/TUnit.Assertions/DelegateAssertionBuilder.cs @@ -0,0 +1,45 @@ +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions; + +public class DelegateAssertionBuilder : AssertionBuilder +{ + private readonly Func _function; + + public Does, DelegateOr> Does => new(this, ConnectorType.None, null); + public Is, DelegateOr> Is => new(this, ConnectorType.None, null); + public Has, DelegateOr> Has => new(this, ConnectorType.None, null); + public Throws, DelegateOr> Throws => new(this, ConnectorType.None, null); + + internal DelegateAssertionBuilder(Func function) + { + _function = function; + } + + protected internal override Task> GetAssertionData() + { + var assertionData = _function.InvokeAndGetException(); + + return Task.FromResult(assertionData)!; + } +} + +public class DelegateAssertionBuilder : AssertionBuilder +{ + private readonly Action _action; + + public Throws, DelegateOr> Throws => new(this, ConnectorType.None, null); + + internal DelegateAssertionBuilder(Action action) + { + _action = action; + } + + protected internal override Task> GetAssertionData() + { + var exception = _action.InvokeAndGetException(); + + return Task.FromResult(new AssertionData(null, exception)); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/Does.cs b/TUnit.Assertions/Does.cs new file mode 100644 index 0000000000..0e5961273b --- /dev/null +++ b/TUnit.Assertions/Does.cs @@ -0,0 +1,18 @@ +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions; + +public class Does : Connector + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected internal AssertionBuilder AssertionBuilder { get; } + + public Does(AssertionBuilder assertionBuilder, ConnectorType connectorType, BaseAssertCondition? otherAssertCondition) : base(connectorType, otherAssertCondition) + { + AssertionBuilder = assertionBuilder; + } + + +} \ No newline at end of file diff --git a/TUnit.Assertions/Exceptions/AssertionException.cs b/TUnit.Assertions/Exceptions/AssertionException.cs new file mode 100644 index 0000000000..d5bfe8360a --- /dev/null +++ b/TUnit.Assertions/Exceptions/AssertionException.cs @@ -0,0 +1,8 @@ +namespace TUnit.Assertions.Exceptions; + +public class AssertionException : TUnitException +{ + public AssertionException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/TUnit.Assertions/Exceptions/TUnitException.cs b/TUnit.Assertions/Exceptions/TUnitException.cs new file mode 100644 index 0000000000..e448e3472a --- /dev/null +++ b/TUnit.Assertions/Exceptions/TUnitException.cs @@ -0,0 +1,22 @@ +using System.Runtime.Serialization; + +namespace TUnit.Assertions.Exceptions; + +public class TUnitException : Exception +{ + public TUnitException() + { + } + + protected TUnitException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } + + public TUnitException(string? message) : base(message) + { + } + + public TUnitException(string? message, Exception? innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/TUnit.Assertions/Extensions/Connector.cs b/TUnit.Assertions/Extensions/Connector.cs new file mode 100644 index 0000000000..672de441f1 --- /dev/null +++ b/TUnit.Assertions/Extensions/Connector.cs @@ -0,0 +1,34 @@ +using System.ComponentModel; +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Connectors; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions; + +public abstract class Connector + where TAnd : And, IAnd + where TOr : Or, IOr +{ + [EditorBrowsable(EditorBrowsableState.Advanced)] + public ConnectorType ConnectorType { get; } + + [EditorBrowsable(EditorBrowsableState.Advanced)] + public BaseAssertCondition? OtherAssertCondition { get; } + + public Connector(ConnectorType connectorType, BaseAssertCondition? otherAssertCondition) + { + ConnectorType = connectorType; + OtherAssertCondition = otherAssertCondition; + } + + public BaseAssertCondition Wrap(BaseAssertCondition assertCondition) + { + return ConnectorType switch + { + ConnectorType.None => assertCondition, + ConnectorType.And => new AssertConditionAnd(OtherAssertCondition!, assertCondition), + ConnectorType.Or => new AssertConditionAnd(OtherAssertCondition!, assertCondition), + _ => throw new ArgumentOutOfRangeException(nameof(ConnectorType), ConnectorType, "Unknown connector type") + }; + } +} \ No newline at end of file diff --git a/TUnit.Assertions/Extensions/DelegateExtensions.cs b/TUnit.Assertions/Extensions/DelegateExtensions.cs new file mode 100644 index 0000000000..c6feb458bb --- /dev/null +++ b/TUnit.Assertions/Extensions/DelegateExtensions.cs @@ -0,0 +1,54 @@ +namespace TUnit.Assertions; + +internal static class DelegateExtensions +{ + public static Exception? InvokeAndGetException(this Action action) + { + try + { + action(); + return null; + } + catch (Exception e) + { + return e; + } + } + + public static async Task InvokeAndGetExceptionAsync(this Func action) + { + try + { + await action(); + return null; + } + catch (Exception e) + { + return e; + } + } + + public static async Task> InvokeAndGetExceptionAsync(this Func> action) + { + try + { + return (await action(), null); + } + catch (Exception e) + { + return (default, e); + } + } + + public static AssertionData InvokeAndGetException(this Func action) + { + try + { + return (action(), null); + } + catch (Exception e) + { + return (default, e); + } + } +} \ No newline at end of file diff --git a/TUnit.Assertions/Extensions/DoesExtensions.cs b/TUnit.Assertions/Extensions/DoesExtensions.cs new file mode 100644 index 0000000000..8b0e8cd520 --- /dev/null +++ b/TUnit.Assertions/Extensions/DoesExtensions.cs @@ -0,0 +1,76 @@ +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Collections; +using TUnit.Assertions.AssertConditions.Operators; +using TUnit.Assertions.AssertConditions.String; + +namespace TUnit.Assertions; + +public static class DoesExtensions +{ + public static BaseAssertCondition Contain(this Does does, TInner expected) + where TActual : IEnumerable + where TAnd : And, IAnd + where TOr : Or, IOr + { + return does.Wrap(new EnumerableContainsAssertCondition(does.AssertionBuilder, expected)); + } + + public static BaseAssertCondition Contain(this Does does, string expected) + where TAnd : And, IAnd + where TOr : Or, IOr + { + return Contain(does, expected, StringComparison.Ordinal); + } + + public static BaseAssertCondition Contain(this Does does, string expected, StringComparison stringComparison) + where TAnd : And, IAnd + where TOr : Or, IOr + { + return does.Wrap(new StringContainsAssertCondition(does.AssertionBuilder, expected, stringComparison)); + } + + public static BaseAssertCondition StartWith(this Does does, string expected) + where TAnd : And, IAnd + where TOr : Or, IOr + { + return StartWith(does, expected, StringComparison.Ordinal); + } + + public static BaseAssertCondition StartWith(this Does does, string expected, StringComparison stringComparison) + where TAnd : And, IAnd + where TOr : Or, IOr + { + return does.Wrap(new DelegateAssertCondition( + does.AssertionBuilder, + expected, + (actual, _, _) => + { + ArgumentNullException.ThrowIfNull(actual); + return actual.StartsWith(expected, stringComparison); + }, + (actual, _) => $"\"{actual}\" does not start with \"{expected}\"")); + } + + + public static BaseAssertCondition EndWith(this Does does, string expected) + where TAnd : And, IAnd + where TOr : Or, IOr + { + return EndWith(does, expected, StringComparison.Ordinal); + } + + public static BaseAssertCondition EndWith(this Does does, string expected, StringComparison stringComparison) + where TAnd : And, IAnd + where TOr : Or, IOr + { + return does.Wrap(new DelegateAssertCondition( + does.AssertionBuilder, + expected, + (actual, _, _) => + { + ArgumentNullException.ThrowIfNull(actual); + return actual.EndsWith(expected, stringComparison); + }, + (actual, _) => $"\"{actual}\" does not start with \"{expected}\"")); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/Extensions/EnumerableCount.cs b/TUnit.Assertions/Extensions/EnumerableCount.cs new file mode 100644 index 0000000000..748b604545 --- /dev/null +++ b/TUnit.Assertions/Extensions/EnumerableCount.cs @@ -0,0 +1,109 @@ +using System.Collections; +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions; + +public class EnumerableCount : Connector + where TActual : IEnumerable + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected internal AssertionBuilder AssertionBuilder { get; } + + public EnumerableCount(AssertionBuilder assertionBuilder, ConnectorType connectorType, + BaseAssertCondition? otherAssertCondition) : base(connectorType, otherAssertCondition) + { + AssertionBuilder = assertionBuilder; + } + + public BaseAssertCondition EqualTo(int expected) + { + return Wrap(new DelegateAssertCondition(AssertionBuilder, expected, (enumerable, expected, _) => + { + ArgumentNullException.ThrowIfNull(enumerable); + return GetCount(enumerable) == expected; + }, + (enumerable, _) => + $"{enumerable} has a expected of {GetCount(enumerable)} but expected to be equal to {expected}") + ); + } + + public BaseAssertCondition Empty => + Wrap(new DelegateAssertCondition(AssertionBuilder, 0, (enumerable, expected, _) => + { + ArgumentNullException.ThrowIfNull(enumerable); + return GetCount(enumerable) == expected; + }, + (enumerable, _) => + $"{enumerable} has a expected of {GetCount(enumerable)} but expected to be equal to {0}") + ); + + public BaseAssertCondition GreaterThan(int expected) + { + return Wrap(new DelegateAssertCondition( + AssertionBuilder, + expected, + (enumerable, _, _) => + { + ArgumentNullException.ThrowIfNull(enumerable); + return GetCount(enumerable) > expected; + }, + (enumerable, _) => + $"{enumerable} has a expected of {GetCount(enumerable)} but expected to be greater than {expected}") + ); + } + + public BaseAssertCondition GreaterThanOrEqualTo(int expected) + { + return Wrap(new DelegateAssertCondition(AssertionBuilder, expected, (enumerable, expected, _) => + { + ArgumentNullException.ThrowIfNull(enumerable); + return GetCount(enumerable) >= expected; + }, + (enumerable, _) => + $"{enumerable} has a expected of {GetCount(enumerable)} but expected to be greater than or equal to {expected}") + ); + } + + public BaseAssertCondition LessThan(int expected) + { + return Wrap(new DelegateAssertCondition(AssertionBuilder, expected, (enumerable, expected, _) => + { + ArgumentNullException.ThrowIfNull(enumerable); + return GetCount(enumerable) < expected; + }, + (enumerable, _) => + $"{enumerable} has a expected of {GetCount(enumerable)} but expected to be less than {expected}") + ); + } + + public BaseAssertCondition LessThanOrEqualTo(int expected) + { + return Wrap(new DelegateAssertCondition(AssertionBuilder, expected, (enumerable, expected, _) => + { + ArgumentNullException.ThrowIfNull(enumerable); + return GetCount(enumerable) <= expected; + }, + (enumerable, _) => + $"{enumerable} has a expected of {GetCount(enumerable)} but expected to be less than or equal to {expected}") + ); + } + + private int GetCount(TActual? actualValue) + { + ArgumentNullException.ThrowIfNull(actualValue); + + if (actualValue is ICollection collection) + { + return collection.Count; + } + + if (actualValue is IList list) + { + return list.Count; + } + + return actualValue.Cast().Count(); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/Extensions/HasExtensions.cs b/TUnit.Assertions/Extensions/HasExtensions.cs new file mode 100644 index 0000000000..a0ed9e6db9 --- /dev/null +++ b/TUnit.Assertions/Extensions/HasExtensions.cs @@ -0,0 +1,22 @@ +using System.Collections; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions; + +public static class HasExtensions +{ + public static EnumerableCount Count(this Has has) + where TActual : IEnumerable + where TAnd : And, IAnd + where TOr : Or, IOr + { + return new EnumerableCount(has.AssertionBuilder, has.ConnectorType, has.OtherAssertCondition); + } + + public static StringLength Length(this Has has) + where TAnd : And, IAnd + where TOr : Or, IOr + { + return new StringLength(has.AssertionBuilder, has.ConnectorType, has.OtherAssertCondition); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/Extensions/IsExtensions.cs b/TUnit.Assertions/Extensions/IsExtensions.cs new file mode 100644 index 0000000000..2ded8f3abe --- /dev/null +++ b/TUnit.Assertions/Extensions/IsExtensions.cs @@ -0,0 +1,190 @@ +using System.Numerics; +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Collections; +using TUnit.Assertions.AssertConditions.Generic; +using TUnit.Assertions.AssertConditions.Operators; +using TUnit.Assertions.AssertConditions.String; + +namespace TUnit.Assertions; + +public static class IsExtensions +{ + #region Strings + + public static BaseAssertCondition EqualTo(this Is @is, string expected) + where TAnd : And, IAnd + where TOr : Or, IOr + { + return EqualTo(@is, expected, StringComparison.Ordinal); + } + + public static BaseAssertCondition EqualTo(this Is @is, string expected, StringComparison stringComparison) + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new StringEqualsAssertCondition(@is.AssertionBuilder, expected, stringComparison)); + } + + #endregion + + #region Numbers + + public static BaseAssertCondition Zero(this Is @is) + where TActual : INumber + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new EqualsAssertCondition(@is.AssertionBuilder, TActual.Zero)); + } + + public static BaseAssertCondition GreaterThan(this Is @is, TActual expected) where TActual : INumber + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new DelegateAssertCondition(@is.AssertionBuilder, default, (value, _, _) => + { + ArgumentNullException.ThrowIfNull(value); + + return value > expected; + }, + (value, _) => $"{value} was not greater than {expected}")); + } + + public static BaseAssertCondition GreaterThanOrEqualTo(this Is @is, TActual expected) + where TActual : INumber + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new DelegateAssertCondition(@is.AssertionBuilder, default, (value, _, _) => + { + ArgumentNullException.ThrowIfNull(value); + + return value >= expected; + }, + (value, _) => $"{value} was not greater than or equal to {expected}")); + } + + public static BaseAssertCondition LessThan(this Is @is, TActual expected) + where TActual : INumber + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new DelegateAssertCondition(@is.AssertionBuilder, default, (value, _, _) => + { + ArgumentNullException.ThrowIfNull(value); + + return value < expected; + }, + (value, _) => $"{value} was not less than {expected}")); + } + + public static BaseAssertCondition LessThanOrEqualTo(this Is @is, TActual expected) + where TActual : INumber + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new DelegateAssertCondition(@is.AssertionBuilder, default, (value, _, _) => + { + ArgumentNullException.ThrowIfNull(value); + + return value <= expected; + }, + (value, _) => $"{value} was not less than or equal to {expected}")); + } + + public static BaseAssertCondition Even(this Is @is) + where TActual : INumber, IModulusOperators + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new DelegateAssertCondition(@is.AssertionBuilder, default, (value, _, _) => + { + ArgumentNullException.ThrowIfNull(value); + + return value % 2 == 0; + }, + (value, _) => $"{value} was not even")); + } + + public static BaseAssertCondition Odd(this Is @is) + where TActual : INumber, IModulusOperators + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new DelegateAssertCondition(@is.AssertionBuilder, default, (value, _, _) => + { + ArgumentNullException.ThrowIfNull(value); + + return value % 2 != 0; + }, + (value, _) => $"{value} was not odd")); + } + + public static BaseAssertCondition Negative(this Is @is) + where TActual : INumber + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new DelegateAssertCondition(@is.AssertionBuilder, default, (value, _, _) => + { + ArgumentNullException.ThrowIfNull(value); + + return value < TActual.Zero; + }, + (value, _) => $"{value} was not negative")); + } + + public static BaseAssertCondition Positive(this Is @is) + where TActual : INumber + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new DelegateAssertCondition(@is.AssertionBuilder, default, (value, _, _) => + { + ArgumentNullException.ThrowIfNull(value); + + return value > TActual.Zero; + }, + (value, _) => $"{value} was not positive")); + } + + #endregion + + #region Enumerables + + public static BaseAssertCondition EquivalentTo(this Is @is, IEnumerable expected) + where TActual : IEnumerable + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new EnumerableEquivalentToAssertCondition(@is.AssertionBuilder, expected)); + } + + public static BaseAssertCondition Empty(this Is @is) + where TActual : IEnumerable + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new EnumerableCountEqualToAssertCondition(@is.AssertionBuilder, 0)); + } + + #endregion + + #region Booleans + + public static BaseAssertCondition True(this Is @is) + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new EqualsAssertCondition(@is.AssertionBuilder, true)); + } + + public static BaseAssertCondition False(this Is @is) + where TAnd : And, IAnd + where TOr : Or, IOr + { + return @is.Wrap(new EqualsAssertCondition(@is.AssertionBuilder, false)); + } + + #endregion +} \ No newline at end of file diff --git a/TUnit.Assertions/Extensions/NotConnector.cs b/TUnit.Assertions/Extensions/NotConnector.cs new file mode 100644 index 0000000000..8e2b235ca7 --- /dev/null +++ b/TUnit.Assertions/Extensions/NotConnector.cs @@ -0,0 +1,18 @@ +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions; + +public abstract class NotConnector : Connector + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected NotConnector(ConnectorType connectorType, BaseAssertCondition? otherAssertCondition) : base(connectorType, otherAssertCondition) + { + } + + protected BaseAssertCondition Invert(BaseAssertCondition assertCondition, Func messageFactory) + { + return Wrap(assertCondition.Invert(messageFactory)); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/Extensions/ReflectionExtensions.cs b/TUnit.Assertions/Extensions/ReflectionExtensions.cs new file mode 100644 index 0000000000..20de79fe97 --- /dev/null +++ b/TUnit.Assertions/Extensions/ReflectionExtensions.cs @@ -0,0 +1,14 @@ +namespace TUnit.Assertions; + +public static class ReflectionExtensions +{ + public static object? GetPropertyValue(this object obj, string propertyName) + { + return obj.GetType().GetProperty(propertyName)?.GetValue(obj); + } + + public static object? GetMethodReturnValue(this object obj, string methodName) + { + return obj.GetType().GetMethod(methodName)?.Invoke(obj, null); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/Extensions/StringLength.cs b/TUnit.Assertions/Extensions/StringLength.cs new file mode 100644 index 0000000000..026b8d6bdc --- /dev/null +++ b/TUnit.Assertions/Extensions/StringLength.cs @@ -0,0 +1,101 @@ +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions; + +public class StringLength : Connector + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected AssertionBuilder AssertionBuilder { get; } + + public StringLength(AssertionBuilder assertionBuilder, ConnectorType connectorType, + BaseAssertCondition? otherAssertCondition) : base(connectorType, otherAssertCondition) + { + AssertionBuilder = assertionBuilder; + } + + public BaseAssertCondition EqualTo(int expected) + { + return Wrap(new DelegateAssertCondition(AssertionBuilder, expected, (@string, _, _) => + { + ArgumentNullException.ThrowIfNull(@string); + return @string.Length == expected; + }, + (@string, _) => + $"\"{@string}\" was {@string?.Length} characters long but expected to be equal to {expected}") + ); + } + + public BaseAssertCondition IsEmpty => + Wrap(new DelegateAssertCondition(AssertionBuilder, 0, (@string, _, _) => + { + ArgumentNullException.ThrowIfNull(@string); + return @string.Length == 0; + }, + (@string, _) => + $"\"{@string}\" was {@string?.Length} characters long but expected to be equal to {0}") + ); + + public BaseAssertCondition IsNotEmpty => + Wrap(new DelegateAssertCondition(AssertionBuilder, default, (@string, _, _) => + { + ArgumentNullException.ThrowIfNull(@string); + return @string.Length > 0; + }, + (@string, _) => + $"\"{@string}\" was {@string?.Length} characters long but expected to empty" + )); + + + public BaseAssertCondition GreaterThan(int expected) + { + return Wrap(new DelegateAssertCondition( + AssertionBuilder, + expected, + (@string, _, _) => + { + ArgumentNullException.ThrowIfNull(@string); + return @string.Length > expected; + }, + (@string, _) => + $"\"{@string}\" was {@string?.Length} characters long but expected to be greater than {expected}") + ); + } + + public BaseAssertCondition GreaterThanOrEqualTo(int expected) + { + return Wrap(new DelegateAssertCondition(AssertionBuilder, expected, (@string, _, _) => + { + ArgumentNullException.ThrowIfNull(@string); + return @string.Length >= expected; + }, + (@string, _) => + $"\"{@string}\" was {@string?.Length} characters long but expected to be greater than or equal to {expected}") + ); + } + + public BaseAssertCondition LessThan(int expected) + { + return Wrap(new DelegateAssertCondition(AssertionBuilder, expected, (@string, _, _) => + { + ArgumentNullException.ThrowIfNull(@string); + return @string.Length < expected; + }, + (@string, _) => + $"\"{@string}\" was {@string?.Length} characters long but expected to be less than {expected}") + ); + } + + public BaseAssertCondition LessThanOrEqualTo(int expected) + { + return Wrap(new DelegateAssertCondition(AssertionBuilder, expected, (@string, _, _) => + { + ArgumentNullException.ThrowIfNull(@string); + return @string.Length <= expected; + }, + (@string, _) => + $"\"{@string}\" was {@string?.Length} characters long but expected to be less than or equal to {expected}") + ); + } +} \ No newline at end of file diff --git a/TUnit.Assertions/Has.cs b/TUnit.Assertions/Has.cs new file mode 100644 index 0000000000..4a81e9631e --- /dev/null +++ b/TUnit.Assertions/Has.cs @@ -0,0 +1,21 @@ +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Generic; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions; + +public class Has : Connector + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected internal AssertionBuilder AssertionBuilder { get; } + + public Has(AssertionBuilder assertionBuilder, ConnectorType connectorType, BaseAssertCondition? otherAssertCondition) : base(connectorType, otherAssertCondition) + { + AssertionBuilder = assertionBuilder; + } + + public Property Property(string name) => new(AssertionBuilder, name); + + public Property Property(string name) => new(AssertionBuilder, name); +} \ No newline at end of file diff --git a/TUnit.Assertions/Is.cs b/TUnit.Assertions/Is.cs new file mode 100644 index 0000000000..ddf75f4c9c --- /dev/null +++ b/TUnit.Assertions/Is.cs @@ -0,0 +1,34 @@ +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Generic; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions; + +public class Is : Connector + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected internal AssertionBuilder AssertionBuilder { get; } + + public Is(AssertionBuilder assertionBuilder, ConnectorType connectorType, + BaseAssertCondition? otherAssertCondition) : base(connectorType, otherAssertCondition) + { + AssertionBuilder = assertionBuilder; + } + + public BaseAssertCondition EqualTo(TActual expected) + { + return Wrap(new EqualsAssertCondition(AssertionBuilder, expected)); + } + + public BaseAssertCondition SameReference(TActual expected) + { + return Wrap(new SameReferenceAssertCondition(AssertionBuilder, expected)); + } + + public BaseAssertCondition Null => Wrap(new NullAssertCondition(AssertionBuilder)); + + public BaseAssertCondition TypeOf() => Wrap(new TypeOfAssertCondition(AssertionBuilder)); + + public IsNot Not => new(AssertionBuilder, ConnectorType, OtherAssertCondition); +} \ No newline at end of file diff --git a/TUnit.Assertions/IsNot.cs b/TUnit.Assertions/IsNot.cs new file mode 100644 index 0000000000..b2483da088 --- /dev/null +++ b/TUnit.Assertions/IsNot.cs @@ -0,0 +1,26 @@ +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Generic; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions; + +public class IsNot : NotConnector + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected internal AssertionBuilder AssertionBuilder { get; } + + public IsNot(AssertionBuilder assertionBuilder, ConnectorType connectorType, BaseAssertCondition? otherAssertCondition) : base(connectorType, otherAssertCondition) + { + AssertionBuilder = assertionBuilder; + } + + public BaseAssertCondition EqualTo(TActual expected) => Invert(new EqualsAssertCondition(AssertionBuilder, expected), + (actual, _) => $"Expected {actual} to equal {expected}"); + + public BaseAssertCondition Null => Invert(new NullAssertCondition(AssertionBuilder), + (actual, _) => $"Expected {actual} to be null"); + + public BaseAssertCondition TypeOf() => Invert(new TypeOfAssertCondition(AssertionBuilder), + (actual, _) => $"Expected {actual} to not be of type {typeof(TExpected)}"); +} \ No newline at end of file diff --git a/TUnit.Assertions/TUnit.Assertions.csproj b/TUnit.Assertions/TUnit.Assertions.csproj new file mode 100644 index 0000000000..94c3e3d046 --- /dev/null +++ b/TUnit.Assertions/TUnit.Assertions.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + enable + enable + latest + + + + + + + + diff --git a/TUnit.Assertions/TUnit.Assertions.csproj.DotSettings b/TUnit.Assertions/TUnit.Assertions.csproj.DotSettings new file mode 100644 index 0000000000..17962b139d --- /dev/null +++ b/TUnit.Assertions/TUnit.Assertions.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/TUnit.Assertions/Throws.cs b/TUnit.Assertions/Throws.cs new file mode 100644 index 0000000000..ed21e3901e --- /dev/null +++ b/TUnit.Assertions/Throws.cs @@ -0,0 +1,29 @@ +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Operators; +using TUnit.Assertions.AssertConditions.Throws; + +namespace TUnit.Assertions; + +public class Throws : Connector + where TAnd : And, IAnd + where TOr : Or, IOr +{ + protected AssertionBuilder AssertionBuilder { get; } + + public Throws(AssertionBuilder assertionBuilder, ConnectorType connectorType, BaseAssertCondition? otherAssertCondition) : base(connectorType, otherAssertCondition) + { + AssertionBuilder = assertionBuilder; + } + + public WithMessage WithMessage => new(AssertionBuilder, ConnectorType, OtherAssertCondition); + + public BaseAssertCondition Nothing => Wrap(new ThrowsNothingAssertCondition(AssertionBuilder)); + + public BaseAssertCondition Exception => + Wrap(new ThrowsAnythingAssertCondition(AssertionBuilder)); + + public BaseAssertCondition TypeOf() => Wrap(new ThrowsExactTypeOfAssertCondition(AssertionBuilder)); + + public BaseAssertCondition SubClassOf() => + Wrap(new ThrowsSubClassOfAssertCondition(AssertionBuilder)); +} \ No newline at end of file diff --git a/TUnit.Assertions/ValueAssertionBuilder.cs b/TUnit.Assertions/ValueAssertionBuilder.cs new file mode 100644 index 0000000000..cf7c3215cc --- /dev/null +++ b/TUnit.Assertions/ValueAssertionBuilder.cs @@ -0,0 +1,24 @@ +using TUnit.Assertions.AssertConditions; +using TUnit.Assertions.AssertConditions.Operators; + +namespace TUnit.Assertions; + + +public class ValueAssertionBuilder : AssertionBuilder +{ + private readonly TActual? _value; + + public Does, ValueOr> Does => new(this, ConnectorType.None, null); + public Is, ValueOr> Is => new(this, ConnectorType.None, null); + public Has, ValueOr> Has => new(this, ConnectorType.None, null); + + internal ValueAssertionBuilder(TActual? value) + { + _value = value; + } + + protected internal override Task> GetAssertionData() + { + return Task.FromResult(new AssertionData(_value, null)); + } +} \ No newline at end of file diff --git a/TUnit.Core/AssembliesAnd.cs b/TUnit.Core/AssembliesAnd.cs new file mode 100644 index 0000000000..4695272b08 --- /dev/null +++ b/TUnit.Core/AssembliesAnd.cs @@ -0,0 +1,5 @@ +using System.Reflection; + +namespace TUnit.Core; + +public record AssembliesAnd(Assembly[] Assemblies, IEnumerable Values); \ No newline at end of file diff --git a/TUnit.Core/Attributes/CleanUpAttribute.cs b/TUnit.Core/Attributes/CleanUpAttribute.cs new file mode 100644 index 0000000000..c64e7850ff --- /dev/null +++ b/TUnit.Core/Attributes/CleanUpAttribute.cs @@ -0,0 +1,4 @@ +namespace TUnit.Core; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class CleanUpAttribute : TUnitAttribute; \ No newline at end of file diff --git a/TUnit.Core/Attributes/OneTimeCleanUpAttribute.cs b/TUnit.Core/Attributes/OneTimeCleanUpAttribute.cs new file mode 100644 index 0000000000..59a72b50ba --- /dev/null +++ b/TUnit.Core/Attributes/OneTimeCleanUpAttribute.cs @@ -0,0 +1,4 @@ +namespace TUnit.Core; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class OneTimeCleanUpAttribute : TUnitAttribute; \ No newline at end of file diff --git a/TUnit.Core/Attributes/OneTimeSetUpAttribute.cs b/TUnit.Core/Attributes/OneTimeSetUpAttribute.cs new file mode 100644 index 0000000000..1d81e422e2 --- /dev/null +++ b/TUnit.Core/Attributes/OneTimeSetUpAttribute.cs @@ -0,0 +1,4 @@ +namespace TUnit.Core; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class OneTimeSetUpAttribute : TUnitAttribute; \ No newline at end of file diff --git a/TUnit.Core/Attributes/RepeatAttribute.cs b/TUnit.Core/Attributes/RepeatAttribute.cs new file mode 100644 index 0000000000..49ca27ff08 --- /dev/null +++ b/TUnit.Core/Attributes/RepeatAttribute.cs @@ -0,0 +1,17 @@ +namespace TUnit.Core; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public class RepeatAttribute : TUnitAttribute +{ + public int Times { get; } + + public RepeatAttribute(int times) + { + if (times < 0) + { + throw new ArgumentOutOfRangeException(nameof(times), "Repeat times must be positive"); + } + + Times = times; + } +} \ No newline at end of file diff --git a/TUnit.Core/Attributes/RetryAttribute.cs b/TUnit.Core/Attributes/RetryAttribute.cs new file mode 100644 index 0000000000..a7c4538ddc --- /dev/null +++ b/TUnit.Core/Attributes/RetryAttribute.cs @@ -0,0 +1,17 @@ +namespace TUnit.Core; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public class RetryAttribute : TUnitAttribute +{ + public int Times { get; } + + public RetryAttribute(int times) + { + if (times < 0) + { + throw new ArgumentOutOfRangeException(nameof(times), "Retry times must be positive"); + } + + Times = times; + } +} \ No newline at end of file diff --git a/TUnit.Core/Attributes/SetUpAttribute.cs b/TUnit.Core/Attributes/SetUpAttribute.cs new file mode 100644 index 0000000000..16e11ba32c --- /dev/null +++ b/TUnit.Core/Attributes/SetUpAttribute.cs @@ -0,0 +1,4 @@ +namespace TUnit.Core; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class SetUpAttribute : TUnitAttribute; \ No newline at end of file diff --git a/TUnit.Core/Attributes/SkipAttribute.cs b/TUnit.Core/Attributes/SkipAttribute.cs new file mode 100644 index 0000000000..ff2ae4a9a5 --- /dev/null +++ b/TUnit.Core/Attributes/SkipAttribute.cs @@ -0,0 +1,12 @@ +namespace TUnit.Core; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public class SkipAttribute : TUnitAttribute +{ + public string Reason { get; } + + public SkipAttribute(string reason) + { + Reason = reason; + } +} \ No newline at end of file diff --git a/TUnit.Core/Attributes/TUnitAttribute.cs b/TUnit.Core/Attributes/TUnitAttribute.cs new file mode 100644 index 0000000000..5b6efc3f3e --- /dev/null +++ b/TUnit.Core/Attributes/TUnitAttribute.cs @@ -0,0 +1,3 @@ +namespace TUnit.Core; + +public class TUnitAttribute : Attribute; \ No newline at end of file diff --git a/TUnit.Core/Attributes/TestAttribute.cs b/TUnit.Core/Attributes/TestAttribute.cs new file mode 100644 index 0000000000..92c84c276c --- /dev/null +++ b/TUnit.Core/Attributes/TestAttribute.cs @@ -0,0 +1,4 @@ +namespace TUnit.Core; + +[AttributeUsage(AttributeTargets.Method)] +public class TestAttribute : TUnitAttribute; \ No newline at end of file diff --git a/TUnit.Core/Attributes/TestCategoryAttribute.cs b/TUnit.Core/Attributes/TestCategoryAttribute.cs new file mode 100644 index 0000000000..796a932c99 --- /dev/null +++ b/TUnit.Core/Attributes/TestCategoryAttribute.cs @@ -0,0 +1,12 @@ +namespace TUnit.Core; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public class TestCategoryAttribute : TUnitAttribute +{ + public string Category { get; } + + public TestCategoryAttribute(string category) + { + Category = category; + } +} \ No newline at end of file diff --git a/TUnit.Core/Attributes/TestDataSourceAttribute.cs b/TUnit.Core/Attributes/TestDataSourceAttribute.cs new file mode 100644 index 0000000000..8966a678a8 --- /dev/null +++ b/TUnit.Core/Attributes/TestDataSourceAttribute.cs @@ -0,0 +1,20 @@ +namespace TUnit.Core; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] +public class TestDataSourceAttribute : TUnitAttribute +{ + public string? ClassNameProvidingDataSource { get; } + public string MethodNameProvidingDataSource { get; } + + + public TestDataSourceAttribute(string methodNameProvidingDataSource) + { + MethodNameProvidingDataSource = methodNameProvidingDataSource; + } + + public TestDataSourceAttribute(string classNameProvidingDataSource, string methodNameProvidingDataSource) + { + ClassNameProvidingDataSource = classNameProvidingDataSource; + MethodNameProvidingDataSource = methodNameProvidingDataSource; + } +} \ No newline at end of file diff --git a/TUnit.Core/Attributes/TestWithDataAttribute.cs b/TUnit.Core/Attributes/TestWithDataAttribute.cs new file mode 100644 index 0000000000..6a7209246e --- /dev/null +++ b/TUnit.Core/Attributes/TestWithDataAttribute.cs @@ -0,0 +1,12 @@ +namespace TUnit.Core; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class TestWithDataAttribute : TUnitAttribute +{ + public object[] Values { get; } + + public TestWithDataAttribute(params object[] values) + { + Values = values; + } +} \ No newline at end of file diff --git a/TUnit.Core/Attributes/TimeoutAttribute.cs b/TUnit.Core/Attributes/TimeoutAttribute.cs new file mode 100644 index 0000000000..23583a0277 --- /dev/null +++ b/TUnit.Core/Attributes/TimeoutAttribute.cs @@ -0,0 +1,11 @@ +namespace TUnit.Core; + +[AttributeUsage(AttributeTargets.Method)] +public class TimeoutAttribute : TUnitAttribute +{ + public TimeSpan Timeout { get; } + public TimeoutAttribute(int timeoutInMilliseconds) + { + Timeout = TimeSpan.FromMilliseconds(timeoutInMilliseconds); + } +} \ No newline at end of file diff --git a/TUnit.Core/Exceptions/TUnitException.cs b/TUnit.Core/Exceptions/TUnitException.cs new file mode 100644 index 0000000000..e2d5084f7d --- /dev/null +++ b/TUnit.Core/Exceptions/TUnitException.cs @@ -0,0 +1,22 @@ +using System.Runtime.Serialization; + +namespace TUnit.Core.Exceptions; + +public class TUnitException : Exception +{ + public TUnitException() + { + } + + protected TUnitException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } + + public TUnitException(string? message) : base(message) + { + } + + public TUnitException(string? message, Exception? innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/TUnit.Core/Exceptions/TimeoutException.cs b/TUnit.Core/Exceptions/TimeoutException.cs new file mode 100644 index 0000000000..4a054476d6 --- /dev/null +++ b/TUnit.Core/Exceptions/TimeoutException.cs @@ -0,0 +1,14 @@ +namespace TUnit.Core.Exceptions; + +public class TimeoutException : TUnitException +{ + internal TimeoutException(TestDetails testDetails) : base(GetMessage(testDetails)) + { + + } + + private static string GetMessage(TestDetails testDetails) + { + return $"The test timed out after {testDetails.Timeout.Milliseconds} milliseconds"; + } +} \ No newline at end of file diff --git a/TUnit.Core/ParameterArgument.cs b/TUnit.Core/ParameterArgument.cs new file mode 100644 index 0000000000..32b782aba1 --- /dev/null +++ b/TUnit.Core/ParameterArgument.cs @@ -0,0 +1,3 @@ +namespace TUnit.Core; + +public record ParameterArgument(Type Type, object? Value); \ No newline at end of file diff --git a/TUnit.Core/SourceLocation.cs b/TUnit.Core/SourceLocation.cs new file mode 100644 index 0000000000..c98e2e3bc9 --- /dev/null +++ b/TUnit.Core/SourceLocation.cs @@ -0,0 +1,3 @@ +namespace TUnit.Core; + +public record SourceLocation(string? FileName, int MinLineNumber, int MaxLineNumber); \ No newline at end of file diff --git a/TUnit.Core/Status.cs b/TUnit.Core/Status.cs new file mode 100644 index 0000000000..6d69ae01ca --- /dev/null +++ b/TUnit.Core/Status.cs @@ -0,0 +1,9 @@ +namespace TUnit.Core; + +public enum Status +{ + None, + Passed, + Failed, + Skipped +} \ No newline at end of file diff --git a/TUnit.Core/TUnit.Core.csproj b/TUnit.Core/TUnit.Core.csproj new file mode 100644 index 0000000000..97b7c882fc --- /dev/null +++ b/TUnit.Core/TUnit.Core.csproj @@ -0,0 +1,10 @@ + + + + net7.0 + enable + enable + latest + + + diff --git a/TUnit.Core/TUnit.Core.csproj.DotSettings b/TUnit.Core/TUnit.Core.csproj.DotSettings new file mode 100644 index 0000000000..bd95c7d75d --- /dev/null +++ b/TUnit.Core/TUnit.Core.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/TUnit.Core/TUnitTestResult.cs b/TUnit.Core/TUnitTestResult.cs new file mode 100644 index 0000000000..baf86e853e --- /dev/null +++ b/TUnit.Core/TUnitTestResult.cs @@ -0,0 +1,12 @@ +namespace TUnit.Core; + +public record TUnitTestResult +{ + public required Status Status { get; init; } + public required DateTimeOffset Start { get; init; } + public required DateTimeOffset End { get; init; } + public required TimeSpan Duration { get; init; } + public required Exception? Exception { get; init; } + public required string ComputerName { get; init; } + public string? Output { get; internal set; } +}; \ No newline at end of file diff --git a/TUnit.Core/TUnitTestResultWithDetails.cs b/TUnit.Core/TUnitTestResultWithDetails.cs new file mode 100644 index 0000000000..2849c2d6ab --- /dev/null +++ b/TUnit.Core/TUnitTestResultWithDetails.cs @@ -0,0 +1,6 @@ +namespace TUnit.Core; + +internal record TUnitTestResultWithDetails : TUnitTestResult +{ + public required TestDetails TestDetails { get; init; } +} \ No newline at end of file diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs new file mode 100644 index 0000000000..64a3c349c3 --- /dev/null +++ b/TUnit.Core/TestContext.cs @@ -0,0 +1,36 @@ +using System.Text; + +namespace TUnit.Core; + +public class TestContext +{ + public CancellationToken CancellationToken { get; internal set; } = CancellationToken.None; + internal readonly StringWriter OutputWriter = new(); + + private readonly TestDetails _testDetails; + private readonly object? _classInstance; + + private static readonly AsyncLocal AsyncLocal = new(); + + public TestInformation TestInformation { get; } + + internal TestContext(TestDetails testDetails, object? classInstance) + { + _testDetails = testDetails; + _classInstance = classInstance; + TestInformation = new(_testDetails, _classInstance); + } + + public static TestContext Current + { + get => AsyncLocal.Value!; + set => AsyncLocal.Value = value; + } + + public TUnitTestResult? Result { get; internal set; } + + public string GetOutput() + { + return OutputWriter.ToString().Trim(); + } +} \ No newline at end of file diff --git a/TUnit.Core/TestDetails.cs b/TUnit.Core/TestDetails.cs new file mode 100644 index 0000000000..e2e865daee --- /dev/null +++ b/TUnit.Core/TestDetails.cs @@ -0,0 +1,167 @@ +using System.Reflection; + +namespace TUnit.Core; + +internal record TestDetails +{ + public TestDetails(MethodInfo methodInfo, + Type classType, + SourceLocation sourceLocation, + ParameterArgument[]? arguments, + int count) + { + MethodInfo = methodInfo; + ClassType = classType; + SourceLocation = sourceLocation; + Count = count; + + ParameterTypes = arguments?.Select(x => x.Type).ToArray(); + ArgumentValues = arguments?.Select(x => x.Value).ToArray(); + + TestName = methodInfo.Name; + DisplayName = methodInfo.Name + GetArgumentValues() + GetCountInBrackets(); + ClassName = ClassType.Name; + FullyQualifiedClassName = ClassType.FullName!; + Assembly = ClassType.Assembly; + Source = ClassType.Assembly.Location; + + var methodAndClassAttributes = methodInfo.CustomAttributes + .Concat(ClassType.CustomAttributes) + .ToArray(); + + IsSingleTest = !methodAndClassAttributes.Any(x => x.AttributeType == typeof(TestWithDataAttribute) + || x.AttributeType == typeof(TestDataSourceAttribute)); + + SkipReason = methodAndClassAttributes + .FirstOrDefault(x => x.AttributeType == typeof(SkipAttribute)) + ?.ConstructorArguments.FirstOrDefault().Value as string; + + RetryCount = methodAndClassAttributes + .FirstOrDefault(x => x.AttributeType == typeof(RetryAttribute)) + ?.ConstructorArguments.FirstOrDefault().Value as int? ?? 0; + + RepeatCount = methodAndClassAttributes + .FirstOrDefault(x => x.AttributeType == typeof(RepeatAttribute)) + ?.ConstructorArguments.FirstOrDefault().Value as int? ?? 0; + + AddCategories(methodAndClassAttributes); + + Timeout = GetTimeout(methodAndClassAttributes); + + FileName = sourceLocation.FileName; + MinLineNumber = sourceLocation.MinLineNumber; + MaxLineNumber = sourceLocation.MaxLineNumber; + + UniqueId = FullyQualifiedClassName + DisplayName + Count + GetParameterTypes(ParameterTypes); + } + + private string GetCountInBrackets() + { + return Count == 1 ? string.Empty : $" [{Count}]"; + } + + public bool IsSingleTest { get; } + + private void AddCategories(CustomAttributeData[] methodAndClassAttributes) + { + var categoryAttributes = methodAndClassAttributes + .Where(x => x.AttributeType == typeof(TestCategoryAttribute)); + + var categories = categoryAttributes + .Select(x => x.ConstructorArguments.FirstOrDefault().Value) + .OfType(); + + Categories.AddRange(categories); + } + + public List Categories { get; } = new(); + + private static TimeSpan GetTimeout(CustomAttributeData[] methodAndClassAttributes) + { + var timeoutMilliseconds = methodAndClassAttributes + .FirstOrDefault(x => x.AttributeType == typeof(TimeoutAttribute)) + ?.ConstructorArguments.FirstOrDefault().Value as int?; + + if (timeoutMilliseconds is 0 or null) + { + return default; + } + + return TimeSpan.FromMilliseconds(timeoutMilliseconds.Value); + } + + + public int RetryCount { get; } + public int RepeatCount { get; } + + private string GetArgumentValues() + { + if (ArgumentValues == null) + { + return string.Empty; + } + + return $"({string.Join(',', ArgumentValues.Select(StringifyArgument))})"; + } + + public string UniqueId { get; } + + public string TestName { get; } + + public string ClassName { get; } + + public string FullyQualifiedClassName { get; } + + public Assembly Assembly { get; } + + public string Source { get; } + public MethodInfo MethodInfo { get; } + public Type ClassType { get; } + public string? FileName { get; } + + public TimeSpan Timeout { get; } + + public int CurrentExecutionCount { get; internal set; } + + public int MinLineNumber { get; } + public int MaxLineNumber { get; } + public Type[]? ParameterTypes { get; } + public object?[]? ArgumentValues { get; } + public SourceLocation SourceLocation { get; } + public int Count { get; } + + public string? SkipReason { get; } + public bool IsSkipped => !string.IsNullOrEmpty(SkipReason); + public string DisplayName { get; } + + private readonly TaskCompletionSource _completionSource = new(); + public Task GetResultAsync() => _completionSource.Task; + + public TUnitTestResultWithDetails SetResult(TUnitTestResultWithDetails unitTestResult) + { + _completionSource.SetResult(unitTestResult); + return unitTestResult; + } + + public static string GetParameterTypes(Type[]? types) + { + if (types is null) + { + return string.Empty; + } + + var argsAsString = types.Select(arg => arg.FullName!); + + return $"({string.Join(',', argsAsString)})"; + } + + private static string StringifyArgument(object? obj) + { + return obj switch + { + null => "null", + string stringValue => $"\"{stringValue}\"", + _ => obj.ToString() ?? string.Empty + }; + } +} \ No newline at end of file diff --git a/TUnit.Core/TestInformation.cs b/TUnit.Core/TestInformation.cs new file mode 100644 index 0000000000..203376c08e --- /dev/null +++ b/TUnit.Core/TestInformation.cs @@ -0,0 +1,25 @@ +namespace TUnit.Core; + +public record TestInformation +{ + private readonly TestDetails _testDetails; + + internal TestInformation(TestDetails testDetails, object? classInstance) + { + _testDetails = testDetails; + ClassInstance = classInstance; + } + + public string TestName => _testDetails.TestName; + + public object?[]? TestArguments => _testDetails.ArgumentValues; + + public List Categories => _testDetails.Categories; + + public Type ClassType => _testDetails.ClassType; + public object? ClassInstance { get; } + + public int RepeatCount => _testDetails.RepeatCount; + public int RetryCount => _testDetails.RetryCount; + public int CurrentExecutionCount => _testDetails.CurrentExecutionCount; +} \ No newline at end of file diff --git a/TUnit.Core/TypeInformation.cs b/TUnit.Core/TypeInformation.cs new file mode 100644 index 0000000000..f8244c6e59 --- /dev/null +++ b/TUnit.Core/TypeInformation.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace TUnit.Core; + +public record TypeInformation(Assembly Assembly) +{ + public Type[] Types { get; } = Assembly.GetTypes(); +} \ No newline at end of file diff --git a/TUnit.Engine/ClassLoader.cs b/TUnit.Engine/ClassLoader.cs new file mode 100644 index 0000000000..7ec680a467 --- /dev/null +++ b/TUnit.Engine/ClassLoader.cs @@ -0,0 +1,27 @@ +using System.Reflection; + +namespace TUnit.Engine; + +public class ClassLoader +{ + public IEnumerable GetAllTypes(Assembly[] assemblies) + { + return assemblies.SelectMany(LoadTypes); + } + + private static IEnumerable LoadTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException reflectionTypeLoadException) + { + return reflectionTypeLoadException.Types.OfType(); + } + catch + { + return []; + } + } +} \ No newline at end of file diff --git a/TUnit.Engine/ConsoleInterceptor.cs b/TUnit.Engine/ConsoleInterceptor.cs new file mode 100644 index 0000000000..1132a362fc --- /dev/null +++ b/TUnit.Engine/ConsoleInterceptor.cs @@ -0,0 +1,320 @@ +using System.Text; +using TUnit.Core; +#pragma warning disable CS8765 // Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes). + +namespace TUnit.Engine; + +public class ConsoleInterceptor : TextWriter +{ + public StringWriter InnerWriter => TestContext.Current.OutputWriter; + + public ConsoleInterceptor() + { + DefaultOut = Console.Out; + Console.SetOut(this); + } + + public new void Dispose() + { + InnerWriter.Dispose(); + Console.SetOut(DefaultOut); + } + + public override async ValueTask DisposeAsync() + { + await InnerWriter.DisposeAsync(); + Console.SetOut(DefaultOut); + } + + public override void Flush() + { + InnerWriter.Flush(); + } + + public override void Write(bool value) + { + InnerWriter.Write(value); + } + + public override void Write(char[]? buffer) + { + InnerWriter.Write(buffer); + } + + public override void Write(decimal value) + { + InnerWriter.Write(value); + } + + public override void Write(double value) + { + InnerWriter.Write(value); + } + + public override void Write(int value) + { + InnerWriter.Write(value); + } + + public override void Write(long value) + { + InnerWriter.Write(value); + } + + public override void Write(object? value) + { + InnerWriter.Write(value); + } + + public override void Write(float value) + { + InnerWriter.Write(value); + } + + public override void Write(string format, object? arg0) + { + InnerWriter.Write(format, arg0); + } + + public override void Write(string format, object? arg0, object? arg1) + { + InnerWriter.Write(format, arg0, arg1); + } + + public override void Write(string format, object? arg0, object? arg1, object? arg2) + { + InnerWriter.Write(format, arg0, arg1, arg2); + } + + public override void Write(string format, params object?[] arg) + { + InnerWriter.Write(format, arg); + } + + public override void Write(uint value) + { + InnerWriter.Write(value); + } + + public override void Write(ulong value) + { + InnerWriter.Write(value); + } + + public new Task WriteAsync(char[]? buffer) + { + return InnerWriter.WriteAsync(buffer); + } + + public override void WriteLine() + { + InnerWriter.WriteLine(); + } + + public override void WriteLine(bool value) + { + InnerWriter.WriteLine(value); + } + + public override void WriteLine(char value) + { + InnerWriter.WriteLine(value); + } + + public override void WriteLine(char[]? buffer) + { + InnerWriter.WriteLine(buffer); + } + + public override void WriteLine(char[] buffer, int index, int count) + { + InnerWriter.WriteLine(buffer, index, count); + } + + public override void WriteLine(decimal value) + { + InnerWriter.WriteLine(value); + } + + public override void WriteLine(double value) + { + InnerWriter.WriteLine(value); + } + + public override void WriteLine(int value) + { + InnerWriter.WriteLine(value); + } + + public override void WriteLine(long value) + { + InnerWriter.WriteLine(value); + } + + public override void WriteLine(object? value) + { + InnerWriter.WriteLine(value); + } + + public override void WriteLine(float value) + { + InnerWriter.WriteLine(value); + } + + public override void WriteLine(string? value) + { + InnerWriter.WriteLine(value); + } + + public override void WriteLine(string format, object? arg0) + { + InnerWriter.WriteLine(format, arg0); + } + + public override void WriteLine(string format, object? arg0, object? arg1) + { + InnerWriter.WriteLine(format, arg0, arg1); + } + + public override void WriteLine(string format, object? arg0, object? arg1, object? arg2) + { + InnerWriter.WriteLine(format, arg0, arg1, arg2); + } + + public override void WriteLine(string format, params object?[] arg) + { + InnerWriter.WriteLine(format, arg); + } + + public override void WriteLine(uint value) + { + InnerWriter.WriteLine(value); + } + + public override void WriteLine(ulong value) + { + InnerWriter.WriteLine(value); + } + + public override Task WriteLineAsync() + { + return InnerWriter.WriteLineAsync(); + } + + public new Task WriteLineAsync(char[]? buffer) + { + return InnerWriter.WriteLineAsync(buffer); + } + + public override IFormatProvider FormatProvider => InnerWriter.FormatProvider; + + public override string NewLine + { + get => InnerWriter.NewLine; + set => InnerWriter.NewLine = value; + } + + public override void Close() + { + InnerWriter.Close(); + } + + public override Task FlushAsync() + { + return InnerWriter.FlushAsync(); + } + + public StringBuilder GetStringBuilder() + { + return InnerWriter.GetStringBuilder(); + } + + public override void Write(char value) + { + InnerWriter.Write(value); + } + + public override void Write(char[] buffer, int index, int count) + { + InnerWriter.Write(buffer, index, count); + } + + public override void Write(ReadOnlySpan buffer) + { + InnerWriter.Write(buffer); + } + + public override void Write(string? value) + { + InnerWriter.Write(value); + } + + public override void Write(StringBuilder? value) + { + InnerWriter.Write(value); + } + + public override Task WriteAsync(char value) + { + return InnerWriter.WriteAsync(value); + } + + public override Task WriteAsync(char[] buffer, int index, int count) + { + return InnerWriter.WriteAsync(buffer, index, count); + } + + public override Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = new CancellationToken()) + { + return InnerWriter.WriteAsync(buffer, cancellationToken); + } + + public override Task WriteAsync(string? value) + { + return InnerWriter.WriteAsync(value); + } + + public override Task WriteAsync(StringBuilder? value, CancellationToken cancellationToken = new CancellationToken()) + { + return InnerWriter.WriteAsync(value, cancellationToken); + } + + public override void WriteLine(ReadOnlySpan buffer) + { + InnerWriter.WriteLine(buffer); + } + + public override void WriteLine(StringBuilder? value) + { + InnerWriter.WriteLine(value); + } + + public override Task WriteLineAsync(char value) + { + return InnerWriter.WriteLineAsync(value); + } + + public override Task WriteLineAsync(char[] buffer, int index, int count) + { + return InnerWriter.WriteLineAsync(buffer, index, count); + } + + public override Task WriteLineAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = new CancellationToken()) + { + return InnerWriter.WriteLineAsync(buffer, cancellationToken); + } + + public override Task WriteLineAsync(string? value) + { + return InnerWriter.WriteLineAsync(value); + } + + public override Task WriteLineAsync(StringBuilder? value, CancellationToken cancellationToken = new CancellationToken()) + { + return InnerWriter.WriteLineAsync(value, cancellationToken); + } + + public override Encoding Encoding => InnerWriter.Encoding; + + public TextWriter DefaultOut { get; } +} \ No newline at end of file diff --git a/TUnit.Engine/Disposer.cs b/TUnit.Engine/Disposer.cs new file mode 100644 index 0000000000..b1bb9060da --- /dev/null +++ b/TUnit.Engine/Disposer.cs @@ -0,0 +1,19 @@ +namespace TUnit.Engine; + +public class Disposer +{ + public ValueTask DisposeAsync(object? obj) + { + if (obj is IAsyncDisposable asyncDisposable) + { + return asyncDisposable.DisposeAsync(); + } + + if (obj is IDisposable disposable) + { + disposable.Dispose(); + } + + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/TUnit.Engine/Extensions/ServiceCollectionExtensions.cs b/TUnit.Engine/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..89b4a0eb63 --- /dev/null +++ b/TUnit.Engine/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace TUnit.Engine.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddTestEngineServices(this IServiceCollection services) + { + return services.AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(new ConsoleInterceptor()); + } +} \ No newline at end of file diff --git a/TUnit.Engine/Extensions/TypeExtensions.cs b/TUnit.Engine/Extensions/TypeExtensions.cs new file mode 100644 index 0000000000..76e2bcb487 --- /dev/null +++ b/TUnit.Engine/Extensions/TypeExtensions.cs @@ -0,0 +1,13 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace TUnit.Engine.Extensions; + +public static class TypeExtensions +{ + public static bool HasAttribute(this Type type, [NotNullWhen(true)] out T[]? attributes) where T : Attribute + { + attributes = type.GetCustomAttributes().ToArray(); + return attributes.Any(); + } +} \ No newline at end of file diff --git a/TUnit.Engine/MethodInvoker.cs b/TUnit.Engine/MethodInvoker.cs new file mode 100644 index 0000000000..2cc278ee49 --- /dev/null +++ b/TUnit.Engine/MethodInvoker.cs @@ -0,0 +1,52 @@ +using System.Globalization; +using System.Reflection; +using System.Runtime.ExceptionServices; + +namespace TUnit.Engine; + +public class MethodInvoker +{ + public async Task InvokeMethod(object? @class, MethodInfo methodInfo, BindingFlags bindingFlags, object?[]? arguments) + { + try + { + var result = methodInfo.Invoke(@class, bindingFlags, null, arguments, CultureInfo.InvariantCulture); + + if (result is ValueTask valueTask) + { + await valueTask; + + if (valueTask.GetType().IsGenericType) + { + return valueTask.GetType() + .GetProperty("Result", BindingFlags.Instance | BindingFlags.Public) + ?.GetValue(valueTask); + } + + return null; + } + + if (result is Task task) + { + await task; + + if (task.GetType().IsGenericType) + { + return task.GetType() + .GetProperty("Result", BindingFlags.Instance | BindingFlags.Public) + ?.GetValue(task); + } + + return null; + } + + return result; + } + catch (TargetInvocationException e) + { + ExceptionDispatchInfo.Capture(e.InnerException ?? e).Throw(); + + return null; + } + } +} \ No newline at end of file diff --git a/TUnit.Engine/SingleTestExecutor.cs b/TUnit.Engine/SingleTestExecutor.cs new file mode 100644 index 0000000000..e83e4dac84 --- /dev/null +++ b/TUnit.Engine/SingleTestExecutor.cs @@ -0,0 +1,252 @@ +using System.Collections.Concurrent; +using System.Reflection; +using TUnit.Core; +using TimeoutException = TUnit.Core.Exceptions.TimeoutException; + +namespace TUnit.Engine; + +internal class SingleTestExecutor +{ + private readonly MethodInvoker _methodInvoker; + private readonly TestClassCreator _testClassCreator; + private readonly Disposer _disposer; + private readonly CancellationTokenSource _cancellationTokenSource; + + public SingleTestExecutor(MethodInvoker methodInvoker, + TestClassCreator testClassCreator, + Disposer disposer, + CancellationTokenSource cancellationTokenSource) + { + _methodInvoker = methodInvoker; + _testClassCreator = testClassCreator; + _disposer = disposer; + _cancellationTokenSource = cancellationTokenSource; + } + + private readonly ConcurrentDictionary _oneTimeSetUpRegistry = new(); + + public async Task ExecuteTest(TestDetails testDetails, Type[] allClasses) + { + var start = DateTimeOffset.Now; + + if (testDetails.IsSkipped) + { + return testDetails.SetResult(new TUnitTestResultWithDetails + { + TestDetails = testDetails, + Duration = TimeSpan.Zero, + Start = start, + End = start, + ComputerName = Environment.MachineName, + Exception = null, + Status = Status.Skipped + }); + } + + var isRetry = testDetails.RetryCount > 0; + + object? @class = null; + TestContext? testContext = null; + try + { + await Task.Run(async () => + { + @class = _testClassCreator.CreateTestClass(testDetails, allClasses); + + testContext = new TestContext(testDetails, @class); + TestContext.Current = testContext; + + try + { + if (isRetry) + { + await ExecuteWithRetries(testContext, testDetails, @class); + } + else + { + await ExecuteWithRepeats(testContext, testDetails, @class); + } + } + finally + { + await _disposer.DisposeAsync(@class); + } + }); + + var end = DateTimeOffset.Now; + + return testDetails.SetResult(new TUnitTestResultWithDetails + { + TestDetails = testDetails, + Duration = end - start, + Start = start, + End = end, + ComputerName = Environment.MachineName, + Exception = null, + Status = Status.Passed, + Output = testContext?.GetOutput() + }); + } + catch (Exception e) + { + var end = DateTimeOffset.Now; + + var unitTestResult = new TUnitTestResultWithDetails + { + TestDetails = testDetails, + Duration = end - start, + Start = start, + End = end, + ComputerName = Environment.MachineName, + Exception = e, + Status = Status.Failed, + Output = testContext?.GetOutput() + }; + + if (testContext != null) + { + testContext.Result = unitTestResult; + } + + await ExecuteCleanUps(@class); + + return testDetails.SetResult(unitTestResult); + } + } + + private async Task ExecuteWithRetries(TestContext testContext, TestDetails testDetails, object? @class) + { + for (var i = 0; i < testDetails.RetryCount + 1; i++) + { + try + { + await ExecuteCore(testContext, testDetails, @class); + break; + } + catch + { + if (i == testDetails.RetryCount) + { + throw; + } + } + } + } + + private async Task ExecuteWithRepeats(TestContext testContext, TestDetails testDetails, object? @class) + { + var tasks = new List(); + + for (var i = 0; i < testDetails.RepeatCount + 1; i++) + { + tasks.Add(ExecuteCore(testContext, testDetails, @class)); + } + + await Task.WhenAll(tasks); + } + + private async Task ExecuteCore(TestContext testContext, TestDetails testDetails, object? @class) + { + testDetails.CurrentExecutionCount++; + + await ExecuteSetUps(@class, testDetails.ClassType); + + var testLevelCancellationTokenSource = + CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.Token); + + if (testDetails.Timeout != default) + { + testLevelCancellationTokenSource.CancelAfter(testDetails.Timeout); + } + + testContext.CancellationToken = testLevelCancellationTokenSource.Token; + + try + { + await ExecuteTestMethodWithTimeout(testDetails, @class, testLevelCancellationTokenSource); + } + catch + { + testLevelCancellationTokenSource.Cancel(); + throw; + } + } + + private async Task ExecuteTestMethodWithTimeout(TestDetails testDetails, object? @class, + CancellationTokenSource cancellationTokenSource) + { + var methodResult = _methodInvoker.InvokeMethod(@class, testDetails.MethodInfo, BindingFlags.Default, + testDetails.ArgumentValues?.ToArray()); + + if (testDetails.Timeout == default) + { + await methodResult; + return; + } + + var timeoutTask = Task.Delay(testDetails.Timeout, cancellationTokenSource.Token) + .ContinueWith(_ => throw new TimeoutException(testDetails)); + + await await Task.WhenAny(timeoutTask, methodResult); + } + + private async Task ExecuteSetUps(object? @class, Type testDetailsClassType) + { + await _oneTimeSetUpRegistry.GetOrAdd(testDetailsClassType.FullName!, _ => ExecuteOneTimeSetUps(@class, testDetailsClassType)); + + var setUpMethods = testDetailsClassType + .GetMethods() + .Where(x => !x.IsStatic) + .Where(x => x.CustomAttributes.Any(attributeData => attributeData.AttributeType == typeof(SetUpAttribute))); + + foreach (var setUpMethod in setUpMethods) + { + await _methodInvoker.InvokeMethod(@class, setUpMethod, BindingFlags.Default, null); + } + } + + private async Task ExecuteCleanUps(object? @class) + { + if (@class is null) + { + return; + } + + var cleanUpMethods = @class.GetType() + .GetMethods() + .Where(x => !x.IsStatic) + .Where(x => x.CustomAttributes.Any(attributeData => attributeData.AttributeType == typeof(CleanUpAttribute))); + + var exceptions = new List(); + + foreach (var cleanUpMethod in cleanUpMethods) + { + try + { + await _methodInvoker.InvokeMethod(@class, cleanUpMethod, BindingFlags.Default, null); + } + catch (Exception e) + { + exceptions.Add(e); + } + } + + if (exceptions.Any()) + { + throw new AggregateException(exceptions); + } + } + + private async Task ExecuteOneTimeSetUps(object? @class, Type testDetailsClassType) + { + var oneTimeSetUpMethods = testDetailsClassType + .GetMethods() + .Where(x => x.IsStatic) + .Where(x => x.CustomAttributes.Any(attributeData => attributeData.AttributeType == typeof(OneTimeSetUpAttribute))); + + foreach (var oneTimeSetUpMethod in oneTimeSetUpMethods) + { + await _methodInvoker.InvokeMethod(@class, oneTimeSetUpMethod, BindingFlags.Static | BindingFlags.Public, null); + } + } +} \ No newline at end of file diff --git a/TUnit.Engine/TUnit.Engine.csproj b/TUnit.Engine/TUnit.Engine.csproj new file mode 100644 index 0000000000..c863aa5807 --- /dev/null +++ b/TUnit.Engine/TUnit.Engine.csproj @@ -0,0 +1,19 @@ + + + + net7.0 + enable + enable + latest + + + + + + + + + + + + diff --git a/TUnit.Engine/TestClassCreator.cs b/TUnit.Engine/TestClassCreator.cs new file mode 100644 index 0000000000..a3b295fafc --- /dev/null +++ b/TUnit.Engine/TestClassCreator.cs @@ -0,0 +1,69 @@ +using TUnit.Core; +using TUnit.Engine.Extensions; + +namespace TUnit.Engine; + +internal class TestClassCreator(TestDataSourceRetriever testDataSourceRetriever) +{ + public object? CreateTestClass(TestDetails testDetails, Type[] allClasses) + { + if (testDetails.MethodInfo.IsStatic) + { + return null; + } + + if (testDetails.ClassType.HasAttribute(out var testDataSourceAttributes)) + { + return CreateWithTestDataSources(testDetails, testDataSourceAttributes, allClasses); + } + + return CreateBasicClass(testDetails); + } + + private object CreateWithTestDataSources(TestDetails testDetails, + IEnumerable testDataSourceAttributes, + Type[] allClasses) + { + var testDataSourceAttribute = testDataSourceAttributes.First(); + + var className = testDataSourceAttribute.ClassNameProvidingDataSource; + + ParameterArgument[]? testData; + if (string.IsNullOrEmpty(className)) + { + var @class = testDetails.MethodInfo.DeclaringType!; + + testData = testDataSourceRetriever.GetTestDataSourceArguments( + @class, + testDataSourceAttribute.MethodNameProvidingDataSource + ); + } + else + { + var @class = allClasses.FirstOrDefault(x => x.FullName == className) + ?? allClasses.First(x => x.Name == className); + + testData = testDataSourceRetriever.GetTestDataSourceArguments( + @class, + testDataSourceAttribute.MethodNameProvidingDataSource + ); + } + + return Activator.CreateInstance( + testDetails.ClassType, + testData?.Select(x => x.Value).ToArray() + )!; + } + + private static object CreateBasicClass(TestDetails testDetails) + { + try + { + return Activator.CreateInstance(testDetails.MethodInfo.DeclaringType!)!; + } + catch (Exception e) + { + throw new Exception("Cannot create an instance of the test class. Is there a public parameterless constructor?", e); + } + } +} \ No newline at end of file diff --git a/TUnit.Engine/TestDataSourceRetriever.cs b/TUnit.Engine/TestDataSourceRetriever.cs new file mode 100644 index 0000000000..8bf0965000 --- /dev/null +++ b/TUnit.Engine/TestDataSourceRetriever.cs @@ -0,0 +1,57 @@ +using System.Reflection; +using TUnit.Core; + +namespace TUnit.Engine; + +public class TestDataSourceRetriever(MethodInvoker methodInvoker) +{ + public ParameterArgument[]? GetTestDataSourceArguments(MethodInfo methodInfo, + CustomAttributeData testDataSourceAttribute, Type[] allClasses) + { + if (testDataSourceAttribute.ConstructorArguments.Count == 1) + { + // 1 argument means only method name supplied - Implies method is in same class + var methodName = (string) testDataSourceAttribute.ConstructorArguments[0].Value!; + var @class = methodInfo.DeclaringType!; + + return GetTestDataSourceArguments(@class, methodName); + } + else + { + // Class name and method name + var className = (string) testDataSourceAttribute.ConstructorArguments[0].Value!; + var methodName = (string) testDataSourceAttribute.ConstructorArguments[1].Value!; + + return GetTestDataSourceArguments(className, methodName, allClasses); + } + } + + public ParameterArgument[]? GetTestDataSourceArguments( + string className, + string methodName, + Type[] allClasses + ) + { + var @class = allClasses.FirstOrDefault(x => x.FullName == className) + ?? allClasses.First(x => x.Name == className); + + return GetTestDataSourceArguments(@class, methodName); + } + + public ParameterArgument[]? GetTestDataSourceArguments( + Type @class, + string methodName + ) + { + var method = @class.GetMethods().First(x => x.IsStatic && x.Name == methodName); + + var result = methodInvoker.InvokeMethod(null, method, BindingFlags.Static | BindingFlags.Public, null).Result; + + if (result is null) + { + return null; + } + + return [new ParameterArgument(result.GetType(), result)]; + } +} \ No newline at end of file diff --git a/TUnit.Pipeline/Modules/AddLocalNuGetDirectoryModule.cs b/TUnit.Pipeline/Modules/AddLocalNuGetDirectoryModule.cs new file mode 100644 index 0000000000..1ca62e5dbf --- /dev/null +++ b/TUnit.Pipeline/Modules/AddLocalNuGetDirectoryModule.cs @@ -0,0 +1,30 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace TUnit.Pipeline.Modules; + +[DependsOn] +public class AddLocalNuGetDirectoryModule : Module +{ + protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken) + { + var directoryResult = await GetModule(); + + var currentNuGetSources = await context.DotNet() + .Nuget + .List + .Source(token: cancellationToken); + + if (currentNuGetSources.StandardOutput.Contains(directoryResult.Value!)) + { + return currentNuGetSources; + } + + return await context.DotNet().Nuget.Add + .Source(new DotNetNugetAddSourceOptions(directoryResult.Value!), cancellationToken); + } +} \ No newline at end of file diff --git a/TUnit.Pipeline/Modules/CleanProjectsModule.cs b/TUnit.Pipeline/Modules/CleanProjectsModule.cs new file mode 100644 index 0000000000..aa4fa793b1 --- /dev/null +++ b/TUnit.Pipeline/Modules/CleanProjectsModule.cs @@ -0,0 +1,18 @@ +using EnumerableAsyncProcessor.Extensions; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Models; +using ModularPipelines.Modules; +using ModularPipelines.Attributes; + +namespace TUnit.Pipeline.Modules; +[DependsOn] +public class CleanProjectsModule : Module +{ + protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken) + { + var projects = await GetModule(); + return await projects.Value!.SelectAsync(x => context.DotNet().Clean(new DotNetCleanOptions(x), cancellationToken), cancellationToken: cancellationToken).ProcessOneAtATime(); + } +} \ No newline at end of file diff --git a/TUnit.Pipeline/Modules/CreateLocalNuGetDirectoryModule.cs b/TUnit.Pipeline/Modules/CreateLocalNuGetDirectoryModule.cs new file mode 100644 index 0000000000..2f2ae5d93c --- /dev/null +++ b/TUnit.Pipeline/Modules/CreateLocalNuGetDirectoryModule.cs @@ -0,0 +1,17 @@ +using ModularPipelines.Context; +using ModularPipelines.Extensions; +using ModularPipelines.FileSystem; +using ModularPipelines.Modules; + +namespace TUnit.Pipeline.Modules; + +public class CreateLocalNuGetDirectoryModule : Module +{ + protected override Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken) + { + return context.FileSystem.GetFolder(Environment.SpecialFolder.UserProfile) + .GetFolder("LocalNuGet") + .Create() + .AsTask(); + } +} \ No newline at end of file diff --git a/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs b/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs new file mode 100644 index 0000000000..68bef7b39c --- /dev/null +++ b/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs @@ -0,0 +1,22 @@ +using ModularPipelines.Context; +using ModularPipelines.Extensions; +using ModularPipelines.Git.Extensions; +using ModularPipelines.Modules; +using File = ModularPipelines.FileSystem.File; + +namespace TUnit.Pipeline.Modules; + +public class GetPackageProjectsModule : Module> +{ + protected override Task?> ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken) + { + return context.Git().RootDirectory + .GetFiles(x => x.Extension == ".csproj") + .Where(x => !x.Name.Contains("Pipeline")) + .Where(x => !x.Name.Contains("Sample")) + .Where(x => !x.Name.Contains("TestProject")) + .Where(x => !x.Name.Contains("Tests")) + .ToList() + .AsTask?>(); + } +} \ No newline at end of file diff --git a/TUnit.Pipeline/Modules/MoveNuGetPackagesToLocalSourceModule.cs b/TUnit.Pipeline/Modules/MoveNuGetPackagesToLocalSourceModule.cs new file mode 100644 index 0000000000..abfc7cb521 --- /dev/null +++ b/TUnit.Pipeline/Modules/MoveNuGetPackagesToLocalSourceModule.cs @@ -0,0 +1,30 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.Extensions; +using ModularPipelines.Git.Extensions; +using ModularPipelines.Modules; +using File = ModularPipelines.FileSystem.File; + +namespace TUnit.Pipeline.Modules; + +[DependsOn] +[DependsOn] +public class MoveNuGetPackagesToLocalSourceModule : Module> +{ + protected override async Task?> ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken) + { + var localNugetDirectory = await GetModule(); + + foreach (var file in localNugetDirectory.Value!.ListFiles().Where(x => x.Name.Contains("TUnit"))) + { + file.Delete(); + } + + var nugetPackages = context.Git().RootDirectory + .GetFiles(x => x.Extension is ".nupkg" or ".snupkg"); + + return nugetPackages + .Select(x => x.MoveTo(localNugetDirectory.Value.AssertExists())) + .ToList(); + } +} \ No newline at end of file diff --git a/TUnit.Pipeline/Modules/PackTUnitFilesModule.cs b/TUnit.Pipeline/Modules/PackTUnitFilesModule.cs new file mode 100644 index 0000000000..a619ad56f9 --- /dev/null +++ b/TUnit.Pipeline/Modules/PackTUnitFilesModule.cs @@ -0,0 +1,45 @@ +using EnumerableAsyncProcessor.Extensions; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Models; +using ModularPipelines.Modules; +using ModularPipelines.Attributes; +using ModularPipelines.Git.Extensions; + +namespace TUnit.Pipeline.Modules; +[DependsOn] +[DependsOn] +public class PackTUnitFilesModule : Module> +{ + protected override async Task?> ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken) + { + var projects = await GetModule(); + + var git = await context.Git().Versioning.GetGitVersioningInformation(); + + var version = git.SemVer; + + if (git.BranchName == "main") + { + version += "alpha01"; + } + + await projects.Value!.SelectAsync(async project => + { + return await context.DotNet() + .Pack( + new DotNetPackOptions(project) + { + Properties = new[] + { + new KeyValue("Version", version!), + new KeyValue("PackageVersion", version!) + } + }, cancellationToken); + }, cancellationToken: cancellationToken) + .ProcessOneAtATime(); + + return projects.Value!.Select(x => new PackedProject(x.NameWithoutExtension, version!)).ToList(); + } +} \ No newline at end of file diff --git a/TUnit.Pipeline/Modules/UploadToNuGetModule.cs b/TUnit.Pipeline/Modules/UploadToNuGetModule.cs new file mode 100644 index 0000000000..3d5b391855 --- /dev/null +++ b/TUnit.Pipeline/Modules/UploadToNuGetModule.cs @@ -0,0 +1,29 @@ +using EnumerableAsyncProcessor.Extensions; +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Git.Attributes; +using ModularPipelines.Git.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace TUnit.Pipeline.Modules; + +[RunOnlyOnBranch("main")] +[DependsOn] +public class UploadToNuGetModule : Module +{ + protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken) + { + var nupkgs = context.Git().RootDirectory + .GetFiles(x => x.Extension is ".nupkg" or ".snupkg"); + + return await nupkgs.SelectAsync(file => + context.DotNet().Nuget.Push(new DotNetNugetPushOptions(file) + { + Source = "https://api.nuget.org/v3/index.json" + }, cancellationToken), cancellationToken: cancellationToken) + .ProcessOneAtATime(); + } +} \ No newline at end of file diff --git a/TUnit.Pipeline/PackedProject.cs b/TUnit.Pipeline/PackedProject.cs new file mode 100644 index 0000000000..aeb3aa1a74 --- /dev/null +++ b/TUnit.Pipeline/PackedProject.cs @@ -0,0 +1,3 @@ +namespace TUnit.Pipeline; + +public record PackedProject(string Name, string Version); \ No newline at end of file diff --git a/TUnit.Pipeline/Program.cs b/TUnit.Pipeline/Program.cs new file mode 100644 index 0000000000..dc6b8c36ed --- /dev/null +++ b/TUnit.Pipeline/Program.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Logging; +using ModularPipelines.Extensions; +using ModularPipelines.Host; + +await PipelineHostBuilder.Create() + .ConfigureServices((_, collection) => + { + collection.AddModulesFromAssembly(typeof(Program).Assembly); + }) + .SetLogLevel(LogLevel.Debug) + .ExecutePipelineAsync(); \ No newline at end of file diff --git a/TUnit.Pipeline/TUnit.Pipeline.csproj b/TUnit.Pipeline/TUnit.Pipeline.csproj new file mode 100644 index 0000000000..f651059b7b --- /dev/null +++ b/TUnit.Pipeline/TUnit.Pipeline.csproj @@ -0,0 +1,16 @@ + + + + Exe + net7.0 + enable + enable + latest + + + + + + + + diff --git a/TUnit.TestAdapter/AssemblyLoader.cs b/TUnit.TestAdapter/AssemblyLoader.cs new file mode 100644 index 0000000000..047537a84a --- /dev/null +++ b/TUnit.TestAdapter/AssemblyLoader.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace TUnit.TestAdapter; + +public class AssemblyLoader +{ + internal Assembly? LoadByPath(string assemblyPath) + { + if (!File.Exists(assemblyPath)) + { + return null; + } + + try + { + return AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); + } + catch + { + return null; + } + } + + internal Assembly? LoadByName(AssemblyName assemblyName) + { + try + { + return Assembly.Load(assemblyName); + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/TUnit.TestAdapter/AsyncTestRunExecutor.cs b/TUnit.TestAdapter/AsyncTestRunExecutor.cs new file mode 100644 index 0000000000..41ded4d416 --- /dev/null +++ b/TUnit.TestAdapter/AsyncTestRunExecutor.cs @@ -0,0 +1,205 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Reflection; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; +using TUnit.Core; +using TUnit.Engine; + +namespace TUnit.TestAdapter; + +internal class AsyncTestRunExecutor + ( + SingleTestExecutor singleTestExecutor, + MethodInvoker methodInvoker, + ITestExecutionRecorder testExecutionRecorder, + ClassLoader classLoader, + CancellationTokenSource cancellationTokenSource + ) +{ + private bool _canRunAnotherTest = true; + + private readonly ConcurrentDictionary _oneTimeCleanUpRegistry = new(); + private readonly List _setResultsTasks = []; + + public async Task RunInAsyncContext(AssembliesAnd assembliesAndTests) + { + var allTestsOrderedByClass = assembliesAndTests + .Values + .GroupBy(x => x.Details.FullyQualifiedClassName) + .SelectMany(x => x.ToList()) + .ToList(); + + var queue = new Queue(allTestsOrderedByClass); + + if (queue.Count is 0) + { + return; + } + + MonitorSystemResources(); + + var executingTests = new List(); + + var allClasses = classLoader.GetAllTypes(assembliesAndTests.Assemblies).ToArray(); + + await foreach (var testWithResult in ProcessQueue(queue, allClasses)) + { + if (cancellationTokenSource.IsCancellationRequested) + { + break; + } + + executingTests.Add(testWithResult); + + SetupRunOneTimeCleanUpForClass(testWithResult.Test.Details, allTestsOrderedByClass, executingTests); + + executingTests.RemoveAll(x => x.Result.IsCompletedSuccessfully); + + _setResultsTasks.Add(testWithResult.Result.ContinueWith(t => + { + var result = t.Result; + var testDetails = testWithResult.Test.Details; + + testExecutionRecorder.RecordResult(new TestResult(testWithResult.Test.TestCase) + { + DisplayName = testDetails.DisplayName, + Outcome = GetOutcome(result.Status), + ComputerName = result.ComputerName, + Duration = result.Duration, + StartTime = result.Start, + EndTime = result.End, + Messages = { new TestResultMessage("Output", result.Output) }, + ErrorMessage = result.Exception?.Message, + ErrorStackTrace = result.Exception?.StackTrace, + }); + })); + } + + executingTests.RemoveAll(x => x.Result.IsCompletedSuccessfully); + + await WhenAllSafely(executingTests.Select(x => x.Result), testExecutionRecorder); + await WhenAllSafely(_oneTimeCleanUpRegistry.Values, testExecutionRecorder); + await Task.WhenAll(_setResultsTasks); + } + + private TestOutcome GetOutcome(Status resultStatus) + { + return resultStatus switch + { + Status.None => TestOutcome.None, + Status.Passed => TestOutcome.Passed, + Status.Failed => TestOutcome.Failed, + Status.Skipped => TestOutcome.Skipped, + _ => throw new ArgumentOutOfRangeException(nameof(resultStatus), resultStatus, null) + }; + } + + private void SetupRunOneTimeCleanUpForClass(TestDetails processingTestDetails, + IEnumerable allTestsOrderedByClass, + IEnumerable executingTests) + { + var lastTestForClass = allTestsOrderedByClass.Last(x => + x.Details.FullyQualifiedClassName == processingTestDetails.FullyQualifiedClassName); + + if (processingTestDetails.UniqueId != lastTestForClass.Details.UniqueId) + { + return; + } + + var executingTestsForThisClass = executingTests + .Where(x => x.Test.Details.FullyQualifiedClassName == processingTestDetails.FullyQualifiedClassName) + .Select(x => x.Result) + .ToArray(); + + Task.WhenAll(executingTestsForThisClass).ContinueWith(x => + { + _ = _oneTimeCleanUpRegistry.GetOrAdd(processingTestDetails.FullyQualifiedClassName, + ExecuteOneTimeCleanUps(processingTestDetails)); + + return Task.CompletedTask; + }); + } + + private async IAsyncEnumerable ProcessQueue(Queue queue, Type[] allClasses) + { + while (queue.Count > 0) + { + if (_canRunAnotherTest && !cancellationTokenSource.IsCancellationRequested) + { + var test = queue.Dequeue(); + + yield return new TestWithResult(test, singleTestExecutor.ExecuteTest(test.Details, allClasses)); + } + else if (cancellationTokenSource.IsCancellationRequested) + { + break; + } + else + { + await Task.Delay(100); + } + } + } + + private void MonitorSystemResources() + { + Task.Factory.StartNew(async _ => + { + while (!cancellationTokenSource.IsCancellationRequested) + { + await Task.Delay(500); + + var cpuUsage = await GetCpuUsageForProcess(); + + _canRunAnotherTest = cpuUsage < 80; + } + }, null, TaskCreationOptions.LongRunning); + } + + private async Task GetCpuUsageForProcess() + { + var startTime = DateTime.UtcNow; + + var startCpuUsage = Process.GetCurrentProcess().TotalProcessorTime; + await Task.Delay(500); + + var endTime = DateTime.UtcNow; + + var endCpuUsage = Process.GetCurrentProcess().TotalProcessorTime; + + var cpuUsedMs = (endCpuUsage - startCpuUsage).TotalMilliseconds; + + var totalMsPassed = (endTime - startTime).TotalMilliseconds; + + var cpuUsageTotal = cpuUsedMs / (Environment.ProcessorCount * totalMsPassed); + + return cpuUsageTotal * 100; + } + + private async Task ExecuteOneTimeCleanUps(TestDetails testDetails) + { + var oneTimeCleanUpMethods = testDetails.MethodInfo.DeclaringType! + .GetMethods() + .Where(x => x.IsStatic) + .Where(x => x.CustomAttributes.Any(attributeData => attributeData.AttributeType == typeof(OneTimeCleanUpAttribute))); + + foreach (var oneTimeCleanUpMethod in oneTimeCleanUpMethods) + { + await methodInvoker.InvokeMethod(null, oneTimeCleanUpMethod, BindingFlags.Static | BindingFlags.Public, null); + } + } + + private async Task WhenAllSafely(IEnumerable tasks, IMessageLogger? messageLogger) + { + try + { + await Task.WhenAll(tasks); + } + catch (Exception e) + { + messageLogger?.SendMessage(TestMessageLevel.Error, e.ToString()); + } + } +} \ No newline at end of file diff --git a/TUnit.TestAdapter/Constants/TestAdapterConstants.cs b/TUnit.TestAdapter/Constants/TestAdapterConstants.cs new file mode 100644 index 0000000000..0572327567 --- /dev/null +++ b/TUnit.TestAdapter/Constants/TestAdapterConstants.cs @@ -0,0 +1,20 @@ +namespace TUnit.TestAdapter.Constants; + +internal static class TestAdapterConstants +{ + internal const string ExecutorUriString = "executor://tunit/TestRunner/net"; + internal static readonly Uri ExecutorUri = new(ExecutorUriString); + + public const string FullyQualifiedName = "TUnit.FullyQualifiedName"; + public const string Name = "TUnit.Name"; + public const string TestCategory = "TUnit.TestCategory"; + + public static class Filters + { + public static readonly IReadOnlyList KnownFilters = [TestName, TestClass, Category, NotCategory]; + public const string TestName = "TestName"; + public const string TestClass = "TestClass"; + public const string Category = "Category"; + public const string NotCategory = "NotCategory"; + } +} \ No newline at end of file diff --git a/TUnit.TestAdapter/Extensions/ServiceCollectionExtensions.cs b/TUnit.TestAdapter/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..a4d6e241fc --- /dev/null +++ b/TUnit.TestAdapter/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace TUnit.TestAdapter.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddTestAdapterServices(this IServiceCollection services) + { + return services.AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); + } +} \ No newline at end of file diff --git a/TUnit.TestAdapter/Extensions/TestExtensions.cs b/TUnit.TestAdapter/Extensions/TestExtensions.cs new file mode 100644 index 0000000000..a3facde71b --- /dev/null +++ b/TUnit.TestAdapter/Extensions/TestExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using TUnit.Core; +using TUnit.TestAdapter.Constants; + +namespace TUnit.TestAdapter.Extensions; + +internal static class TestExtensions +{ + public static TestCase ToTestCase(this TestDetails testDetails) + { + var testCase = new TestCase(testDetails.UniqueId, TestAdapterConstants.ExecutorUri, testDetails.Source) + { + DisplayName = testDetails.DisplayName, + CodeFilePath = testDetails.FileName, + LineNumber = testDetails.MinLineNumber, + }; + + testCase.SetPropertyValue(TUnitTestProperties.UniqueId, testDetails.UniqueId); + + testCase.SetPropertyValue(TUnitTestProperties.ManagedType, testDetails.FullyQualifiedClassName); + testCase.SetPropertyValue(TUnitTestProperties.ManagedMethod, testDetails.MethodInfo.Name + TestDetails.GetParameterTypes(testDetails.ParameterTypes)); + + return testCase; + } +} \ No newline at end of file diff --git a/TUnit.TestAdapter/Filter.cs b/TUnit.TestAdapter/Filter.cs new file mode 100644 index 0000000000..feacdfecf9 --- /dev/null +++ b/TUnit.TestAdapter/Filter.cs @@ -0,0 +1,53 @@ +using TUnit.TestAdapter.Constants; + +namespace TUnit.TestAdapter; + +public record Filter +{ + public bool IsEmpty { get; private set; } = true; + + public List RunnableCategories { get; } = new(); + public List BannedCategories { get; } = new(); + public List RunnableTestNames { get; } = new(); + public List RunnableClasses { get; } = new(); + public List RunnableFullyQualifiedClasses { get; } = new(); + + public void AddFilter(string filterName, string? rawValue) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + return; + } + + foreach (var value in rawValue.Split(',')) + { + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + IsEmpty = false; + + switch (filterName) + { + case TestAdapterConstants.Filters.TestName: + RunnableTestNames.Add(value); + return; + case TestAdapterConstants.Filters.TestClass: + if (value.Contains('.')) + { + RunnableFullyQualifiedClasses.Add(value); + } + else + { + RunnableClasses.Add(value); + } + + return; + case TestAdapterConstants.Filters.Category: + RunnableCategories.Add(value); + return; + } + } + } +} \ No newline at end of file diff --git a/TUnit.TestAdapter/ReflectionMetadataProvider.cs b/TUnit.TestAdapter/ReflectionMetadataProvider.cs new file mode 100644 index 0000000000..3118fa33b1 --- /dev/null +++ b/TUnit.TestAdapter/ReflectionMetadataProvider.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace TUnit.TestAdapter +{ + internal sealed class ReflectionMetadataProvider + { + public Type? GetStateMachineType(string assemblyPath, string reflectedTypeName, string methodName) + { + var method = TryGetSingleMethod(assemblyPath, reflectedTypeName, methodName); + if (method == null) + { + return null; + } + + var candidate = null as Type; + + foreach (var attributeData in CustomAttributeData.GetCustomAttributes(method)) + { + for (var current = attributeData.Constructor.DeclaringType; current != null; current = current.GetTypeInfo().BaseType) + { + if (current.FullName != "System.Runtime.CompilerServices.StateMachineAttribute") + { + continue; + } + + var parameters = attributeData.Constructor.GetParameters(); + for (var i = 0; i < parameters.Length; i++) + { + if (parameters[i].Name != "stateMachineType") + { + continue; + } + + if (attributeData.ConstructorArguments[i].Value is Type argument) + { + if (candidate != null) + { + return null; + } + + candidate = argument; + } + } + } + } + + return candidate; + } + + private static MethodInfo? TryGetSingleMethod(string assemblyPath, string reflectedTypeName, string methodName) + { + try + { + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); + + var type = assembly.GetType(reflectedTypeName, throwOnError: false); + + var methods = type?.GetMethods().Where(m => m.Name == methodName).Take(2).ToList(); + return methods?.Count == 1 ? methods[0] : null; + } + catch (FileNotFoundException) + { + return null; + } + } + } +} diff --git a/TUnit.TestAdapter/SourceLocationHelper.cs b/TUnit.TestAdapter/SourceLocationHelper.cs new file mode 100644 index 0000000000..46e4bfe62c --- /dev/null +++ b/TUnit.TestAdapter/SourceLocationHelper.cs @@ -0,0 +1,99 @@ +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; +using TUnit.Core; + +namespace TUnit.TestAdapter; + +public class SourceLocationHelper(IMessageLogger logger) : IDisposable +{ + private static readonly SourceLocation EmptySourceLocation = new(null, 0, 0); + + private readonly ReflectionMetadataProvider _metadataProvider = new(); + private readonly Dictionary _sessionsByAssemblyPath = new (StringComparer.OrdinalIgnoreCase); + + public SourceLocation GetSourceLocation(string assemblyLocation, string className, string methodName) + { + try + { + var navigationData = TryGetNavigationData(assemblyLocation, className, methodName); + + if (navigationData is null) + { + logger.SendMessage(TestMessageLevel.Error, $"No navigation data found for {className}.{methodName}"); + logger.SendMessage(TestMessageLevel.Error, $"Assembly: {assemblyLocation}"); + + return EmptySourceLocation; + } + + return new SourceLocation(navigationData.FileName, navigationData.MinLineNumber, navigationData.MaxLineNumber); + } + catch (Exception e) + { + logger.SendMessage(TestMessageLevel.Error, $"Error retrieving source location for {className}.{methodName}"); + logger.SendMessage(TestMessageLevel.Error, e.ToString()); + + return EmptySourceLocation; + } + } + + private SourceLocation? TryGetNavigationData(string assemblyLocation, string className, string methodName) + { + var sessionData = TryGetSessionData(assemblyLocation, className, methodName); + + if (sessionData != null) + { + return sessionData; + } + + var stateMachine = + _metadataProvider.GetStateMachineType(assemblyLocation, className, methodName); + + if (stateMachine != null) + { + sessionData = TryGetSessionData(stateMachine.Assembly.Location, stateMachine.FullName!, "MoveNext"); + + if (sessionData != null) + { + return sessionData; + } + } + + var declaringType2 = + _metadataProvider.GetStateMachineType(assemblyLocation, className, methodName); + + if (declaringType2 != null) + { + sessionData = TryGetSessionData(declaringType2.Assembly.Location, declaringType2.FullName!, methodName); + + if (sessionData != null) + { + return sessionData; + } + } + + return null; + } + + private SourceLocation? TryGetSessionData(string assemblyPath, string declaringTypeFullName, string methodName) + { + if (!_sessionsByAssemblyPath.TryGetValue(assemblyPath, out var session)) + { + session = new DiaSession(assemblyPath); + _sessionsByAssemblyPath.Add(assemblyPath, session); + } + + var data = session.GetNavigationData(declaringTypeFullName, methodName); + + return string.IsNullOrEmpty(data?.FileName) + ? null + : new SourceLocation(data.FileName, data.MinLineNumber, data.MaxLineNumber); + } + + public void Dispose() + { + foreach (var diaSession in _sessionsByAssemblyPath.Values) + { + diaSession.Dispose(); + } + } +} \ No newline at end of file diff --git a/TUnit.TestAdapter/Stubs/NoOpExecutionRecorder.cs b/TUnit.TestAdapter/Stubs/NoOpExecutionRecorder.cs new file mode 100644 index 0000000000..fa8cb7acc5 --- /dev/null +++ b/TUnit.TestAdapter/Stubs/NoOpExecutionRecorder.cs @@ -0,0 +1,28 @@ +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; + +namespace TUnit.TestAdapter.Stubs; + +public class NoOpExecutionRecorder : ITestExecutionRecorder +{ + public void SendMessage(TestMessageLevel testMessageLevel, string message) + { + } + + public void RecordResult(TestResult testResult) + { + } + + public void RecordStart(TestCase testCase) + { + } + + public void RecordEnd(TestCase testCase, TestOutcome outcome) + { + } + + public void RecordAttachments(IList attachmentSets) + { + } +} \ No newline at end of file diff --git a/TUnit.TestAdapter/Stubs/NoOpFrameworkHandle.cs b/TUnit.TestAdapter/Stubs/NoOpFrameworkHandle.cs new file mode 100644 index 0000000000..d1f180252e --- /dev/null +++ b/TUnit.TestAdapter/Stubs/NoOpFrameworkHandle.cs @@ -0,0 +1,36 @@ +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; + +namespace TUnit.TestAdapter.Stubs; + +public class NoOpFrameworkHandle : IFrameworkHandle +{ + public void SendMessage(TestMessageLevel testMessageLevel, string message) + { + } + + public void RecordResult(TestResult testResult) + { + } + + public void RecordStart(TestCase testCase) + { + } + + public void RecordEnd(TestCase testCase, TestOutcome outcome) + { + } + + public void RecordAttachments(IList attachmentSets) + { + } + + public int LaunchProcessWithDebuggerAttached(string filePath, string? workingDirectory, string? arguments, + IDictionary? environmentVariables) + { + return 0; + } + + public bool EnableShutdownAfterTestRun { get; set; } = true; +} \ No newline at end of file diff --git a/TUnit.TestAdapter/Stubs/NoOpRunContext.cs b/TUnit.TestAdapter/Stubs/NoOpRunContext.cs new file mode 100644 index 0000000000..936dba2aa8 --- /dev/null +++ b/TUnit.TestAdapter/Stubs/NoOpRunContext.cs @@ -0,0 +1,23 @@ +using Microsoft.VisualStudio.TestPlatform.Common; +using Microsoft.VisualStudio.TestPlatform.Common.Filtering; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; + +namespace TUnit.TestAdapter.Stubs; + +public class NoOpRunContext : IRunContext +{ + public IRunSettings? RunSettings { get; } = new RunSettings(); + + public ITestCaseFilterExpression? GetTestCaseFilter(IEnumerable? supportedProperties, Func propertyProvider) + { + return new TestCaseFilterExpression(new FilterExpressionWrapper(string.Empty)); + } + + public bool KeepAlive => false; + public bool InIsolation => false; + public bool IsDataCollectionEnabled => false; + public bool IsBeingDebugged => false; + public string? TestRunDirectory { get; } = Directory.GetCurrentDirectory(); + public string? SolutionDirectory { get; } = Directory.GetCurrentDirectory(); +} \ No newline at end of file diff --git a/TUnit.TestAdapter/TUnit.TestAdapter.csproj b/TUnit.TestAdapter/TUnit.TestAdapter.csproj new file mode 100644 index 0000000000..4ccc068f64 --- /dev/null +++ b/TUnit.TestAdapter/TUnit.TestAdapter.csproj @@ -0,0 +1,26 @@ + + + + net7.0 + enable + enable + latest + true + Library + true + + + + + + + + + + + + + + + + diff --git a/TUnit.TestAdapter/TUnitProperties.cs b/TUnit.TestAdapter/TUnitProperties.cs new file mode 100644 index 0000000000..7ea517cffd --- /dev/null +++ b/TUnit.TestAdapter/TUnitProperties.cs @@ -0,0 +1,14 @@ +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using TUnit.TestAdapter.Constants; + +namespace TUnit.TestAdapter; + +public class TUnitProperties +{ + internal static readonly TestProperty TestCategory = TestProperty.Register( + id: TestAdapterConstants.TestCategory, + label: "TestCategory", + valueType: typeof(string[]), + TestPropertyAttributes.Hidden, + owner: typeof(TestCase)); +} \ No newline at end of file diff --git a/TUnit.TestAdapter/TUnitTestFilterProvider.cs b/TUnit.TestAdapter/TUnitTestFilterProvider.cs new file mode 100644 index 0000000000..f37adfc26c --- /dev/null +++ b/TUnit.TestAdapter/TUnitTestFilterProvider.cs @@ -0,0 +1,115 @@ +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; +using TUnit.Core; +using TUnit.TestAdapter.Constants; + +namespace TUnit.TestAdapter; + +internal class TUnitTestFilterProvider(IRunContext runContext, IMessageLogger messageLogger) +{ + public IEnumerable FilterTests(IEnumerable tests) + { + var filterExpression = runContext.GetTestCaseFilter(null, _ => null); + + messageLogger.SendMessage(TestMessageLevel.Informational, $"TestCaseFilterValue is: {filterExpression?.TestCaseFilterValue}"); + + if (filterExpression is null) + { + foreach (var testWithTestCase in tests) + { + yield return testWithTestCase; + } + + yield break; + } + + foreach (var testWithTestCase in tests) + { + var (testDetails, _) = testWithTestCase; + + if (string.IsNullOrWhiteSpace(filterExpression.TestCaseFilterValue)) + { + yield return testWithTestCase; + continue; + } + + var filter = ParseFilter(filterExpression.TestCaseFilterValue); + + if (TestMatchesFilter(testDetails, filter)) + { + yield return testWithTestCase; + } + } + } + + private Filter ParseFilter(string testCaseFilterValue) + { + var filter = new Filter(); + + foreach (var filterSegment in testCaseFilterValue.Split(';')) + { + var filterSplit = filterSegment.Split('='); + var filterName = filterSplit.FirstOrDefault(); + var filterValue = filterSplit.ElementAtOrDefault(1); + + if (string.IsNullOrWhiteSpace(filterName) || + !TestAdapterConstants.Filters.KnownFilters.Contains(filterName, StringComparer.InvariantCultureIgnoreCase)) + { + continue; + } + + filter.AddFilter(filterName, filterValue); + } + + return filter; + } + + private bool TestMatchesFilter(TestDetails test, Filter filter) + { + messageLogger.SendMessage(TestMessageLevel.Informational, test.ToString()); + + if (filter.IsEmpty) + { + return true; + } + + if (filter.BannedCategories.Intersect(test.Categories).Any()) + { + return false; + } + + return AllowedTestName(test, filter) + && AllowedCategory(test, filter) + && AllowedClass(test, filter); + } + + private static bool AllowedTestName(TestDetails test, Filter filter) + { + return !filter.RunnableTestNames.Any() || + filter.RunnableTestNames.Contains(test.TestName, StringComparer.InvariantCultureIgnoreCase); + } + + private static bool AllowedCategory(TestDetails test, Filter filter) + { + return !filter.RunnableCategories.Any() || + filter.RunnableCategories.Intersect(test.Categories, StringComparer.InvariantCultureIgnoreCase).Any(); + } + + private static bool AllowedClass(TestDetails test, Filter filter) + { + return AllowedSimpleClass(test, filter) + && AllowedFullyQualifiedClass(test, filter); + } + + private static bool AllowedSimpleClass(TestDetails test, Filter filter) + { + return !filter.RunnableClasses.Any() || + filter.RunnableClasses.Contains(test.ClassType.Name, StringComparer.InvariantCultureIgnoreCase); + } + + private static bool AllowedFullyQualifiedClass(TestDetails test, Filter filter) + { + return !filter.RunnableFullyQualifiedClasses.Any() || + filter.RunnableFullyQualifiedClasses.Contains(test.ClassType.FullName, StringComparer.InvariantCultureIgnoreCase); + } +} \ No newline at end of file diff --git a/TUnit.TestAdapter/TUnitTestProperties.cs b/TUnit.TestAdapter/TUnitTestProperties.cs new file mode 100644 index 0000000000..a86083ce77 --- /dev/null +++ b/TUnit.TestAdapter/TUnitTestProperties.cs @@ -0,0 +1,17 @@ +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using TUnit.Core; + +namespace TUnit.TestAdapter; + +public static class TUnitTestProperties +{ + public static TestProperty GetOrRegisterTestProperty(string name) + { + return TestProperty.Find(name) + ?? TestProperty.Register(name, name, typeof(T), typeof(TestCase)); + } + + public static TestProperty UniqueId => GetOrRegisterTestProperty(nameof(TestDetails.UniqueId)); + public static TestProperty ManagedType => GetOrRegisterTestProperty("ManagedType"); + public static TestProperty ManagedMethod => GetOrRegisterTestProperty("ManagedMethod"); +} \ No newline at end of file diff --git a/TUnit.TestAdapter/TestAndClass.cs b/TUnit.TestAdapter/TestAndClass.cs new file mode 100644 index 0000000000..5c4851fe19 --- /dev/null +++ b/TUnit.TestAdapter/TestAndClass.cs @@ -0,0 +1,5 @@ +using TUnit.Core; + +namespace TUnit.TestAdapter; + +internal record TestAndClass(TestDetails TestDetails, object Class); \ No newline at end of file diff --git a/TUnit.TestAdapter/TestCollector.cs b/TUnit.TestAdapter/TestCollector.cs new file mode 100644 index 0000000000..81eb283f0f --- /dev/null +++ b/TUnit.TestAdapter/TestCollector.cs @@ -0,0 +1,82 @@ +using System.Reflection; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using TUnit.Core; + +namespace TUnit.TestAdapter; + +internal class TestCollector(AssemblyLoader assemblyLoader, TestsLoader testsLoader, ITestExecutionRecorder testExecutionRecorder) +{ + public AssembliesAnd TestsFromTestCases(IEnumerable testCases) + { + var testCasesArray = testCases.ToArray(); + var allAssemblies = testCasesArray + .Select(x => assemblyLoader.LoadByPath(x.Source)) + .OfType() + .ToArray(); + + return new AssembliesAnd(allAssemblies, TestWithTestCaseCore(testCasesArray, allAssemblies)); + } + + private IEnumerable TestWithTestCaseCore(TestCase[] testCasesArray, Assembly[] allAssemblies) + { + foreach (var testCase in testCasesArray) + { + var source = testCase.Source; + var assembly = assemblyLoader.LoadByPath(source); + + if (assembly is null) + { + MarkNotFound(testCase); + continue; + } + + var tests = testsLoader.GetTests(new TypeInformation(assembly), allAssemblies); + + var matchingTest = tests.FirstOrDefault(x => MatchTest(x, testCase)); + + if (matchingTest is null) + { + MarkNotFound(testCase); + continue; + } + + yield return new TestWithTestCase(matchingTest, testCase); + } + } + + private static bool MatchTest(TestDetails testDetails, TestCase testCase) + { + return testDetails.UniqueId == testCase.FullyQualifiedName; + } + + private void MarkNotFound(TestCase testCase) + { + var now = DateTimeOffset.Now; + + testExecutionRecorder.RecordResult(new TestResult(testCase) + { + DisplayName = testCase.DisplayName, + Outcome = TestOutcome.NotFound, + Duration = TimeSpan.Zero, + StartTime = now, + EndTime = now, + ComputerName = Environment.MachineName + }); + } + + public AssembliesAnd TestsFromSources(IEnumerable sources) + { + var allAssemblies = sources + .Select(source => Path.IsPathRooted(source) ? source : Path.Combine(Directory.GetCurrentDirectory(), source)) + .Select(assemblyLoader.LoadByPath) + .OfType() + .ToArray(); + + var tests = allAssemblies + .Select(x => new TypeInformation(x)) + .SelectMany(x => testsLoader.GetTests(x, allAssemblies)); + + return new AssembliesAnd(allAssemblies, tests); + } +} \ No newline at end of file diff --git a/TUnit.TestAdapter/TestDiscoverer.cs b/TUnit.TestAdapter/TestDiscoverer.cs new file mode 100644 index 0000000000..924a3f8532 --- /dev/null +++ b/TUnit.TestAdapter/TestDiscoverer.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; +using TUnit.Engine.Extensions; +using TUnit.TestAdapter.Constants; +using TUnit.TestAdapter.Extensions; +using TUnit.TestAdapter.Stubs; + +namespace TUnit.TestAdapter; + +[FileExtension(".dll")] +[FileExtension(".exe")] +[DefaultExecutorUri(TestAdapterConstants.ExecutorUriString)] +[ExtensionUri(TestAdapterConstants.ExecutorUriString)] +public class TestDiscoverer : ITestDiscoverer +{ + public void DiscoverTests(IEnumerable sources, + IDiscoveryContext discoveryContext, + IMessageLogger logger, + ITestCaseDiscoverySink discoverySink) + { + var testCollector = BuildServices(discoveryContext, logger) + .GetRequiredService(); + + var assembliesAndTests = testCollector.TestsFromSources(sources); + + foreach (var test in assembliesAndTests.Values) + { + logger.SendMessage(TestMessageLevel.Informational, "Test found: " + test.DisplayName); + discoverySink.SendTestCase(test.ToTestCase()); + } + } + + private IServiceProvider BuildServices(IDiscoveryContext discoveryContext, IMessageLogger messageLogger) + { + return new ServiceCollection() + .AddSingleton(discoveryContext) + .AddSingleton(messageLogger) + .AddSingleton() + .AddTestAdapterServices() + .AddTestEngineServices() + .BuildServiceProvider(); + } +} \ No newline at end of file diff --git a/TUnit.TestAdapter/TestExecutor.cs b/TUnit.TestAdapter/TestExecutor.cs new file mode 100644 index 0000000000..0a91e19dc2 --- /dev/null +++ b/TUnit.TestAdapter/TestExecutor.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; +using TUnit.Core; +using TUnit.Engine.Extensions; +using TUnit.TestAdapter.Constants; +using TUnit.TestAdapter.Extensions; +using TUnit.TestAdapter.Stubs; + +namespace TUnit.TestAdapter; + +[FileExtension(".dll")] +[FileExtension(".exe")] +[DefaultExecutorUri(TestAdapterConstants.ExecutorUriString)] +[ExtensionUri(TestAdapterConstants.ExecutorUriString)] +public class TestExecutor : ITestExecutor2 +{ + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + public void RunTests(IEnumerable? testCases, IRunContext? runContext, IFrameworkHandle? frameworkHandle) + { + if (testCases is null) + { + return; + } + + var serviceProvider = BuildServices(runContext, frameworkHandle); + + var testsWithTestCases = + serviceProvider.GetRequiredService() + .TestsFromTestCases(testCases); + + serviceProvider.GetRequiredService() + .RunInAsyncContext(Filter(testsWithTestCases, serviceProvider)) + .GetAwaiter() + .GetResult(); + } + + public void RunTests(IEnumerable? sources, IRunContext? runContext, IFrameworkHandle? frameworkHandle) + { + if (sources is null) + { + return; + } + + var serviceProvider = BuildServices(runContext, frameworkHandle); + + var assembliesAndTestsFromSources = serviceProvider.GetRequiredService() + .TestsFromSources(sources); + + var tests = assembliesAndTestsFromSources + .Values + .Select(x => new TestWithTestCase(x, x.ToTestCase())); + + serviceProvider.GetRequiredService() + .RunInAsyncContext(Filter(new AssembliesAnd(assembliesAndTestsFromSources.Assemblies, tests), serviceProvider)) + .GetAwaiter() + .GetResult(); + + } + + private AssembliesAnd Filter(AssembliesAnd assembliesAnd, IServiceProvider serviceProvider) + { + var testFilterProvider = serviceProvider.GetRequiredService(); + + var tests = assembliesAnd.Values; + + var filteredTests = testFilterProvider.FilterTests(tests); + + return assembliesAnd with { Values = filteredTests }; + } + + public void Cancel() + { + _cancellationTokenSource.Cancel(); + } + + public bool ShouldAttachToTestHost(IEnumerable? tests, IRunContext runContext) + { + return ShouldAttachToTestHost(tests?.Select(x => x.Source), runContext); + } + + public bool ShouldAttachToTestHost(IEnumerable? sources, IRunContext runContext) + { + return runContext.IsBeingDebugged; + } + + private IServiceProvider BuildServices(IRunContext? runContext, IFrameworkHandle? frameworkHandle) + { + return new ServiceCollection() + .AddSingleton(runContext ?? new NoOpRunContext()) + .AddSingleton(frameworkHandle ?? new NoOpFrameworkHandle()) + .AddSingleton(x => x.GetRequiredService()) + .AddSingleton(x => x.GetRequiredService()) + .AddSingleton(_cancellationTokenSource) + .AddTestAdapterServices() + .AddTestEngineServices() + .BuildServiceProvider(); + } +} \ No newline at end of file diff --git a/TUnit.TestAdapter/TestWithResult.cs b/TUnit.TestAdapter/TestWithResult.cs new file mode 100644 index 0000000000..68852e4dc6 --- /dev/null +++ b/TUnit.TestAdapter/TestWithResult.cs @@ -0,0 +1,5 @@ +using TUnit.Core; + +namespace TUnit.TestAdapter; + +internal record TestWithResult(TestWithTestCase Test, Task Result); \ No newline at end of file diff --git a/TUnit.TestAdapter/TestWithTestCase.cs b/TUnit.TestAdapter/TestWithTestCase.cs new file mode 100644 index 0000000000..621205e1d9 --- /dev/null +++ b/TUnit.TestAdapter/TestWithTestCase.cs @@ -0,0 +1,6 @@ +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using TUnit.Core; + +namespace TUnit.TestAdapter; + +internal record TestWithTestCase(TestDetails Details, TestCase TestCase); \ No newline at end of file diff --git a/TUnit.TestAdapter/TestsLoader.cs b/TUnit.TestAdapter/TestsLoader.cs new file mode 100644 index 0000000000..eac0130132 --- /dev/null +++ b/TUnit.TestAdapter/TestsLoader.cs @@ -0,0 +1,93 @@ +using System.Reflection; +using TUnit.Core; +using TUnit.Engine; + +namespace TUnit.TestAdapter; + +internal class TestsLoader(SourceLocationHelper sourceLocationHelper, ClassLoader classLoader, TestDataSourceRetriever testDataSourceRetriever) +{ + private static readonly Type[] TestAttributes = [typeof(TestAttribute), typeof(TestWithDataAttribute), typeof(TestDataSourceAttribute)]; + + public IEnumerable GetTests(TypeInformation typeInformation, Assembly[] allAssemblies) + { + var methods = typeInformation.Types.SelectMany(x => x.GetMethods()); + + foreach (var methodInfo in methods) + { + if (!HasTestAttributes(methodInfo)) + { + continue; + } + + var sourceLocation = sourceLocationHelper + .GetSourceLocation(typeInformation.Assembly.Location, methodInfo.DeclaringType!.FullName!, methodInfo.Name); + + var allClasses = classLoader.GetAllTypes(allAssemblies).ToArray(); + var nonAbstractClassesContainingTest = allClasses + .Where(t => t.IsAssignableTo(methodInfo.DeclaringType!) && !t.IsAbstract) + .ToArray(); + + var count = 0; + + foreach (var testWithDataAttribute in methodInfo.CustomAttributes.Where(x => x.AttributeType == typeof(TestWithDataAttribute))) + { + count++; + foreach (var customAttributeTypedArgument in testWithDataAttribute.ConstructorArguments) + { + var arguments = + (customAttributeTypedArgument.Value as IEnumerable) + ?.Select(x => new ParameterArgument(x.Value?.GetType()!, x.Value)) + .ToArray(); + + foreach (var classType in nonAbstractClassesContainingTest) + { + yield return new TestDetails( + methodInfo: methodInfo, + classType: classType, + sourceLocation: sourceLocation, + arguments: arguments, + count: count + ); + } + } + } + + if(methodInfo.CustomAttributes.Any(x => x.AttributeType == typeof(TestAttribute))) + { + foreach (var classType in nonAbstractClassesContainingTest) + { + yield return new TestDetails( + methodInfo: methodInfo, + classType: classType, + sourceLocation: sourceLocation, + arguments: null, + count: 1 + ); + } + } + + foreach (var testDataSourceAttribute in methodInfo.CustomAttributes.Where(x => x.AttributeType == typeof(TestDataSourceAttribute))) + { + count++; + foreach (var classType in nonAbstractClassesContainingTest) + { + yield return new TestDetails( + methodInfo: methodInfo, + classType: classType, + sourceLocation: sourceLocation, + arguments: testDataSourceRetriever.GetTestDataSourceArguments(methodInfo, testDataSourceAttribute, allClasses), + count: count + ); + } + } + } + } + + private static bool HasTestAttributes(MethodInfo methodInfo) + { + return methodInfo.CustomAttributes + .Select(x => x.AttributeType) + .Intersect(TestAttributes) + .Any(); + } +} \ No newline at end of file diff --git a/TUnit.TestProject/ConsoleConcurrentTests.cs b/TUnit.TestProject/ConsoleConcurrentTests.cs new file mode 100644 index 0000000000..7ac9620789 --- /dev/null +++ b/TUnit.TestProject/ConsoleConcurrentTests.cs @@ -0,0 +1,67 @@ +using System.Reflection; +using TUnit.Core; + +namespace TUnit.TestProject; + +public class ConsoleConcurrentTests +{ + [Test, Repeat(1000)] + public void Test1() + { + Console.WriteLine(MethodBase.GetCurrentMethod()?.Name); + } + + [Test, Repeat(1000)] + public void Test2() + { + Console.WriteLine(MethodBase.GetCurrentMethod()?.Name); + } + + [Test, Repeat(1000)] + public void Test3() + { + Console.WriteLine(MethodBase.GetCurrentMethod()?.Name); + } + + [Test, Repeat(1000)] + public void Test4() + { + Console.WriteLine(MethodBase.GetCurrentMethod()?.Name); + } + + [Test, Repeat(1000)] + public void Test5() + { + Console.WriteLine(MethodBase.GetCurrentMethod()?.Name); + } + + [Test, Repeat(1000)] + public void Test6() + { + Console.WriteLine(MethodBase.GetCurrentMethod()?.Name); + } + + [Test, Repeat(1000)] + public void Test7() + { + Console.WriteLine(MethodBase.GetCurrentMethod()?.Name); + } + + [Test, Repeat(1000)] + public void Test8() + { + Console.WriteLine(MethodBase.GetCurrentMethod()?.Name); + } + + [Test, Repeat(1000)] + public void Test9() + { + Console.WriteLine(MethodBase.GetCurrentMethod()?.Name); + } + + [Test, Repeat(1000)] + public void Test10() + { + Console.WriteLine(MethodBase.GetCurrentMethod()?.Name); + } +} \ No newline at end of file diff --git a/TUnit.TestProject/GlobalUsings.cs b/TUnit.TestProject/GlobalUsings.cs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/TUnit.TestProject/TUnit.TestProject.csproj b/TUnit.TestProject/TUnit.TestProject.csproj new file mode 100644 index 0000000000..866250782b --- /dev/null +++ b/TUnit.TestProject/TUnit.TestProject.csproj @@ -0,0 +1,28 @@ + + + + net7.0 + enable + enable + false + true + latest + + + + + + + + + + + + + + + + + + diff --git a/TUnit.TestProject/TestDataSources.cs b/TUnit.TestProject/TestDataSources.cs new file mode 100644 index 0000000000..21c3a1d78d --- /dev/null +++ b/TUnit.TestProject/TestDataSources.cs @@ -0,0 +1,7 @@ +namespace TUnit.TestProject; + +public class TestDataSources +{ + public static int One() => 1; + public static int Two() => 2; +} \ No newline at end of file diff --git a/TUnit.TestProject/Tests.cs b/TUnit.TestProject/Tests.cs new file mode 100644 index 0000000000..c79e1c3e0b --- /dev/null +++ b/TUnit.TestProject/Tests.cs @@ -0,0 +1,233 @@ +using TUnit.Assertions; +using TUnit.Core; + +namespace TUnit.TestProject; + +public class Tests +{ + [Test] + [TestCategory("Pass")] + public async Task ConsoleOutput() + { + Console.WriteLine("Blah!"); + + await Assert.That(TestContext.Current.GetOutput()).Is.EqualTo("Blah!"); + } + + [Test] + [TestCategory("Pass")] + public async Task Test1() + { + var value = "1"; + await Assert.That(value).Is.EqualTo("1"); + } + + [Test] + [TestCategory("Fail")] + public async Task Test2() + { + var value = "2"; + await Assert.That(value).Is.EqualTo("1"); + } + + [Test] + [TestCategory("Pass")] + public async Task Test3() + { + await Task.Yield(); + var value = "1"; + await Assert.That(value).Is.EqualTo("1"); + } + + [Test] + [TestCategory("Fail")] + public async Task Test4() + { + await Task.Yield(); + var value = "2"; + await Assert.That(value).Is.EqualTo("1"); + } + + [TestWithData("1")] + [TestWithData("2")] + [TestCategory("Fail")] + public async Task ParameterisedTests1(string value) + { + await Assert.That(value).Is.EqualTo("1").And.Has.Length().EqualTo(1); + } + + [TestWithData("1")] + [TestWithData("2")] + [TestCategory("Fail")] + public async Task ParameterisedTests2(string value) + { + await Task.Yield(); + await Assert.That(value).Is.EqualTo("1"); + } + + [Test, Skip("Reason1")] + [TestCategory("Skip")] + public async Task Skip1() + { + var value = "1"; + await Assert.That(value).Is.EqualTo("1"); + } + + [Test, Skip("Reason2")] + [TestCategory("Skip")] + public async Task Skip2() + { + await Task.Yield(); + var value = "1"; + await Assert.That(value).Is.EqualTo("1"); + } + + [TestDataSource(nameof(One))] + [TestCategory("Pass")] + public async Task TestDataSource1(int value) + { + await Assert.That(value).Is.EqualTo(1); + } + + [TestDataSource(nameof(One))] + [TestCategory("Pass")] + public async Task TestDataSource2(int value) + { + await Task.Yield(); + await Assert.That(value).Is.EqualTo(1); + } + + [TestDataSource(nameof(Two))] + [TestCategory("Fail")] + public async Task TestDataSource3(int value) + { + await Assert.That(value).Is.EqualTo(1); + } + + [TestDataSource(nameof(Two))] + [TestCategory("Fail")] + public async Task TestDataSource4(int value) + { + await Task.Yield(); + await Assert.That(value).Is.EqualTo(1); + } + + [TestDataSource(nameof(TestDataSources), nameof(One))] + [TestCategory("Pass")] + public async Task TestDataSource5(int value) + { + await Assert.That(value).Is.EqualTo(1); + } + + [TestDataSource(nameof(TestDataSources), nameof(One))] + [TestCategory("Pass")] + public async Task TestDataSource6(int value) + { + await Task.Yield(); + await Assert.That(value).Is.EqualTo(1); + } + + [TestDataSource(nameof(TestDataSources), nameof(Two))] + [TestCategory("Fail")] + public async Task TestDataSource7(int value) + { + await Assert.That(value).Is.EqualTo(1); + } + + [TestDataSource(nameof(TestDataSources), nameof(Two))] + [TestCategory("Fail")] + public async Task TestDataSource8(int value) + { + await Task.Yield(); + await Assert.That(value).Is.EqualTo(1); + } + + [Test] + [TestCategory("Pass")] + public async Task TestContext1() + { + await Assert.That(TestContext.Current.TestInformation.TestName).Is.EqualTo(nameof(TestContext1)); + } + + [Test] + [TestCategory("Fail")] + public async Task TestContext2() + { + await Assert.That(TestContext.Current.TestInformation.TestName).Is.EqualTo(nameof(TestContext1)); + } + + [Test] + [TestCategory("Fail")] + public async Task Throws1() + { + await Assert.That(() => new string([])).Throws.Exception; + } + + [Test] + [TestCategory("Fail")] + public async Task Throws2() + { + await Assert.That(async () => + { + await Task.Yield();; + }).Throws.Exception; + } + + [Test] + [TestCategory("Pass")] + public async Task Throws3() + { + await Assert.That(() => throw new ApplicationException()).Throws.Exception; + } + + [Test] + [TestCategory("Pass")] + public async Task Throws4() + { + await Assert.That(async () => + { + await Task.Yield(); + return true; + }).Throws.Nothing; + } + + [Test, Timeout(500)] + [TestCategory("Fail")] + public async Task Timeout1() + { + await Task.Delay(TimeSpan.FromSeconds(5)); + } + + [Test] + [TestCategory("Pass")] + public async Task String_And_Condition() + { + await Assert.That("1").Is.EqualTo("1").And.Has.Length().EqualTo(1); + } + + [Test] + [TestCategory("Fail")] + public async Task String_And_Condition2() + { + await Assert.That("1").Is.EqualTo("2").And.Has.Length().EqualTo(2); + } + + [Test] + [TestCategory("Pass")] + public async Task Count1() + { + var list = new List { 1, 2, 3 }; + await Assert.That(list).Is.EquivalentTo(new[] { 1, 2, 3 }).And.Has.Count().EqualTo(3); + } + + [Test] + [TestCategory("Fail")] + public async Task Count2() + { + var list = new List { 1, 2, 3 }; + await Assert.That(list).Is.EquivalentTo(new[] { 1, 2, 3, 4, 5 }).And.Has.Count().EqualTo(5); + } + + public static int One() => 1; + public static int Two() => 2; +} \ No newline at end of file diff --git a/TUnit.sln b/TUnit.sln new file mode 100644 index 0000000000..ea39d6f612 --- /dev/null +++ b/TUnit.sln @@ -0,0 +1,76 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit", "TUnit\TUnit.csproj", "{45A310AD-151B-4E0F-8A2C-FC55D31B16BD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.TestAdapter", "TUnit.TestAdapter\TUnit.TestAdapter.csproj", "{37BF09F2-8CDA-4388-A13A-AB24572AACD5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Core", "TUnit.Core\TUnit.Core.csproj", "{252CD110-7923-403F-9CCA-827E7352BF54}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.TestProject", "TUnit.TestProject\TUnit.TestProject.csproj", "{2F9038D3-96AD-4D86-B06B-9E59C2B941A8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Assertions", "TUnit.Assertions\TUnit.Assertions.csproj", "{1F1276E0-0DB5-4CD4-BF4B-74760E50B923}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Pipeline", "TUnit.Pipeline\TUnit.Pipeline.csproj", "{527337F5-0F78-4141-901C-72EA81222AB1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Assertions.UnitTests", "TUnit.Assertions.UnitTests\TUnit.Assertions.UnitTests.csproj", "{2D9CBBB5-2FF5-44F4-8358-D39CD0BD19EA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Engine", "TUnit.Engine\TUnit.Engine.csproj", "{6C960AFF-E533-4B61-A559-107CA9AA5E76}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Analyzers", "TUnit.Analyzers\TUnit.Analyzers\TUnit.Analyzers.csproj", "{68EE0A31-F949-445D-80D7-CD7160510655}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Analyzers.Sample", "TUnit.Analyzers\TUnit.Analyzers.Sample\TUnit.Analyzers.Sample.csproj", "{175A7375-E1B4-4BED-98FC-988A46263DE4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Analyzers.Tests", "TUnit.Analyzers\TUnit.Analyzers.Tests\TUnit.Analyzers.Tests.csproj", "{EE52CB5D-86DF-47E6-B105-F453A0B6FE66}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {45A310AD-151B-4E0F-8A2C-FC55D31B16BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {45A310AD-151B-4E0F-8A2C-FC55D31B16BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {45A310AD-151B-4E0F-8A2C-FC55D31B16BD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {45A310AD-151B-4E0F-8A2C-FC55D31B16BD}.Release|Any CPU.Build.0 = Release|Any CPU + {37BF09F2-8CDA-4388-A13A-AB24572AACD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37BF09F2-8CDA-4388-A13A-AB24572AACD5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37BF09F2-8CDA-4388-A13A-AB24572AACD5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37BF09F2-8CDA-4388-A13A-AB24572AACD5}.Release|Any CPU.Build.0 = Release|Any CPU + {252CD110-7923-403F-9CCA-827E7352BF54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {252CD110-7923-403F-9CCA-827E7352BF54}.Debug|Any CPU.Build.0 = Debug|Any CPU + {252CD110-7923-403F-9CCA-827E7352BF54}.Release|Any CPU.ActiveCfg = Release|Any CPU + {252CD110-7923-403F-9CCA-827E7352BF54}.Release|Any CPU.Build.0 = Release|Any CPU + {2F9038D3-96AD-4D86-B06B-9E59C2B941A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2F9038D3-96AD-4D86-B06B-9E59C2B941A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F9038D3-96AD-4D86-B06B-9E59C2B941A8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2F9038D3-96AD-4D86-B06B-9E59C2B941A8}.Release|Any CPU.Build.0 = Release|Any CPU + {1F1276E0-0DB5-4CD4-BF4B-74760E50B923}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F1276E0-0DB5-4CD4-BF4B-74760E50B923}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F1276E0-0DB5-4CD4-BF4B-74760E50B923}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F1276E0-0DB5-4CD4-BF4B-74760E50B923}.Release|Any CPU.Build.0 = Release|Any CPU + {527337F5-0F78-4141-901C-72EA81222AB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {527337F5-0F78-4141-901C-72EA81222AB1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {527337F5-0F78-4141-901C-72EA81222AB1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {527337F5-0F78-4141-901C-72EA81222AB1}.Release|Any CPU.Build.0 = Release|Any CPU + {2D9CBBB5-2FF5-44F4-8358-D39CD0BD19EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2D9CBBB5-2FF5-44F4-8358-D39CD0BD19EA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D9CBBB5-2FF5-44F4-8358-D39CD0BD19EA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2D9CBBB5-2FF5-44F4-8358-D39CD0BD19EA}.Release|Any CPU.Build.0 = Release|Any CPU + {6C960AFF-E533-4B61-A559-107CA9AA5E76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C960AFF-E533-4B61-A559-107CA9AA5E76}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C960AFF-E533-4B61-A559-107CA9AA5E76}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C960AFF-E533-4B61-A559-107CA9AA5E76}.Release|Any CPU.Build.0 = Release|Any CPU + {68EE0A31-F949-445D-80D7-CD7160510655}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68EE0A31-F949-445D-80D7-CD7160510655}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68EE0A31-F949-445D-80D7-CD7160510655}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68EE0A31-F949-445D-80D7-CD7160510655}.Release|Any CPU.Build.0 = Release|Any CPU + {175A7375-E1B4-4BED-98FC-988A46263DE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {175A7375-E1B4-4BED-98FC-988A46263DE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {175A7375-E1B4-4BED-98FC-988A46263DE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {175A7375-E1B4-4BED-98FC-988A46263DE4}.Release|Any CPU.Build.0 = Release|Any CPU + {EE52CB5D-86DF-47E6-B105-F453A0B6FE66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EE52CB5D-86DF-47E6-B105-F453A0B6FE66}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE52CB5D-86DF-47E6-B105-F453A0B6FE66}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EE52CB5D-86DF-47E6-B105-F453A0B6FE66}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/TUnit/GlobalUsings.cs b/TUnit/GlobalUsings.cs new file mode 100644 index 0000000000..83faddbd2e --- /dev/null +++ b/TUnit/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using TUnit.Assertions; +global using TUnit.Core; +global using Assert = TUnit.Assertions.Assert; \ No newline at end of file diff --git a/TUnit/TUnit.csproj b/TUnit/TUnit.csproj new file mode 100644 index 0000000000..d9809494e8 --- /dev/null +++ b/TUnit/TUnit.csproj @@ -0,0 +1,18 @@ + + + + net7.0 + enable + enable + latest + + + + + + + + + +