From bc727d527e15b9beaac9bbfe8208aeeda3b8c590 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 3 Sep 2023 10:41:37 +0200 Subject: [PATCH] Introduced a new attribute to allow global initialization of the assertion engine --- FluentAssertions.sln | 11 ++- Src/FluentAssertions/AssertionExtensions.cs | 5 ++ Src/FluentAssertions/AssertionOptions.cs | 1 + Src/FluentAssertions/Common/Services.cs | 80 +++++++++++++++++-- Src/FluentAssertions/Execution/Execute.cs | 13 ++- .../AssertionEngineInitializerAttribute.cs | 30 +++++++ .../FluentAssertions/net47.verified.txt | 8 ++ .../FluentAssertions/net6.0.verified.txt | 8 ++ .../netstandard2.0.verified.txt | 8 ++ .../netstandard2.1.verified.txt | 8 ++ .../AssertionEngineInitializer.cs | 20 +++++ .../ExtensionAssemblyAttributeSpecs.cs | 15 ++++ ...luentAssertions.Extensibility.Specs.csproj | 48 +++++++++++ .../Usings.cs | 1 + docs/_data/navigation.yml | 2 + docs/_pages/extensibility.md | 15 ++++ docs/_pages/introduction.md | 18 +++++ docs/_pages/releases.md | 2 + 18 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 Src/FluentAssertions/Extensibility/AssertionEngineInitializerAttribute.cs create mode 100644 Tests/FluentAssertions.Extensibility.Specs/AssertionEngineInitializer.cs create mode 100644 Tests/FluentAssertions.Extensibility.Specs/ExtensionAssemblyAttributeSpecs.cs create mode 100644 Tests/FluentAssertions.Extensibility.Specs/FluentAssertions.Extensibility.Specs.csproj create mode 100644 Tests/FluentAssertions.Extensibility.Specs/Usings.cs diff --git a/FluentAssertions.sln b/FluentAssertions.sln index 52e93a3c91..89affe21e4 100644 --- a/FluentAssertions.sln +++ b/FluentAssertions.sln @@ -51,7 +51,9 @@ Project("{778DAE3C-4631-46EA-AA77-85C1314464D9}") = "VB.Specs", "Tests\VB.Specs\ EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Specs", "Tests\FSharp.Specs\FSharp.Specs.fsproj", "{0A69DC62-CA14-44E5-BAF9-2EB2E2E2CADF}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExampleExtensions", "Tests\ExampleExtensions\ExampleExtensions.csproj", "{8DF4A6FE-AAD0-41E5-B2F4-34166D1B139C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentAssertions.Extensibility.Specs", "Tests\FluentAssertions.Extensibility.Specs\FluentAssertions.Extensibility.Specs.csproj", "{07268C80-07CB-4B23-9113-288438499E7B}" +EndProject +Project("{D1738D04-17F6-4388-AA12-99AC02656040}") = "ExampleExtensions", "Tests\ExampleExtensions\ExampleExtensions.csproj", "{8DF4A6FE-AAD0-41E5-B2F4-34166D1B139C}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -143,6 +145,12 @@ Global {0A69DC62-CA14-44E5-BAF9-2EB2E2E2CADF}.Debug|Any CPU.Build.0 = Debug|Any CPU {0A69DC62-CA14-44E5-BAF9-2EB2E2E2CADF}.Release|Any CPU.ActiveCfg = Release|Any CPU {0A69DC62-CA14-44E5-BAF9-2EB2E2E2CADF}.Release|Any CPU.Build.0 = Release|Any CPU + {D1738D04-17F6-4388-AA12-99AC02656040}.CI|Any CPU.ActiveCfg = Debug|Any CPU + {D1738D04-17F6-4388-AA12-99AC02656040}.CI|Any CPU.Build.0 = Debug|Any CPU + {D1738D04-17F6-4388-AA12-99AC02656040}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1738D04-17F6-4388-AA12-99AC02656040}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1738D04-17F6-4388-AA12-99AC02656040}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1738D04-17F6-4388-AA12-99AC02656040}.Release|Any CPU.Build.0 = Release|Any CPU {8DF4A6FE-AAD0-41E5-B2F4-34166D1B139C}.CI|Any CPU.ActiveCfg = Debug|Any CPU {8DF4A6FE-AAD0-41E5-B2F4-34166D1B139C}.CI|Any CPU.Build.0 = Debug|Any CPU {8DF4A6FE-AAD0-41E5-B2F4-34166D1B139C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -169,6 +177,7 @@ Global {0C0211B6-D185-4518-A15A-38AC092EDC50} = {963262D0-9FD5-4741-8C0E-E2F34F110EF3} {0A69DC62-CA14-44E5-BAF9-2EB2E2E2CADF} = {963262D0-9FD5-4741-8C0E-E2F34F110EF3} {8DF4A6FE-AAD0-41E5-B2F4-34166D1B139C} = {963262D0-9FD5-4741-8C0E-E2F34F110EF3} + {D1738D04-17F6-4388-AA12-99AC02656040} = {963262D0-9FD5-4741-8C0E-E2F34F110EF3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {75DDA3D8-9D6F-4865-93F4-DDE11DEE8290} diff --git a/Src/FluentAssertions/AssertionExtensions.cs b/Src/FluentAssertions/AssertionExtensions.cs index 5a0c9d831b..d2ed6822d9 100644 --- a/Src/FluentAssertions/AssertionExtensions.cs +++ b/Src/FluentAssertions/AssertionExtensions.cs @@ -33,6 +33,11 @@ public static class AssertionExtensions { private static readonly AggregateExceptionExtractor Extractor = new(); + static AssertionExtensions() + { + Services.EnsureInitialized(); + } + /// /// Invokes the specified action on a subject so that you can chain it /// with any of the assertions from diff --git a/Src/FluentAssertions/AssertionOptions.cs b/Src/FluentAssertions/AssertionOptions.cs index 3ce1f64f0e..7f30d1b36a 100644 --- a/Src/FluentAssertions/AssertionOptions.cs +++ b/Src/FluentAssertions/AssertionOptions.cs @@ -15,6 +15,7 @@ public static class AssertionOptions static AssertionOptions() { EquivalencyPlan = new EquivalencyPlan(); + Services.EnsureInitialized(); } /// diff --git a/Src/FluentAssertions/Common/Services.cs b/Src/FluentAssertions/Common/Services.cs index dd283265ee..49dd4e9cb0 100644 --- a/Src/FluentAssertions/Common/Services.cs +++ b/Src/FluentAssertions/Common/Services.cs @@ -1,5 +1,10 @@ -using System; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; using FluentAssertions.Execution; +using FluentAssertions.Extensibility; +using JetBrains.Annotations; namespace FluentAssertions.Common; @@ -10,10 +15,11 @@ public static class Services { private static readonly object Lockable = new(); private static Configuration configuration; + private static bool isInitialized; static Services() { - ResetToDefaults(); + EnsureInitialized(); } public static IConfigurationStore ConfigurationStore { get; set; } @@ -33,14 +39,76 @@ public static Configuration Configuration public static IReflector Reflector { get; set; } + [PublicAPI] public static void ResetToDefaults() { - Reflector = new FullFrameworkReflector(); + isInitialized = false; + EnsureInitialized(); + } + + internal static void EnsureInitialized() + { + if (isInitialized) + { + return; + } + + lock (Lockable) + { + if (!isInitialized) + { + ExecuteCustomInitializers(); + + Reflector = new FullFrameworkReflector(); #if NETFRAMEWORK || NET6_0_OR_GREATER - ConfigurationStore = new ConfigurationStoreExceptionInterceptor(new AppSettingsConfigurationStore()); + ConfigurationStore = new ConfigurationStoreExceptionInterceptor(new AppSettingsConfigurationStore()); #else - ConfigurationStore = new NullConfigurationStore(); + ConfigurationStore = new NullConfigurationStore(); #endif - ThrowException = new TestFrameworkProvider(Configuration).Throw; + ThrowException = new TestFrameworkProvider(Configuration).Throw; + + isInitialized = true; + } + } + } + + private static void ExecuteCustomInitializers() + { + var currentAssembly = Assembly.GetExecutingAssembly(); + var currentAssemblyName = currentAssembly.GetName(); + + var attributes = Array.Empty(); + + try + { + attributes = AppDomain.CurrentDomain + .GetAssemblies() + .Where(assembly => assembly != currentAssembly && !assembly.IsDynamic && !IsFramework(assembly)) + .Where(a => a.GetReferencedAssemblies().Any(r => r.FullName == currentAssemblyName.FullName)) + .SelectMany(a => a.GetCustomAttributes()) + .ToArray(); + } + catch + { + // Just ignore any exceptions that might happen while trying to find the attributes + } + + foreach (var attribute in attributes) + { + try + { + attribute.Initialize(); + } + catch + { + // Just ignore any exceptions that might happen while trying to find the attributes + } + } + } + + private static bool IsFramework(Assembly assembly) + { + return assembly?.FullName?.StartsWith("Microsoft", StringComparison.OrdinalIgnoreCase) == true || + assembly?.FullName?.StartsWith("System", StringComparison.OrdinalIgnoreCase) == true; } } diff --git a/Src/FluentAssertions/Execution/Execute.cs b/Src/FluentAssertions/Execution/Execute.cs index 208e98fb35..9533780cb4 100644 --- a/Src/FluentAssertions/Execution/Execute.cs +++ b/Src/FluentAssertions/Execution/Execute.cs @@ -1,4 +1,6 @@ -namespace FluentAssertions.Execution; +using FluentAssertions.Common; + +namespace FluentAssertions.Execution; /// /// Helper class for verifying a condition and/or throwing a test harness specific exception representing an assertion failure. @@ -8,5 +10,12 @@ public static class Execute /// /// Gets an object that wraps and executes a conditional or unconditional assertion. /// - public static AssertionScope Assertion => AssertionScope.Current; + public static AssertionScope Assertion + { + get + { + Services.EnsureInitialized(); + return AssertionScope.Current; + } + } } diff --git a/Src/FluentAssertions/Extensibility/AssertionEngineInitializerAttribute.cs b/Src/FluentAssertions/Extensibility/AssertionEngineInitializerAttribute.cs new file mode 100644 index 0000000000..dff4509f6f --- /dev/null +++ b/Src/FluentAssertions/Extensibility/AssertionEngineInitializerAttribute.cs @@ -0,0 +1,30 @@ +using System; +using System.Reflection; + +namespace FluentAssertions.Extensibility; + +/// +/// Can be added to an assembly so it gets a change to initialize Fluent Assertions before the first assertion happens. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class AssertionEngineInitializerAttribute : Attribute +{ + private readonly string methodName; + private readonly Type type; + + /// + /// Defines the static void-returned and parameterless method that should be invoked before the first assertion happens. + /// +#pragma warning disable CA1019 + public AssertionEngineInitializerAttribute(Type type, string methodName) +#pragma warning restore CA1019 + { + this.type = type; + this.methodName = methodName; + } + + internal void Initialize() + { + type?.GetMethod(methodName, BindingFlags.Public | BindingFlags.Static)?.Invoke(obj: null, parameters: null); + } +} diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt index 521562257a..7a7d238434 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt @@ -1255,6 +1255,14 @@ namespace FluentAssertions.Execution public string FormattedMessage { get; set; } } } +namespace FluentAssertions.Extensibility +{ + [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple=true)] + public sealed class AssertionEngineInitializerAttribute : System.Attribute + { + public AssertionEngineInitializerAttribute(System.Type type, string methodName) { } + } +} namespace FluentAssertions.Extensions { public static class FluentDateTimeExtensions diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt index 29636f86a4..8ab95145fa 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt @@ -1268,6 +1268,14 @@ namespace FluentAssertions.Execution public string FormattedMessage { get; set; } } } +namespace FluentAssertions.Extensibility +{ + [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple=true)] + public sealed class AssertionEngineInitializerAttribute : System.Attribute + { + public AssertionEngineInitializerAttribute(System.Type type, string methodName) { } + } +} namespace FluentAssertions.Extensions { public static class FluentDateTimeExtensions diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt index dbd61567d4..437f323d88 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt @@ -1206,6 +1206,14 @@ namespace FluentAssertions.Execution public string FormattedMessage { get; set; } } } +namespace FluentAssertions.Extensibility +{ + [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple=true)] + public sealed class AssertionEngineInitializerAttribute : System.Attribute + { + public AssertionEngineInitializerAttribute(System.Type type, string methodName) { } + } +} namespace FluentAssertions.Extensions { public static class FluentDateTimeExtensions diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt index d6e22422b5..0c651a41f7 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt @@ -1255,6 +1255,14 @@ namespace FluentAssertions.Execution public string FormattedMessage { get; set; } } } +namespace FluentAssertions.Extensibility +{ + [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple=true)] + public sealed class AssertionEngineInitializerAttribute : System.Attribute + { + public AssertionEngineInitializerAttribute(System.Type type, string methodName) { } + } +} namespace FluentAssertions.Extensions { public static class FluentDateTimeExtensions diff --git a/Tests/FluentAssertions.Extensibility.Specs/AssertionEngineInitializer.cs b/Tests/FluentAssertions.Extensibility.Specs/AssertionEngineInitializer.cs new file mode 100644 index 0000000000..80cf5b9c4a --- /dev/null +++ b/Tests/FluentAssertions.Extensibility.Specs/AssertionEngineInitializer.cs @@ -0,0 +1,20 @@ +using System.Threading; + +// With specific initialization code to invoke before the first assertion happens +[assembly: FluentAssertions.Extensibility.AssertionEngineInitializer( + typeof(FluentAssertions.Extensibility.Specs.AssertionEngineInitializer), + nameof(FluentAssertions.Extensibility.Specs.AssertionEngineInitializer.InitializeBeforeFirstAssertion))] + +namespace FluentAssertions.Extensibility.Specs; + +public static class AssertionEngineInitializer +{ + private static int shouldBeCalledOnlyOnce; + + public static int ShouldBeCalledOnlyOnce => shouldBeCalledOnlyOnce; + + public static void InitializeBeforeFirstAssertion() + { + Interlocked.Increment(ref shouldBeCalledOnlyOnce); + } +} diff --git a/Tests/FluentAssertions.Extensibility.Specs/ExtensionAssemblyAttributeSpecs.cs b/Tests/FluentAssertions.Extensibility.Specs/ExtensionAssemblyAttributeSpecs.cs new file mode 100644 index 0000000000..3a07dbefd7 --- /dev/null +++ b/Tests/FluentAssertions.Extensibility.Specs/ExtensionAssemblyAttributeSpecs.cs @@ -0,0 +1,15 @@ +namespace FluentAssertions.Extensibility.Specs; + +public class ExtensionAssemblyAttributeSpecs +{ + [Fact] + public void Calls_assembly_initialization_code_only_once() + { + for (int i = 0; i < 10; i++) + { + var act = () => AssertionEngineInitializer.ShouldBeCalledOnlyOnce.Should().Be(1); + + act.Should().NotThrow(); + } + } +} diff --git a/Tests/FluentAssertions.Extensibility.Specs/FluentAssertions.Extensibility.Specs.csproj b/Tests/FluentAssertions.Extensibility.Specs/FluentAssertions.Extensibility.Specs.csproj new file mode 100644 index 0000000000..b285be912b --- /dev/null +++ b/Tests/FluentAssertions.Extensibility.Specs/FluentAssertions.Extensibility.Specs.csproj @@ -0,0 +1,48 @@ + + + + net6.0;net47 + True + ..\..\Src\FluentAssertions\FluentAssertions.snk + false + $(NoWarn),IDE0052,1573,1591,1712 + full + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/Tests/FluentAssertions.Extensibility.Specs/Usings.cs b/Tests/FluentAssertions.Extensibility.Specs/Usings.cs new file mode 100644 index 0000000000..c802f4480b --- /dev/null +++ b/Tests/FluentAssertions.Extensibility.Specs/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index 7726d5c268..e283cca660 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -33,6 +33,8 @@ sidebar: url: /introduction#detecting-test-frameworks - title: Subject Identification url: /introduction#subject-identification + - title: Global Configuration + url: /introduction#global-configuration - title: Assertion Scopes url: /introduction#assertion-scopes - title: Basic Assertions diff --git a/docs/_pages/extensibility.md b/docs/_pages/extensibility.md index dc4fc52bec..a6615ac8f1 100644 --- a/docs/_pages/extensibility.md +++ b/docs/_pages/extensibility.md @@ -275,6 +275,21 @@ internal static class Initializer } ``` +Unfortunately, this only works for .NET 5 and higher. That's why Fluent Assertions supports its own "module initializer" through the `[AssertionEngineInitializer]` attribute. It can be used multiple times. + +```csharp +[assembly: AssertionEngineInitializer(typeof(Initializer), nameof(Initializer.Initialize))] + +public static class Initializer +{ + public static void Initialize() + { + AssertionOptions.AssertEquivalencyUsing(options => options + .ComparingByValue()); + } +} +``` + ### MSTest MSTest provides the `AssemblyInitializeAttribute` to annotate that a method in a `TestClass` should be run once per assembly. diff --git a/docs/_pages/introduction.md b/docs/_pages/introduction.md index 8f98ede3f1..43d06b7fa3 100644 --- a/docs/_pages/introduction.md +++ b/docs/_pages/introduction.md @@ -63,6 +63,23 @@ xDocument.Should().HaveElement("child").Which.Should().BeOfType().And. This chaining can make your unit tests a lot easier to read. +## Global Configurations + +Fluent Assertions `AssertionOptions` has several methods and properties that can be used to change the way it executes assertions or the defaults it will use for comparing object graphs. Changing those settings at the right time can be difficult, depending on the test framework. That's why Fluent Assertions offers a special assembly-level attribute that can be used to have some code executed _before_ the first assertion is executed. It will be called only once per test run, but you can use the attribute multiple times. + +```csharp +[assembly: AssertionEngineInitializer(typeof(Initializer), nameof(Initializer.Initialize))] + +public static class Initializer +{ + public static void Initialize() + { + AssertionOptions.AssertEquivalencyUsing(options => options + .ComparingByValue()); + } +} +``` + ## Detecting Test Frameworks Fluent Assertions supports a lot of different unit testing frameworks. Just add a reference to the corresponding test framework assembly to the unit test project. Fluent Assertions will automatically find the corresponding assembly and use it for throwing the framework-specific exceptions. @@ -152,3 +169,4 @@ Expected string to be "Expected" with a length of 8, but "Actual" has a length o ``` For more information take a look at the [AssertionScopeSpecs.cs](https://github.com/fluentassertions/fluentassertions/blob/master/Tests/FluentAssertions.Specs/Execution/AssertionScopeSpecs.cs) in Unit Tests. + diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index 829acdaa5c..3735e9b06d 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -11,6 +11,8 @@ sidebar: ### What's new +* Introduced a new assembly-level attribute that you can use to initialize Fluent Assertions before the first assertion - [#2292](https://github.com/fluentassertions/fluentassertions/pull/2292) + ### Improvements * Improve failure message for string assertions when checking for equality - [#2307](https://github.com/fluentassertions/fluentassertions/pull/2307) * You can mark all assertions in an assembly as custom assertions using the `[CustomAssertionsAssembly]` attribute - [#2389](https://github.com/fluentassertions/fluentassertions/pull/2389)