-
-
Notifications
You must be signed in to change notification settings - Fork 107
feat: add JUnit reporter and command provider for test results output #3987
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
dc7ff91
c612ac8
adfd92c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| using Microsoft.Testing.Platform.Extensions; | ||
| using TUnit.Core; | ||
| using TUnit.Engine.Reporters; | ||
|
|
||
| namespace TUnit.Engine.Tests; | ||
|
|
||
| [NotInParallel] | ||
| public class JUnitReporterTests | ||
| { | ||
| private sealed class MockExtension : IExtension | ||
| { | ||
| public string Uid => "MockExtension"; | ||
| public string DisplayName => "Mock"; | ||
| public string Version => "1.0.0"; | ||
| public string Description => "Mock Extension"; | ||
| public Task<bool> IsEnabledAsync() => Task.FromResult(true); | ||
| } | ||
|
|
||
| [After(Test)] | ||
| public void Cleanup() | ||
| { | ||
| // Clean up environment variables after each test | ||
| Environment.SetEnvironmentVariable("TUNIT_DISABLE_JUNIT_REPORTER", null); | ||
| Environment.SetEnvironmentVariable("TUNIT_ENABLE_JUNIT_REPORTER", null); | ||
| Environment.SetEnvironmentVariable("GITLAB_CI", null); | ||
| Environment.SetEnvironmentVariable("CI_SERVER", null); | ||
| Environment.SetEnvironmentVariable("JUNIT_XML_OUTPUT_PATH", null); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task IsEnabledAsync_Should_Return_False_When_TUNIT_DISABLE_JUNIT_REPORTER_Is_Set() | ||
| { | ||
| // Arrange | ||
| Environment.SetEnvironmentVariable("TUNIT_DISABLE_JUNIT_REPORTER", "true"); | ||
| Environment.SetEnvironmentVariable("GITLAB_CI", "true"); // Even with GitLab CI, should be disabled | ||
| var extension = new MockExtension(); | ||
| var reporter = new JUnitReporter(extension); | ||
|
|
||
| // Act | ||
| var isEnabled = await reporter.IsEnabledAsync(); | ||
|
|
||
| // Assert | ||
| await Assert.That(isEnabled).IsFalse(); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task IsEnabledAsync_Should_Return_True_When_GITLAB_CI_Is_Set() | ||
| { | ||
| // Arrange | ||
| Environment.SetEnvironmentVariable("GITLAB_CI", "true"); | ||
| var extension = new MockExtension(); | ||
| var reporter = new JUnitReporter(extension); | ||
|
|
||
| // Act | ||
| var isEnabled = await reporter.IsEnabledAsync(); | ||
|
|
||
| // Assert | ||
| await Assert.That(isEnabled).IsTrue(); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task IsEnabledAsync_Should_Return_True_When_CI_SERVER_Is_Set() | ||
| { | ||
| // Arrange | ||
| Environment.SetEnvironmentVariable("CI_SERVER", "yes"); | ||
| var extension = new MockExtension(); | ||
| var reporter = new JUnitReporter(extension); | ||
|
|
||
| // Act | ||
| var isEnabled = await reporter.IsEnabledAsync(); | ||
|
|
||
| // Assert | ||
| await Assert.That(isEnabled).IsTrue(); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task IsEnabledAsync_Should_Return_True_When_TUNIT_ENABLE_JUNIT_REPORTER_Is_Set() | ||
| { | ||
| // Arrange | ||
| Environment.SetEnvironmentVariable("TUNIT_ENABLE_JUNIT_REPORTER", "true"); | ||
| var extension = new MockExtension(); | ||
| var reporter = new JUnitReporter(extension); | ||
|
|
||
| // Act | ||
| var isEnabled = await reporter.IsEnabledAsync(); | ||
|
|
||
| // Assert | ||
| await Assert.That(isEnabled).IsTrue(); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task IsEnabledAsync_Should_Return_False_When_No_Environment_Variables_Are_Set() | ||
| { | ||
| // Arrange | ||
| var extension = new MockExtension(); | ||
| var reporter = new JUnitReporter(extension); | ||
|
|
||
| // Act | ||
| var isEnabled = await reporter.IsEnabledAsync(); | ||
|
|
||
| // Assert | ||
| await Assert.That(isEnabled).IsFalse(); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task IsEnabledAsync_Should_Prefer_Disable_Over_Enable() | ||
| { | ||
| // Arrange | ||
| Environment.SetEnvironmentVariable("TUNIT_DISABLE_JUNIT_REPORTER", "true"); | ||
| Environment.SetEnvironmentVariable("TUNIT_ENABLE_JUNIT_REPORTER", "true"); | ||
| var extension = new MockExtension(); | ||
| var reporter = new JUnitReporter(extension); | ||
|
|
||
| // Act | ||
| var isEnabled = await reporter.IsEnabledAsync(); | ||
|
|
||
| // Assert | ||
| await Assert.That(isEnabled).IsFalse(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,50 @@ | ||||||||||
| using Microsoft.Testing.Platform.CommandLine; | ||||||||||
| using Microsoft.Testing.Platform.Extensions; | ||||||||||
| using Microsoft.Testing.Platform.Extensions.CommandLine; | ||||||||||
|
|
||||||||||
| namespace TUnit.Engine.CommandLineProviders; | ||||||||||
|
|
||||||||||
| internal class JUnitReporterCommandProvider(IExtension extension) : ICommandLineOptionsProvider | ||||||||||
| { | ||||||||||
| public const string JUnitOutputPathOption = "junit-output-path"; | ||||||||||
|
|
||||||||||
| public Task<bool> IsEnabledAsync() => extension.IsEnabledAsync(); | ||||||||||
|
|
||||||||||
| public string Uid => extension.Uid; | ||||||||||
|
|
||||||||||
| public string Version => extension.Version; | ||||||||||
|
|
||||||||||
| public string DisplayName => extension.DisplayName; | ||||||||||
|
|
||||||||||
| public string Description => extension.Description; | ||||||||||
|
|
||||||||||
| public IReadOnlyCollection<CommandLineOption> GetCommandLineOptions() | ||||||||||
| { | ||||||||||
| return | ||||||||||
| [ | ||||||||||
| new CommandLineOption( | ||||||||||
| JUnitOutputPathOption, | ||||||||||
| "Path to output JUnit XML file (default: TestResults/{AssemblyName}-junit.xml)", | ||||||||||
| ArgumentArity.ExactlyOne, | ||||||||||
| false) | ||||||||||
| ]; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| public Task<ValidationResult> ValidateOptionArgumentsAsync( | ||||||||||
| CommandLineOption commandOption, | ||||||||||
| string[] arguments) | ||||||||||
| { | ||||||||||
| if (commandOption.Name == JUnitOutputPathOption && arguments.Length != 1) | ||||||||||
| { | ||||||||||
| return ValidationResult.InvalidTask("A single output path must be provided for --junit-output-path"); | ||||||||||
| } | ||||||||||
|
||||||||||
| if (commandOption.Name == JUnitOutputPathOption && arguments.Length != 1) | |
| { | |
| return ValidationResult.InvalidTask("A single output path must be provided for --junit-output-path"); | |
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,160 @@ | ||||||||||||||
| using System.Collections.Concurrent; | ||||||||||||||
| using System.Reflection; | ||||||||||||||
| using System.Text; | ||||||||||||||
| using Microsoft.Testing.Platform.Extensions; | ||||||||||||||
| using Microsoft.Testing.Platform.Extensions.Messages; | ||||||||||||||
| using Microsoft.Testing.Platform.Extensions.TestHost; | ||||||||||||||
| using TUnit.Engine.Framework; | ||||||||||||||
| using TUnit.Engine.Helpers; | ||||||||||||||
| using TUnit.Engine.Xml; | ||||||||||||||
|
|
||||||||||||||
| namespace TUnit.Engine.Reporters; | ||||||||||||||
|
|
||||||||||||||
| public class JUnitReporter(IExtension extension) : IDataConsumer, ITestHostApplicationLifetime, IFilterReceiver | ||||||||||||||
| { | ||||||||||||||
| private string _outputPath = null!; | ||||||||||||||
| private bool _isEnabled; | ||||||||||||||
|
|
||||||||||||||
| public async Task<bool> IsEnabledAsync() | ||||||||||||||
| { | ||||||||||||||
| // Check if explicitly disabled | ||||||||||||||
| if (EnvironmentVariableCache.Get("TUNIT_DISABLE_JUNIT_REPORTER") is not null) | ||||||||||||||
| { | ||||||||||||||
| return false; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Check if explicitly enabled OR running in GitLab CI | ||||||||||||||
| var explicitlyEnabled = EnvironmentVariableCache.Get("TUNIT_ENABLE_JUNIT_REPORTER") is not null; | ||||||||||||||
| var runningInGitLab = EnvironmentVariableCache.Get("GITLAB_CI") is not null || | ||||||||||||||
| EnvironmentVariableCache.Get("CI_SERVER") is not null; | ||||||||||||||
|
|
||||||||||||||
| if (!explicitlyEnabled && !runningInGitLab) | ||||||||||||||
| { | ||||||||||||||
| return false; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Determine output path | ||||||||||||||
| _outputPath = EnvironmentVariableCache.Get("JUNIT_XML_OUTPUT_PATH") | ||||||||||||||
| ?? GetDefaultOutputPath(); | ||||||||||||||
|
||||||||||||||
|
|
||||||||||||||
| _isEnabled = true; | ||||||||||||||
| return await extension.IsEnabledAsync(); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| public string Uid { get; } = $"{extension.Uid}JUnitReporter"; | ||||||||||||||
|
|
||||||||||||||
| public string Version => extension.Version; | ||||||||||||||
|
|
||||||||||||||
| public string DisplayName => extension.DisplayName; | ||||||||||||||
|
|
||||||||||||||
| public string Description => extension.Description; | ||||||||||||||
|
|
||||||||||||||
| private readonly ConcurrentDictionary<string, List<TestNodeUpdateMessage>> _updates = []; | ||||||||||||||
|
|
||||||||||||||
| public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) | ||||||||||||||
| { | ||||||||||||||
| var testNodeUpdateMessage = (TestNodeUpdateMessage)value; | ||||||||||||||
|
|
||||||||||||||
| _updates.GetOrAdd(testNodeUpdateMessage.TestNode.Uid.Value, []).Add(testNodeUpdateMessage); | ||||||||||||||
|
|
||||||||||||||
| return Task.CompletedTask; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; | ||||||||||||||
|
|
||||||||||||||
| public Task BeforeRunAsync(CancellationToken cancellationToken) | ||||||||||||||
| { | ||||||||||||||
| return Task.CompletedTask; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| public async Task AfterRunAsync(int exitCode, CancellationToken cancellation) | ||||||||||||||
| { | ||||||||||||||
| if (!_isEnabled || _updates.Count == 0) | ||||||||||||||
| { | ||||||||||||||
| return; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Get the last update for each test | ||||||||||||||
| var lastUpdates = new List<TestNodeUpdateMessage>(_updates.Count); | ||||||||||||||
| foreach (var kvp in _updates) | ||||||||||||||
| { | ||||||||||||||
| if (kvp.Value.Count > 0) | ||||||||||||||
| { | ||||||||||||||
| lastUpdates.Add(kvp.Value[kvp.Value.Count - 1]); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
Comment on lines
79
to
82
|
||||||||||||||
| // Generate JUnit XML | ||||||||||||||
| var xmlContent = JUnitXmlWriter.GenerateXml(lastUpdates, Filter); | ||||||||||||||
|
|
||||||||||||||
| if (string.IsNullOrEmpty(xmlContent)) | ||||||||||||||
| { | ||||||||||||||
| return; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Write to file with retry logic | ||||||||||||||
| await WriteXmlFileAsync(_outputPath, xmlContent, cancellation); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| public string? Filter { get; set; } | ||||||||||||||
|
|
||||||||||||||
| internal void SetOutputPath(string path) | ||||||||||||||
| { | ||||||||||||||
|
||||||||||||||
| { | |
| { | |
| if (string.IsNullOrEmpty(path)) | |
| { | |
| throw new ArgumentException("Output path must not be null or empty.", nameof(path)); | |
| } |
Copilot
AI
Dec 5, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Creating a new Random instance on each call can lead to predictable sequences if called in quick succession. Consider using Random.Shared (available in .NET 6+) for better randomness, or create a single static Random instance if targeting older frameworks. For async retry scenarios, Random.Shared is the modern best practice.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The tests will not work as expected because they set environment variables directly, but the
JUnitReporterusesEnvironmentVariableCache.Get()which caches environment variables on first access. Once the cache is initialized, setting environment variables withEnvironment.SetEnvironmentVariable()won't affect the cached values.To fix this, you need to either:
EnvironmentVariableCacheand call it in theCleanup()method