Test frameworks and initialization
An extension can run code before its first expectation, support a test framework of its own and read the running test. This page also shows how the test project passes values and code to your extension before the first test.
Namespaces used on this page
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using aweXpect.Core.Adapters;
using aweXpect.Core.Extending;
using aweXpect.Formatting;
Module initializers
An extension often has to run code once before its first expectation is evaluated, e.g. to register a value formatter. Use a module initializer, which runs before any other code of your assembly, i.e. when a test first uses the extension. In a class library, the attribute triggers the warning CA2255, so suppress it as shown:
namespace MusicStore
{
internal static class MusicStoreInitializer
{
#pragma warning disable CA2255 // The initializer of a class library is intended here
[ModuleInitializer]
internal static void Initialize()
=> ValueFormatter.Register(new TrackFormatter());
#pragma warning restore CA2255
}
}
ModuleInitializerAttribute is missing in netstandard2.0 and net48: declare it in your own package there, see
polyfills for older targets.
Test framework adapter
aweXpect reports a failed, skipped or inconclusive expectation by throwing the exception of the test framework. For
MSTest, NUnit, TUnit and xUnit (v2 and v3), the aweXpect package generates an adapter into the test project and
registers it (test frameworks lists the exceptions it throws). To support
another test framework, implement ITestFrameworkAdapter and register it with
TestFrameworkRegistry.Register from a module initializer in the test project itself, as the generated adapters are.
A module initializer only runs when its assembly is first used, and the tests never use an assembly that only contains
an adapter, so a registration in a separate package would never run:
namespace MyTests
{
internal sealed class MyTestFrameworkAdapter : ITestFrameworkAdapter
{
public bool IsAvailable => true;
public void Skip(string message) => throw new MyTestSkippedException(message);
public void Fail(string message) => throw new MyTestFailedException(message);
public void Fail(string message, Exception innerException)
=> throw new MyTestFailedException(message, innerException);
public void Inconclusive(string message) => throw new MyTestInconclusiveException(message);
public bool TryGetCurrentTest([NotNullWhen(true)] out RunningTest? currentTest)
{
MyTestContext? context = MyTestContext.Current;
currentTest = context is null
? null
: new RunningTest(
new TestIdentity(context.ClassName, context.ClassArguments,
context.MethodName, context.Arguments),
context.Id, context.DisplayName, context.RetryCount);
return currentTest is not null;
}
public bool TryGetCancellationToken(out CancellationToken cancellationToken)
{
MyTestContext? context = MyTestContext.Current;
cancellationToken = context?.CancellationToken ?? CancellationToken.None;
return context is not null;
}
// The test framework cannot attach files to the result of a test.
public bool TryAddAttachment(string filePath, string? description) => false;
}
internal static class MyTestFrameworkRegistration
{
#pragma warning disable CA2255 // A test project is a class library, too
[ModuleInitializer]
internal static void Register()
=> TestFrameworkRegistry.Register(new MyTestFrameworkAdapter());
#pragma warning restore CA2255
}
}
- The adapter is resolved once, when the first expectation is created or a value is first formatted with
Format.Formatter, so register it before that.
Availability, precedence and fallbacks
- An adapter whose
IsAvailableisfalseis ignored, e.g. when the test framework it throws for is not loaded. - By default,
Registerreplaces an adapter that was registered before. Withoverwrite: false, as the generated registrations use it, the adapter is only used when no other one was registered, so an explicit registration always wins, independent of the order in which the module initializers run. - Without any registration, aweXpect throws its own
FailException,SkipExceptionorInconclusiveException. Only below .NET 8, e.g. on .NET Framework, it first scans the loaded assemblies for a type that implementsITestFrameworkAdapter, see settings. - The generated registration is a module initializer, which needs C# 9 or later. On .NET 8 or later, a test project
with an older
<LangVersion>has to register the generated adapter itself, which the warning aweXpect2002 points out.
The running test
The TryGetCurrentTest of an adapter returns the RunningTest, or false when its test framework does not expose it.
Its Identity holds what identifies the test in every run: the full name of the test class and its arguments, the name
of the test method and the arguments of the test case. When the test framework reports them, the RunningTest also
holds the Id and the DisplayName of the test case and its RetryAttempt, which counts the runs of the test before,
e.g. by a retry: 0 for the first run. The Id is only unique within one test run, so it does not belong in a persisted name.
An extension reads the running test with TestFrameworkRegistry.TryGetCurrentTest, e.g. to tell the cases of a
parameterized test apart, as aweXpect.Snapshots does with NamedAfterTestArguments:
internal static class TrackCaseNames
{
internal static string? GetCaseName()
=> TestFrameworkRegistry.TryGetCurrentTest(out RunningTest? currentTest) &&
currentTest.Identity.Arguments.Count > 0
? string.Join("_", currentTest.Identity.Arguments)
: null;
}
It returns false when the test framework does not expose the running test, or when no test is running, e.g. in a
setup of the whole assembly. Handle that case, as not every test framework exposes the running test; the
test frameworks page lists what each of them supports.
TestFrameworkRegistry.TryGetCancellationToken returns the token with which the test framework cancels the running
test. Expectations only observe it when Settings().TestCancellation is set to
TestCancellation.FromCancellationToken(…) with it, see
time and cancellation.
TestFrameworkRegistry.TryAddAttachment(filePath, description) attaches a file to the result of the running test, as
aweXpect.Snapshots does with a recorded mismatch when AttachPendingFiles is set:
internal static class PlaylistReports
{
internal static void Attach(string reportFile)
=> _ = TestFrameworkRegistry.TryAddAttachment(reportFile, "The report of the test");
}
- Both return
falsewhen the test framework does not support it, no test is running or the test framework fails, e.g. because the file does not exist. LikeTryGetCurrentTest, they never throw: the registry also returnsfalsewhen an adapter throws, but an adapter of your own should still returnfalseinstead. - MSTest, NUnit and TUnit only read the file when the test ends, so it must still exist then.
Registration from the test project
Some extensions need values that the build of the test project records, e.g. its directory, or have to run code
before the first test, e.g. to add a reporter, although no test uses their assembly before. Instead of scanning the
loaded assemblies, let the source generator of aweXpect.Core emit a module initializer into the test project. The
build targets of your package (in its buildTransitive folder) only pass names to it, in two MSBuild properties:
AweXpectRegisteredMetadataKeys: the keys of theAssemblyMetadataAttributes of the test project whose values are registered inExtensionRegistry.AweXpectModuleInitializers: the static methods without parameters, accessible from the test project, that are called afterwards, so that they can already read the registered values.
Both are lists separated by , or ;. Append your names to the value that other packages set, and make the property
visible to the generator:
<Project>
<PropertyGroup>
<AweXpectRegisteredMetadataKeys>$(AweXpectRegisteredMetadataKeys),MusicStore.Catalog</AweXpectRegisteredMetadataKeys>
<AweXpectModuleInitializers>$(AweXpectModuleInitializers),MusicStore.MusicStoreSetup.Initialize</AweXpectModuleInitializers>
</PropertyGroup>
<ItemGroup>
<CompilerVisibleProperty Remove="AweXpectRegisteredMetadataKeys"/>
<CompilerVisibleProperty Include="AweXpectRegisteredMetadataKeys"/>
<CompilerVisibleProperty Remove="AweXpectModuleInitializers"/>
<CompilerVisibleProperty Include="AweXpectModuleInitializers"/>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>MusicStore.Catalog</_Parameter1>
<_Parameter2>$(MSBuildProjectDirectory)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
</Project>
ExtensionRegistry.GetValues returns the values that are registered under a key, from every test assembly in the
process, in the order of their registration:
namespace MusicStore
{
public static class MusicStoreSetup
{
internal static List<string> Catalogs { get; } = new();
public static void Initialize()
=> Catalogs.AddRange(ExtensionRegistry.GetValues("MusicStore.Catalog"));
}
}
Projects without module initializers
- The generator runs under the same conditions as the one for the type metadata.
- Otherwise, e.g. on .NET Framework with C# 7.3, or for a test project in F# or Visual Basic, nothing is registered
and no initializer is called. Tell your users to call the initializer themselves, e.g. in an assembly-level setup of
the test framework, and read the values from the loaded assemblies as a fallback, but only from those that
AssemblyPrefixes.IsExcludeddoes not exclude. - One process can load assemblies of both kinds, e.g. an F# test project that calls a helper library in C#. Decide for
each assembly whether it registered its values: let your build targets also record the assembly name, e.g. as
$(AssemblyName), register it with the values, and skip the assemblies with a registered name in the fallback. - The registered values stay for the lifetime of the process. The list that
GetValuesreturns does not change, and it is the same instance until another value is registered under the key, so you can keep what you derived from it.