| name | nunit-performance |
| description | Use when modifying hot paths in NUnit — constraint evaluation, equality comparison, value formatting, reflection-based dispatch, collection inspection, or anything that runs once per assertion or per test discovery. Covers allocation patterns, reflection caching, and how the team expects performance claims to be backed up. |
NUnit performance
NUnit's hot paths run on every assertion in every test. A 10 µs regression in EqualConstraint is invisible in a single test and a wall in a 100k-test suite. The conventions below exist because the codebase has been bitten by each of them.
Cache reflection results per Type
Type.GetProperties(), GetMethods(), GetInterfaces(), GetCustomAttributes() are not free — they walk the type, allocate arrays, and on .NET Framework hit a backing dictionary lookup. Inside hot paths, cache the result keyed by Type.
private static IEnumerable<PropertyInfo> InterestingProperties(Type type)
=> type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.GetIndexParameters().Length == 0);
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _propertyCache = new();
private static PropertyInfo[] InterestingProperties(Type type)
=> _propertyCache.GetOrAdd(type, static t =>
t.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.GetIndexParameters().Length == 0)
.ToArray());
Use a static field so the cache is per-process, not per-instance. Use ConcurrentDictionary when the hot path may run on multiple threads (constraint evaluation can).
Allocate lazily
Don't allocate state up-front for the failure path. Many constraints succeed; only the failing path needs the diagnostic state.
private List<object> _missingItems = new();
public bool ApplyTo(IEnumerable expected, IEnumerable actual)
{
_missingItems = new List<object>();
...
}
private List<object>? _missingItems;
public bool ApplyTo(IEnumerable expected, IEnumerable actual)
{
foreach (var item in expected)
{
if (!Contains(actual, item))
(_missingItems ??= new List<object>()).Add(item);
}
...
}
Same pattern for diagnostic strings, exception messages, and per-call scratch buffers.
Prefer direct iteration over LINQ in hot code
LINQ is fine for clarity in setup code, tests, and one-shot helpers. In assertion / discovery / equality paths it allocates iterators, closures, and lambdas. Use for/foreach directly.
return delegates.GetInvocationList().FirstOrDefault(d => d.Method == method) is not null;
foreach (var d in delegates.GetInvocationList())
{
if (d.Method == method) return true;
}
return false;
Note: Delegate.GetInvocationList() itself returns an array — iterate it directly, don't wrap it in .ForEach(...) extension methods (which are 3× slower than foreach).
stackalloc for small buffers, ArrayPool for larger ones
For temporary byte/char/int buffers in hot code:
- ≤ ~1 KB (rule of thumb on the team):
stackalloc the buffer. No GC pressure, no rental cost.
- > 1 KB or size unknown at compile time: rent from
ArrayPool<T>.Shared and return it in a try/finally.
- Tiny and bounded (a few elements): a plain stack
Span<T> over a stackalloc is usually fastest.
Span<byte> buffer = stackalloc byte[64];
var rented = ArrayPool<byte>.Shared.Rent(size);
try
{
var span = rented.AsSpan(0, size);
...
}
finally
{
ArrayPool<byte>.Shared.Return(rented);
}
Don't new byte[size] in a hot path unless the buffer escapes.
Avoid boxing on value types
object.Equals, IComparable.CompareTo non-generic forms, and IEnumerable (non-generic) box every value-type element. In equality / comparison paths, prefer the generic interfaces (IEquatable<T>, IComparable<T>, IEnumerable<T>) and constrain or check for them at the entry point.
if (((IComparable)x).CompareTo(y) != 0) ...
if (EqualityComparer<T>.Default.Equals(x, y)) ...
if (x is IEquatable<T> eq) return eq.Equals((T)y);
For collection comparison, EqualityComparer<T>.Default and Comparer<T>.Default cache the per-T comparer instance; use them rather than constructing one per call.
Don't allocate strings you don't use
Diagnostic message construction is a common offender. The constraint succeeds 99% of the time — don't pay for the message until you know it's needed.
var message = $"Expected {expected} but was {actual}";
if (!matches)
throw new AssertionException(message);
if (!matches)
throw new AssertionException($"Expected {expected} but was {actual}");
Same for string.Format, interpolated strings stored in a variable, and concatenation.
Beware of hidden params allocations
If you are designing internal helper methods for diagnostics, avoid params object[] arrays if the method is called on the happy path. Provide explicit overloads for 1, 2, or 3 arguments to avoid the array allocation, or ensure the formatting method is only invoked after an assertion has already failed.
Back performance claims with a benchmark
The team expects performance changes to come with evidence. For non-trivial perf PRs, include a BenchmarkDotNet benchmark showing the before/after numbers.
Agent Action: When proposing a performance change, explicitly outline the benchmark setup. Define a repeatable baseline, isolate the target endpoint or method, and structure a re-measure loop that tests against distinct data profiles: an empty input, a single element, a 100-element input, and a high-cardinality 10k-element input. "I think this should be faster" is unacceptable; provide the deterministic metrics.