From 1a29bdbb3af1f694648b2ffc039b27f7108a883e Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 20 Apr 2026 11:19:05 +0200 Subject: [PATCH 1/3] [tests] Fix NUnit summary counts for ignored tests Use NUnit's aggregate TestSuiteResult counts when reporting the final summary so skipped/ignored fixtures are included in the console output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/TestRunner.NUnit/NUnitTestRunner.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/TestRunner.NUnit/NUnitTestRunner.cs b/tests/TestRunner.NUnit/NUnitTestRunner.cs index 3da3ea4e07e..b34cd101a4d 100644 --- a/tests/TestRunner.NUnit/NUnitTestRunner.cs +++ b/tests/TestRunner.NUnit/NUnitTestRunner.cs @@ -72,11 +72,25 @@ public override void Run (IList testAssemblies) if (testResult == null) throw new InvalidOperationException ($"Unexpected test result type '{result.GetType ()}'"); results.AddResult (testResult); + UpdateSummaryCounts (); } LogFailureSummary (); } + void UpdateSummaryCounts () + { + if (results == null) { + return; + } + + PassedTests = results.PassCount; + FailedTests = results.FailCount; + SkippedTests = results.SkipCount; + InconclusiveTests = results.InconclusiveCount; + ExecutedTests = PassedTests + FailedTests + SkippedTests + InconclusiveTests; + } + public bool Pass (ITest test) { return true; From 3ebd97fc71bb426caf8b3ea8e7b8448ae40d59d3 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 20 Apr 2026 11:53:16 +0200 Subject: [PATCH 2/3] [tests] Add NUnit dry-run and exclusion audit support Add discovery-only dry runs, a noexclusions switch, explicit skip reasons, and ignored-test visibility for the Android NUnit harness. Also overwrite stale logcat captures so crash diagnosis reflects the current run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../RunInstrumentationTests.cs | 2 +- .../RunUITests.cs | 2 +- build-tools/scripts/TestApks.targets | 4 +- tests/TestRunner.Core/TestInstrumentation.cs | 23 +++ tests/TestRunner.Core/TestRunner.cs | 1 + .../NUnitTestInstrumentation.cs | 57 ++++++- tests/TestRunner.NUnit/NUnitTestRunner.cs | 144 +++++++++++++++++- 7 files changed, 221 insertions(+), 12 deletions(-) diff --git a/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RunInstrumentationTests.cs b/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RunInstrumentationTests.cs index eb03232b392..8e63422fbe7 100644 --- a/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RunInstrumentationTests.cs +++ b/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RunInstrumentationTests.cs @@ -94,7 +94,7 @@ public override bool Execute () new CommandInfo { ArgumentsString = $"{AdbTarget} {AdbOptions} logcat -v threadtime -d", StdoutFilePath = LogcatFilename, - StdoutAppend = true, + StdoutAppend = false, }, new CommandInfo { diff --git a/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RunUITests.cs b/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RunUITests.cs index a1aa2c9b27c..0b4c4cbc54b 100644 --- a/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RunUITests.cs +++ b/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RunUITests.cs @@ -36,7 +36,7 @@ protected override void AfterCommand (int commandIndex, CommandInfo info) ArgumentsString = $"{AdbTarget} {AdbOptions} logcat -v threadtime -d", MergeStdoutAndStderr = false, StdoutFilePath = LogcatFilename, - StdoutAppend = true, + StdoutAppend = false, }, new CommandInfo { diff --git a/build-tools/scripts/TestApks.targets b/build-tools/scripts/TestApks.targets index 2a98197cabf..00182feb939 100644 --- a/build-tools/scripts/TestApks.targets +++ b/build-tools/scripts/TestApks.targets @@ -274,6 +274,8 @@ <_IncludeCategories Condition=" '$(IncludeCategories)' != '' ">include=$(IncludeCategories) <_ExcludeCategories Condition=" '$(ExcludeCategories)' != '' ">exclude=$(ExcludeCategories) + <_DryRunTests Condition=" '$(DryRunTests)' == 'true' ">dryrun=true + <_NoTestExclusions Condition=" '$(NoTestExclusions)' == 'true' ">noexclusions=true $(TestsFlavor) StringExtrasInBundle { get; set; } = new Dictionary (); protected string TestSuiteToRun { get; set; } + protected bool DryRunRequested { get; set; } + protected bool NoExclusionsRequested { get; set; } protected TestInstrumentation () {} @@ -104,6 +108,20 @@ protected virtual void ProcessArguments () if (StringExtrasInBundle.ContainsKey (KnownArguments.Suite)) { TestSuiteToRun = StringExtrasInBundle [KnownArguments.Suite]?.Trim (); } + + if (StringExtrasInBundle.TryGetValue (KnownArguments.DryRun, out string dryRunValue)) { + DryRunRequested = + String.Equals (dryRunValue?.Trim (), "true", StringComparison.OrdinalIgnoreCase) || + String.Equals (dryRunValue?.Trim (), "1", StringComparison.OrdinalIgnoreCase) || + String.Equals (dryRunValue?.Trim (), "yes", StringComparison.OrdinalIgnoreCase); + } + + if (StringExtrasInBundle.TryGetValue (KnownArguments.NoExclusions, out string noExclusionsValue)) { + NoExclusionsRequested = + String.Equals (noExclusionsValue?.Trim (), "true", StringComparison.OrdinalIgnoreCase) || + String.Equals (noExclusionsValue?.Trim (), "1", StringComparison.OrdinalIgnoreCase) || + String.Equals (noExclusionsValue?.Trim (), "yes", StringComparison.OrdinalIgnoreCase); + } } public override void OnStart () @@ -259,8 +277,13 @@ bool RunTests (ref Bundle results) TRunner runner = CreateRunner (Logger, arguments); runner.LogTag = LogTag; + runner.DryRun = DryRunRequested; ConfigureFilters (runner); + if (runner.DryRun) { + Log.Info (LogTag, "Dry-run discovery mode enabled; tests will be enumerated but not executed."); + } + Log.Info (LogTag, "Starting unit tests"); runner.Run (assemblies); Log.Info (LogTag, "Unit tests completed"); diff --git a/tests/TestRunner.Core/TestRunner.cs b/tests/TestRunner.Core/TestRunner.cs index 886063a53bf..62d3c6904a4 100644 --- a/tests/TestRunner.Core/TestRunner.cs +++ b/tests/TestRunner.Core/TestRunner.cs @@ -19,6 +19,7 @@ public abstract class TestRunner public long ExecutedTests { get; protected set; } = 0; public long TotalTests { get; protected set; } = 0; public long FilteredTests { get; protected set; } = 0; + public bool DryRun { get; set; } = false; public bool RunInParallel { get; set; } = false; public string TestsRootDirectory { get; set; } public Context Context { get; } diff --git a/tests/TestRunner.NUnit/NUnitTestInstrumentation.cs b/tests/TestRunner.NUnit/NUnitTestInstrumentation.cs index 6ce32f987ca..15674ad1b01 100644 --- a/tests/TestRunner.NUnit/NUnitTestInstrumentation.cs +++ b/tests/TestRunner.NUnit/NUnitTestInstrumentation.cs @@ -17,6 +17,8 @@ public abstract class NUnitTestInstrumentation : TestInstrumentation IncludedCategories { get; set; } protected IEnumerable ExcludedCategories { get; set; } protected IEnumerable ExcludedTestNames { get; set; } + protected IDictionary ExcludedCategoryReasons { get; set; } + protected IDictionary ExcludedTestReasons { get; set; } protected string TestsDirectory { get; set; } protected NUnitTestInstrumentation () @@ -67,14 +69,22 @@ protected override void ConfigureFilters (NUnitTestRunner runner) Log.Info (LogTag, "Configuring test categories to include from extras:"); ChainCategoryFilter (GetFilterValuesFromExtras (KnownArguments.Include), false, ref filter); - Log.Info (LogTag, "Configuring test categories to exclude:"); - ChainCategoryFilter (ExcludedCategories, true, ref filter); + if (NoExclusionsRequested) { + Log.Info (LogTag, "Skipping built-in test exclusions due to noexclusions=true."); + } else { + Log.Info (LogTag, "Configuring test categories to exclude:"); + RegisterExcludedCategories (runner, ExcludedCategories); + } Log.Info(LogTag, "Configuring test categories to exclude from extras:"); - ChainCategoryFilter (GetFilterValuesFromExtras (KnownArguments.Exclude), true, ref filter); + RegisterExcludedCategories (runner, GetFilterValuesFromExtras (KnownArguments.Exclude)); - Log.Info (LogTag, "Configuring tests to exclude (by name):"); - ChainTestNameFilter (ExcludedTestNames?.ToArray (), ref filter); + if (NoExclusionsRequested) { + Log.Info (LogTag, "Skipping built-in test-name exclusions due to noexclusions=true."); + } else { + Log.Info (LogTag, "Configuring tests to exclude (by name):"); + RegisterExcludedTestNames (runner, ExcludedTestNames?.ToArray ()); + } if (filter.IsEmpty) return; @@ -104,7 +114,31 @@ void ChainCategoryFilter (IEnumerable categories, bool negate, ref ITes Log.Info (LogTag, " none"); } - void ChainTestNameFilter (string[] testNames, ref ITestFilter filter) + void RegisterExcludedCategories (NUnitTestRunner runner, IEnumerable categories) + { + bool gotCategories = false; + if (categories != null) { + foreach (string c in categories) { + Log.Info (LogTag, $" {c}"); + runner.AddExcludedCategory (c, GetExcludedCategoryReason (c)); + gotCategories = true; + } + } + + if (!gotCategories) + Log.Info (LogTag, " none"); + } + + string GetExcludedCategoryReason (string category) + { + if (ExcludedCategoryReasons != null && !String.IsNullOrEmpty (category) && ExcludedCategoryReasons.TryGetValue (category, out string reason)) { + return reason; + } + + return $"Excluded category '{category}'."; + } + + void RegisterExcludedTestNames (NUnitTestRunner runner, string[] testNames) { if (testNames == null || testNames.Length == 0) { Log.Info (LogTag, " none"); @@ -115,10 +149,17 @@ void ChainTestNameFilter (string[] testNames, ref ITestFilter filter) if (String.IsNullOrEmpty (name)) continue; Log.Info (LogTag, $" {name}"); + runner.AddExcludedTestName (name, GetExcludedTestReason (name)); + } + } + + string GetExcludedTestReason (string testName) + { + if (ExcludedTestReasons != null && !String.IsNullOrEmpty (testName) && ExcludedTestReasons.TryGetValue (testName, out string reason)) { + return reason; } - var excludeTestNamesFilter = new SimpleNameFilter (testNames); - filter = new AndFilter (filter, new NotFilter (excludeTestNamesFilter)); + return $"Excluded test '{testName}'."; } } } diff --git a/tests/TestRunner.NUnit/NUnitTestRunner.cs b/tests/TestRunner.NUnit/NUnitTestRunner.cs index b34cd101a4d..41eb7679712 100644 --- a/tests/TestRunner.NUnit/NUnitTestRunner.cs +++ b/tests/TestRunner.NUnit/NUnitTestRunner.cs @@ -20,8 +20,12 @@ namespace Xamarin.Android.UnitTests.NUnit { public class NUnitTestRunner : TestRunner, ITestListener { + const string DryRunSkipReason = "Dry run: discovery only."; + Dictionary builderSettings; TestSuiteResult results; + readonly Dictionary excludedCategories = new Dictionary (StringComparer.OrdinalIgnoreCase); + readonly Dictionary excludedTestNames = new Dictionary (StringComparer.Ordinal); public ITestFilter Filter { get; set; } = TestFilter.Empty; public bool GCAfterEachFixture { get; set; } @@ -33,6 +37,24 @@ public NUnitTestRunner (Context context, LogWriter logger, Bundle bundle) : base builderSettings = new Dictionary (StringComparer.OrdinalIgnoreCase); } + public void AddExcludedCategory (string category, string reason) + { + if (String.IsNullOrEmpty (category)) { + return; + } + + excludedCategories [category] = reason; + } + + public void AddExcludedTestName (string testName, string reason) + { + if (String.IsNullOrEmpty (testName)) { + return; + } + + excludedTestNames [testName] = reason; + } + public override void Run (IList testAssemblies) { if (testAssemblies == null) @@ -51,8 +73,14 @@ public override void Run (IList testAssemblies) OnWarning ($"Failed to load tests from assembly '{assemblyInfo.Assembly}"); continue; } - if (runner.LoadedTest is NUnitTest tests) + if (runner.LoadedTest is NUnitTest tests) { testSuite.Add (tests); + ApplyIgnoredExclusions (tests); + UpdateDiscoveredTestCounts (tests); + if (DryRun) { + ApplyDryRunToMatchingTests (tests); + } + } // Messy API. .Run returns ITestResult which is, in reality, an instance of TestResult since that's // what WorkItem returns and we need an instance of TestResult to add it to TestSuiteResult. So, cast @@ -91,6 +119,120 @@ void UpdateSummaryCounts () ExecutedTests = PassedTests + FailedTests + SkippedTests + InconclusiveTests; } + void ApplyIgnoredExclusions (NUnitTest test) + { + if (test.RunState != RunState.Runnable && test.RunState != RunState.Explicit) { + return; + } + + if (!TryGetSkipReason (test, out string reason)) { + if (test is TestSuite suite) { + foreach (NUnitTest child in suite.Tests) { + ApplyIgnoredExclusions (child); + } + } + return; + } + + test.RunState = RunState.Ignored; + test.Properties.Set (PropertyNames.SkipReason, reason); + } + + void UpdateDiscoveredTestCounts (NUnitTest test) + { + if (test is TestSuite suite) { + foreach (NUnitTest child in suite.Tests) { + UpdateDiscoveredTestCounts (child); + } + return; + } + + TotalTests++; + if (Filter == null || Filter.IsEmpty || Filter.Pass (test)) { + FilteredTests++; + } + } + + void ApplyDryRunToMatchingTests (NUnitTest test) + { + if (test is TestSuite suite) { + foreach (NUnitTest child in suite.Tests) { + ApplyDryRunToMatchingTests (child); + } + return; + } + + if (Filter != null && !Filter.IsEmpty && !Filter.Pass (test)) { + return; + } + + if (test.RunState == RunState.Runnable || test.RunState == RunState.Explicit) { + test.RunState = RunState.Ignored; + test.Properties.Set (PropertyNames.SkipReason, DryRunSkipReason); + } + + string reason = test.Properties.Get (PropertyNames.SkipReason) as string; + if (String.IsNullOrEmpty (reason)) { + Logger.OnInfo (LogTag, $"[DRY-RUN] {test.FullName}"); + } else { + Logger.OnInfo (LogTag, $"[DRY-RUN] {test.FullName} [{reason}]"); + } + } + + bool TryGetSkipReason (NUnitTest test, out string reason) + { + if (TryGetNamedSkipReason (test, out reason)) { + return true; + } + + return TryGetCategorySkipReason (test, out reason); + } + + bool TryGetNamedSkipReason (NUnitTest test, out string reason) + { + foreach (var kvp in excludedTestNames) { + if (TestNameMatches (test.FullName, kvp.Key)) { + reason = kvp.Value; + return true; + } + } + + reason = String.Empty; + return false; + } + + static bool TestNameMatches (string fullName, string excludedName) + { + if (String.IsNullOrEmpty (fullName) || String.IsNullOrEmpty (excludedName)) { + return false; + } + + if (fullName == excludedName || + fullName.StartsWith (excludedName + ".", StringComparison.Ordinal) || + fullName.StartsWith (excludedName + "+", StringComparison.Ordinal) || + fullName.Contains (", " + excludedName, StringComparison.Ordinal) || + fullName.Contains (excludedName + ".", StringComparison.Ordinal) || + fullName.Contains (excludedName + "+", StringComparison.Ordinal)) { + return true; + } + + return false; + } + + bool TryGetCategorySkipReason (NUnitTest test, out string reason) + { + if (test.Properties [PropertyNames.Category] is IList categories) { + foreach (object value in categories) { + if (value is string category && excludedCategories.TryGetValue (category, out reason)) { + return true; + } + } + } + + reason = String.Empty; + return false; + } + public bool Pass (ITest test) { return true; From 0fb861a2b5de19e20bedaa4677ff9cc53f605e2e Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 20 Apr 2026 12:00:24 -0500 Subject: [PATCH 3/3] Address code review feedback - Fix TestNameMatches to use word-boundary-aware Contains checks (prefix with '.' or '+') instead of bare Contains that could match mid-word substrings - Remove dead counter increments in TestFinished since UpdateSummaryCounts overwrites them from the NUnit aggregate result - Extract static ParseBool helper to eliminate repeated Trim() calls in ProcessArguments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/TestRunner.Core/TestInstrumentation.cs | 18 ++++++------ tests/TestRunner.NUnit/NUnitTestRunner.cs | 30 ++++++-------------- 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/tests/TestRunner.Core/TestInstrumentation.cs b/tests/TestRunner.Core/TestInstrumentation.cs index b88cf0e1534..4602e668c77 100644 --- a/tests/TestRunner.Core/TestInstrumentation.cs +++ b/tests/TestRunner.Core/TestInstrumentation.cs @@ -110,20 +110,22 @@ protected virtual void ProcessArguments () } if (StringExtrasInBundle.TryGetValue (KnownArguments.DryRun, out string dryRunValue)) { - DryRunRequested = - String.Equals (dryRunValue?.Trim (), "true", StringComparison.OrdinalIgnoreCase) || - String.Equals (dryRunValue?.Trim (), "1", StringComparison.OrdinalIgnoreCase) || - String.Equals (dryRunValue?.Trim (), "yes", StringComparison.OrdinalIgnoreCase); + DryRunRequested = ParseBool (dryRunValue); } if (StringExtrasInBundle.TryGetValue (KnownArguments.NoExclusions, out string noExclusionsValue)) { - NoExclusionsRequested = - String.Equals (noExclusionsValue?.Trim (), "true", StringComparison.OrdinalIgnoreCase) || - String.Equals (noExclusionsValue?.Trim (), "1", StringComparison.OrdinalIgnoreCase) || - String.Equals (noExclusionsValue?.Trim (), "yes", StringComparison.OrdinalIgnoreCase); + NoExclusionsRequested = ParseBool (noExclusionsValue); } } + static bool ParseBool (string value) + { + string trimmed = value?.Trim (); + return String.Equals (trimmed, "true", StringComparison.OrdinalIgnoreCase) || + String.Equals (trimmed, "1", StringComparison.OrdinalIgnoreCase) || + String.Equals (trimmed, "yes", StringComparison.OrdinalIgnoreCase); + } + public override void OnStart () { base.OnStart (); diff --git a/tests/TestRunner.NUnit/NUnitTestRunner.cs b/tests/TestRunner.NUnit/NUnitTestRunner.cs index 41eb7679712..de8f835a73b 100644 --- a/tests/TestRunner.NUnit/NUnitTestRunner.cs +++ b/tests/TestRunner.NUnit/NUnitTestRunner.cs @@ -210,9 +210,9 @@ static bool TestNameMatches (string fullName, string excludedName) if (fullName == excludedName || fullName.StartsWith (excludedName + ".", StringComparison.Ordinal) || fullName.StartsWith (excludedName + "+", StringComparison.Ordinal) || - fullName.Contains (", " + excludedName, StringComparison.Ordinal) || - fullName.Contains (excludedName + ".", StringComparison.Ordinal) || - fullName.Contains (excludedName + "+", StringComparison.Ordinal)) { + fullName.Contains ("." + excludedName + ".", StringComparison.Ordinal) || + fullName.Contains ("." + excludedName + "+", StringComparison.Ordinal) || + fullName.Contains (", " + excludedName, StringComparison.Ordinal)) { return true; } @@ -257,10 +257,8 @@ public void TestFinished (ITestResult result) Action log = Logger.OnInfo; StringBuilder failedMessage = null; - ExecutedTests++; if (result.ResultState.Status == TestStatus.Passed) { Logger.OnInfo (LogTag, $"\t{result.ResultState.ToString ()}"); - PassedTests++; } else if (result.ResultState.Status == TestStatus.Failed) { Logger.OnError (LogTag, "\t[FAIL]"); log = Logger.OnError; @@ -269,24 +267,12 @@ public void TestFinished (ITestResult result) if (result.Test.FixtureType != null) failedMessage.Append ($" ({result.Test.FixtureType.Assembly.GetName ().Name})"); failedMessage.AppendLine (); - FailedTests++; } else { - string status; - switch (result.ResultState.Status) { - case TestStatus.Skipped: - SkippedTests++; - status = "SKIPPED"; - break; - - case TestStatus.Inconclusive: - InconclusiveTests++; - status = "INCONCLUSIVE"; - break; - - default: - status = "UNKNOWN"; - break; - } + string status = result.ResultState.Status switch { + TestStatus.Skipped => "SKIPPED", + TestStatus.Inconclusive => "INCONCLUSIVE", + _ => "UNKNOWN", + }; Logger.OnInfo (LogTag, $"\t[{status}]"); }