NUnit allows for data-driven parameterized tests and does so by incorporating the data points into the test name as method arguments. For example, something like this:
[TestCaseSource(nameof(GenerateData))]public void MyTest(int cost){ Assert.Pass();}private static int[] GenerateData(){ return [14, 140 1400];}
Will translate into three distinct tests named (by default):
- “MyTest(14)”
- “MyTest(140)”
- “MyTest(1400)”
This is concise but it’s not clear what the data points represent, especially if the test runs or reports are being reviewed by someone unfamiliar with the test implementation. It would be clearer if the parameter name could be included so that it’s clear what the number represents. Ex:
- “MyTest(cost: 14)”
- “MyTest(cost: 140)”
- “MyTest(cost: 1400)”
The argument display in the test name can be customized if desired by updating the source of the data to return TestCaseData instances instead of the raw underlying data. The TestCaseData class supports a few different ways to include the parameter name in the test method. Each of the below are built-in ways to do that.
[TestCaseSource(nameof(GenerateData))]public void MyTest(int cost){ Assert.Pass();}private static TestCaseData[] GenerateData(){ return [ new TestCaseData(14).SetArgDisplayNames("cost: 14"), new TestCaseData(140).SetName("{m}{a}"), new TestCaseData(1400).SetName("{m}{p}") ];}
One downside of this is they must specified separately for each test case, and in some cases even duplicated within a single test case. This leaves the possibility of inconsistencies over time. While NUnit doesn’t support a centralized way to support this formatting today, a custom attribute could be developed to help. For example, something like this can be applied as desired to individual method parameters to format them. Note that it requires both a parameter-level attribute as well as a custom test case source attribute.
Usage:
[CustomTestCaseSource(nameof(GenerateData))]public void MyTest([IncludeParamName]int cost){ Assert.Pass();}private static TestCaseData[] GenerateData(){ return [ new TestCaseData(14), new TestCaseData(140).SetArgDisplayNames("19"), // Setting the name here will cause the parameter-level pretty printing to get skipped new TestCaseData(1400).SetName("{m}{p}") ];}
Implementation:
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]public class IncludeParamNameAttribute : Attribute { }public class CustomTestCaseSource : TestCaseSourceAttribute, ITestBuilder{ private readonly NUnitTestCaseBuilder _builder = new(); private const BindingFlags PrivateInternalBinding = BindingFlags.NonPublic | BindingFlags.Instance; private static readonly MethodInfo? GetTestCasesForMethod = typeof(TestCaseSourceAttribute) .GetMethod("GetTestCasesFor", PrivateInternalBinding, [typeof(IMethodInfo)] ); public CustomTestCaseSource(string source) : base(source) { } IEnumerable<TestMethod> ITestBuilder.BuildFrom(IMethodInfo method, Test? suite) { var GetTestCasesFor = GetTestCasesForMethod!.CreateDelegate<Func<IMethodInfo, IEnumerable<ITestCaseData>>>(this); var argDisplayNamesProperty = typeof(NUnit.Framework.Internal.TestParameters) .GetProperty("ArgDisplayNames", PrivateInternalBinding); var methodParams = method.MethodInfo.GetParameters(); var paramNameDisplayMap = methodParams.Select(o => o.GetCustomAttribute<IncludeParamNameAttribute>() is not null).ToArray(); var doShowParameterNames = paramNameDisplayMap.Any(o => o); int count = 0; foreach (ITestCaseData parms in GetTestCasesFor(method)) { // Check if TestName is set or not as ArgDisplayNames can not be used if TestName is set if (doShowParameterNames && parms.TestName is null && parms is TestCaseData tcd) { var displayNames = (string[]?)argDisplayNamesProperty?.GetValue(tcd) ?? new string[methodParams.Length]; var hasChangedNames = false; for(var i = 0; i < displayNames.Length; i++) { var displayName = displayNames[i] ?? parms.Arguments[i]?.ToString() ?? "null"; if (paramNameDisplayMap[i]) { displayNames[i] = $"{methodParams[i].Name}: {displayName}"; hasChangedNames = true; } else { displayNames[i] = displayName; } } if (hasChangedNames) { argDisplayNamesProperty?.SetValue(tcd, displayNames); } } count++; yield return _builder.BuildTestMethod(method, suite, (TestCaseParameters)parms); } // BELOW WAS COPIED FROM THE BUILT-IN TESTCASESOURCE IMPLEMENTATION // ----------------------------- // If count > 0, error messages will be shown for each case // but if it's 0, we need to add an extra "test" to show the message. if (count == 0 && method.GetParameters().Length == 0) { var parms = new TestCaseParameters(); parms.RunState = RunState.NotRunnable; parms.Properties.Set(PropertyNames.SkipReason, "TestCaseSourceAttribute may not be used on a method without parameters"); yield return _builder.BuildTestMethod(method, suite, parms); } }}
Disclaimer: This was written as a PoC for a one-off purpose and may still have a few edge cases not accounted for but it should be stable enough for the majority of cases. Enjoy!
