Reflection and Custom Attributes – Dynamic Tests at Runtime
Consider a team with six hundred test methods spread across a monorepo. Half should run only on the staging environment, a quarter belong exclusively to production smoke runs, and the rest execute everywhere. Someone decided to track this in a shared spreadsheet. Every sprint the spreadsheet drifts further from reality – a renamed test drops off the list, a new test gets forgotten, and eventually a production-only test runs against a dev database and fails for reasons that have nothing to do with the code. The spreadsheet approach puts the responsibility for test metadata on humans, outside the codebase, where it cannot be reviewed, version-controlled, or enforced automatically. The alternative is to put the metadata in the code itself, attached to the test methods as structured annotations the framework can read at runtime.
This lesson covers the two interlocking features that make that possible: custom attributes, which let you annotate types and methods with structured, queryable metadata, and reflection, which lets you read that metadata at runtime to drive dynamic behaviour. Together they enable test discovery, metadata-driven filtering, extensible framework hooks, and a category of infrastructure that simply cannot be built any other way. The lesson covers System.Reflection fundamentals, designing and applying custom attributes, reading attribute metadata at runtime, building dynamic test discovery pipelines, invoking methods programmatically, and the performance trade-offs that govern when reflection is appropriate and when it needs a caching layer.
Reflection is the mechanism that makes test frameworks like NUnit and xUnit work. Understanding it moves you from a framework consumer who follows conventions to a framework author who can extend those conventions – or build new ones entirely.
How .NET Reflection Works
Every .NET program carries a complete description of itself inside its compiled assembly. Type names, method signatures, parameter types, return types, interface implementations, applied attributes – all of it is encoded in the assembly's metadata tables. System.Reflection is the API that reads those tables at runtime, giving you access to type information that the compiler computed statically.
The entry point for almost everything in reflection is the Type class. It represents a single type – a class, struct, interface, enum, or delegate – and exposes its members as collections of MethodInfo, PropertyInfo, FieldInfo, ConstructorInfo, and EventInfo objects. Each of these descriptor types carries the metadata for one member: its name, its modifiers, its parameter list, its custom attributes.
Obtaining a Type Object
There are three common ways to get a Type object, each appropriate in different circumstances:
// 1. typeof() operator – resolved at compile time, no instance needed.
// Use this when you know the type statically.
Type loginPageType = typeof(LoginPageTests);
// 2. GetType() instance method – resolved at runtime from an existing object.
// Use this when you have an instance and need its exact runtime type.
object test = new LoginPageTests();
Type runtimeType = test.GetType(); // returns LoginPageTests, even if held as object
// 3. Assembly.GetType(string) – resolved by name string at runtime.
// Use this when the type name comes from configuration or user input.
Assembly assembly = Assembly.GetExecutingAssembly();
Type? dynamicType = assembly.GetType("MyTests.LoginPageTests");
// Discovering all types in an assembly – the basis for test discovery
Type[] allTypes = assembly.GetTypes();
IEnumerable<Type> testClasses = allTypes
.Where(t => t.IsClass && !t.IsAbstract)
.Where(t => t.GetCustomAttribute<TestFixtureAttribute>() != null);
Exploring Members with BindingFlags
By default, methods like GetMethods() return only public instance members. BindingFlags is a flags enum that controls which members are included. For test discovery, you typically need to reach non-public members, static methods, or members declared on base classes:
// Default: public instance members only
MethodInfo[] publicMethods = typeof(LoginPageTests).GetMethods();
// Include non-public and static members – useful for framework internals
MethodInfo[] allDeclaredMethods = typeof(LoginPageTests).GetMethods(
BindingFlags.Public |
BindingFlags.NonPublic |
BindingFlags.Instance |
BindingFlags.Static |
BindingFlags.DeclaredOnly); // DeclaredOnly excludes inherited methods
// Get a specific method by name and parameter types
MethodInfo? loginMethod = typeof(LoginPageTests).GetMethod(
"VerifyLoginWithValidCredentials",
BindingFlags.Public | BindingFlags.Instance);
// PropertyInfo for reading or setting values at runtime
PropertyInfo? titleProperty = typeof(LoginPageTests)
.GetProperty("PageTitle", BindingFlags.Public | BindingFlags.Instance);
// Read a property value from an instance
var tests = new LoginPageTests();
string? title = (string?)titleProperty?.GetValue(tests);
BindingFlags.DeclaredOnly vs Inherited Members
Omitting DeclaredOnly returns members from the entire inheritance chain, including those inherited from object – methods like ToString, GetHashCode, and Equals. For test discovery, you almost always want DeclaredOnly combined with a custom attribute filter, which naturally excludes framework boilerplate. If you omit both the flag and the attribute filter, you'll discover dozens of methods you never intended to execute.
The key insight is that Type, MethodInfo, PropertyInfo, and their siblings are just data objects. They describe structure but don't execute anything on their own. The reflection API is fundamentally a read-only query system for the metadata the compiler already computed – until you explicitly invoke something, reading metadata has no side effects on the program's state.
Designing Custom Attributes
An attribute in C# is a class that inherits from System.Attribute. Applying an attribute to a program element is syntactic sugar for embedding a structured object into the assembly's metadata at that location. The compiler validates the attribute's constructor arguments and named properties at compile time; the runtime reads them back through reflection when requested.
Creating an Attribute Class
Every custom attribute class follows the same structure: inherit from Attribute, provide a constructor for required data, and expose properties for optional named parameters. By convention, the class name ends with Attribute, which the compiler allows you to omit when applying the attribute:
// Attribute that marks which environments a test should run in.
// The suffix "Attribute" is required on the class name but omitted when applying it.
[AttributeUsage(
AttributeTargets.Method | AttributeTargets.Class,
AllowMultiple = false,
Inherited = true)]
public sealed class TestEnvironmentAttribute : Attribute
{
// Immutable: attributes are data, not behaviour
public string[] Environments { get; }
// params allows: [TestEnvironment("Staging")] or [TestEnvironment("Staging", "Production")]
public TestEnvironmentAttribute(params string[] environments)
{
if (environments.Length == 0)
throw new ArgumentException(
"At least one environment must be specified.", nameof(environments));
// Store as a copy so callers can't mutate the attribute's state
Environments = environments.ToArray();
}
}
// Usage: the Attribute suffix is optional in the [] syntax
[TestEnvironment("Staging", "Production")]
public void VerifyPaymentProcessing_CompletesSuccessfully()
{
// This test only runs against Staging and Production
}
Understanding AttributeUsage
The AttributeUsage attribute controls how your attribute behaves. Its three properties define the scope and applicability of every custom attribute:
// AttributeTargets controls where the attribute may be applied.
// Combining targets with | allows multiple locations.
[AttributeUsage(
// Allowed on both methods and classes
AttributeTargets.Method | AttributeTargets.Class,
// AllowMultiple = true: the same attribute can be applied more than once
// on the same element (useful for attributes with single-value constructors)
AllowMultiple = false,
// Inherited = true: a class that derives from an attributed base class
// also carries the attribute. Relevant for test base class patterns.
Inherited = true)]
public sealed class TestEnvironmentAttribute : Attribute { /* ... */ }
// AttributeTargets reference – the most common values in test automation:
// AttributeTargets.Method – test methods
// AttributeTargets.Class – test fixture classes
// AttributeTargets.Assembly – applied once in AssemblyInfo.cs
// AttributeTargets.Property – for property injection patterns
Named Parameters vs Constructor Parameters
Attributes support two forms of initialisation: positional (constructor) parameters for required data, and named parameters (public properties or fields) for optional configuration. The distinction matters for readability at the call site:
// Attribute with both required and optional parameters
[AttributeUsage(AttributeTargets.Method)]
public sealed class RetryAttribute : Attribute
{
// Required: positional constructor parameter
public int MaxAttempts { get; }
// Optional: named parameters with sensible defaults
public int DelayMilliseconds { get; set; } = 1000;
public Type? RetryOn { get; set; } = null; // null = retry on any exception
public RetryAttribute(int maxAttempts)
{
if (maxAttempts < 1)
throw new ArgumentOutOfRangeException(nameof(maxAttempts));
MaxAttempts = maxAttempts;
}
}
// Positional only: [Retry(3)]
// Positional + named: [Retry(3, DelayMilliseconds = 500)]
// Positional + typed named: [Retry(5, RetryOn = typeof(HttpRequestException))]
Attribute Constructor Arguments Must Be Constants
The C# compiler evaluates attribute arguments at compile time, so they must be compile-time constants: string literals, numeric literals, typeof() expressions, enum values, or null. You cannot pass a variable, a property access, or the result of a method call as an attribute argument. This is why attribute parameters for environment names use string[] literals, not a reference to an environment list defined elsewhere. This constraint forces attribute metadata to be self-contained – it's metadata, not executable logic.
Well-designed attributes are small, immutable, and focused on a single concern. An attribute that carries eight parameters is usually a sign that the concept needs to be split, or that the behaviour it drives should live in a different part of the framework. The best attributes read like clear, declarative annotations – [TestEnvironment("Staging")], [Retry(3)], [Category("Smoke")] – rather than configuration files embedded in square brackets.
Reading Attribute Metadata
Applying an attribute to a method does nothing on its own. The attribute's data sits quietly in the assembly's metadata until something reads it. That reading happens through GetCustomAttribute<T>() and GetCustomAttributes<T>(), extension methods defined in System.Reflection that work on Type, MethodInfo, PropertyInfo, and every other member descriptor.
Reading a Single Attribute
GetCustomAttribute<T>() returns the single attribute of type T applied to the member, or null if no such attribute is present. It throws InvalidOperationException if more than one attribute of that type is present and AllowMultiple isn't set – but when AllowMultiple = false, that situation can't arise anyway:
// Read an attribute from a MethodInfo
MethodInfo method = typeof(PaymentTests)
.GetMethod("VerifyPaymentProcessing_CompletesSuccessfully")!;
// Returns null if the attribute is not present – always check!
TestEnvironmentAttribute? envAttr =
method.GetCustomAttribute<TestEnvironmentAttribute>();
if (envAttr is not null)
{
// envAttr.Environments is string[] {"Staging", "Production"}
Console.WriteLine($"Test targets: {string.Join(", ", envAttr.Environments)}");
}
// IsDefined: faster existence check when you don't need the attribute's data
bool hasEnvRestriction = method.IsDefined(typeof(TestEnvironmentAttribute));
// Reading an attribute from a Type (class-level)
TestEnvironmentAttribute? classEnvAttr =
typeof(PaymentTests).GetCustomAttribute<TestEnvironmentAttribute>();
Reading Multiple Attributes
When AllowMultiple = true, multiple instances of the same attribute may be stacked. GetCustomAttributes<T>() returns all of them as IEnumerable<T>:
// An attribute that can be applied multiple times – for tagging tests with categories
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class TestCategoryAttribute : Attribute
{
public string Name { get; }
public TestCategoryAttribute(string name) => Name = name;
}
// Multiple applications on the same method
[TestCategory("Smoke")]
[TestCategory("Payment")]
[TestCategory("CriticalPath")]
public void VerifyCheckoutFlow() { }
// Reading all applied categories
MethodInfo checkoutMethod =
typeof(CheckoutTests).GetMethod("VerifyCheckoutFlow")!;
IEnumerable<TestCategoryAttribute> categories =
checkoutMethod.GetCustomAttributes<TestCategoryAttribute>();
// categories yields: { Name="Smoke" }, { Name="Payment" }, { Name="CriticalPath" }
HashSet<string> categoryNames = categories.Select(c => c.Name).ToHashSet();
Attribute Inheritance Behaviour
When Inherited = true (the default) in AttributeUsage, an attribute applied to a base class is visible through GetCustomAttribute on derived classes. This powers test base class patterns where a single attribute on the base fixture applies to all derived test classes:
// Base class: attribute applied here
[TestEnvironment("Staging", "Production")]
public abstract class ProductionSafeTestBase { }
// Derived class: no explicit attribute
public class PaymentTests : ProductionSafeTestBase
{
public void VerifyRefundProcessing() { }
}
// GetCustomAttribute on the derived type finds the inherited attribute
TestEnvironmentAttribute? attr =
typeof(PaymentTests).GetCustomAttribute<TestEnvironmentAttribute>(inherit: true);
// attr.Environments == ["Staging", "Production"]
// The 'inherit' parameter defaults to true – shown explicitly here for clarity
Attribute Retrieval Precedence Pattern
A common pattern in test framework code is to check the method first, then fall back to the class-level attribute, then to an assembly-level default. This gives individual test methods the finest-grained control while still allowing class or assembly-wide defaults. The pattern looks like: methodAttr ?? classAttr ?? assemblyDefault, resolved in that order. When writing framework code that reads attributes, always decide explicitly whether method-level attributes should override or merge with class-level ones.
Attribute retrieval is the linchpin of metadata-driven frameworks. Every call to GetCustomAttribute<T>() is a lookup into assembly metadata – deterministic, complete, and always in sync with the code because it is the code. The spreadsheet that drifts out of date is replaced by metadata that can't drift because it lives next to the test method it describes.
Test Discovery with Reflection
Test frameworks like NUnit and xUnit use reflection to discover test classes and methods at startup. Understanding that process lets you build the same kind of dynamic discovery for custom scenarios: an environment-aware CI runner that only executes tests tagged for the current target, a smoke test runner that extracts [Category("Smoke")] methods across all assemblies, or a diagnostics tool that inventories all tests by category and ownership.
Building a Test Inventory
Test discovery always follows the same shape: load an assembly, enumerate its types, filter to test classes, enumerate their methods, filter to test methods. The attribute filter at each step is what makes the inventory meaningful:
// A lightweight test descriptor – the output of discovery
public record TestDescriptor(
Type TestClass,
MethodInfo Method,
string[] Environments, // from TestEnvironmentAttribute (empty = runs everywhere)
string[] Categories); // from TestCategoryAttribute (all applied instances)
// Discover all tests in the assembly, building descriptors for each
public static IReadOnlyList<TestDescriptor> DiscoverTests(Assembly assembly)
{
var descriptors = new List<TestDescriptor>();
foreach (var type in assembly.GetTypes())
{
// Skip non-instantiable types: interfaces, abstract classes, generics
if (!type.IsClass || type.IsAbstract || type.ContainsGenericParameters)
continue;
// Only process test fixture classes (marked with NUnit's [TestFixture])
if (type.GetCustomAttribute<TestFixtureAttribute>() is null)
continue;
// Class-level environment attribute applies to all methods unless overridden
var classEnvAttr = type.GetCustomAttribute<TestEnvironmentAttribute>();
foreach (var method in type.GetMethods(
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
// Only process test methods
if (method.GetCustomAttribute<TestAttribute>() is null)
continue;
// Method-level attribute takes precedence; fall back to class-level
var methodEnvAttr = method.GetCustomAttribute<TestEnvironmentAttribute>();
var effectiveEnv = (methodEnvAttr ?? classEnvAttr)?.Environments
?? Array.Empty<string>();
// Collect all TestCategory attributes (AllowMultiple = true)
var categories = method
.GetCustomAttributes<TestCategoryAttribute>()
.Select(c => c.Name)
.ToArray();
descriptors.Add(new TestDescriptor(type, method, effectiveEnv, categories));
}
}
return descriptors;
}
Filtering for a Target Environment
Once a test inventory exists, filtering it for a specific environment run is straightforward. Tests with no environment restriction run everywhere; tests with restrictions run only when the current environment is in their list:
// Filter the discovered test inventory to tests appropriate for an environment
public static IReadOnlyList<TestDescriptor> FilterForEnvironment(
IReadOnlyList<TestDescriptor> allTests,
string currentEnvironment)
{
return allTests
.Where(t =>
// No environment restriction: runs in all environments
t.Environments.Length == 0 ||
// Restriction present: run only if the current env is in the list
t.Environments.Contains(currentEnvironment, StringComparer.OrdinalIgnoreCase))
.ToList();
}
// Usage: select tests for a staging run
var assembly = Assembly.GetExecutingAssembly();
var allTests = DiscoverTests(assembly);
var stagingTests = FilterForEnvironment(allTests, "Staging");
Console.WriteLine($"Discovered {allTests.Count} total tests.");
Console.WriteLine($"Running {stagingTests.Count} tests for Staging.");
// Category filtering – also common for smoke and regression run modes
var smokeTests = allTests
.Where(t => t.Categories.Contains("Smoke", StringComparer.OrdinalIgnoreCase))
.ToList();
How NUnit Discovers Tests
NUnit's test discovery engine performs exactly this process through its ITestAssemblyBuilder. It loads each test assembly, scans all types for [TestFixture] (or types that match heuristics like having [Test] methods directly), then scans methods for [Test], [TestCase], [Theory], and other test-bearing attributes. The attributes carry test case data, expected exceptions, categories, timeouts, and ordering constraints. Understanding this architecture explains why NUnit and xUnit are extensible through custom attributes – the framework's discovery mechanism is open to the same reflection API that anyone can use.
Reflection-based test discovery transforms a static list of test methods into a queryable, filterable metadata graph. Once the graph exists, filtering by environment, category, ownership, priority, or any other attribute is just a Where clause. The framework doesn't need updating every time a new filter dimension is added – it just needs a new attribute class and a new property on the descriptor record.
Dynamic Method Invocation
Reading attribute metadata tells you what to run. MethodInfo.Invoke is the mechanism for actually running it. Dynamic invocation is how test frameworks execute the methods they discover – without compile-time knowledge of the method signatures, without hand-written dispatch tables, and without requiring every test to implement a common interface.
Invoking Test Methods Reflectively
The signature of MethodInfo.Invoke is object? Invoke(object? obj, object?[]? parameters). For instance methods, obj is the instance; for static methods, pass null. Parameters are passed as an object array, in the same order as the method signature:
// Run a single test method from a TestDescriptor
public static async Task<TestResult> RunTestAsync(TestDescriptor descriptor)
{
// Instantiate the test class – requires a public parameterless constructor
var instance = Activator.CreateInstance(descriptor.TestClass)
?? throw new InvalidOperationException(
$"Could not instantiate {descriptor.TestClass.Name}.");
try
{
// Invoke the method. For async test methods, the return value is a Task.
var returnValue = descriptor.Method.Invoke(instance, parameters: null);
// Await the task if the method is async
if (returnValue is Task task)
await task;
return TestResult.Passed(descriptor);
}
catch (TargetInvocationException tie)
{
// MethodInfo.Invoke wraps thrown exceptions in TargetInvocationException.
// Unwrap it to surface the actual test failure.
var actualException = tie.InnerException ?? tie;
return TestResult.Failed(descriptor, actualException);
}
catch (Exception ex)
{
// Reflection errors (wrong parameter count, type mismatch) surface as other types
return TestResult.Failed(descriptor, ex);
}
finally
{
// IDisposable cleanup if the test class implements it
if (instance is IDisposable disposable)
disposable.Dispose();
if (instance is IAsyncDisposable asyncDisposable)
await asyncDisposable.DisposeAsync();
}
}
Detecting Async Methods
A common mistake in custom test runners is treating all methods as synchronous. Async test methods return Task or Task<T>; calling Invoke only starts the async state machine. The caller must await the returned task to let the test body complete and for exceptions to propagate:
// Safely determine whether a method is async before invoking
public static bool IsAsyncMethod(MethodInfo method)
{
// Async methods have a return type of Task, Task<T>, or ValueTask variants
var returnType = method.ReturnType;
return returnType == typeof(Task)
|| (returnType.IsGenericType
&& returnType.GetGenericTypeDefinition() == typeof(Task<>))
|| returnType == typeof(ValueTask)
|| (returnType.IsGenericType
&& returnType.GetGenericTypeDefinition() == typeof(ValueTask<>));
}
// Unified invocation that handles both sync and async methods
public static async Task InvokeTestMethodAsync(object instance, MethodInfo method)
{
var returnValue = method.Invoke(instance, null);
if (returnValue is ValueTask valueTask)
await valueTask;
else if (returnValue is Task task)
await task;
// Synchronous methods return null or a value – nothing to await
}
TargetInvocationException is Always a Wrapper
Every exception thrown by code called through MethodInfo.Invoke is caught by the reflection infrastructure and re-thrown wrapped inside a TargetInvocationException. If you catch Exception from Invoke and log ex.Message, you'll see "Exception has been thrown by the target of an invocation" rather than the actual assertion failure message. Always unwrap: ex.InnerException ?? ex. This is one of the few non-obvious gotchas in the reflection API and it surfaces in every custom test runner that doesn't account for it.
Dynamic invocation is the last piece that makes a fully self-contained custom runner possible – discovery, filtering, and execution all driven by metadata. For most teams the need is less dramatic: a pre-test hook that reads attributes to set up environment-specific configuration, a results collector that attaches category tags to test output, or a CI pipeline filter that skips tests not tagged for the current environment. In each case, the pattern is the same: reflect, read, act.
Caching Reflection Results
Reflection's convenience comes with a cost. Reading metadata through the reflection API is significantly slower than a direct method call or property access, because it involves traversing data structures the runtime doesn't optimise the way it optimises compiled code. In isolation – reading an attribute once during test suite setup – the cost is negligible. In a hot path – reading an attribute inside every test assertion, or re-discovering types on every HTTP request – the cost accumulates and becomes measurable.
Why Reflection Is Slow
Three factors drive reflection's cost. First, GetCustomAttribute<T>() on a MethodInfo allocates a new attribute instance each time it's called – it doesn't return a cached reference. Second, GetMethods() allocates a new array every call. Third, MethodInfo.Invoke bypasses the JIT's inlining and specialisation, adding per-call overhead compared to a direct call. None of these matter for one-time initialisation; all of them matter in code that executes thousands of times per second.
// Demonstrating the allocation issue – never do this in a hot path
public bool ShouldRunInEnvironment(MethodInfo method, string environment)
{
// BAD: allocates a new attribute instance on every call
var attr = method.GetCustomAttribute<TestEnvironmentAttribute>();
return attr is null || attr.Environments.Contains(environment);
}
// Better: cache the lookup result keyed by the MethodInfo
private static readonly ConcurrentDictionary<MethodInfo, string[]> _envCache = new();
public bool ShouldRunInEnvironmentCached(MethodInfo method, string environment)
{
// GetOrAdd is atomic – safe for concurrent test runners
var environments = _envCache.GetOrAdd(method, m =>
m.GetCustomAttribute<TestEnvironmentAttribute>()?.Environments
?? Array.Empty<string>());
return environments.Length == 0 ||
environments.Contains(environment, StringComparer.OrdinalIgnoreCase);
}
Caching an Entire Test Inventory
For test discovery specifically, compute the full inventory once during suite initialization and store it. All subsequent lookups read from the in-memory collection, which is as fast as any other dictionary access:
// A registry that builds its inventory once and serves all subsequent queries from cache
public sealed class TestRegistry
{
private readonly IReadOnlyList<TestDescriptor> _allTests;
private readonly ILookup<string, TestDescriptor> _byCategory;
private readonly ILookup<string, TestDescriptor> _byEnvironment;
public TestRegistry(Assembly assembly)
{
// Reflection happens once here, at construction time
_allTests = DiscoverTests(assembly);
// Each test can carry multiple categories – SelectMany flattens them so the
// test appears in the lookup under every category it carries, not just the first.
_byCategory = _allTests
.SelectMany(t => t.Categories.Length == 0
? new[] { (cat: string.Empty, test: t) }
: t.Categories.Select(c => (cat: c, test: t)))
.ToLookup(x => x.cat, x => x.test, StringComparer.OrdinalIgnoreCase);
// Tests with no environment restriction appear under the wildcard "*" sentinel.
// Tests with restrictions appear under each of their environment names.
// SelectMany pairs each descriptor with every environment key it belongs to,
// so the lookup correctly maps "Staging" → tests that run on Staging, etc.
_byEnvironment = _allTests
.SelectMany(t => (t.Environments.Length == 0
? new[] { "*" }
: t.Environments)
.Select(env => (env, test: t)))
.ToLookup(x => x.env, x => x.test, StringComparer.OrdinalIgnoreCase);
}
public IEnumerable<TestDescriptor> GetForEnvironment(string environment)
{
// Tests tagged for this environment + unrestricted tests (wildcard sentinel)
return _byEnvironment[environment].Concat(_byEnvironment["*"]).Distinct();
}
public IEnumerable<TestDescriptor> GetByCategory(string category) =>
_byCategory[category];
public int TotalCount => _allTests.Count;
}
Compiled Expressions as a Reflection Alternative
When repeated invocation of a discovered method is the performance concern – rather than attribute reading – compiled expression trees offer a way to pay the reflection cost once and get direct-call performance thereafter. The pattern compiles a MethodInfo into a typed delegate that the JIT can optimise like any other delegate invocation:
// Compile a parameterless instance method into a Func<object, Task> delegate.
// The compilation cost is paid once; subsequent calls have near-direct-call performance.
public static Func<object, Task> CompileAsyncTestMethod(MethodInfo method)
{
// Parameter expression: the test class instance (typed as object for generality)
var instanceParam = Expression.Parameter(typeof(object), "instance");
// Cast the object parameter to the concrete type
var castInstance = Expression.Convert(instanceParam, method.DeclaringType!);
// Build a call expression: ((ConcreteType)instance).Method()
var callExpr = Expression.Call(castInstance, method);
// If the method returns void, wrap it to return Task.CompletedTask
Expression body = method.ReturnType == typeof(void)
? Expression.Block(callExpr, Expression.Constant(Task.CompletedTask))
: callExpr; // already returns Task
return Expression.Lambda<Func<object, Task>>(body, instanceParam).Compile();
}
// Usage: compile once during discovery, invoke many times during execution
var compiled = DiscoverTests(assembly)
.Select(t => new
{
Descriptor = t,
CompiledRun = CompileAsyncTestMethod(t.Method)
})
.ToList();
// Each call to CompiledRun(instance) is as fast as a direct method call
foreach (var entry in compiled)
{
var instance = Activator.CreateInstance(entry.Descriptor.TestClass)!;
await entry.CompiledRun(instance);
}
When to Reach for Caching
Reflection caching is not always necessary. During test suite startup, even repeated reflection over hundreds of types is fast enough that it won't be the bottleneck. Cache when reflection results are read inside a loop that executes during the test run itself – per-assertion attribute checks, per-request middleware lookups, per-iteration dynamic dispatch. The heuristic: if the reflective call executes more than once per test method, cache it. If it executes once per suite or once per type at startup, it's almost certainly fast enough as-is.
Reflection's performance profile is well understood: expensive to read, essentially free to use the result once cached. The discipline of separating the discovery phase (reflection, done once) from the execution phase (compiled delegates or cached metadata, done repeatedly) keeps custom framework code both powerful and fast.
Key Takeaways
- Reflection reads assembly metadata at runtime. The
Typeclass is the entry point.MethodInfo,PropertyInfo, andFieldInfodescribe individual members. All are obtained throughtypeof(T),obj.GetType(), orAssembly.GetTypes(). - Custom attributes are classes that inherit from
Attribute. Constructor parameters are required; public properties provide optional named parameters. Attribute arguments must be compile-time constants – string literals, numeric values,typeof()expressions, or enum members. AttributeUsagecontrols placement, repetition, and inheritance.AttributeTargetsrestricts where the attribute may be applied.AllowMultiple = trueenables stacking.Inherited = truepropagates the attribute through the inheritance hierarchy – important for base class test patterns.GetCustomAttribute<T>()returns null if the attribute is absent. Always null-check the result. UseIsDefined()for a faster existence check when you don't need the attribute's data. UseGetCustomAttributes<T>()whenAllowMultiple = trueand multiple instances may be present.- Test discovery follows a consistent pattern. Load the assembly, enumerate types, filter to test classes by attribute, enumerate methods, filter to test methods by attribute, build descriptors with all relevant metadata. The result is a queryable inventory that drives filtering, execution, and reporting.
MethodInfo.Invokewraps exceptions inTargetInvocationException. Always unwrap withex.InnerException ?? exto surface the actual failure. Async test methods return aTaskfromInvoke– await it to let the async body complete and exceptions propagate.- Cache reflection results that are read repeatedly.
GetCustomAttribute<T>()allocates a new instance each call.GetMethods()allocates a new array each call. UseConcurrentDictionary.GetOrAddfor thread-safe caching in parallel test runners. Compute the full test inventory once at startup and serve queries from the cached collection. - Compiled expression trees eliminate per-call reflection overhead. For hot-path dynamic dispatch, compile a
MethodInfointo a typed delegate once usingExpression.Lambda.Compile(). Subsequent calls have near-direct-call performance while preserving the flexibility of dynamic discovery.
Further Reading
- Reflection and Attributes (Microsoft) The official C# guide to reflection and attributes, covering Type exploration, custom attribute design, runtime attribute retrieval, and late binding through dynamic invocation.
- Expression Trees (Microsoft) Complete documentation for expression trees in C#: building, compiling, and executing expressions at runtime. Essential reading for understanding how to eliminate reflection overhead in hot-path dynamic dispatch.
- Writing Custom Attributes (Microsoft) Deep reference on custom attribute design including AttributeUsage configuration, positional and named parameters, attribute retrieval patterns, and how the C# compiler processes attribute declarations.
- Unit Testing Best Practices (Microsoft) Microsoft's guidance on structuring unit tests in .NET, including naming conventions, test organisation principles, and the use of test attributes – useful context for understanding how attribute-driven frameworks are intended to be consumed.